feat: transcoding and player datastores and configuration

This commit is contained in:
Deluan 2020-02-29 20:01:09 -05:00 committed by Deluan Quintão
parent a0e0fbad58
commit 8ec78900c5
32 changed files with 783 additions and 29 deletions

View file

@ -20,3 +20,20 @@ const (
DevInitialUserName = "admin" DevInitialUserName = "admin"
DevInitialName = "Dev 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 -",
},
}
)

View file

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

57
engine/players.go Normal file
View file

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

112
engine/players_test.go Normal file
View file

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

View file

@ -51,7 +51,7 @@ func (p *playlists) Create(ctx context.Context, playlistId, name string, ids []s
} }
func (p *playlists) getUser(ctx context.Context) string { func (p *playlists) getUser(ctx context.Context) string {
user, ok := ctx.Value("user").(*model.User) user, ok := ctx.Value("user").(model.User)
if ok { if ok {
return user.UserName return user.UserName
} }

View file

@ -18,4 +18,5 @@ var Set = wire.NewSet(
NewMediaStreamer, NewMediaStreamer,
transcoder.New, transcoder.New,
NewTranscodingCache, NewTranscodingCache,
NewPlayers,
) )

View file

@ -28,6 +28,8 @@ type DataStore interface {
Playlist(ctx context.Context) PlaylistRepository Playlist(ctx context.Context) PlaylistRepository
Property(ctx context.Context) PropertyRepository Property(ctx context.Context) PropertyRepository
User(ctx context.Context) UserRepository User(ctx context.Context) UserRepository
Transcoding(ctx context.Context) TranscodingRepository
Player(ctx context.Context) PlayerRepository
Resource(ctx context.Context, model interface{}) ResourceRepository Resource(ctx context.Context, model interface{}) ResourceRepository

25
model/player.go Normal file
View file

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

15
model/transcoding.go Normal file
View file

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

View file

@ -14,7 +14,7 @@ var _ = Describe("AlbumRepository", func() {
var repo model.AlbumRepository var repo model.AlbumRepository
BeforeEach(func() { 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()) repo = NewAlbumRepository(ctx, orm.NewOrm())
}) })

View file

@ -14,7 +14,7 @@ var _ = Describe("ArtistRepository", func() {
var repo model.ArtistRepository var repo model.ArtistRepository
BeforeEach(func() { 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()) repo = NewArtistRepository(ctx, orm.NewOrm())
}) })

View file

@ -16,7 +16,7 @@ var _ = Describe("MediaRepository", func() {
var mr model.MediaFileRepository var mr model.MediaFileRepository
BeforeEach(func() { 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()) mr = NewMediaFileRepository(ctx, orm.NewOrm())
}) })

View file

