From 8ec78900c55b654fdc378502eff00f1aa98d063c Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 29 Feb 2020 20:01:09 -0500 Subject: [PATCH] feat: transcoding and player datastores and configuration --- consts/consts.go | 17 +++ ...81627_add_transcoding_and_player_tables.go | 49 ++++++++ engine/players.go | 57 +++++++++ engine/players_test.go | 112 ++++++++++++++++++ engine/playlists.go | 2 +- engine/wire_providers.go | 1 + model/datastore.go | 2 + model/player.go | 25 ++++ model/transcoding.go | 15 +++ persistence/album_repository_test.go | 2 +- persistence/artist_repository_test.go | 2 +- persistence/mediafile_repository_test.go | 2 +- persistence/mock_persistence.go | 12 ++ persistence/persistence.go | 12 ++ persistence/persistence_suite_test.go | 2 +- persistence/player_repository.go | 94 +++++++++++++++ persistence/sql_base_repository.go | 5 +- persistence/transcoding_repository.go | 84 +++++++++++++ server/app/app.go | 2 + server/app/auth.go | 2 +- server/initial_setup.go | 31 ++++- server/subsonic/api.go | 6 +- server/subsonic/middlewares.go | 51 +++++++- server/subsonic/middlewares_test.go | 77 ++++++++++-- ui/src/App.js | 12 +- ui/src/i18n/en.js | 3 +- ui/src/layout/Menu.js | 14 ++- ui/src/transcoding/TranscodingCreate.js | 54 +++++++++ ui/src/transcoding/TranscodingEdit.js | 35 ++++++ ui/src/transcoding/TranscodingList.js | 16 +++ ui/src/transcoding/index.js | 11 ++ wire_gen.go | 3 +- 32 files changed, 783 insertions(+), 29 deletions(-) create mode 100644 db/migration/20200310181627_add_transcoding_and_player_tables.go create mode 100644 engine/players.go create mode 100644 engine/players_test.go create mode 100644 model/player.go create mode 100644 model/transcoding.go create mode 100644 persistence/player_repository.go create mode 100644 persistence/transcoding_repository.go create mode 100644 ui/src/transcoding/TranscodingCreate.js create mode 100644 ui/src/transcoding/TranscodingEdit.js create mode 100644 ui/src/transcoding/TranscodingList.js create mode 100644 ui/src/transcoding/index.js diff --git a/consts/consts.go b/consts/consts.go index 5b84817918f..a8ef159aca6 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -20,3 +20,20 @@ const ( DevInitialUserName = "admin" DevInitialName = "Dev Admin" ) + +var ( + DefaultTranscodings = []map[string]interface{}{ + { + "name": "mp3 audio", + "targetFormat": "mp3", + "defaultBitRate": 192, + "command": "ffmpeg -i %s -ab %bk -v 0 -f mp3 -", + }, + { + "name": "opus audio", + "targetFormat": "oga", + "defaultBitRate": 128, + "command": "ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -c:a libopus -f opus -", + }, + } +) diff --git a/db/migration/20200310181627_add_transcoding_and_player_tables.go b/db/migration/20200310181627_add_transcoding_and_player_tables.go new file mode 100644 index 00000000000..5de1422cc65 --- /dev/null +++ b/db/migration/20200310181627_add_transcoding_and_player_tables.go @@ -0,0 +1,49 @@ +package migration + +import ( + "database/sql" + + "github.com/pressly/goose" +) + +func init() { + goose.AddMigration(Up20200310181627, Down20200310181627) +} + +func Up20200310181627(tx *sql.Tx) error { + _, err := tx.Exec(` +create table transcoding +( + id varchar(255) not null primary key, + name varchar(255) not null, + target_format varchar(255) not null, + command varchar(255) default '' not null, + default_bit_rate int default 192, + unique (name), + unique (target_format) +); + +create table player +( + id varchar(255) not null primary key, + name varchar not null, + type varchar, + user_name varchar not null, + client varchar not null, + ip_address varchar, + last_seen timestamp, + transcoding_id varchar, -- todo foreign key + max_bit_rate int default 0, + unique (name) +); +`) + return err +} + +func Down20200310181627(tx *sql.Tx) error { + _, err := tx.Exec(` +drop table transcoding; +drop table player; +`) + return err +} diff --git a/engine/players.go b/engine/players.go new file mode 100644 index 00000000000..e84fef490eb --- /dev/null +++ b/engine/players.go @@ -0,0 +1,57 @@ +package engine + +import ( + "context" + "fmt" + "time" + + "github.com/deluan/navidrome/log" + "github.com/deluan/navidrome/model" + "github.com/google/uuid" +) + +type Players interface { + Get(ctx context.Context, playerId string) (*model.Player, error) + Register(ctx context.Context, id, client, typ, ip string) (*model.Player, error) +} + +func NewPlayers(ds model.DataStore) Players { + return &players{ds} +} + +type players struct { + ds model.DataStore +} + +func (p *players) Register(ctx context.Context, id, client, typ, ip string) (*model.Player, error) { + var plr *model.Player + var err error + userName := ctx.Value("username").(string) + if id != "" { + plr, err = p.ds.Player(ctx).Get(id) + } + if err != nil || id == "" { + plr, err = p.ds.Player(ctx).FindByName(client, userName) + if err == nil { + log.Trace("Found player by name", "id", plr.ID, "client", client, "userName", userName) + } else { + r, _ := uuid.NewRandom() + plr = &model.Player{ + ID: r.String(), + Name: fmt.Sprintf("%s (%s)", client, userName), + UserName: userName, + Client: client, + } + log.Trace("Create new player", "id", plr.ID, "client", client, "userName", userName) + } + } + plr.LastSeen = time.Now() + plr.Type = typ + plr.IPAddress = ip + err = p.ds.Player(ctx).Put(plr) + return plr, err +} + +func (p *players) Get(ctx context.Context, playerId string) (*model.Player, error) { + return p.ds.Player(ctx).Get(playerId) +} diff --git a/engine/players_test.go b/engine/players_test.go new file mode 100644 index 00000000000..addb31275a9 --- /dev/null +++ b/engine/players_test.go @@ -0,0 +1,112 @@ +package engine + +import ( + "context" + "time" + + "github.com/deluan/navidrome/log" + "github.com/deluan/navidrome/model" + "github.com/deluan/navidrome/persistence" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Players", func() { + var players Players + var repo *mockPlayerRepository + ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid", UserName: "johndoe"}) + ctx = context.WithValue(ctx, "username", "johndoe") + var beforeRegister time.Time + + BeforeEach(func() { + repo = &mockPlayerRepository{} + ds := &persistence.MockDataStore{MockedPlayer: repo} + players = NewPlayers(ds) + beforeRegister = time.Now() + }) + + Describe("Register", func() { + It("creates a new player when no ID is specified", func() { + p, err := players.Register(ctx, "", "client", "chrome", "1.2.3.4") + Expect(err).ToNot(HaveOccurred()) + Expect(p.ID).ToNot(BeEmpty()) + Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister)) + Expect(p.Client).To(Equal("client")) + Expect(p.UserName).To(Equal("johndoe")) + Expect(p.Type).To(Equal("chrome")) + Expect(repo.lastSaved).To(Equal(p)) + }) + + It("creates a new player if it cannot find any matching player", func() { + p, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4") + Expect(err).ToNot(HaveOccurred()) + Expect(p.ID).ToNot(BeEmpty()) + Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister)) + Expect(repo.lastSaved).To(Equal(p)) + }) + + It("finds players by ID", func() { + plr := &model.Player{ID: "123", Name: "A Player", LastSeen: time.Time{}} + repo.add(plr) + p, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4") + Expect(err).ToNot(HaveOccurred()) + Expect(p.ID).To(Equal("123")) + Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister)) + Expect(repo.lastSaved).To(Equal(p)) + }) + + It("finds player by client and user names when ID is not found", func() { + plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserName: "johndoe", LastSeen: time.Time{}} + repo.add(plr) + p, err := players.Register(ctx, "999", "client", "chrome", "1.2.3.4") + Expect(err).ToNot(HaveOccurred()) + Expect(p.ID).To(Equal("123")) + Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister)) + Expect(repo.lastSaved).To(Equal(p)) + }) + + It("finds player by client and user names when not ID is provided", func() { + plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserName: "johndoe", LastSeen: time.Time{}} + repo.add(plr) + p, err := players.Register(ctx, "", "client", "chrome", "1.2.3.4") + Expect(err).ToNot(HaveOccurred()) + Expect(p.ID).To(Equal("123")) + Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister)) + Expect(repo.lastSaved).To(Equal(p)) + }) + }) +}) + +type mockPlayerRepository struct { + model.PlayerRepository + lastSaved *model.Player + data map[string]model.Player +} + +func (m *mockPlayerRepository) add(p *model.Player) { + if m.data == nil { + m.data = make(map[string]model.Player) + } + m.data[p.ID] = *p +} + +func (m *mockPlayerRepository) Get(id string) (*model.Player, error) { + if p, ok := m.data[id]; ok { + return &p, nil + } + return nil, model.ErrNotFound +} + +func (m *mockPlayerRepository) FindByName(client, userName string) (*model.Player, error) { + for _, p := range m.data { + if p.Client == client && p.UserName == userName { + return &p, nil + } + } + return nil, model.ErrNotFound +} + +func (m *mockPlayerRepository) Put(p *model.Player) error { + m.lastSaved = p + return nil +} diff --git a/engine/playlists.go b/engine/playlists.go index 97828d22c3b..f4a0a0ae438 100644 --- a/engine/playlists.go +++ b/engine/playlists.go @@ -51,7 +51,7 @@ func (p *playlists) Create(ctx context.Context, playlistId, name string, ids []s } func (p *playlists) getUser(ctx context.Context) string { - user, ok := ctx.Value("user").(*model.User) + user, ok := ctx.Value("user").(model.User) if ok { return user.UserName } diff --git a/engine/wire_providers.go b/engine/wire_providers.go index 547e19bbdba..17d76f8ec75 100644 --- a/engine/wire_providers.go +++ b/engine/wire_providers.go @@ -18,4 +18,5 @@ var Set = wire.NewSet( NewMediaStreamer, transcoder.New, NewTranscodingCache, + NewPlayers, ) diff --git a/model/datastore.go b/model/datastore.go index 96cf1ed9dfd..a2fa14bdd1c 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -28,6 +28,8 @@ type DataStore interface { Playlist(ctx context.Context) PlaylistRepository Property(ctx context.Context) PropertyRepository User(ctx context.Context) UserRepository + Transcoding(ctx context.Context) TranscodingRepository + Player(ctx context.Context) PlayerRepository Resource(ctx context.Context, model interface{}) ResourceRepository diff --git a/model/player.go b/model/player.go new file mode 100644 index 00000000000..3fc855102be --- /dev/null +++ b/model/player.go @@ -0,0 +1,25 @@ +package model + +import ( + "time" +) + +type Player struct { + ID string `json:"id" orm:"column(id)"` + Name string `json:"name"` + Type string `json:"type"` + UserName string `json:"userName"` + Client string `json:"client"` + IPAddress string `json:"ipAddress"` + LastSeen time.Time `json:"lastSeen"` + TranscodingId string `json:"transcodingId"` + MaxBitRate int `json:"maxBitRate"` +} + +type Players []Player + +type PlayerRepository interface { + Get(id string) (*Player, error) + FindByName(client, userName string) (*Player, error) + Put(p *Player) error +} diff --git a/model/transcoding.go b/model/transcoding.go new file mode 100644 index 00000000000..9bcce037933 --- /dev/null +++ b/model/transcoding.go @@ -0,0 +1,15 @@ +package model + +type Transcoding struct { + ID string `json:"id" orm:"column(id)"` + Name string `json:"name"` + TargetFormat string `json:"targetFormat"` + Command string `json:"command"` + DefaultBitRate int `json:"defaultBitRate"` +} + +type Transcodings []Transcoding + +type TranscodingRepository interface { + Put(*Transcoding) error +} diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index d0dab2d4c00..98222ee3a31 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -14,7 +14,7 @@ var _ = Describe("AlbumRepository", func() { var repo model.AlbumRepository BeforeEach(func() { - ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"}) + ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"}) repo = NewAlbumRepository(ctx, orm.NewOrm()) }) diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index f7e272c9403..b5ae5e43e21 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -14,7 +14,7 @@ var _ = Describe("ArtistRepository", func() { var repo model.ArtistRepository BeforeEach(func() { - ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"}) + ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"}) repo = NewArtistRepository(ctx, orm.NewOrm()) }) diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 5bff8310fc6..20af87f46b4 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -16,7 +16,7 @@ var _ = Describe("MediaRepository", func() { var mr model.MediaFileRepository BeforeEach(func() { - ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"}) + ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"}) mr = NewMediaFileRepository(ctx, orm.NewOrm()) }) diff --git a/persistence/mock_persistence.go b/persistence/mock_persistence.go index 91b3bd7fd69..850e1bd749c 100644 --- a/persistence/mock_persistence.go +++ b/persistence/mock_persistence.go @@ -12,6 +12,7 @@ type MockDataStore struct { MockedArtist model.ArtistRepository MockedMediaFile model.MediaFileRepository MockedUser model.UserRepository + MockedPlayer model.PlayerRepository } func (db *MockDataStore) Album(context.Context) model.AlbumRepository { @@ -61,6 +62,17 @@ func (db *MockDataStore) User(context.Context) model.UserRepository { return db.MockedUser } +func (db *MockDataStore) Transcoding(context.Context) model.TranscodingRepository { + return struct{ model.TranscodingRepository }{} +} + +func (db *MockDataStore) Player(context.Context) model.PlayerRepository { + if db.MockedPlayer != nil { + return db.MockedPlayer + } + return struct{ model.PlayerRepository }{} +} + func (db *MockDataStore) WithTx(block func(db model.DataStore) error) error { return block(db) } diff --git a/persistence/persistence.go b/persistence/persistence.go index f2de436693e..e292e3b29d7 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -55,10 +55,22 @@ func (s *SQLStore) User(ctx context.Context) model.UserRepository { return NewUserRepository(ctx, s.getOrmer()) } +func (s *SQLStore) Transcoding(ctx context.Context) model.TranscodingRepository { + return NewTranscodingRepository(ctx, s.getOrmer()) +} + +func (s *SQLStore) Player(ctx context.Context) model.PlayerRepository { + return NewPlayerRepository(ctx, s.getOrmer()) +} + func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository { switch m.(type) { case model.User: return s.User(ctx).(model.ResourceRepository) + case model.Transcoding: + return s.Transcoding(ctx).(model.ResourceRepository) + case model.Player: + return s.Player(ctx).(model.ResourceRepository) case model.Artist: return s.Artist(ctx).(model.ResourceRepository) case model.Album: diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index fd1d379f02e..0e5e4e95845 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -86,7 +86,7 @@ var _ = Describe("Initialize test DB", func() { // TODO Load this data setup from file(s) BeforeSuite(func() { o := orm.NewOrm() - ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"}) + ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"}) mr := NewMediaFileRepository(ctx, o) for _, s := range testSongs { err := mr.Put(&s) diff --git a/persistence/player_repository.go b/persistence/player_repository.go new file mode 100644 index 00000000000..a9bf4d4becf --- /dev/null +++ b/persistence/player_repository.go @@ -0,0 +1,94 @@ +package persistence + +import ( + "context" + + . "github.com/Masterminds/squirrel" + "github.com/astaxie/beego/orm" + "github.com/deluan/navidrome/model" + "github.com/deluan/rest" +) + +type playerRepository struct { + sqlRepository +} + +func NewPlayerRepository(ctx context.Context, o orm.Ormer) model.PlayerRepository { + r := &playerRepository{} + r.ctx = ctx + r.ormer = o + r.tableName = "player" + return r +} + +func (r *playerRepository) Put(p *model.Player) error { + _, err := r.put(p.ID, p) + return err +} + +func (r *playerRepository) Get(id string) (*model.Player, error) { + sel := r.newSelect().Columns("*").Where(Eq{"id": id}) + var res model.Player + err := r.queryOne(sel, &res) + return &res, err +} + +func (r *playerRepository) FindByName(client, userName string) (*model.Player, error) { + sel := r.newSelect().Columns("*").Where(And{Eq{"client": client}, Eq{"user_name": userName}}) + var res model.Player + err := r.queryOne(sel, &res) + return &res, err +} + +func (r *playerRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.count(Select(), r.parseRestOptions(options...)) +} + +func (r *playerRepository) Read(id string) (interface{}, error) { + return r.Get(id) +} + +func (r *playerRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + sel := r.newSelect(r.parseRestOptions(options...)).Columns("*") + res := model.Players{} + err := r.queryAll(sel, &res) + return res, err +} + +func (r *playerRepository) EntityName() string { + return "player" +} + +func (r *playerRepository) NewInstance() interface{} { + return &model.Player{} +} + +func (r *playerRepository) Save(entity interface{}) (string, error) { + t := entity.(*model.Player) + id, err := r.put(t.ID, t) + if err == model.ErrNotFound { + return "", rest.ErrNotFound + } + return id, err +} + +func (r *playerRepository) Update(entity interface{}, cols ...string) error { + t := entity.(*model.Player) + _, err := r.put(t.ID, t) + if err == model.ErrNotFound { + return rest.ErrNotFound + } + return err +} + +func (r *playerRepository) Delete(id string) error { + err := r.delete(Eq{"id": id}) + if err == model.ErrNotFound { + return rest.ErrNotFound + } + return err +} + +var _ model.PlayerRepository = (*playerRepository)(nil) +var _ rest.Repository = (*playerRepository)(nil) +var _ rest.Persistable = (*playerRepository)(nil) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index f906ce0bb4c..9b21db352f5 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -29,7 +29,7 @@ func userId(ctx context.Context) string { if user == nil { return invalidUserId } - usr := user.(*model.User) + usr := user.(model.User) return usr.ID } @@ -38,7 +38,8 @@ func loggedUser(ctx context.Context) *model.User { if user == nil { return &model.User{} } - return user.(*model.User) + u := user.(model.User) + return &u } func (r sqlRepository) newSelect(options ...model.QueryOptions) SelectBuilder { diff --git a/persistence/transcoding_repository.go b/persistence/transcoding_repository.go new file mode 100644 index 00000000000..7e21bc70868 --- /dev/null +++ b/persistence/transcoding_repository.go @@ -0,0 +1,84 @@ +package persistence + +import ( + "context" + + . "github.com/Masterminds/squirrel" + "github.com/astaxie/beego/orm" + "github.com/deluan/navidrome/model" + "github.com/deluan/rest" +) + +type transcodingRepository struct { + sqlRepository +} + +func NewTranscodingRepository(ctx context.Context, o orm.Ormer) model.TranscodingRepository { + r := &transcodingRepository{} + r.ctx = ctx + r.ormer = o + r.tableName = "transcoding" + return r +} + +func (r *transcodingRepository) Put(t *model.Transcoding) error { + _, err := r.put(t.ID, t) + return err +} + +func (r *transcodingRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.count(Select(), r.parseRestOptions(options...)) +} + +func (r *transcodingRepository) Read(id string) (interface{}, error) { + sel := r.newSelect().Columns("*").Where(Eq{"id": id}) + var res model.Transcoding + err := r.queryOne(sel, &res) + return &res, err + +} + +func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + sel := r.newSelect(r.parseRestOptions(options...)).Columns("*") + res := model.Transcodings{} + err := r.queryAll(sel, &res) + return res, err +} + +func (r *transcodingRepository) EntityName() string { + return "transcoding" +} + +func (r *transcodingRepository) NewInstance() interface{} { + return &model.Transcoding{} +} + +func (r *transcodingRepository) Save(entity interface{}) (string, error) { + t := entity.(*model.Transcoding) + id, err := r.put(t.ID, t) + if err == model.ErrNotFound { + return "", rest.ErrNotFound + } + return id, err +} + +func (r *transcodingRepository) Update(entity interface{}, cols ...string) error { + t := entity.(*model.Transcoding) + _, err := r.put(t.ID, t) + if err == model.ErrNotFound { + return rest.ErrNotFound + } + return err +} + +func (r *transcodingRepository) Delete(id string) error { + err := r.delete(Eq{"id": id}) + if err == model.ErrNotFound { + return rest.ErrNotFound + } + return err +} + +var _ model.TranscodingRepository = (*transcodingRepository)(nil) +var _ rest.Repository = (*transcodingRepository)(nil) +var _ rest.Persistable = (*transcodingRepository)(nil) diff --git a/server/app/app.go b/server/app/app.go index 749f7dd975c..aa57e3413cf 100644 --- a/server/app/app.go +++ b/server/app/app.go @@ -43,6 +43,8 @@ func (app *Router) routes() http.Handler { app.R(r, "/song", model.MediaFile{}) app.R(r, "/album", model.Album{}) app.R(r, "/artist", model.Artist{}) + app.R(r, "/transcoding", model.Transcoding{}) + app.R(r, "/player", model.Player{}) // Keepalive endpoint to be used to keep the session valid (ex: while playing songs) r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"response":"ok"}`)) }) diff --git a/server/app/auth.go b/server/app/auth.go index 1eb00d68388..cc5403af50b 100644 --- a/server/app/auth.go +++ b/server/app/auth.go @@ -149,7 +149,7 @@ func validateLogin(userRepo model.UserRepository, userName, password string) (*m func contextWithUser(ctx context.Context, ds model.DataStore, claims jwt.MapClaims) context.Context { userName := claims["sub"].(string) user, _ := ds.User(ctx).FindByUsername(userName) - return context.WithValue(ctx, "user", user) + return context.WithValue(ctx, "user", *user) } func getToken(ds model.DataStore, ctx context.Context) (*jwt.Token, error) { diff --git a/server/initial_setup.go b/server/initial_setup.go index 6661cf07baf..72347400cdb 100644 --- a/server/initial_setup.go +++ b/server/initial_setup.go @@ -1,7 +1,7 @@ package server import ( - "context" + "encoding/json" "fmt" "time" @@ -29,14 +29,17 @@ func initialSetup(ds model.DataStore) { } } + if err = createDefaultTranscodings(ds); err != nil { + return err + } + err = ds.Property(nil).Put(consts.InitialSetupFlagKey, time.Now().String()) return err }) } func createInitialAdminUser(ds model.DataStore) error { - ctx := context.Background() - c, err := ds.User(ctx).CountAll() + c, err := ds.User(nil).CountAll() if err != nil { panic(fmt.Sprintf("Could not access User table: %s", err)) } @@ -56,7 +59,7 @@ func createInitialAdminUser(ds model.DataStore) error { Password: initialPassword, IsAdmin: true, } - err := ds.User(ctx).Put(&initialUser) + err := ds.User(nil).Put(&initialUser) if err != nil { log.Error("Could not create initial admin user", "user", initialUser, err) } @@ -77,3 +80,23 @@ func createJWTSecret(ds model.DataStore) error { } return err } + +func createDefaultTranscodings(ds model.DataStore) error { + repo := ds.Transcoding(nil) + for _, d := range consts.DefaultTranscodings { + var j []byte + var err error + if j, err = json.Marshal(d); err != nil { + return err + } + var t model.Transcoding + if err = json.Unmarshal(j, &t); err != nil { + return err + } + log.Info("Creating default transcoding config", "name", t.Name) + if err = repo.Put(&t); err != nil { + return err + } + } + return nil +} diff --git a/server/subsonic/api.go b/server/subsonic/api.go index d969d6600e8..cea352472b7 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -27,16 +27,17 @@ type Router struct { Search engine.Search Users engine.Users Streamer engine.MediaStreamer + Players engine.Players mux http.Handler } func New(browser engine.Browser, cover engine.Cover, listGenerator engine.ListGenerator, users engine.Users, playlists engine.Playlists, ratings engine.Ratings, scrobbler engine.Scrobbler, search engine.Search, - streamer engine.MediaStreamer) *Router { + streamer engine.MediaStreamer, players engine.Players) *Router { r := &Router{Browser: browser, Cover: cover, ListGenerator: listGenerator, Playlists: playlists, - Ratings: ratings, Scrobbler: scrobbler, Search: search, Users: users, Streamer: streamer} + Ratings: ratings, Scrobbler: scrobbler, Search: search, Users: users, Streamer: streamer, Players: players} r.mux = r.routes() return r } @@ -50,6 +51,7 @@ func (api *Router) routes() http.Handler { r.Use(postFormToQueryParams) r.Use(checkRequiredParameters) + r.Use(getPlayer(api.Players)) // Add validation middleware r.Use(authenticate(api.Users)) diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 32b612cb67d..dc84708940b 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -3,6 +3,7 @@ package subsonic import ( "context" "fmt" + "net" "net/http" "net/url" "strings" @@ -14,6 +15,10 @@ import ( "github.com/deluan/navidrome/utils" ) +const ( + cookieExpiry = 365 * 24 * 3600 // One year +) + func postFormToQueryParams(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { err := r.ParseForm() @@ -82,10 +87,54 @@ func authenticate(users engine.Users) func(next http.Handler) http.Handler { } ctx := r.Context() - ctx = context.WithValue(ctx, "user", usr) + ctx = context.WithValue(ctx, "user", *usr) r = r.WithContext(ctx) next.ServeHTTP(w, r) }) } } + +func getPlayer(players engine.Players) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + userName := ctx.Value("username").(string) + client := ctx.Value("client").(string) + playerId := playerIDFromCookie(r, userName) + ip, _, _ := net.SplitHostPort(r.RemoteAddr) + player, err := players.Register(ctx, playerId, client, r.Header.Get("user-agent"), ip) + if err != nil { + log.Error("Could not register player", "userName", userName, "client", client) + } + + ctx = context.WithValue(ctx, "player", *player) + r = r.WithContext(ctx) + + cookie := &http.Cookie{ + Name: playerIDCookieName(userName), + Value: player.ID, + MaxAge: cookieExpiry, + HttpOnly: true, + Path: "/", + } + http.SetCookie(w, cookie) + next.ServeHTTP(w, r) + }) + } +} + +func playerIDFromCookie(r *http.Request, userName string) string { + cookieName := playerIDCookieName(userName) + var playerId string + if c, err := r.Cookie(cookieName); err == nil { + playerId = c.Value + log.Trace(r, "playerId found in cookies", "playerId", playerId) + } + return playerId +} + +func playerIDCookieName(userName string) string { + cookieName := fmt.Sprintf("nd-player-%x", userName) + return cookieName +} diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go index e5573e18edd..66ead808b0c 100644 --- a/server/subsonic/middlewares_test.go +++ b/server/subsonic/middlewares_test.go @@ -107,35 +107,80 @@ var _ = Describe("Middlewares", func() { }) Describe("Authenticate", func() { - var mockedUser *mockUsers + var mockedUsers *mockUsers BeforeEach(func() { - mockedUser = &mockUsers{} + mockedUsers = &mockUsers{} }) It("passes all parameters to users.Authenticate ", func() { r := newGetRequest("u=valid", "p=password", "t=token", "s=salt", "jwt=jwt") - cp := authenticate(mockedUser)(next) + cp := authenticate(mockedUsers)(next) cp.ServeHTTP(w, r) - Expect(mockedUser.username).To(Equal("valid")) - Expect(mockedUser.password).To(Equal("password")) - Expect(mockedUser.token).To(Equal("token")) - Expect(mockedUser.salt).To(Equal("salt")) - Expect(mockedUser.jwt).To(Equal("jwt")) + Expect(mockedUsers.username).To(Equal("valid")) + Expect(mockedUsers.password).To(Equal("password")) + Expect(mockedUsers.token).To(Equal("token")) + Expect(mockedUsers.salt).To(Equal("salt")) + Expect(mockedUsers.jwt).To(Equal("jwt")) Expect(next.called).To(BeTrue()) - user := next.req.Context().Value("user").(*model.User) + user := next.req.Context().Value("user").(model.User) Expect(user.UserName).To(Equal("valid")) }) It("fails authentication with wrong password", func() { r := newGetRequest("u=invalid", "", "", "") - cp := authenticate(mockedUser)(next) + cp := authenticate(mockedUsers)(next) cp.ServeHTTP(w, r) Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) Expect(next.called).To(BeFalse()) }) }) + + Describe("GetPlayer", func() { + var mockedPlayers *mockPlayers + var r *http.Request + BeforeEach(func() { + mockedPlayers = &mockPlayers{} + r = newGetRequest() + ctx := context.WithValue(r.Context(), "username", "someone") + ctx = context.WithValue(ctx, "client", "client") + r = r.WithContext(ctx) + }) + + It("returns a new player in the cookies when none is specified", func() { + gp := getPlayer(mockedPlayers)(next) + gp.ServeHTTP(w, r) + + cookieStr := w.Header().Get("Set-Cookie") + Expect(cookieStr).To(ContainSubstring(playerIDCookieName("someone"))) + }) + + Context("PlayerId specified in Cookies", func() { + BeforeEach(func() { + cookie := &http.Cookie{ + Name: playerIDCookieName("someone"), + Value: "123", + MaxAge: cookieExpiry, + } + r.AddCookie(cookie) + + gp := getPlayer(mockedPlayers)(next) + gp.ServeHTTP(w, r) + }) + + It("stores the player in the context", func() { + Expect(next.called).To(BeTrue()) + player := next.req.Context().Value("player").(model.Player) + Expect(player.ID).To(Equal("123")) + }) + + It("returns the playerId in the cookie", func() { + cookieStr := w.Header().Get("Set-Cookie") + Expect(cookieStr).To(ContainSubstring(playerIDCookieName("someone") + "=123")) + }) + }) + }) }) type mockHandler struct { @@ -164,3 +209,15 @@ func (m *mockUsers) Authenticate(ctx context.Context, username, password, token, } return nil, model.ErrInvalidAuth } + +type mockPlayers struct { + engine.Players +} + +func (mp *mockPlayers) Get(ctx context.Context, playerId string) (*model.Player, error) { + return &model.Player{ID: playerId}, nil +} + +func (mp *mockPlayers) Register(ctx context.Context, id, client, typ, ip string) (*model.Player, error) { + return &model.Player{ID: id}, nil +} diff --git a/ui/src/App.js b/ui/src/App.js index ca39a1d7527..485efaa1a41 100644 --- a/ui/src/App.js +++ b/ui/src/App.js @@ -5,6 +5,7 @@ import authProvider from './authProvider' import polyglotI18nProvider from 'ra-i18n-polyglot' import messages from './i18n' import { DarkTheme, Layout, Login } from './layout' +import transcoding from './transcoding' import user from './user' import song from './song' import album from './album' @@ -44,7 +45,16 @@ const App = () => { , , , - permissions === 'admin' ? : null, + permissions === 'admin' ? ( + + ) : null, + permissions === 'admin' ? ( + + ) : null, ]} diff --git a/ui/src/i18n/en.js b/ui/src/i18n/en.js index f627a586a67..f35d77e0baf 100644 --- a/ui/src/i18n/en.js +++ b/ui/src/i18n/en.js @@ -40,7 +40,8 @@ export default deepmerge(englishMessages, { } }, menu: { - library: 'Library' + library: 'Library', + settings: 'Settings' }, player: { panelTitle: 'Play Queue', diff --git a/ui/src/layout/Menu.js b/ui/src/layout/Menu.js index ac8201b3608..dcaf94b95a1 100644 --- a/ui/src/layout/Menu.js +++ b/ui/src/layout/Menu.js @@ -4,6 +4,7 @@ import { useMediaQuery } from '@material-ui/core' import { useTranslate, MenuItemLink, getResources } from 'react-admin' import { withRouter } from 'react-router-dom' import LibraryMusicIcon from '@material-ui/icons/LibraryMusic' +import SettingsIcon from '@material-ui/icons/Settings' import ViewListIcon from '@material-ui/icons/ViewList' import SubMenu from './SubMenu' import inflection from 'inflection' @@ -27,7 +28,8 @@ const Menu = ({ onMenuClick, dense, logout }) => { const resources = useSelector(getResources) const [state, setState] = useState({ - menuLibrary: true + menuLibrary: true, + menuSettings: false }) const handleToggle = (menu) => { @@ -63,6 +65,16 @@ const Menu = ({ onMenuClick, dense, logout }) => { > {resources.filter(subItems('library')).map(renderMenuItemLink)} + handleToggle('menuSettings')} + isOpen={state.menuSettings} + sidebarIsOpen={open} + name="menu.settings" + icon={} + dense={dense} + > + {resources.filter(subItems('settings')).map(renderMenuItemLink)} + {resources.filter(subItems(undefined)).map(renderMenuItemLink)} {isXsmall && logout} diff --git a/ui/src/transcoding/TranscodingCreate.js b/ui/src/transcoding/TranscodingCreate.js new file mode 100644 index 00000000000..c20799d619f --- /dev/null +++ b/ui/src/transcoding/TranscodingCreate.js @@ -0,0 +1,54 @@ +import React from 'react' +import { + TextInput, + SelectInput, + Create, + required, + SimpleForm +} from 'react-admin' +import { Title } from '../common' + +const TranscodingTitle = ({ record }) => { + return +} + +const TranscodingCreate = (props) => ( + <Create title={<TranscodingTitle />} {...props}> + <SimpleForm> + <TextInput source="name" validate={[required()]} /> + <TextInput source="targetFormat" validate={[required()]} /> + <SelectInput + source="defaultBitRate" + choices={[ + { id: 32, name: '32' }, + { id: 48, name: '48' }, + { id: 64, name: '64' }, + { id: 80, name: '80' }, + { id: 96, name: '96' }, + { id: 112, name: '112' }, + { id: 128, name: '128' }, + { id: 160, name: '160' }, + { id: 192, name: '192' }, + { id: 256, name: '256' }, + { id: 320, name: '320' } + ]} + initialValue={192} + /> + <TextInput + source="command" + fullWidth + validate={[required()]} + helperText={ + <span> + Substitutions: <br /> + %s: File path <br /> + %b: BitRate (in kbps) + <br /> + </span> + } + /> + </SimpleForm> + </Create> +) + +export default TranscodingCreate diff --git a/ui/src/transcoding/TranscodingEdit.js b/ui/src/transcoding/TranscodingEdit.js new file mode 100644 index 00000000000..923f3d1a4f2 --- /dev/null +++ b/ui/src/transcoding/TranscodingEdit.js @@ -0,0 +1,35 @@ +import React from 'react' +import { TextInput, SelectInput, Edit, required, SimpleForm } from 'react-admin' +import { Title } from '../common' + +const TranscodingTitle = ({ record }) => { + return <Title subTitle={`Transcoding ${record ? record.name : ''}`} /> +} + +const TranscodingEdit = (props) => ( + <Edit title={<TranscodingTitle />} {...props}> + <SimpleForm> + <TextInput source="name" validate={[required()]} /> + <TextInput source="targetFormat" validate={[required()]} /> + <SelectInput + source="defaultBitRate" + choices={[ + { id: 32, name: '32' }, + { id: 48, name: '48' }, + { id: 64, name: '64' }, + { id: 80, name: '80' }, + { id: 96, name: '96' }, + { id: 112, name: '112' }, + { id: 128, name: '128' }, + { id: 160, name: '160' }, + { id: 192, name: '192' }, + { id: 256, name: '256' }, + { id: 320, name: '320' } + ]} + /> + <TextInput source="command" fullWidth validate={[required()]} /> + </SimpleForm> + </Edit> +) + +export default TranscodingEdit diff --git a/ui/src/transcoding/TranscodingList.js b/ui/src/transcoding/TranscodingList.js new file mode 100644 index 00000000000..eace0c34791 --- /dev/null +++ b/ui/src/transcoding/TranscodingList.js @@ -0,0 +1,16 @@ +import React from 'react' +import { Datagrid, List, TextField } from 'react-admin' +import { Title } from '../common' + +const TranscodingList = (props) => ( + <List title={<Title subTitle={'Transcodings'} />} exporter={false} {...props}> + <Datagrid rowClick="edit"> + <TextField source="name" /> + <TextField source="targetFormat" /> + <TextField source="defaultBitRate" /> + <TextField source="command" /> + </Datagrid> + </List> +) + +export default TranscodingList diff --git a/ui/src/transcoding/index.js b/ui/src/transcoding/index.js new file mode 100644 index 00000000000..72a4139501c --- /dev/null +++ b/ui/src/transcoding/index.js @@ -0,0 +1,11 @@ +import TransformIcon from '@material-ui/icons/Transform' +import TranscodingList from './TranscodingList' +import TranscodingEdit from './TranscodingEdit' +import TranscodingCreate from './TranscodingCreate' + +export default { + list: TranscodingList, + edit: TranscodingEdit, + create: TranscodingCreate, + icon: TransformIcon +} diff --git a/wire_gen.go b/wire_gen.go index 63e0d7f7bf9..29bef55933b 100644 --- a/wire_gen.go +++ b/wire_gen.go @@ -48,7 +48,8 @@ func CreateSubsonicAPIRouter() (*subsonic.Router, error) { return nil, err } mediaStreamer := engine.NewMediaStreamer(dataStore, transcoderTranscoder, cache) - router := subsonic.New(browser, cover, listGenerator, users, playlists, ratings, scrobbler, search, mediaStreamer) + players := engine.NewPlayers(dataStore) + router := subsonic.New(browser, cover, listGenerator, users, playlists, ratings, scrobbler, search, mediaStreamer, players) return router, nil }