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"
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 {
user, ok := ctx.Value("user").(*model.User)
user, ok := ctx.Value("user").(model.User)
if ok {
return user.UserName
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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, "/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"}`)) })

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = () => {
<Resource name="album" {...album} options={{ subMenu: 'library' }} />,
<Resource name="song" {...song} options={{ subMenu: 'library' }} />,
<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 />
]}
</Admin>

View File

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

View File

@ -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)}
</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)}
{isXsmall && logout}
</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
}
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
}