@ -12,6 +12,7 @@ type MockDataStore struct {
MockedArtist model.ArtistRepository MockedArtist model.ArtistRepository
MockedMediaFile model.MediaFileRepository MockedMediaFile model.MediaFileRepository
MockedUser model.UserRepository MockedUser model.UserRepository
MockedPlayer model.PlayerRepository
} }
func (db *MockDataStore) Album(context.Context) model.AlbumRepository { func (db *MockDataStore) Album(context.Context) model.AlbumRepository {
@ -61,6 +62,17 @@ func (db *MockDataStore) User(context.Context) model.UserRepository {
return db.MockedUser 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 { func (db *MockDataStore) WithTx(block func(db model.DataStore) error) error {
return block(db) return block(db)
} }

View file

@ -55,10 +55,22 @@ func (s *SQLStore) User(ctx context.Context) model.UserRepository {
return NewUserRepository(ctx, s.getOrmer()) 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 { func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
switch m.(type) { switch m.(type) {
case model.User: case model.User:
return s.User(ctx).(model.ResourceRepository) 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: case model.Artist:
return s.Artist(ctx).(model.ResourceRepository) return s.Artist(ctx).(model.ResourceRepository)
case model.Album: case model.Album:

View file

@ -86,7 +86,7 @@ var _ = Describe("Initialize test DB", func() {
// TODO Load this data setup from file(s) // TODO Load this data setup from file(s)
BeforeSuite(func() { BeforeSuite(func() {
o := orm.NewOrm() 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) mr := NewMediaFileRepository(ctx, o)
for _, s := range testSongs { for _, s := range testSongs {
err := mr.Put(&s) err := mr.Put(&s)

View file

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

View file

@ -29,7 +29,7 @@ func userId(ctx context.Context) string {
if user == nil { if user == nil {
return invalidUserId return invalidUserId
} }
usr := user.(*model.User) usr := user.(model.User)
return usr.ID return usr.ID
} }
@ -38,7 +38,8 @@ func loggedUser(ctx context.Context) *model.User {
if user == nil { if user == nil {
return &model.User{} return &model.User{}
} }
return user.(*model.User) u := user.(model.User)
return &u
} }
func (r sqlRepository) newSelect(options ...model.QueryOptions) SelectBuilder { func (r sqlRepository) newSelect(options ...model.QueryOptions) SelectBuilder {

View file

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

View file

@ -43,6 +43,8 @@ func (app *Router) routes() http.Handler {
app.R(r, "/song", model.MediaFile{}) app.R(r, "/song", model.MediaFile{})
app.R(r, "/album", model.Album{}) app.R(r, "/album", model.Album{})
app.R(r, "/artist", model.Artist{}) 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) // 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"}`)) }) r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"response":"ok"}`)) })

View file

@ -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 { func contextWithUser(ctx context.Context, ds model.DataStore, claims jwt.MapClaims) context.Context {
userName := claims["sub"].(string) userName := claims["sub"].(string)
user, _ := ds.User(ctx).FindByUsername(userName) 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) { func getToken(ds model.DataStore, ctx context.Context) (*jwt.Token, error) {

View file

@ -1,7 +1,7 @@
package server package server
import ( import (
"context" "encoding/json"
"fmt" "fmt"
"time" "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()) err = ds.Property(nil).Put(consts.InitialSetupFlagKey, time.Now().String())
return err return err
}) })
} }
func createInitialAdminUser(ds model.DataStore) error { func createInitialAdminUser(ds model.DataStore) error {
ctx := context.Background() c, err := ds.User(nil).CountAll()
c, err := ds.User(ctx).CountAll()
if err != nil { if err != nil {
panic(fmt.Sprintf("Could not access User table: %s", err)) panic(fmt.Sprintf("Could not access User table: %s", err))
} }
@ -56,7 +59,7 @@ func createInitialAdminUser(ds model.DataStore) error {
Password: initialPassword, Password: initialPassword,
IsAdmin: true, IsAdmin: true,
} }
err := ds.User(ctx).Put(&initialUser) err := ds.User(nil).Put(&initialUser)
if err != nil { if err != nil {
log.Error("Could not create initial admin user", "user", initialUser, err) log.Error("Could not create initial admin user", "user", initialUser, err)
} }
@ -77,3 +80,23 @@ func createJWTSecret(ds model.DataStore) error {
} }
return err 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
}

View file

@ -27,16 +27,17 @@ type Router struct {
Search engine.Search Search engine.Search
Users engine.Users Users engine.Users
Streamer engine.MediaStreamer Streamer engine.MediaStreamer
Players engine.Players
mux http.Handler mux http.Handler
} }
func New(browser engine.Browser, cover engine.Cover, listGenerator engine.ListGenerator, users engine.Users, 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, 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, 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() r.mux = r.routes()
return r return r
} }
@ -50,6 +51,7 @@ func (api *Router) routes() http.Handler {
r.Use(postFormToQueryParams) r.Use(postFormToQueryParams)
r.Use(checkRequiredParameters) r.Use(checkRequiredParameters)
r.Use(getPlayer(api.Players))
// Add validation middleware // Add validation middleware
r.Use(authenticate(api.Users)) r.Use(authenticate(api.Users))

View file

@ -3,6 +3,7 @@ package subsonic
import ( import (
"context" "context"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@ -14,6 +15,10 @@ import (
"github.com/deluan/navidrome/utils" "github.com/deluan/navidrome/utils"
) )
const (
cookieExpiry = 365 * 24 * 3600 // One year
)
func postFormToQueryParams(next http.Handler) http.Handler { func postFormToQueryParams(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm() err := r.ParseForm()
@ -82,10 +87,54 @@ func authenticate(users engine.Users) func(next http.Handler) http.Handler {
} }
ctx := r.Context() ctx := r.Context()
ctx = context.WithValue(ctx, "user", usr) ctx = context.WithValue(ctx, "user", *usr)
r = r.WithContext(ctx) r = r.WithContext(ctx)
next.ServeHTTP(w, r) 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
}

View file

@ -107,35 +107,80 @@ var _ = Describe("Middlewares", func() {
}) })
Describe("Authenticate", func() { Describe("Authenticate", func() {
var mockedUser *mockUsers var mockedUsers *mockUsers
BeforeEach(func() { BeforeEach(func() {
mockedUser = &mockUsers{} mockedUsers = &mockUsers{}
}) })
It("passes all parameters to users.Authenticate ", func() { It("passes all parameters to users.Authenticate ", func() {
r := newGetRequest("u=valid", "p=password", "t=token", "s=salt", "jwt=jwt") r := newGetRequest("u=valid", "p=password", "t=token", "s=salt", "jwt=jwt")
cp := authenticate(mockedUser)(next) cp := authenticate(mockedUsers)(next)
cp.ServeHTTP(w, r) cp.ServeHTTP(w, r)
Expect(mockedUser.username).To(Equal("valid")) Expect(mockedUsers.username).To(Equal("valid"))
Expect(mockedUser.password).To(Equal("password")) Expect(mockedUsers.password).To(Equal("password"))
Expect(mockedUser.token).To(Equal("token")) Expect(mockedUsers.token).To(Equal("token"))
Expect(mockedUser.salt).To(Equal("salt")) Expect(mockedUsers.salt).To(Equal("salt"))
Expect(mockedUser.jwt).To(Equal("jwt")) Expect(mockedUsers.jwt).To(Equal("jwt"))
Expect(next.called).To(BeTrue()) 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")) Expect(user.UserName).To(Equal("valid"))
}) })
It("fails authentication with wrong password", func() { It("fails authentication with wrong password", func() {
r := newGetRequest("u=invalid", "", "", "") r := newGetRequest("u=invalid", "", "", "")
cp := authenticate(mockedUser)(next) cp := authenticate(mockedUsers)(next)
cp.ServeHTTP(w, r) cp.ServeHTTP(w, r)
Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
Expect(next.called).To(BeFalse()) 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 { type mockHandler struct {
@ -164,3 +209,15 @@ func (m *mockUsers) Authenticate(ctx context.Context, username, password, token,
} }
return nil, model.ErrInvalidAuth 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
}

View file

@ -5,6 +5,7 @@ import authProvider from './authProvider'
import polyglotI18nProvider from 'ra-i18n-polyglot' import polyglotI18nProvider from 'ra-i18n-polyglot'
import messages from './i18n' import messages from './i18n'
import { DarkTheme, Layout, Login } from './layout' import { DarkTheme, Layout, Login } from './layout'
import transcoding from './transcoding'
import user from './user' import user from './user'
import song from './song' import song from './song'
import album from './album' import album from './album'
@ -44,7 +45,16 @@ const App = () => {
<Resource name="album" {...album} options={{ subMenu: 'library' }} />, <Resource name="album" {...album} options={{ subMenu: 'library' }} />,
<Resource name="song" {...song} options={{ subMenu: 'library' }} />, <Resource name="song" {...song} options={{ subMenu: 'library' }} />,
<Resource name="albumSong" />, <Resource name="albumSong" />,
permissions === 'admin' ? <Resource name="user" {...user} /> : null, permissions === 'admin' ? (
<Resource name="user" {...user} options={{ subMenu: 'settings' }} />
) : null,
permissions === 'admin' ? (
<Resource
name="transcoding"
{...transcoding}
options={{ subMenu: 'settings' }}
/>
) : null,
<Player /> <Player />
]} ]}
</Admin> </Admin>

View file

@ -40,7 +40,8 @@ export default deepmerge(englishMessages, {
} }
}, },
menu: { menu: {
library: 'Library' library: 'Library',
settings: 'Settings'
}, },
player: { player: {
panelTitle: 'Play Queue', panelTitle: 'Play Queue',

View file

@ -4,6 +4,7 @@ import { useMediaQuery } from '@material-ui/core'
import { useTranslate, MenuItemLink, getResources } from 'react-admin' import { useTranslate, MenuItemLink, getResources } from 'react-admin'
import { withRouter } from 'react-router-dom' import { withRouter } from 'react-router-dom'
import LibraryMusicIcon from '@material-ui/icons/LibraryMusic' import LibraryMusicIcon from '@material-ui/icons/LibraryMusic'
import SettingsIcon from '@material-ui/icons/Settings'
import ViewListIcon from '@material-ui/icons/ViewList' import ViewListIcon from '@material-ui/icons/ViewList'
import SubMenu from './SubMenu' import SubMenu from './SubMenu'
import inflection from 'inflection' import inflection from 'inflection'
@ -27,7 +28,8 @@ const Menu = ({ onMenuClick, dense, logout }) => {
const resources = useSelector(getResources) const resources = useSelector(getResources)
const [state, setState] = useState({ const [state, setState] = useState({
menuLibrary: true menuLibrary: true,
menuSettings: false
}) })
const handleToggle = (menu) => { const handleToggle = (menu) => {
@ -63,6 +65,16 @@ const Menu = ({ onMenuClick, dense, logout }) => {
> >
{resources.filter(subItems('library')).map(renderMenuItemLink)} {resources.filter(subItems('library')).map(renderMenuItemLink)}
</SubMenu> </SubMenu>
<SubMenu
handleToggle={() => handleToggle('menuSettings')}
isOpen={state.menuSettings}
sidebarIsOpen={open}
name="menu.settings"
icon={<SettingsIcon />}
dense={dense}
>
{resources.filter(subItems('settings')).map(renderMenuItemLink)}
</SubMenu>
{resources.filter(subItems(undefined)).map(renderMenuItemLink)} {resources.filter(subItems(undefined)).map(renderMenuItemLink)}
{isXsmall && logout} {isXsmall && logout}
</div> </div>

View file

@ -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 <Title subTitle={`Transcoding ${record ? record.name : ''}`} />
}
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

View file

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

View file

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

View file

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

View file

@ -48,7 +48,8 @@ func CreateSubsonicAPIRouter() (*subsonic.Router, error) {
return nil, err return nil, err
} }
mediaStreamer := engine.NewMediaStreamer(dataStore, transcoderTranscoder, cache) 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 return router, nil
} }