Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
894536c8ec | ||
|
|
92f6e55821 | ||
|
|
c3bd181648 | ||
|
|
3b12c92ad5 | ||
|
|
272d897ec9 | ||
|
|
e6d717cbbc | ||
|
|
b7f1fc0374 | ||
|
|
de525edde0 | ||
|
|
7f94660183 | ||
|
|
b2d022b823 | ||
|
|
ba08f00c20 | ||
|
|
d9993c5877 | ||
|
|
edb839a41d | ||
|
|
9fa73e3b7b | ||
|
|
8ebb85b0af | ||
|
|
a37beac753 | ||
|
|
8a31e80b7a | ||
|
|
ce11a2f3be | ||
|
|
5a95feeedc | ||
|
|
400fa65326 | ||
|
|
ab10719d27 | ||
|
|
029290f304 | ||
|
|
2c146ea1fe | ||
|
|
10ead1f5f2 | ||
|
|
730722cfe3 | ||
|
|
dc352834b9 | ||
|
|
313a3342a0 | ||
|
|
0f13bbdbd0 | ||
|
|
4310f2c94f | ||
|
|
6ce4811460 | ||
|
|
52cd17963f | ||
|
|
8f0c07d29f | ||
|
|
a50735a94c | ||
|
|
f0e7f3ef25 | ||
|
|
2ca98d8e81 | ||
|
|
81e1a7088f | ||
|
|
d37351610a | ||
|
|
99361c0d9f | ||
|
|
8673533cd4 | ||
|
|
d9dd9fe587 | ||
|
|
abb99a8501 | ||
|
|
690f92a671 | ||
|
|
c57007db52 | ||
|
|
cc229dcee6 | ||
|
|
7aab82c246 | ||
|
|
989deb1200 | ||
|
|
6aaee4342e | ||
|
|
b5dadf55f4 | ||
|
|
18c7397709 | ||
|
|
4a82a6cb02 | ||
|
|
220ffd5324 | ||
|
|
e33d2305a1 | ||
|
|
7815b57920 | ||
|
|
18cbb153f3 | ||
|
|
9f086b5f7b | ||
|
|
c8d6f2d506 |
BIN
.github/screenshots/screenshot-desktop.png
vendored
|
Before Width: | Height: | Size: 264 KiB |
BIN
.github/screenshots/ss-desktop-player.png
vendored
Normal file
|
After Width: | Height: | Size: 364 KiB |
BIN
.github/screenshots/ss-mobile-album-view.png
vendored
Normal file
|
After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 709 KiB After Width: | Height: | Size: 709 KiB |
BIN
.github/screenshots/ss-mobile-player.png
vendored
Normal file
|
After Width: | Height: | Size: 411 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
1
.gitignore
vendored
@@ -19,3 +19,4 @@ navidrome.db
|
||||
*_gen.go
|
||||
dist
|
||||
music
|
||||
docker-compose.override.yml
|
||||
|
||||
@@ -48,6 +48,11 @@ RUN GIT_TAG=$(git name-rev --name-only HEAD) && \
|
||||
FROM alpine as release
|
||||
MAINTAINER Deluan Quintao <navidrome@deluan.com>
|
||||
|
||||
# Download Tini
|
||||
ENV TINI_VERSION v0.18.0
|
||||
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-static /tini
|
||||
RUN chmod +x /tini
|
||||
|
||||
COPY --from=gobuilder /src/navidrome /app/
|
||||
COPY --from=gobuilder /tmp/ffmpeg*/ffmpeg /usr/bin/
|
||||
|
||||
@@ -64,4 +69,5 @@ ENV ND_PORT 4533
|
||||
EXPOSE 4533
|
||||
WORKDIR /app
|
||||
|
||||
ENTRYPOINT "/app/navidrome"
|
||||
ENTRYPOINT ["/tini", "--"]
|
||||
CMD ["/app/navidrome"]
|
||||
|
||||
15
README.md
@@ -26,14 +26,13 @@ please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join
|
||||
[Airsonic](https://airsonic.github.io/) and [Madsonic](https://www.madsonic.org/).
|
||||
See the [complete list of available mobile and web apps](https://airsonic.github.io/docs/apps/)
|
||||
- Transcoding/Downsampling on-the-fly (WIP. Experimental support is available)
|
||||
|
||||
- Integrated music player (WIP)
|
||||
|
||||
## Road map
|
||||
|
||||
This project is being actively worked on. Expect a more polished experience and new features/releases
|
||||
on a frequent basis. Some upcoming features planned:
|
||||
|
||||
- Integrated music player
|
||||
- Last.FM integration
|
||||
- Pre-build binaries for Raspberry Pi
|
||||
- Smart/dynamic playlists (similar to iTunes)
|
||||
@@ -47,7 +46,7 @@ on a frequent basis. Some upcoming features planned:
|
||||
|
||||
Various options are available:
|
||||
|
||||
### Pre-build executables
|
||||
### Pre-built executables
|
||||
|
||||
Just head to the [releases page](https://github.com/deluan/navidrome/releases) and download the latest version for you
|
||||
platform. There are builds available for Linux, macOS and Windows (32 and 64 bits).
|
||||
@@ -80,7 +79,7 @@ services:
|
||||
ND_PORT: 4533
|
||||
volumes:
|
||||
- "./data:/data"
|
||||
- "/Users/deluan/Music/iTunes/iTunes Media/Music:/music"
|
||||
- "/path/to/your/music/folder:/music"
|
||||
```
|
||||
|
||||
To get the cutting-edge, latest version from master, use the image `deluan/navidrome:develop`
|
||||
@@ -119,10 +118,10 @@ For more options, run `navidrome --help`
|
||||
|
||||
<p align="center">
|
||||
<p float="left">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/screenshot-login-mobile.png">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/screenshot-mobile.png">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/screenshot-users-mobile.png">
|
||||
<img width="900"src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/screenshot-desktop.png">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-login.png">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-player.png">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-album-view.png">
|
||||
<img width="900"src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-desktop-player.png">
|
||||
</p>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -29,8 +29,9 @@ type nd struct {
|
||||
ScanInterval string `default:"1m"`
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
DevDisableBanner bool `default:"false"`
|
||||
DevLogSourceLine bool `default:"false"`
|
||||
DevDisableBanner bool `default:"false"`
|
||||
DevLogSourceLine bool `default:"false"`
|
||||
DevAutoCreateAdminPassword string `default:""`
|
||||
}
|
||||
|
||||
var Server = &nd{}
|
||||
|
||||
@@ -13,4 +13,7 @@ const (
|
||||
JWTTokenExpiration = 30 * time.Minute
|
||||
|
||||
UIAssetsLocalPath = "ui/build"
|
||||
|
||||
DevInitialUserName = "admin"
|
||||
DevInitialName = "Dev Admin"
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ func init() {
|
||||
".ogx": "application/ogg",
|
||||
".aac": "audio/mp4",
|
||||
".m4a": "audio/mp4",
|
||||
".m4b": "audio/mp4",
|
||||
".flac": "audio/flac",
|
||||
".wav": "audio/x-wav",
|
||||
".wma": "audio/x-ms-wma",
|
||||
|
||||
2
db/db.go
@@ -6,7 +6,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
_ "github.com/deluan/navidrome/db/migrations"
|
||||
_ "github.com/deluan/navidrome/db/migration"
|
||||
"github.com/deluan/navidrome/log"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/pressly/goose"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package migrations
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migrations
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
56
db/migration/20200208222418_add_defaults_to_annotations.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200208222418, Down20200208222418)
|
||||
}
|
||||
|
||||
func Up20200208222418(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
update annotation set play_count = 0 where play_count is null;
|
||||
update annotation set rating = 0 where rating is null;
|
||||
create table annotation_dg_tmp
|
||||
(
|
||||
ann_id varchar(255) not null
|
||||
primary key,
|
||||
user_id varchar(255) default '' not null,
|
||||
item_id varchar(255) default '' not null,
|
||||
item_type varchar(255) default '' not null,
|
||||
play_count integer default 0,
|
||||
play_date datetime,
|
||||
rating integer default 0,
|
||||
starred bool default FALSE not null,
|
||||
starred_at datetime,
|
||||
unique (user_id, item_id, item_type)
|
||||
);
|
||||
|
||||
insert into annotation_dg_tmp(ann_id, user_id, item_id, item_type, play_count, play_date, rating, starred, starred_at) select ann_id, user_id, item_id, item_type, play_count, play_date, rating, starred, starred_at from annotation;
|
||||
|
||||
drop table annotation;
|
||||
|
||||
alter table annotation_dg_tmp rename to annotation;
|
||||
|
||||
create index annotation_play_count
|
||||
on annotation (play_count);
|
||||
|
||||
create index annotation_play_date
|
||||
on annotation (play_date);
|
||||
|
||||
create index annotation_rating
|
||||
on annotation (rating);
|
||||
|
||||
create index annotation_starred
|
||||
on annotation (starred);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200208222418(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
@@ -3,14 +3,16 @@
|
||||
version: "3"
|
||||
services:
|
||||
navidrome:
|
||||
build: .
|
||||
image: deluan/navidrome:latest
|
||||
ports:
|
||||
- "4533:4533"
|
||||
environment:
|
||||
# See all options and defaults in conf/configuration.go
|
||||
# All options with their default values:
|
||||
ND_MUSICFOLDER: /music
|
||||
ND_DATAFOLDER: /data
|
||||
ND_SCANINTERVAL: 1m
|
||||
ND_LOGLEVEL: info
|
||||
ND_PORT: 4533
|
||||
ND_SCANINTERVAL: 5s
|
||||
ND_LOGLEVEL: debug
|
||||
volumes:
|
||||
- "./data:/data"
|
||||
- "/Users/deluan/Music/iTunes/iTunes Media/Music:/music"
|
||||
- "./music:/music"
|
||||
|
||||
64
engine/auth/auth.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/go-chi/jwtauth"
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
JwtSecret []byte
|
||||
TokenAuth *jwtauth.JWTAuth
|
||||
)
|
||||
|
||||
func InitTokenAuth(ds model.DataStore) {
|
||||
once.Do(func() {
|
||||
secret, err := ds.Property(nil).DefaultGet(consts.JWTSecretKey, "not so secret")
|
||||
if err != nil {
|
||||
log.Error("No JWT secret found in DB. Setting a temp one, but please report this error", err)
|
||||
}
|
||||
JwtSecret = []byte(secret)
|
||||
TokenAuth = jwtauth.New("HS256", JwtSecret, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func CreateToken(u *model.User) (string, error) {
|
||||
token := jwt.New(jwt.SigningMethodHS256)
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims["iss"] = consts.JWTIssuer
|
||||
claims["sub"] = u.UserName
|
||||
claims["adm"] = u.IsAdmin
|
||||
|
||||
return TouchToken(token)
|
||||
}
|
||||
|
||||
func TouchToken(token *jwt.Token) (string, error) {
|
||||
expireIn := time.Now().Add(consts.JWTTokenExpiration).Unix()
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims["exp"] = expireIn
|
||||
|
||||
return token.SignedString(JwtSecret)
|
||||
}
|
||||
|
||||
func Validate(tokenStr string) (jwt.MapClaims, error) {
|
||||
token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) {
|
||||
// Don't forget to validate the alg is what you expect:
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
// hmacSampleSecret is a []byte containing your secret, e.g. []byte("my_secret_key")
|
||||
return JwtSecret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return token.Claims.(jwt.MapClaims), err
|
||||
}
|
||||
55
engine/auth/auth_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/engine/auth"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestAuth(t *testing.T) {
|
||||
log.SetLevel(log.LevelCritical)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Auth Test Suite")
|
||||
}
|
||||
|
||||
const testJWTSecret = "not so secret"
|
||||
|
||||
var _ = Describe("Auth", func() {
|
||||
BeforeEach(func() {
|
||||
auth.JwtSecret = []byte(testJWTSecret)
|
||||
})
|
||||
Context("Validate", func() {
|
||||
It("returns error with an invalid JWT token", func() {
|
||||
_, err := auth.Validate("invalid.token")
|
||||
Expect(err).To(Not(BeNil()))
|
||||
})
|
||||
|
||||
It("returns the claims from a valid JWT token", func() {
|
||||
token := jwt.New(jwt.SigningMethodHS256)
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims["iss"] = "issuer"
|
||||
claims["exp"] = time.Now().Add(1 * time.Minute).Unix()
|
||||
tokenStr, _ := token.SignedString(auth.JwtSecret)
|
||||
|
||||
decodedClaims, err := auth.Validate(tokenStr)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(decodedClaims["iss"]).To(Equal("issuer"))
|
||||
})
|
||||
|
||||
It("returns ErrExpired if the `exp` field is in the past", func() {
|
||||
token := jwt.New(jwt.SigningMethodHS256)
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims["iss"] = "issuer"
|
||||
claims["exp"] = time.Now().Add(-1 * time.Minute).Unix()
|
||||
tokenStr, _ := token.SignedString(auth.JwtSecret)
|
||||
|
||||
_, err := auth.Validate(tokenStr)
|
||||
Expect(err).To(MatchError("Token is expired"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -31,6 +31,7 @@ type mediaStream interface {
|
||||
Name() string
|
||||
ModTime() time.Time
|
||||
Close() error
|
||||
Duration() int
|
||||
}
|
||||
|
||||
type mediaStreamer struct {
|
||||
@@ -108,6 +109,10 @@ func (m *rawMediaStream) ModTime() time.Time {
|
||||
return m.mf.UpdatedAt
|
||||
}
|
||||
|
||||
func (m *rawMediaStream) Duration() int {
|
||||
return m.mf.Duration
|
||||
}
|
||||
|
||||
func (m *rawMediaStream) Close() error {
|
||||
log.Trace(m.ctx, "Closing file", "id", m.mf.ID, "path", m.mf.Path)
|
||||
return m.file.Close()
|
||||
@@ -150,7 +155,7 @@ func (m *transcodedMediaStream) Read(p []byte) (n int, err error) {
|
||||
// a Seek happens. This is ok-ish for audio, but would kill the server for video.
|
||||
func (m *transcodedMediaStream) Seek(offset int64, whence int) (int64, error) {
|
||||
size := int64((m.mf.Duration)*m.bitRate*1000) / 8
|
||||
log.Trace(m.ctx, "Seeking file", "path", m.mf.Path, "offset", offset, "whence", whence, "size", size)
|
||||
log.Trace(m.ctx, "Seeking transcoded stream", "path", m.mf.Path, "offset", offset, "whence", whence, "size", size)
|
||||
|
||||
switch whence {
|
||||
case io.SeekEnd:
|
||||
@@ -186,6 +191,10 @@ func (m *transcodedMediaStream) ModTime() time.Time {
|
||||
return m.mf.UpdatedAt
|
||||
}
|
||||
|
||||
func (m *transcodedMediaStream) Duration() int {
|
||||
return m.mf.Duration
|
||||
}
|
||||
|
||||
func (m *transcodedMediaStream) Close() error {
|
||||
log.Trace(m.ctx, "Closing stream", "id", m.mf.ID, "path", m.mf.Path)
|
||||
err := m.pipe.Close()
|
||||
@@ -203,7 +212,11 @@ func newTranscode(ctx context.Context, path string, maxBitRate int, format strin
|
||||
if f, err = cmd.StdoutPipe(); err != nil {
|
||||
return f, err
|
||||
}
|
||||
return f, cmd.Start()
|
||||
if err = cmd.Start(); err != nil {
|
||||
return f, err
|
||||
}
|
||||
go cmd.Wait() // prevent zombies
|
||||
return f, err
|
||||
}
|
||||
|
||||
func createTranscodeCommand(path string, maxBitRate int, format string) (string, []string) {
|
||||
|
||||
@@ -7,11 +7,12 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/engine/auth"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
type Users interface {
|
||||
Authenticate(ctx context.Context, username, password, token, salt string) (*model.User, error)
|
||||
Authenticate(ctx context.Context, username, password, token, salt, jwt string) (*model.User, error)
|
||||
}
|
||||
|
||||
func NewUsers(ds model.DataStore) Users {
|
||||
@@ -22,7 +23,7 @@ type users struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func (u *users) Authenticate(ctx context.Context, username, pass, token, salt string) (*model.User, error) {
|
||||
func (u *users) Authenticate(ctx context.Context, username, pass, token, salt, jwt string) (*model.User, error) {
|
||||
user, err := u.ds.User(ctx).FindByUsername(username)
|
||||
if err == model.ErrNotFound {
|
||||
return nil, model.ErrInvalidAuth
|
||||
@@ -33,6 +34,9 @@ func (u *users) Authenticate(ctx context.Context, username, pass, token, salt st
|
||||
valid := false
|
||||
|
||||
switch {
|
||||
case jwt != "":
|
||||
claims, err := auth.Validate(jwt)
|
||||
valid = err == nil && claims["sub"] == username
|
||||
case pass != "":
|
||||
if strings.HasPrefix(pass, "enc:") {
|
||||
if dec, err := hex.DecodeString(pass[4:]); err == nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package engine
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/navidrome/engine/auth"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo"
|
||||
@@ -19,20 +20,20 @@ var _ = Describe("Users", func() {
|
||||
|
||||
Context("Plaintext password", func() {
|
||||
It("authenticates with plaintext password ", func() {
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "wordpass", "", "")
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "wordpass", "", "", "")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
|
||||
})
|
||||
|
||||
It("fails authentication with wrong password", func() {
|
||||
_, err := users.Authenticate(context.TODO(), "admin", "INVALID", "", "")
|
||||
_, err := users.Authenticate(context.TODO(), "admin", "INVALID", "", "", "")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Encoded password", func() {
|
||||
It("authenticates with simple encoded password ", func() {
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "enc:776f726470617373", "", "")
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "enc:776f726470617373", "", "", "")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
|
||||
})
|
||||
@@ -40,13 +41,41 @@ var _ = Describe("Users", func() {
|
||||
|
||||
Context("Token based authentication", func() {
|
||||
It("authenticates with token based authentication", func() {
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "", "23b342970e25c7928831c3317edd0b67", "retnlmjetrymazgkt")
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "", "23b342970e25c7928831c3317edd0b67", "retnlmjetrymazgkt", "")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
|
||||
})
|
||||
|
||||
It("fails if salt is missing", func() {
|
||||
_, err := users.Authenticate(context.TODO(), "admin", "", "23b342970e25c7928831c3317edd0b67", "")
|
||||
_, err := users.Authenticate(context.TODO(), "admin", "", "23b342970e25c7928831c3317edd0b67", "", "")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
})
|
||||
|
||||
Context("JWT based authentication", func() {
|
||||
var validToken string
|
||||
BeforeEach(func() {
|
||||
u := &model.User{UserName: "admin"}
|
||||
var err error
|
||||
validToken, err = auth.CreateToken(u)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
It("authenticates with JWT token based authentication", func() {
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "", "", "", validToken)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
|
||||
})
|
||||
|
||||
It("fails if JWT token is invalid", func() {
|
||||
_, err := users.Authenticate(context.TODO(), "admin", "", "", "", "invalid.token")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
|
||||
It("fails if JWT token sub is different than username", func() {
|
||||
_, err := users.Authenticate(context.TODO(), "not_admin", "", "", "", validToken)
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
})
|
||||
|
||||
2
go.mod
@@ -5,7 +5,7 @@ go 1.13
|
||||
require (
|
||||
github.com/BurntSushi/toml v0.3.1 // indirect
|
||||
github.com/Masterminds/squirrel v1.2.0
|
||||
github.com/astaxie/beego v1.12.0
|
||||
github.com/astaxie/beego v1.12.1
|
||||
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible
|
||||
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||
|
||||
15
go.sum
@@ -4,8 +4,8 @@ github.com/Knetic/govaluate v3.0.0+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8L
|
||||
github.com/Masterminds/squirrel v1.2.0 h1:K1NhbTO21BWG47IVR0OnIZuE0LZcXAYqywrC3Ko53KI=
|
||||
github.com/Masterminds/squirrel v1.2.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
|
||||
github.com/OwnLocal/goes v1.0.0/go.mod h1:8rIFjBGTue3lCU0wplczcUgt9Gxgrkkrw7etMIcn8TM=
|
||||
github.com/astaxie/beego v1.12.0 h1:MRhVoeeye5N+Flul5PoVfD9CslfdoH+xqC/xvSQ5u2Y=
|
||||
github.com/astaxie/beego v1.12.0/go.mod h1:fysx+LZNZKnvh4GED/xND7jWtjCR6HzydR2Hh2Im57o=
|
||||
github.com/astaxie/beego v1.12.1 h1:dfpuoxpzLVgclveAXe4PyNKqkzgm5zF4tgF2B3kkM2I=
|
||||
github.com/astaxie/beego v1.12.1/go.mod h1:kPBWpSANNbSdIqOc8SUL9h+1oyBMZhROeYsXQDbidWQ=
|
||||
github.com/beego/goyaml2 v0.0.0-20130207012346-5545475820dd/go.mod h1:1b+Y/CofkYwXMUU0OhQqGvsY2Bvgr4j6jfT699wyZKQ=
|
||||
github.com/beego/x2j v0.0.0-20131220205130-a0352aadc542/go.mod h1:kSeGC/p1AbBiEp5kat81+DSQrZenVBZXklMLaELspWU=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60=
|
||||
@@ -130,19 +130,24 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
|
||||
github.com/syndtr/goleveldb v0.0.0-20181127023241-353a9fca669c h1:3eGShk3EQf5gJCYW+WzA0TEJQd37HLOmlYF7N0YJwv0=
|
||||
github.com/syndtr/goleveldb v0.0.0-20181127023241-353a9fca669c/go.mod h1:Z4AUp2Km+PwemOoO/VB5AOx9XSsIItzFjoJlOSiYmn0=
|
||||
github.com/wendal/errors v0.0.0-20130201093226-f66c77a7882b/go.mod h1:Q12BUT7DqIlHRmgv3RskH+UCM/4eqVMgI0EMmlSpAXc=
|
||||
golang.org/x/crypto v0.0.0-20181127143415-eb0de9b17e85/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2 h1:CCH4IOTTfewWjGOlSp+zGcjutRKlBEZQ6wTn8ozI/nI=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -157,8 +162,10 @@ golang.org/x/tools v0.0.0-20190328211700-ab21143f2384 h1:TFlARGu6Czu1z7q93HTxcP1
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b h1:NVD8gBK33xpdqCaZVVtd6OFJp+3dxkXuz7+U7KaVN6s=
|
||||
golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20200117065230-39095c1d176c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/dhowden/tag/mbz"
|
||||
)
|
||||
|
||||
type albumRepository struct {
|
||||
@@ -36,7 +37,7 @@ func (r *albumRepository) Put(a *model.Album) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.index(a.ID, a.Name)
|
||||
return r.index(a.ID, a.Name, a.Artist, mbz.AlbumArtist)
|
||||
}
|
||||
|
||||
func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder {
|
||||
|
||||
@@ -21,6 +21,10 @@ func NewMediaFileRepository(ctx context.Context, o orm.Ormer) *mediaFileReposito
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
r.tableName = "media_file"
|
||||
r.sortMappings = map[string]string{
|
||||
"artist": "artist asc, album asc, disc_number asc, track_number asc",
|
||||
"album": "album asc, disc_number asc, track_number asc",
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -37,7 +41,7 @@ func (r mediaFileRepository) Put(m *model.MediaFile) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.index(m.ID, m.Title)
|
||||
return r.index(m.ID, m.Title, m.Album, m.Artist, m.AlbumArtist)
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder {
|
||||
|
||||
@@ -2,6 +2,7 @@ package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
@@ -86,4 +87,33 @@ var _ = Describe("MediaRepository", func() {
|
||||
_, err := mr.Get(id3)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
Context("Annotations", func() {
|
||||
It("increments play count when the tracks does not have annotations", func() {
|
||||
id := "incplay.firsttime"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil())
|
||||
playDate := time.Now()
|
||||
Expect(mr.IncPlayCount(id, playDate)).To(BeNil())
|
||||
|
||||
mf, err := mr.Get(id)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix()))
|
||||
Expect(mf.PlayCount).To(Equal(1))
|
||||
})
|
||||
|
||||
It("increments play count on newly starred items", func() {
|
||||
id := "star.incplay"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil())
|
||||
Expect(mr.SetStar(true, id)).To(BeNil())
|
||||
playDate := time.Now()
|
||||
Expect(mr.IncPlayCount(id, playDate)).To(BeNil())
|
||||
|
||||
mf, err := mr.Get(id)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix()))
|
||||
Expect(mf.PlayCount).To(Equal(1))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,6 +33,14 @@ func (r sqlRepository) newSelectWithAnnotation(idField string, options ...model.
|
||||
Columns("starred", "starred_at", "play_count", "play_date", "rating")
|
||||
}
|
||||
|
||||
func (r sqlRepository) annId(itemID ...string) And {
|
||||
return And{
|
||||
Eq{"user_id": userId(r.ctx)},
|
||||
Eq{"item_type": r.tableName},
|
||||
Eq{"item_id": itemID},
|
||||
}
|
||||
}
|
||||
|
||||
func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...string) error {
|
||||
upd := Update(annotationTable).Where(r.annId(itemIDs...))
|
||||
for f, v := range values {
|
||||
@@ -56,12 +64,13 @@ func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...strin
|
||||
return err
|
||||
}
|
||||
|
||||
func (r sqlRepository) annId(itemID ...string) And {
|
||||
return And{
|
||||
Eq{"user_id": userId(r.ctx)},
|
||||
Eq{"item_type": r.tableName},
|
||||
Eq{"item_id": itemID},
|
||||
}
|
||||
func (r sqlRepository) SetStar(starred bool, ids ...string) error {
|
||||
starredAt := time.Now()
|
||||
return r.annUpsert(map[string]interface{}{"starred": starred, "starred_at": starredAt}, ids...)
|
||||
}
|
||||
|
||||
func (r sqlRepository) SetRating(rating int, itemID string) error {
|
||||
return r.annUpsert(map[string]interface{}{"rating": rating}, itemID)
|
||||
}
|
||||
|
||||
func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
|
||||
@@ -88,15 +97,6 @@ func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (r sqlRepository) SetStar(starred bool, ids ...string) error {
|
||||
starredAt := time.Now()
|
||||
return r.annUpsert(map[string]interface{}{"starred": starred, "starred_at": starredAt}, ids...)
|
||||
}
|
||||
|
||||
func (r sqlRepository) SetRating(rating int, itemID string) error {
|
||||
return r.annUpsert(map[string]interface{}{"rating": rating}, itemID)
|
||||
}
|
||||
|
||||
func (r sqlRepository) cleanAnnotations() error {
|
||||
del := Delete(annotationTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")")
|
||||
c, err := r.executeSQL(del)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/scanner"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
@@ -15,9 +16,10 @@ import (
|
||||
)
|
||||
|
||||
type sqlRepository struct {
|
||||
ctx context.Context
|
||||
tableName string
|
||||
ormer orm.Ormer
|
||||
ctx context.Context
|
||||
tableName string
|
||||
ormer orm.Ormer
|
||||
sortMappings map[string]string
|
||||
}
|
||||
|
||||
const invalidUserId = "-1"
|
||||
@@ -55,11 +57,30 @@ func (r sqlRepository) applyOptions(sq SelectBuilder, options ...model.QueryOpti
|
||||
sq = sq.Offset(uint64(options[0].Offset))
|
||||
}
|
||||
if options[0].Sort != "" {
|
||||
if options[0].Order == "desc" {
|
||||
sq = sq.OrderBy(toSnakeCase(options[0].Sort + " desc"))
|
||||
} else {
|
||||
sq = sq.OrderBy(toSnakeCase(options[0].Sort))
|
||||
sort := toSnakeCase(options[0].Sort)
|
||||
if mapping, ok := r.sortMappings[sort]; ok {
|
||||
sort = mapping
|
||||
}
|
||||
if !strings.Contains(sort, "asc") && !strings.Contains(sort, "desc") {
|
||||
sort = sort + " asc"
|
||||
}
|
||||
if options[0].Order == "desc" {
|
||||
var s scanner.Scanner
|
||||
s.Init(strings.NewReader(sort))
|
||||
var newSort string
|
||||
for tok := s.Scan(); tok != scanner.EOF; tok = s.Scan() {
|
||||
switch s.TokenText() {
|
||||
case "asc":
|
||||
newSort += " " + "desc"
|
||||
case "desc":
|
||||
newSort += " " + "asc"
|
||||
default:
|
||||
newSort += " " + s.TokenText()
|
||||
}
|
||||
}
|
||||
sort = newSort
|
||||
}
|
||||
sq = sq.OrderBy(sort)
|
||||
}
|
||||
}
|
||||
return sq
|
||||
@@ -190,7 +211,7 @@ func (r sqlRepository) parseRestOptions(options ...rest.QueryOptions) model.Quer
|
||||
qo := model.QueryOptions{}
|
||||
if len(options) > 0 {
|
||||
qo.Sort = options[0].Sort
|
||||
qo.Order = options[0].Order
|
||||
qo.Order = strings.ToLower(options[0].Order)
|
||||
qo.Max = options[0].Max
|
||||
qo.Offset = options[0].Offset
|
||||
if len(options[0].Filters) > 0 {
|
||||
|
||||
@@ -10,13 +10,16 @@ import (
|
||||
|
||||
const searchTable = "search"
|
||||
|
||||
func (r sqlRepository) index(id string, text string) error {
|
||||
sanitizedText := strings.TrimSpace(sanitize.Accents(strings.ToLower(text)))
|
||||
func (r sqlRepository) index(id string, text ...string) error {
|
||||
sanitizedText := strings.Builder{}
|
||||
for _, txt := range text {
|
||||
sanitizedText.WriteString(strings.TrimSpace(sanitize.Accents(strings.ToLower(txt))) + " ")
|
||||
}
|
||||
|
||||
values := map[string]interface{}{
|
||||
"id": id,
|
||||
"item_type": r.tableName,
|
||||
"full_text": sanitizedText,
|
||||
"full_text": strings.TrimSpace(sanitizedText.String()),
|
||||
}
|
||||
update := Update(searchTable).Where(Eq{"id": id}).SetMap(values)
|
||||
count, err := r.executeSQL(update)
|
||||
@@ -33,7 +36,7 @@ func (r sqlRepository) index(id string, text string) error {
|
||||
|
||||
func (r sqlRepository) doSearch(q string, offset, size int, results interface{}, orderBys ...string) error {
|
||||
q = strings.TrimSpace(sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*"))))
|
||||
if len(q) <= 2 {
|
||||
if len(q) < 2 {
|
||||
return nil
|
||||
}
|
||||
sq := Select("*").From(r.tableName)
|
||||
|
||||
@@ -30,7 +30,7 @@ func (m *Metadata) Artist() string { return m.tags["artist"] }
|
||||
func (m *Metadata) AlbumArtist() string { return m.tags["album_artist"] }
|
||||
func (m *Metadata) Composer() string { return m.tags["composer"] }
|
||||
func (m *Metadata) Genre() string { return m.tags["genre"] }
|
||||
func (m *Metadata) Year() int { return m.parseInt("year") }
|
||||
func (m *Metadata) Year() int { return m.parseYear("year") }
|
||||
func (m *Metadata) TrackNumber() (int, int) { return m.parseTuple("trackNum", "trackTotal") }
|
||||
func (m *Metadata) DiscNumber() (int, int) { return m.parseTuple("discNum", "discTotal") }
|
||||
func (m *Metadata) HasPicture() bool { return m.tags["hasPicture"] == "Video" }
|
||||
@@ -43,7 +43,7 @@ func (m *Metadata) FilePath() string { return m.filePath }
|
||||
func (m *Metadata) Suffix() string { return m.suffix }
|
||||
func (m *Metadata) Size() int { return int(m.fileInfo.Size()) }
|
||||
|
||||
func ExtractAllMetadata(dirPath string) (map[string]*Metadata, error) {
|
||||
func LoadAllAudioFiles(dirPath string) (map[string]os.FileInfo, error) {
|
||||
dir, err := os.Open(dirPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -52,7 +52,7 @@ func ExtractAllMetadata(dirPath string) (map[string]*Metadata, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var audioFiles []string
|
||||
audioFiles := make(map[string]os.FileInfo)
|
||||
for _, f := range files {
|
||||
if f.IsDir() {
|
||||
continue
|
||||
@@ -62,16 +62,18 @@ func ExtractAllMetadata(dirPath string) (map[string]*Metadata, error) {
|
||||
if !isAudioFile(extension) {
|
||||
continue
|
||||
}
|
||||
audioFiles = append(audioFiles, filePath)
|
||||
fi, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
log.Error("Could not stat file", "filePath", filePath, err)
|
||||
} else {
|
||||
audioFiles[filePath] = fi
|
||||
}
|
||||
}
|
||||
|
||||
if len(audioFiles) == 0 {
|
||||
return map[string]*Metadata{}, nil
|
||||
}
|
||||
return probe(audioFiles)
|
||||
return audioFiles, nil
|
||||
}
|
||||
|
||||
func probe(inputs []string) (map[string]*Metadata, error) {
|
||||
func ExtractAllMetadata(inputs []string) (map[string]*Metadata, error) {
|
||||
cmdLine, args := createProbeCommand(inputs)
|
||||
|
||||
log.Trace("Executing command", "arg0", cmdLine, "args", args)
|
||||
@@ -192,6 +194,30 @@ func (m *Metadata) parseInt(tagName string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
var tagYearFormats = []string{
|
||||
"2006",
|
||||
"2006.01",
|
||||
"2006.01.02",
|
||||
"2006-01",
|
||||
"2006-01-02",
|
||||
time.RFC3339,
|
||||
}
|
||||
|
||||
var dateRegex = regexp.MustCompile(`^([12]\d\d\d)`)
|
||||
|
||||
func (m *Metadata) parseYear(tagName string) int {
|
||||
if v, ok := m.tags[tagName]; ok {
|
||||
match := dateRegex.FindStringSubmatch(v)
|
||||
if len(match) == 0 {
|
||||
log.Error("Error parsing year from ffmpeg date field. Please report this issue", "file", m.filePath, "date", v)
|
||||
return 0
|
||||
}
|
||||
year, _ := strconv.Atoi(match[1])
|
||||
return year
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m *Metadata) parseTuple(numTag string, totalTag string) (int, int) {
|
||||
if v, ok := m.tags[numTag]; ok {
|
||||
tuple := strings.Split(v, "/")
|
||||
|
||||
@@ -9,9 +9,9 @@ var _ = Describe("Metadata", func() {
|
||||
// TODO Need to mock `ffmpeg`
|
||||
XContext("ExtractAllMetadata", func() {
|
||||
It("correctly parses metadata from all files in folder", func() {
|
||||
mds, err := ExtractAllMetadata("tests/fixtures")
|
||||
mds, err := ExtractAllMetadata([]string{"tests/fixtures/test.mp3", "tests/fixtures/test.ogg"})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(mds).To(HaveLen(3))
|
||||
Expect(mds).To(HaveLen(2))
|
||||
|
||||
m := mds["tests/fixtures/test.mp3"]
|
||||
Expect(m.Title()).To(Equal("Song"))
|
||||
@@ -45,14 +45,24 @@ var _ = Describe("Metadata", func() {
|
||||
Expect(m.FilePath()).To(Equal("tests/fixtures/test.ogg"))
|
||||
Expect(m.Size()).To(Equal(4408))
|
||||
})
|
||||
})
|
||||
|
||||
Context("LoadAllAudioFiles", func() {
|
||||
It("return all audiofiles from the folder", func() {
|
||||
files, err := LoadAllAudioFiles("tests/fixtures")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(files).To(HaveLen(3))
|
||||
Expect(files).To(HaveKey("tests/fixtures/test.ogg"))
|
||||
Expect(files).To(HaveKey("tests/fixtures/test.mp3"))
|
||||
Expect(files).To(HaveKey("tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
|
||||
})
|
||||
It("returns error if path does not exist", func() {
|
||||
_, err := ExtractAllMetadata("./INVALID/PATH")
|
||||
_, err := LoadAllAudioFiles("./INVALID/PATH")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns empty map if there are no audio files in path", func() {
|
||||
Expect(ExtractAllMetadata(".")).To(BeEmpty())
|
||||
Expect(LoadAllAudioFiles(".")).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,21 +71,8 @@ var _ = Describe("Metadata", func() {
|
||||
const outputWithOverlappingTitleTag = `
|
||||
Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3':
|
||||
Metadata:
|
||||
iTunSMPB : 00000000 000002D6 00000216 0000000000CB9F94 02000003 0049D539 00000000 00000000 00000000 00000000 00000000 00000000
|
||||
iTunNORM : 000002FF 0000027E 00000FEF 00000C17 0002E647 00044605 00007F02 00007A92 0000273E 0000273E
|
||||
title : Pablo's Blues
|
||||
artist : Gare Du Nord
|
||||
album : Putumayo Presents Blues Lounge
|
||||
TT1 : Putumayo
|
||||
track : 9/10
|
||||
compilation : 1
|
||||
genre : Blues
|
||||
date : 2004
|
||||
Duration: 00:05:02.63, start: 0.000000, bitrate: 140 kb/s
|
||||
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 128 kb/s
|
||||
Stream #0:1: Video: png, rgb24(pc), 500x478 [SAR 2835:2835 DAR 250:239], 90k tbr, 90k tbn, 90k tbc
|
||||
Metadata:
|
||||
comment : Other`
|
||||
Duration: 00:05:02.63, start: 0.000000, bitrate: 140 kb/s`
|
||||
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithOverlappingTitleTag)
|
||||
Expect(md.Compilation()).To(BeTrue())
|
||||
})
|
||||
@@ -85,22 +82,9 @@ Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/
|
||||
Input #0, mp3, from 'groovin.mp3':
|
||||
Metadata:
|
||||
title : Groovin' (feat. Daniel Sneijers, Susanne Alt)
|
||||
artist : Bone 40
|
||||
track : 1
|
||||
album : Groovin'
|
||||
album_artist : Bone 40
|
||||
comment : Visit http://bone40.bandcamp.com
|
||||
date : 2016
|
||||
Duration: 00:03:34.28, start: 0.025056, bitrate: 323 kb/s
|
||||
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 320 kb/s
|
||||
Metadata:
|
||||
encoder : LAME3.99r
|
||||
Side data:
|
||||
replaygain: track gain - -6.000000, track peak - unknown, album gain - unknown, album peak - unknown,
|
||||
Stream #0:1: Video: mjpeg, yuvj444p(pc, bt470bg/unknown/unknown), 700x700 [SAR 72:72 DAR 1:1], 90k tbr, 90k tbn, 90k tbc
|
||||
Metadata:
|
||||
title : cover
|
||||
comment : Cover (front)
|
||||
At least one output file must be specified`
|
||||
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithOverlappingTitleTag)
|
||||
Expect(md.Title()).To(Equal("Groovin' (feat. Daniel Sneijers, Susanne Alt)"))
|
||||
@@ -111,24 +95,14 @@ At least one output file must be specified`
|
||||
Input #0, flac, from '/Users/deluan/Downloads/06. Back In Black.flac':
|
||||
Metadata:
|
||||
ALBUM : Back In Black
|
||||
album_artist : AC/DC
|
||||
ARTIST : AC/DC
|
||||
COMPOSER : Angus Young;Malcolm Young;Brian Johnson
|
||||
DATE : 1980.07.25
|
||||
disc : 1
|
||||
GENRE : Hard Rock
|
||||
LANGUAGE : EN
|
||||
RATING : 2
|
||||
TITLE : Back In Black
|
||||
DISCTOTAL : 1
|
||||
TRACKTOTAL : 10
|
||||
track : 6
|
||||
REPLAYGAIN_TRACK_GAIN: -8.51 dB
|
||||
REPLAYGAIN_TRACK_PEAK: 0.998322
|
||||
Duration: 00:04:16.00, start: 0.000000, bitrate: 995 kb/s
|
||||
Stream #0:0: Audio: flac, 44100 Hz, stereo, s16
|
||||
Side data:
|
||||
replaygain: track gain - -8.510000, track peak - 0.000023, album gain - unknown, album peak - unknown,`
|
||||
Duration: 00:04:16.00, start: 0.000000, bitrate: 995 kb/s`
|
||||
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithOverlappingTitleTag)
|
||||
Expect(md.Title()).To(Equal("Back In Black"))
|
||||
Expect(md.Album()).To(Equal("Back In Black"))
|
||||
@@ -139,7 +113,7 @@ Input #0, flac, from '/Users/deluan/Downloads/06. Back In Black.flac':
|
||||
n, t = md.DiscNumber()
|
||||
Expect(n).To(Equal(1))
|
||||
Expect(t).To(Equal(1))
|
||||
|
||||
Expect(md.Year()).To(Equal(1980))
|
||||
})
|
||||
|
||||
// TODO Handle multiline tags
|
||||
@@ -147,14 +121,6 @@ Input #0, flac, from '/Users/deluan/Downloads/06. Back In Black.flac':
|
||||
const outputWithMultilineComment = `
|
||||
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'modulo.m4a':
|
||||
Metadata:
|
||||
major_brand : mp42
|
||||
minor_version : 0
|
||||
compatible_brands: M4A mp42isom
|
||||
creation_time : 2014-05-10T21:11:57.000000Z
|
||||
iTunSMPB : 00000000 00000920 000000E0 00000000021CA200 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
|
||||
encoder : Nero AAC codec / 1.5.4.0
|
||||
title : Módulo Especial
|
||||
artist : Saara Saara
|
||||
comment : https://www.mixcloud.com/codigorock/30-minutos-com-saara-saara/
|
||||
:
|
||||
: Tracklist:
|
||||
@@ -167,18 +133,7 @@ Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'modulo.m4a':
|
||||
: 06. Doktor Fritz
|
||||
: 07. Wunderbar
|
||||
: 08. Quarta Dimensão
|
||||
album : Módulo Especial
|
||||
genre : Electronic
|
||||
track : 1
|
||||
Duration: 00:26:46.96, start: 0.052971, bitrate: 69 kb/s
|
||||
Chapter #0:0: start 0.105941, end 1607.013149
|
||||
Metadata:
|
||||
title :
|
||||
Stream #0:0(und): Audio: aac (HE-AAC) (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 69 kb/s (default)
|
||||
Metadata:
|
||||
creation_time : 2014-05-10T21:11:57.000000Z
|
||||
handler_name : Sound Media Handler
|
||||
At least one output file must be specified`
|
||||
Duration: 00:26:46.96, start: 0.052971, bitrate: 69 kb/s`
|
||||
const expectedComment = `https://www.mixcloud.com/codigorock/30-minutos-com-saara-saara/
|
||||
|
||||
Tracklist:
|
||||
@@ -196,4 +151,27 @@ Tracklist:
|
||||
Expect(md.Comment()).To(Equal(expectedComment))
|
||||
})
|
||||
})
|
||||
|
||||
Context("parseYear", func() {
|
||||
It("parses the year correctly", func() {
|
||||
var examples = map[string]int{
|
||||
"1985": 1985,
|
||||
"2002-01": 2002,
|
||||
"1969.06": 1969,
|
||||
"1980.07.25": 1980,
|
||||
"2004-00-00": 2004,
|
||||
"2013-May-12": 2013,
|
||||
"May 12, 2016": 0,
|
||||
}
|
||||
for tag, expected := range examples {
|
||||
md := &Metadata{tags: map[string]string{"year": tag}}
|
||||
Expect(md.Year()).To(Equal(expected))
|
||||
}
|
||||
})
|
||||
|
||||
It("returns 0 if year is invalid", func() {
|
||||
md := &Metadata{tags: map[string]string{"year": "invalid"}}
|
||||
Expect(md.Year()).To(Equal(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,7 +40,6 @@ func (s *Scanner) Rescan(mediaFolder string, fullRescan bool) error {
|
||||
}
|
||||
|
||||
s.updateLastModifiedSince(mediaFolder, start)
|
||||
log.Debug("Finished scanning folder", "folder", mediaFolder, "elapsed", time.Since(start))
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -39,12 +39,14 @@ func NewTagScanner(rootFolder string, ds model.DataStore) *TagScanner {
|
||||
// refresh the collected albums and artists with the metadata from the mediafiles
|
||||
// Delete all empty albums, delete all empty Artists
|
||||
func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time) error {
|
||||
start := time.Now()
|
||||
changed, deleted, err := s.detector.Scan(lastModifiedSince)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(changed)+len(deleted) == 0 {
|
||||
log.Debug(ctx, "No changes found in Music Folder", "folder", s.rootFolder)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -108,11 +110,10 @@ func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time) erro
|
||||
return err
|
||||
}
|
||||
|
||||
if len(changed)+len(deleted) == 0 {
|
||||
return nil
|
||||
}
|
||||
err = s.ds.GC(log.NewContext(nil))
|
||||
log.Info("Finished Music Folder", "folder", s.rootFolder, "elapsed", time.Since(start))
|
||||
|
||||
return s.ds.GC(log.NewContext(nil))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TagScanner) refreshAlbums(ctx context.Context, updatedAlbums map[string]bool) error {
|
||||
@@ -133,7 +134,6 @@ func (s *TagScanner) refreshArtists(ctx context.Context, updatedArtists map[stri
|
||||
|
||||
func (s *TagScanner) processChangedDir(ctx context.Context, dir string, updatedArtists map[string]bool, updatedAlbums map[string]bool) error {
|
||||
dir = filepath.Join(s.rootFolder, dir)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// Load folder's current tracks from DB into a map
|
||||
@@ -143,54 +143,68 @@ func (s *TagScanner) processChangedDir(ctx context.Context, dir string, updatedA
|
||||
return err
|
||||
}
|
||||
for _, t := range ct {
|
||||
currentTracks[t.ID] = t
|
||||
updatedArtists[t.ArtistID] = true
|
||||
updatedAlbums[t.AlbumID] = true
|
||||
currentTracks[t.Path] = t
|
||||
}
|
||||
|
||||
// Load tracks from the folder
|
||||
newTracks, err := s.loadTracks(dir)
|
||||
// Load tracks FileInfo from the folder
|
||||
files, err := LoadAllAudioFiles(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If no tracks to process, return
|
||||
if len(newTracks)+len(currentTracks) == 0 {
|
||||
// If no files to process, return
|
||||
if len(files)+len(currentTracks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If track from folder is newer than the one in DB, select for update/insert in DB and delete from the current tracks
|
||||
log.Trace("Processing changed folder", "dir", dir, "tracksInDB", len(currentTracks), "tracksInFolder", len(files))
|
||||
var filesToUpdate []string
|
||||
for filePath, info := range files {
|
||||
c, ok := currentTracks[filePath]
|
||||
if !ok || (ok && info.ModTime().After(c.UpdatedAt)) {
|
||||
filesToUpdate = append(filesToUpdate, filePath)
|
||||
}
|
||||
delete(currentTracks, filePath)
|
||||
}
|
||||
|
||||
// Load tracks Metadata from the folder
|
||||
newTracks, err := s.loadTracks(filesToUpdate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If track from folder is newer than the one in DB, update/insert in DB and delete from the current tracks
|
||||
log.Trace("Processing changed folder", "dir", dir, "tracksInDB", len(currentTracks), "tracksInFolder", len(newTracks))
|
||||
log.Trace("Updating mediaFiles in DB", "dir", dir, "files", filesToUpdate, "numFiles", len(filesToUpdate))
|
||||
numUpdatedTracks := 0
|
||||
numPurgedTracks := 0
|
||||
for _, n := range newTracks {
|
||||
c, ok := currentTracks[n.ID]
|
||||
if !ok || (ok && n.UpdatedAt.After(c.UpdatedAt)) {
|
||||
err := s.ds.MediaFile(ctx).Put(&n)
|
||||
updatedArtists[n.ArtistID] = true
|
||||
updatedAlbums[n.AlbumID] = true
|
||||
numUpdatedTracks++
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
delete(currentTracks, n.ID)
|
||||
}
|
||||
|
||||
// Remaining tracks from DB that are not in the folder are deleted
|
||||
for id := range currentTracks {
|
||||
numPurgedTracks++
|
||||
if err := s.ds.MediaFile(ctx).Delete(id); err != nil {
|
||||
err := s.ds.MediaFile(ctx).Put(&n)
|
||||
updatedArtists[n.ArtistID] = true
|
||||
updatedAlbums[n.AlbumID] = true
|
||||
numUpdatedTracks++
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug("Finished processing changed folder", "dir", dir, "updated", numUpdatedTracks, "purged", numPurgedTracks, "elapsed", time.Since(start))
|
||||
// Remaining tracks from DB that are not in the folder are deleted
|
||||
for _, ct := range currentTracks {
|
||||
numPurgedTracks++
|
||||
updatedArtists[ct.ArtistID] = true
|
||||
updatedAlbums[ct.AlbumID] = true
|
||||
if err := s.ds.MediaFile(ctx).Delete(ct.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("Finished processing changed folder", "dir", dir, "updated", numUpdatedTracks, "purged", numPurgedTracks, "elapsed", time.Since(start))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TagScanner) processDeletedDir(ctx context.Context, dir string, updatedArtists map[string]bool, updatedAlbums map[string]bool) error {
|
||||
dir = filepath.Join(s.rootFolder, dir)
|
||||
start := time.Now()
|
||||
|
||||
ct, err := s.ds.MediaFile(ctx).FindByPath(dir)
|
||||
if err != nil {
|
||||
@@ -201,14 +215,16 @@ func (s *TagScanner) processDeletedDir(ctx context.Context, dir string, updatedA
|
||||
updatedAlbums[t.AlbumID] = true
|
||||
}
|
||||
|
||||
log.Info("Finished processing deleted folder", "dir", dir, "deleted", len(ct), "elapsed", time.Since(start))
|
||||
return s.ds.MediaFile(ctx).DeleteByPath(dir)
|
||||
}
|
||||
|
||||
func (s *TagScanner) loadTracks(dirPath string) (model.MediaFiles, error) {
|
||||
mds, err := ExtractAllMetadata(dirPath)
|
||||
func (s *TagScanner) loadTracks(filePaths []string) (model.MediaFiles, error) {
|
||||
mds, err := ExtractAllMetadata(filePaths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var mfs model.MediaFiles
|
||||
for _, md := range mds {
|
||||
mf := s.toMediaFile(md)
|
||||
|
||||
@@ -7,18 +7,13 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/assets"
|
||||
"github.com/deluan/navidrome/engine/auth"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/jwtauth"
|
||||
)
|
||||
|
||||
var initialUser = model.User{
|
||||
UserName: "admin",
|
||||
Name: "Admin",
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
ds model.DataStore
|
||||
mux http.Handler
|
||||
@@ -38,22 +33,23 @@ func (app *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
func (app *Router) routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Basic unauthenticated ping
|
||||
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"response":"pong"}`)) })
|
||||
|
||||
r.Post("/login", Login(app.ds))
|
||||
r.Post("/createAdmin", CreateAdmin(app.ds))
|
||||
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(TokenAuth))
|
||||
r.Use(jwtauth.Verifier(auth.TokenAuth))
|
||||
r.Use(Authenticator(app.ds))
|
||||
app.R(r, "/user", model.User{})
|
||||
app.R(r, "/song", model.MediaFile{})
|
||||
app.R(r, "/album", model.Album{})
|
||||
app.R(r, "/artist", model.Artist{})
|
||||
|
||||
// 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"}`)) })
|
||||
})
|
||||
|
||||
// Serve UI app assets
|
||||
r.Handle("/", ServeIndex(app.ds))
|
||||
r.Handle("/*", http.StripPrefix(app.path, http.FileServer(assets.AssetFile())))
|
||||
|
||||
return r
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/engine/auth"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/rest"
|
||||
@@ -20,13 +21,11 @@ import (
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
jwtSecret []byte
|
||||
TokenAuth *jwtauth.JWTAuth
|
||||
ErrFirstTime = errors.New("no users created")
|
||||
)
|
||||
|
||||
func Login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) {
|
||||
initTokenAuth(ds)
|
||||
auth.InitTokenAuth(ds)
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, err := getCredentialsFromBody(r)
|
||||
@@ -52,7 +51,7 @@ func handleLogin(ds model.DataStore, username string, password string, w http.Re
|
||||
return
|
||||
}
|
||||
|
||||
tokenString, err := createToken(user)
|
||||
tokenString, err := auth.CreateToken(user)
|
||||
if err != nil {
|
||||
rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authenticating user. Please try again")
|
||||
return
|
||||
@@ -82,7 +81,7 @@ func getCredentialsFromBody(r *http.Request) (username string, password string,
|
||||
}
|
||||
|
||||
func CreateAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) {
|
||||
initTokenAuth(ds)
|
||||
auth.InitTokenAuth(ds)
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, err := getCredentialsFromBody(r)
|
||||
@@ -129,16 +128,6 @@ func createDefaultUser(ctx context.Context, ds model.DataStore, username, passwo
|
||||
return nil
|
||||
}
|
||||
|
||||
func initTokenAuth(ds model.DataStore) {
|
||||
once.Do(func() {
|
||||
secret, err := ds.Property(nil).DefaultGet(consts.JWTSecretKey, "not so secret")
|
||||
if err != nil {
|
||||
log.Error("No JWT secret found in DB. Setting a temp one, but please report this error", err)
|
||||
}
|
||||
jwtSecret = []byte(secret)
|
||||
TokenAuth = jwtauth.New("HS256", jwtSecret, nil)
|
||||
})
|
||||
}
|
||||
func validateLogin(userRepo model.UserRepository, userName, password string) (*model.User, error) {
|
||||
u, err := userRepo.FindByUsername(userName)
|
||||
if err == model.ErrNotFound {
|
||||
@@ -157,24 +146,6 @@ func validateLogin(userRepo model.UserRepository, userName, password string) (*m
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func createToken(u *model.User) (string, error) {
|
||||
token := jwt.New(jwt.SigningMethodHS256)
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims["iss"] = consts.JWTIssuer
|
||||
claims["sub"] = u.UserName
|
||||
claims["adm"] = u.IsAdmin
|
||||
|
||||
return touchToken(token)
|
||||
}
|
||||
|
||||
func touchToken(token *jwt.Token) (string, error) {
|
||||
expireIn := time.Now().Add(consts.JWTTokenExpiration).Unix()
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims["exp"] = expireIn
|
||||
|
||||
return token.SignedString(jwtSecret)
|
||||
}
|
||||
|
||||
func contextWithUser(ctx context.Context, ds model.DataStore, claims jwt.MapClaims) context.Context {
|
||||
userName := claims["sub"].(string)
|
||||
user, _ := ds.User(ctx).FindByUsername(userName)
|
||||
@@ -199,7 +170,7 @@ func getToken(ds model.DataStore, ctx context.Context) (*jwt.Token, error) {
|
||||
}
|
||||
|
||||
func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
initTokenAuth(ds)
|
||||
auth.InitTokenAuth(ds)
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -216,7 +187,7 @@ func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
|
||||
newCtx := contextWithUser(r.Context(), ds, claims)
|
||||
newTokenString, err := touchToken(token)
|
||||
newTokenString, err := auth.TouchToken(token)
|
||||
if err != nil {
|
||||
log.Error(r, "signing new token", err)
|
||||
rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
|
||||
|
||||
43
server/app/serve_index.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
|
||||
"github.com/deluan/navidrome/assets"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
// Injects the `firstTime` config in the `index.html` template
|
||||
func ServeIndex(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := ds.User(r.Context()).CountAll()
|
||||
firstTime := c == 0 && err == nil
|
||||
|
||||
t := template.New("initial state")
|
||||
fs := assets.AssetFile()
|
||||
indexHtml, err := fs.Open("index.html")
|
||||
if err != nil {
|
||||
log.Error(r, "Could not find `index.html` template", err)
|
||||
}
|
||||
indexStr, err := ioutil.ReadAll(indexHtml)
|
||||
if err != nil {
|
||||
log.Error(r, "Could not read from `index.html`", err)
|
||||
}
|
||||
t, _ = t.Parse(string(indexStr))
|
||||
appConfig := map[string]interface{}{
|
||||
"firstTime": firstTime,
|
||||
}
|
||||
j, _ := json.Marshal(appConfig)
|
||||
data := map[string]interface{}{
|
||||
"AppConfig": string(j),
|
||||
}
|
||||
err = t.Execute(w, data)
|
||||
if err != nil {
|
||||
log.Error(r, "Could not execute `index.html` template", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
@@ -20,11 +23,47 @@ func initialSetup(ds model.DataStore) {
|
||||
return err
|
||||
}
|
||||
|
||||
if conf.Server.DevAutoCreateAdminPassword != "" {
|
||||
if err = createInitialAdminUser(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()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Could not access User table: %s", err))
|
||||
}
|
||||
if c == 0 {
|
||||
id, _ := uuid.NewRandom()
|
||||
random, _ := uuid.NewRandom()
|
||||
initialPassword := random.String()
|
||||
if conf.Server.DevAutoCreateAdminPassword != "" {
|
||||
initialPassword = conf.Server.DevAutoCreateAdminPassword
|
||||
}
|
||||
log.Warn("Creating initial admin user. This should only be used for development purposes!!", "user", consts.DevInitialUserName, "password", initialPassword)
|
||||
initialUser := model.User{
|
||||
ID: id.String(),
|
||||
UserName: consts.DevInitialUserName,
|
||||
Name: consts.DevInitialName,
|
||||
Email: "",
|
||||
Password: initialPassword,
|
||||
IsAdmin: true,
|
||||
}
|
||||
err := ds.User(ctx).Put(&initialUser)
|
||||
if err != nil {
|
||||
log.Error("Could not create initial admin user", "user", initialUser, err)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func createJWTSecret(ds model.DataStore) error {
|
||||
_, err := ds.Property(nil).Get(consts.JWTSecretKey)
|
||||
if err == nil {
|
||||
|
||||
@@ -47,8 +47,8 @@ func (c *AlbumListController) getAlbumList(r *http.Request) (engine.Entries, err
|
||||
return nil, errors.New("Not implemented!")
|
||||
}
|
||||
|
||||
offset := ParamInt(r, "offset", 0)
|
||||
size := utils.MinInt(ParamInt(r, "size", 10), 500)
|
||||
offset := utils.ParamInt(r, "offset", 0)
|
||||
size := utils.MinInt(utils.ParamInt(r, "size", 10), 500)
|
||||
|
||||
albums, err := listFunc(r.Context(), offset, size)
|
||||
if err != nil {
|
||||
@@ -132,8 +132,8 @@ func (c *AlbumListController) GetNowPlaying(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
func (c *AlbumListController) GetRandomSongs(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
size := utils.MinInt(ParamInt(r, "size", 10), 500)
|
||||
genre := ParamString(r, "genre")
|
||||
size := utils.MinInt(utils.ParamInt(r, "size", 10), 500)
|
||||
genre := utils.ParamString(r, "genre")
|
||||
|
||||
songs, err := c.listGen.GetRandomSongs(r.Context(), size, genre)
|
||||
if err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/deluan/navidrome/engine"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/server/subsonic/responses"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
"github.com/go-chi/chi"
|
||||
)
|
||||
|
||||
@@ -72,6 +73,8 @@ func (api *Router) routes() http.Handler {
|
||||
H(reqParams, "getArtist", c.GetArtist)
|
||||
H(reqParams, "getAlbum", c.GetAlbum)
|
||||
H(reqParams, "getSong", c.GetSong)
|
||||
H(reqParams, "getArtistInfo", c.GetArtistInfo)
|
||||
H(reqParams, "getArtistInfo2", c.GetArtistInfo2)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
c := initAlbumListController(api)
|
||||
@@ -163,7 +166,7 @@ func SendError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
}
|
||||
|
||||
func SendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Subsonic) {
|
||||
f := ParamString(r, "f")
|
||||
f := utils.ParamString(r, "f")
|
||||
var response []byte
|
||||
switch f {
|
||||
case "json":
|
||||
@@ -172,7 +175,7 @@ func SendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub
|
||||
response, _ = json.Marshal(wrapper)
|
||||
case "jsonp":
|
||||
w.Header().Set("Content-Type", "application/javascript")
|
||||
callback := ParamString(r, "callback")
|
||||
callback := utils.ParamString(r, "callback")
|
||||
wrapper := &responses.JsonWrapper{Subsonic: *payload}
|
||||
data, _ := json.Marshal(wrapper)
|
||||
response = []byte(fmt.Sprintf("%s(%s)", callback, data))
|
||||
|
||||
@@ -59,8 +59,8 @@ func (c *BrowsingController) getArtistIndex(r *http.Request, musicFolderId strin
|
||||
}
|
||||
|
||||
func (c *BrowsingController) GetIndexes(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
musicFolderId := ParamString(r, "musicFolderId")
|
||||
ifModifiedSince := ParamTime(r, "ifModifiedSince", time.Time{})
|
||||
musicFolderId := utils.ParamString(r, "musicFolderId")
|
||||
ifModifiedSince := utils.ParamTime(r, "ifModifiedSince", time.Time{})
|
||||
|
||||
res, err := c.getArtistIndex(r, musicFolderId, ifModifiedSince)
|
||||
if err != nil {
|
||||
@@ -73,7 +73,7 @@ func (c *BrowsingController) GetIndexes(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (c *BrowsingController) GetArtists(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
musicFolderId := ParamString(r, "musicFolderId")
|
||||
musicFolderId := utils.ParamString(r, "musicFolderId")
|
||||
res, err := c.getArtistIndex(r, musicFolderId, time.Time{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -85,7 +85,7 @@ func (c *BrowsingController) GetArtists(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (c *BrowsingController) GetMusicDirectory(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
id := ParamString(r, "id")
|
||||
id := utils.ParamString(r, "id")
|
||||
dir, err := c.browser.Directory(r.Context(), id)
|
||||
switch {
|
||||
case err == model.ErrNotFound:
|
||||
@@ -102,7 +102,7 @@ func (c *BrowsingController) GetMusicDirectory(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
func (c *BrowsingController) GetArtist(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
id := ParamString(r, "id")
|
||||
id := utils.ParamString(r, "id")
|
||||
dir, err := c.browser.Artist(r.Context(), id)
|
||||
switch {
|
||||
case err == model.ErrNotFound:
|
||||
@@ -119,7 +119,7 @@ func (c *BrowsingController) GetArtist(w http.ResponseWriter, r *http.Request) (
|
||||
}
|
||||
|
||||
func (c *BrowsingController) GetAlbum(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
id := ParamString(r, "id")
|
||||
id := utils.ParamString(r, "id")
|
||||
dir, err := c.browser.Album(r.Context(), id)
|
||||
switch {
|
||||
case err == model.ErrNotFound:
|
||||
@@ -136,7 +136,7 @@ func (c *BrowsingController) GetAlbum(w http.ResponseWriter, r *http.Request) (*
|
||||
}
|
||||
|
||||
func (c *BrowsingController) GetSong(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
id := ParamString(r, "id")
|
||||
id := utils.ParamString(r, "id")
|
||||
song, err := c.browser.GetSong(r.Context(), id)
|
||||
switch {
|
||||
case err == model.ErrNotFound:
|
||||
@@ -165,6 +165,30 @@ func (c *BrowsingController) GetGenres(w http.ResponseWriter, r *http.Request) (
|
||||
return response, nil
|
||||
}
|
||||
|
||||
const noImageAvailableUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ac/No_image_available.svg/1024px-No_image_available.svg.png"
|
||||
|
||||
// TODO Integrate with Last.FM
|
||||
func (c *BrowsingController) GetArtistInfo(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
response := NewResponse()
|
||||
response.ArtistInfo = &responses.ArtistInfo{}
|
||||
response.ArtistInfo.Biography = "Biography not available"
|
||||
response.ArtistInfo.SmallImageUrl = noImageAvailableUrl
|
||||
response.ArtistInfo.MediumImageUrl = noImageAvailableUrl
|
||||
response.ArtistInfo.LargeImageUrl = noImageAvailableUrl
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// TODO Integrate with Last.FM
|
||||
func (c *BrowsingController) GetArtistInfo2(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
response := NewResponse()
|
||||
response.ArtistInfo2 = &responses.ArtistInfo2{}
|
||||
response.ArtistInfo2.Biography = "Biography not available"
|
||||
response.ArtistInfo2.SmallImageUrl = noImageAvailableUrl
|
||||
response.ArtistInfo2.MediumImageUrl = noImageAvailableUrl
|
||||
response.ArtistInfo2.LargeImageUrl = noImageAvailableUrl
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *BrowsingController) buildDirectory(d *engine.DirectoryInfo) *responses.Directory {
|
||||
dir := &responses.Directory{
|
||||
Id: d.Id,
|
||||
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"mime"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/engine"
|
||||
@@ -20,7 +18,7 @@ func NewResponse() *responses.Subsonic {
|
||||
}
|
||||
|
||||
func RequiredParamString(r *http.Request, param string, msg string) (string, error) {
|
||||
p := ParamString(r, param)
|
||||
p := utils.ParamString(r, param)
|
||||
if p == "" {
|
||||
return "", NewError(responses.ErrorMissingParameter, msg)
|
||||
}
|
||||
@@ -28,83 +26,19 @@ func RequiredParamString(r *http.Request, param string, msg string) (string, err
|
||||
}
|
||||
|
||||
func RequiredParamStrings(r *http.Request, param string, msg string) ([]string, error) {
|
||||
ps := ParamStrings(r, param)
|
||||
ps := utils.ParamStrings(r, param)
|
||||
if len(ps) == 0 {
|
||||
return nil, NewError(responses.ErrorMissingParameter, msg)
|
||||
}
|
||||
return ps, nil
|
||||
}
|
||||
|
||||
func ParamString(r *http.Request, param string) string {
|
||||
return r.URL.Query().Get(param)
|
||||
}
|
||||
|
||||
func ParamStrings(r *http.Request, param string) []string {
|
||||
return r.URL.Query()[param]
|
||||
}
|
||||
|
||||
func ParamTimes(r *http.Request, param string) []time.Time {
|
||||
pStr := ParamStrings(r, param)
|
||||
times := make([]time.Time, len(pStr))
|
||||
for i, t := range pStr {
|
||||
ti, err := strconv.ParseInt(t, 10, 64)
|
||||
if err == nil {
|
||||
times[i] = utils.ToTime(ti)
|
||||
}
|
||||
}
|
||||
return times
|
||||
}
|
||||
|
||||
func ParamTime(r *http.Request, param string, def time.Time) time.Time {
|
||||
v := ParamString(r, param)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
value, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return utils.ToTime(value)
|
||||
}
|
||||
|
||||
func RequiredParamInt(r *http.Request, param string, msg string) (int, error) {
|
||||
p := ParamString(r, param)
|
||||
p := utils.ParamString(r, param)
|
||||
if p == "" {
|
||||
return 0, NewError(responses.ErrorMissingParameter, msg)
|
||||
}
|
||||
return ParamInt(r, param, 0), nil
|
||||
}
|
||||
|
||||
func ParamInt(r *http.Request, param string, def int) int {
|
||||
v := ParamString(r, param)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
value, err := strconv.ParseInt(v, 10, 32)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return int(value)
|
||||
}
|
||||
|
||||
func ParamInts(r *http.Request, param string) []int {
|
||||
pStr := ParamStrings(r, param)
|
||||
ints := make([]int, 0, len(pStr))
|
||||
for _, s := range pStr {
|
||||
i, err := strconv.ParseInt(s, 10, 32)
|
||||
if err == nil {
|
||||
ints = append(ints, int(i))
|
||||
}
|
||||
}
|
||||
return ints
|
||||
}
|
||||
|
||||
func ParamBool(r *http.Request, param string, def bool) bool {
|
||||
p := ParamString(r, param)
|
||||
if p == "" {
|
||||
return def
|
||||
}
|
||||
return strings.Index("/true/on/1/", "/"+p+"/") != -1
|
||||
return utils.ParamInt(r, param, 0), nil
|
||||
}
|
||||
|
||||
type SubsonicError struct {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/server/subsonic/responses"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
type MediaAnnotationController struct {
|
||||
@@ -49,9 +50,9 @@ func (c *MediaAnnotationController) SetRating(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
func (c *MediaAnnotationController) Star(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
ids := ParamStrings(r, "id")
|
||||
albumIds := ParamStrings(r, "albumId")
|
||||
artistIds := ParamStrings(r, "artistId")
|
||||
ids := utils.ParamStrings(r, "id")
|
||||
albumIds := utils.ParamStrings(r, "albumId")
|
||||
artistIds := utils.ParamStrings(r, "artistId")
|
||||
if len(ids)+len(albumIds)+len(artistIds) == 0 {
|
||||
return nil, NewError(responses.ErrorMissingParameter, "Required id parameter is missing")
|
||||
}
|
||||
@@ -84,9 +85,9 @@ func (c *MediaAnnotationController) star(ctx context.Context, starred bool, ids
|
||||
}
|
||||
|
||||
func (c *MediaAnnotationController) Unstar(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
ids := ParamStrings(r, "id")
|
||||
albumIds := ParamStrings(r, "albumId")
|
||||
artistIds := ParamStrings(r, "artistId")
|
||||
ids := utils.ParamStrings(r, "id")
|
||||
albumIds := utils.ParamStrings(r, "albumId")
|
||||
artistIds := utils.ParamStrings(r, "artistId")
|
||||
if len(ids)+len(albumIds)+len(artistIds) == 0 {
|
||||
return nil, NewError(responses.ErrorMissingParameter, "Required id parameter is missing")
|
||||
}
|
||||
@@ -106,14 +107,14 @@ func (c *MediaAnnotationController) Scrobble(w http.ResponseWriter, r *http.Requ
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
times := ParamTimes(r, "time")
|
||||
times := utils.ParamTimes(r, "time")
|
||||
if len(times) > 0 && len(times) != len(ids) {
|
||||
return nil, NewError(responses.ErrorGeneric, "Wrong number of timestamps: %d, should be %d", len(times), len(ids))
|
||||
}
|
||||
submission := ParamBool(r, "submission", true)
|
||||
submission := utils.ParamBool(r, "submission", true)
|
||||
playerId := 1 // TODO Multiple players, based on playerName/username/clientIP(?)
|
||||
playerName := ParamString(r, "c")
|
||||
username := ParamString(r, "u")
|
||||
playerName := utils.ParamString(r, "c")
|
||||
username := utils.ParamString(r, "u")
|
||||
|
||||
log.Debug(r, "Scrobbling tracks", "ids", ids, "times", times, "submission", submission)
|
||||
for i, id := range ids {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/server/subsonic/responses"
|
||||
"github.com/deluan/navidrome/static"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
type MediaRetrievalController struct {
|
||||
@@ -36,8 +37,9 @@ func (c *MediaRetrievalController) GetCoverArt(w http.ResponseWriter, r *http.Re
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size := ParamInt(r, "size", 0)
|
||||
size := utils.ParamInt(r, "size", 0)
|
||||
|
||||
w.Header().Set("cache-control", "public, max-age=300")
|
||||
err = c.cover.Get(r.Context(), id, size, w)
|
||||
|
||||
switch {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/server/subsonic/responses"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
func postFormToQueryParams(next http.Handler) http.Handler {
|
||||
@@ -36,7 +37,7 @@ func checkRequiredParameters(next http.Handler) http.Handler {
|
||||
requiredParameters := []string{"u", "v", "c"}
|
||||
|
||||
for _, p := range requiredParameters {
|
||||
if ParamString(r, p) == "" {
|
||||
if utils.ParamString(r, p) == "" {
|
||||
msg := fmt.Sprintf(`Missing required parameter "%s"`, p)
|
||||
log.Warn(r, msg)
|
||||
SendError(w, r, NewError(responses.ErrorMissingParameter, msg))
|
||||
@@ -44,13 +45,9 @@ func checkRequiredParameters(next http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
if ParamString(r, "p") == "" && (ParamString(r, "s") == "" || ParamString(r, "t") == "") {
|
||||
log.Warn(r, "Missing authentication information")
|
||||
}
|
||||
|
||||
user := ParamString(r, "u")
|
||||
client := ParamString(r, "c")
|
||||
version := ParamString(r, "v")
|
||||
user := utils.ParamString(r, "u")
|
||||
client := utils.ParamString(r, "c")
|
||||
version := utils.ParamString(r, "v")
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, "username", user)
|
||||
ctx = context.WithValue(ctx, "client", client)
|
||||
@@ -65,12 +62,13 @@ func checkRequiredParameters(next http.Handler) http.Handler {
|
||||
func authenticate(users engine.Users) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
username := ParamString(r, "u")
|
||||
pass := ParamString(r, "p")
|
||||
token := ParamString(r, "t")
|
||||
salt := ParamString(r, "s")
|
||||
username := utils.ParamString(r, "u")
|
||||
pass := utils.ParamString(r, "p")
|
||||
token := utils.ParamString(r, "t")
|
||||
salt := utils.ParamString(r, "s")
|
||||
jwt := utils.ParamString(r, "jwt")
|
||||
|
||||
usr, err := users.Authenticate(r.Context(), username, pass, token, salt)
|
||||
usr, err := users.Authenticate(r.Context(), username, pass, token, salt, jwt)
|
||||
if err == model.ErrInvalidAuth {
|
||||
log.Warn(r, "Invalid login", "username", username, err)
|
||||
} else if err != nil {
|
||||
|
||||
@@ -113,7 +113,7 @@ var _ = Describe("Middlewares", func() {
|
||||
})
|
||||
|
||||
It("passes all parameters to users.Authenticate ", func() {
|
||||
r := newGetRequest("u=valid", "p=password", "t=token", "s=salt")
|
||||
r := newGetRequest("u=valid", "p=password", "t=token", "s=salt", "jwt=jwt")
|
||||
cp := authenticate(mockedUser)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
@@ -121,6 +121,7 @@ var _ = Describe("Middlewares", func() {
|
||||
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(next.called).To(BeTrue())
|
||||
user := next.req.Context().Value("user").(*model.User)
|
||||
Expect(user.UserName).To(Equal("valid"))
|
||||
@@ -149,14 +150,15 @@ func (mh *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
type mockUsers struct {
|
||||
engine.Users
|
||||
username, password, token, salt string
|
||||
username, password, token, salt, jwt string
|
||||
}
|
||||
|
||||
func (m *mockUsers) Authenticate(ctx context.Context, username, password, token, salt string) (*model.User, error) {
|
||||
func (m *mockUsers) Authenticate(ctx context.Context, username, password, token, salt, jwt string) (*model.User, error) {
|
||||
m.username = username
|
||||
m.password = password
|
||||
m.token = token
|
||||
m.salt = salt
|
||||
m.jwt = jwt
|
||||
if username == "valid" {
|
||||
return &model.User{UserName: username, Password: password}, nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/server/subsonic/responses"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
type PlaylistsController struct {
|
||||
@@ -61,9 +62,9 @@ func (c *PlaylistsController) GetPlaylist(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
func (c *PlaylistsController) CreatePlaylist(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
songIds := ParamStrings(r, "songId")
|
||||
playlistId := ParamString(r, "playlistId")
|
||||
name := ParamString(r, "name")
|
||||
songIds := utils.ParamStrings(r, "songId")
|
||||
playlistId := utils.ParamString(r, "playlistId")
|
||||
name := utils.ParamString(r, "name")
|
||||
if playlistId == "" && name == "" {
|
||||
return nil, errors.New("Required parameter name is missing")
|
||||
}
|
||||
@@ -96,8 +97,8 @@ func (c *PlaylistsController) UpdatePlaylist(w http.ResponseWriter, r *http.Requ
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
songsToAdd := ParamStrings(r, "songIdToAdd")
|
||||
songIndexesToRemove := ParamInts(r, "songIndexToRemove")
|
||||
songsToAdd := utils.ParamStrings(r, "songIdToAdd")
|
||||
songIndexesToRemove := utils.ParamInts(r, "songIndexToRemove")
|
||||
|
||||
var pname *string
|
||||
if len(r.URL.Query()["name"]) > 0 {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","artistInfo":{"biography":"Black Sabbath is an English \u003ca target='_blank' href=\"http://www.last.fm/tag/heavy%20metal\" class=\"bbcode_tag\" rel=\"tag\"\u003eheavy metal\u003c/a\u003e band","musicBrainzId":"5182c1d9-c7d2-4dad-afa0-ccfeada921a8","lastFmUrl":"http://www.last.fm/music/Black+Sabbath","smallImageUrl":"http://userserve-ak.last.fm/serve/64/27904353.jpg","mediumImageUrl":"http://userserve-ak.last.fm/serve/126/27904353.jpg","largeImageUrl":"http://userserve-ak.last.fm/serve/_/27904353/Black+Sabbath+sabbath+1970.jpg","similarArtist":[{"id":"22","name":"Accept"},{"id":"101","name":"Bruce Dickinson"},{"id":"26","name":"Aerosmith"}]}}
|
||||
@@ -0,0 +1 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><artistInfo><biography>Black Sabbath is an English <a target='_blank' href="http://www.last.fm/tag/heavy%20metal" class="bbcode_tag" rel="tag">heavy metal</a> band</biography><musicBrainzId>5182c1d9-c7d2-4dad-afa0-ccfeada921a8</musicBrainzId><lastFmUrl>http://www.last.fm/music/Black+Sabbath</lastFmUrl><smallImageUrl>http://userserve-ak.last.fm/serve/64/27904353.jpg</smallImageUrl><mediumImageUrl>http://userserve-ak.last.fm/serve/126/27904353.jpg</mediumImageUrl><largeImageUrl>http://userserve-ak.last.fm/serve/_/27904353/Black+Sabbath+sabbath+1970.jpg</largeImageUrl><similarArtist id="22" name="Accept"></similarArtist><similarArtist id="101" name="Bruce Dickinson"></similarArtist><similarArtist id="26" name="Aerosmith"></similarArtist></artistInfo></subsonic-response>
|
||||
@@ -0,0 +1 @@
|
||||
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","artistInfo":{}}
|
||||
@@ -0,0 +1 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><artistInfo></artistInfo></subsonic-response>
|
||||
@@ -34,6 +34,9 @@ type Subsonic struct {
|
||||
Artist *Indexes `xml:"artists,omitempty" json:"artists,omitempty"`
|
||||
ArtistWithAlbumsID3 *ArtistWithAlbumsID3 `xml:"artist,omitempty" json:"artist,omitempty"`
|
||||
AlbumWithSongsID3 *AlbumWithSongsID3 `xml:"album,omitempty" json:"album,omitempty"`
|
||||
|
||||
ArtistInfo *ArtistInfo `xml:"artistInfo,omitempty" json:"artistInfo,omitempty"`
|
||||
ArtistInfo2 *ArtistInfo2 `xml:"artistInfo2,omitempty" json:"artistInfo2,omitempty"`
|
||||
}
|
||||
|
||||
type JsonWrapper struct {
|
||||
@@ -272,3 +275,22 @@ type Genre struct {
|
||||
type Genres struct {
|
||||
Genre []Genre `xml:"genre,omitempty" json:"genre,omitempty"`
|
||||
}
|
||||
|
||||
type ArtistInfoBase struct {
|
||||
Biography string `xml:"biography,omitempty" json:"biography,omitempty"`
|
||||
MusicBrainzID string `xml:"musicBrainzId,omitempty" json:"musicBrainzId,omitempty"`
|
||||
LastFmUrl string `xml:"lastFmUrl,omitempty" json:"lastFmUrl,omitempty"`
|
||||
SmallImageUrl string `xml:"smallImageUrl,omitempty" json:"smallImageUrl,omitempty"`
|
||||
MediumImageUrl string `xml:"mediumImageUrl,omitempty" json:"mediumImageUrl,omitempty"`
|
||||
LargeImageUrl string `xml:"largeImageUrl,omitempty" json:"largeImageUrl,omitempty"`
|
||||
}
|
||||
|
||||
type ArtistInfo struct {
|
||||
ArtistInfoBase
|
||||
SimilarArtist []Artist `xml:"similarArtist,omitempty" json:"similarArtist,omitempty"`
|
||||
}
|
||||
|
||||
type ArtistInfo2 struct {
|
||||
ArtistInfoBase
|
||||
SimilarArtist []ArtistID3 `xml:"similarArtist,omitempty" json:"similarArtist,omitempty"`
|
||||
}
|
||||
|
||||
@@ -282,4 +282,42 @@ var _ = Describe("Responses", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ArtistInfo", func() {
|
||||
BeforeEach(func() {
|
||||
response.ArtistInfo = &ArtistInfo{}
|
||||
})
|
||||
|
||||
Context("without data", func() {
|
||||
It("should match .XML", func() {
|
||||
Expect(xml.Marshal(response)).To(MatchSnapshot())
|
||||
})
|
||||
It("should match .JSON", func() {
|
||||
Expect(json.Marshal(response)).To(MatchSnapshot())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with data", func() {
|
||||
BeforeEach(func() {
|
||||
response.ArtistInfo.Biography = `Black Sabbath is an English <a target='_blank' href="http://www.last.fm/tag/heavy%20metal" class="bbcode_tag" rel="tag">heavy metal</a> band`
|
||||
response.ArtistInfo.MusicBrainzID = "5182c1d9-c7d2-4dad-afa0-ccfeada921a8"
|
||||
response.ArtistInfo.LastFmUrl = "http://www.last.fm/music/Black+Sabbath"
|
||||
response.ArtistInfo.SmallImageUrl = "http://userserve-ak.last.fm/serve/64/27904353.jpg"
|
||||
response.ArtistInfo.MediumImageUrl = "http://userserve-ak.last.fm/serve/126/27904353.jpg"
|
||||
response.ArtistInfo.LargeImageUrl = "http://userserve-ak.last.fm/serve/_/27904353/Black+Sabbath+sabbath+1970.jpg"
|
||||
response.ArtistInfo.SimilarArtist = []Artist{
|
||||
{Id: "22", Name: "Accept"},
|
||||
{Id: "101", Name: "Bruce Dickinson"},
|
||||
{Id: "26", Name: "Aerosmith"},
|
||||
}
|
||||
})
|
||||
It("should match .XML", func() {
|
||||
Expect(xml.Marshal(response)).To(MatchSnapshot())
|
||||
})
|
||||
It("should match .JSON", func() {
|
||||
Expect(json.Marshal(response)).To(MatchSnapshot())
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/deluan/navidrome/engine"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/server/subsonic/responses"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
type SearchingController struct {
|
||||
@@ -34,12 +35,12 @@ func (c *SearchingController) getParams(r *http.Request) (*searchParams, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sp.artistCount = ParamInt(r, "artistCount", 20)
|
||||
sp.artistOffset = ParamInt(r, "artistOffset", 0)
|
||||
sp.albumCount = ParamInt(r, "albumCount", 20)
|
||||
sp.albumOffset = ParamInt(r, "albumOffset", 0)
|
||||
sp.songCount = ParamInt(r, "songCount", 20)
|
||||
sp.songOffset = ParamInt(r, "songOffset", 0)
|
||||
sp.artistCount = utils.ParamInt(r, "artistCount", 20)
|
||||
sp.artistOffset = utils.ParamInt(r, "artistOffset", 0)
|
||||
sp.albumCount = utils.ParamInt(r, "albumCount", 20)
|
||||
sp.albumOffset = utils.ParamInt(r, "albumOffset", 0)
|
||||
sp.songCount = utils.ParamInt(r, "songCount", 20)
|
||||
sp.songOffset = utils.ParamInt(r, "songOffset", 0)
|
||||
return sp, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@ package subsonic
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/deluan/navidrome/engine"
|
||||
"github.com/deluan/navidrome/server/subsonic/responses"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
type StreamController struct {
|
||||
@@ -20,8 +22,8 @@ func (c *StreamController) Stream(w http.ResponseWriter, r *http.Request) (*resp
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maxBitRate := ParamInt(r, "maxBitRate", 0)
|
||||
format := ParamString(r, "format")
|
||||
maxBitRate := utils.ParamInt(r, "maxBitRate", 0)
|
||||
format := utils.ParamString(r, "format")
|
||||
|
||||
ms, err := c.streamer.NewStream(r.Context(), id, maxBitRate, format)
|
||||
if err != nil {
|
||||
@@ -30,6 +32,7 @@ func (c *StreamController) Stream(w http.ResponseWriter, r *http.Request) (*resp
|
||||
|
||||
// Override Content-Type detected by http.FileServer
|
||||
w.Header().Set("Content-Type", ms.ContentType())
|
||||
w.Header().Set("X-Content-Duration", strconv.Itoa(ms.Duration()))
|
||||
http.ServeContent(w, r, ms.Name(), ms.ModTime(), ms)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
200
ui/package-lock.json
generated
@@ -2491,6 +2491,14 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-6.2.0.tgz",
|
||||
"integrity": "sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA=="
|
||||
},
|
||||
"add-dom-event-listener": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/add-dom-event-listener/-/add-dom-event-listener-1.1.0.tgz",
|
||||
"integrity": "sha512-WCxx1ixHT0GQU9hb0KI/mhgRQhnU+U3GvwY6ZvVjYq8rsihIGoaIOUbY0yMPBxLH5MDtr0kz3fisWGNcbWW7Jw==",
|
||||
"requires": {
|
||||
"object-assign": "4.x"
|
||||
}
|
||||
},
|
||||
"address": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/address/-/address-1.1.2.tgz",
|
||||
@@ -3312,6 +3320,11 @@
|
||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
|
||||
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
|
||||
},
|
||||
"blueimp-md5": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.12.0.tgz",
|
||||
"integrity": "sha512-zo+HIdIhzojv6F1siQPqPFROyVy7C50KzHv/k/Iz+BtvtVzSHXiMXOpq2wCfNkeBqdCv+V8XOV96tsEt2W/3rQ=="
|
||||
},
|
||||
"bn.js": {
|
||||
"version": "4.11.8",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz",
|
||||
@@ -4039,11 +4052,24 @@
|
||||
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
|
||||
"integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs="
|
||||
},
|
||||
"component-classes": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/component-classes/-/component-classes-1.2.6.tgz",
|
||||
"integrity": "sha1-xkI5TDYYpNiwuJGe/Mu9kw5c1pE=",
|
||||
"requires": {
|
||||
"component-indexof": "0.0.3"
|
||||
}
|
||||
},
|
||||
"component-emitter": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
|
||||
"integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg=="
|
||||
},
|
||||
"component-indexof": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/component-indexof/-/component-indexof-0.0.3.tgz",
|
||||
"integrity": "sha1-EdCRMSI5648yyPJa6csAL/6NPCQ="
|
||||
},
|
||||
"compose-function": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/compose-function/-/compose-function-3.0.3.tgz",
|
||||
@@ -4352,6 +4378,15 @@
|
||||
"urix": "^0.1.0"
|
||||
}
|
||||
},
|
||||
"css-animation": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/css-animation/-/css-animation-1.6.1.tgz",
|
||||
"integrity": "sha512-/48+/BaEaHRY6kNQ2OIPzKf9A6g8WjZYjhiNDNuIVbsm5tXCGIAsHDjB4Xu1C4vXJtUWZo26O68OQkDpNBaPog==",
|
||||
"requires": {
|
||||
"babel-runtime": "6.x",
|
||||
"component-classes": "^1.2.5"
|
||||
}
|
||||
},
|
||||
"css-blank-pseudo": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-0.1.4.tgz",
|
||||
@@ -4685,6 +4720,11 @@
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz",
|
||||
"integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ="
|
||||
},
|
||||
"deepmerge": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
|
||||
"integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg=="
|
||||
},
|
||||
"default-gateway": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-4.2.0.tgz",
|
||||
@@ -4906,6 +4946,11 @@
|
||||
"esutils": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"dom-align": {
|
||||
"version": "1.10.4",
|
||||
"resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.10.4.tgz",
|
||||
"integrity": "sha512-wytDzaru67AmqFOY4B9GUb/hrwWagezoYYK97D/vpK+ezg+cnuZO0Q2gltUPa7KfNmIqfRIYVCF8UhRDEHAmgQ=="
|
||||
},
|
||||
"dom-converter": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
|
||||
@@ -4992,6 +5037,11 @@
|
||||
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz",
|
||||
"integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA=="
|
||||
},
|
||||
"downloadjs": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/downloadjs/-/downloadjs-1.4.7.tgz",
|
||||
"integrity": "sha1-9p+W+UDg0FU9rCkROYZaPNAQHjw="
|
||||
},
|
||||
"downshift": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/downshift/-/downshift-3.2.7.tgz",
|
||||
@@ -7564,6 +7614,11 @@
|
||||
"resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz",
|
||||
"integrity": "sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU="
|
||||
},
|
||||
"is-mobile": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-2.1.0.tgz",
|
||||
"integrity": "sha512-M5OhlZwh+aTlmRUvDg0Wq3uWVNa+w4DyZ2SjbrS+BhSLu9Po+JXHendC305ZEu+Hh7lywb19Zu4kYXu3L1Oo8A=="
|
||||
},
|
||||
"is-number": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
|
||||
@@ -10444,6 +10499,14 @@
|
||||
"object-visit": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"md5-hex": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/md5-hex/-/md5-hex-3.0.1.tgz",
|
||||
"integrity": "sha512-BUiRtTtV39LIJwinWBjqVsU9xhdnz7/i889V859IBFpuqGAj6LuOvHv5XLbgZ2R7ptJoJaEcxkv88/h25T7Ciw==",
|
||||
"requires": {
|
||||
"blueimp-md5": "^2.10.0"
|
||||
}
|
||||
},
|
||||
"md5.js": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
|
||||
@@ -13046,6 +13109,92 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"rc-align": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/rc-align/-/rc-align-2.4.5.tgz",
|
||||
"integrity": "sha512-nv9wYUYdfyfK+qskThf4BQUSIadeI/dCsfaMZfNEoxm9HwOIioQ+LyqmMK6jWHAZQgOzMLaqawhuBXlF63vgjw==",
|
||||
"requires": {
|
||||
"babel-runtime": "^6.26.0",
|
||||
"dom-align": "^1.7.0",
|
||||
"prop-types": "^15.5.8",
|
||||
"rc-util": "^4.0.4"
|
||||
}
|
||||
},
|
||||
"rc-animate": {
|
||||
"version": "2.10.2",
|
||||
"resolved": "https://registry.npmjs.org/rc-animate/-/rc-animate-2.10.2.tgz",
|
||||
"integrity": "sha512-cE/A7piAzoWFSgUD69NmmMraqCeqVBa51UErod8NS3LUEqWfppSVagHfa0qHAlwPVPiIBg3emRONyny3eiH0Dg==",
|
||||
"requires": {
|
||||
"babel-runtime": "6.x",
|
||||
"classnames": "^2.2.6",
|
||||
"css-animation": "^1.3.2",
|
||||
"prop-types": "15.x",
|
||||
"raf": "^3.4.0",
|
||||
"rc-util": "^4.15.3",
|
||||
"react-lifecycles-compat": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"rc-slider": {
|
||||
"version": "8.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-8.7.1.tgz",
|
||||
"integrity": "sha512-WMT5mRFUEcrLWwTxsyS8jYmlaMsTVCZIGENLikHsNv+tE8ThU2lCoPfi/xFNUfJFNFSBFP3MwPez9ZsJmNp13g==",
|
||||
"requires": {
|
||||
"babel-runtime": "6.x",
|
||||
"classnames": "^2.2.5",
|
||||
"prop-types": "^15.5.4",
|
||||
"rc-tooltip": "^3.7.0",
|
||||
"rc-util": "^4.0.4",
|
||||
"react-lifecycles-compat": "^3.0.4",
|
||||
"shallowequal": "^1.1.0",
|
||||
"warning": "^4.0.3"
|
||||
}
|
||||
},
|
||||
"rc-switch": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-1.9.0.tgz",
|
||||
"integrity": "sha512-Isas+egaK6qSk64jaEw4GgPStY4umYDbT7ZY93bZF1Af+b/JEsKsJdNOU2qG3WI0Z6tXo2DDq0kJCv8Yhu0zww==",
|
||||
"requires": {
|
||||
"classnames": "^2.2.1",
|
||||
"prop-types": "^15.5.6",
|
||||
"react-lifecycles-compat": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"rc-tooltip": {
|
||||
"version": "3.7.3",
|
||||
"resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-3.7.3.tgz",
|
||||
"integrity": "sha512-dE2ibukxxkrde7wH9W8ozHKUO4aQnPZ6qBHtrTH9LoO836PjDdiaWO73fgPB05VfJs9FbZdmGPVEbXCeOP99Ww==",
|
||||
"requires": {
|
||||
"babel-runtime": "6.x",
|
||||
"prop-types": "^15.5.8",
|
||||
"rc-trigger": "^2.2.2"
|
||||
}
|
||||
},
|
||||
"rc-trigger": {
|
||||
"version": "2.6.5",
|
||||
"resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-2.6.5.tgz",
|
||||
"integrity": "sha512-m6Cts9hLeZWsTvWnuMm7oElhf+03GOjOLfTuU0QmdB9ZrW7jR2IpI5rpNM7i9MvAAlMAmTx5Zr7g3uu/aMvZAw==",
|
||||
"requires": {
|
||||
"babel-runtime": "6.x",
|
||||
"classnames": "^2.2.6",
|
||||
"prop-types": "15.x",
|
||||
"rc-align": "^2.4.0",
|
||||
"rc-animate": "2.x",
|
||||
"rc-util": "^4.4.0",
|
||||
"react-lifecycles-compat": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"rc-util": {
|
||||
"version": "4.19.0",
|
||||
"resolved": "https://registry.npmjs.org/rc-util/-/rc-util-4.19.0.tgz",
|
||||
"integrity": "sha512-mptALlLwpeczS3nrv83DbwJNeupolbuvlIEjcvimSiWI8NUBjpF0HgG3kWp1RymiuiRCNm9yhaXqDz0a99dpgQ==",
|
||||
"requires": {
|
||||
"add-dom-event-listener": "^1.1.0",
|
||||
"babel-runtime": "6.x",
|
||||
"prop-types": "^15.5.10",
|
||||
"react-lifecycles-compat": "^3.0.4",
|
||||
"shallowequal": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"react": {
|
||||
"version": "16.12.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-16.12.0.tgz",
|
||||
@@ -13231,6 +13380,23 @@
|
||||
"scheduler": "^0.18.0"
|
||||
}
|
||||
},
|
||||
"react-drag-listview": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/react-drag-listview/-/react-drag-listview-0.1.6.tgz",
|
||||
"integrity": "sha512-0nSWkR1bMLKgLZIYY2YVURYapppzy46FNSs9uAcCxceo2lnajngzLQ3tBgWaTjKTlWMXD0MAcDUWFDYdqMPYUg==",
|
||||
"requires": {
|
||||
"prop-types": "^15.5.8"
|
||||
}
|
||||
},
|
||||
"react-draggable": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-3.3.2.tgz",
|
||||
"integrity": "sha512-oaz8a6enjbPtx5qb0oDWxtDNuybOylvto1QLydsXgKmwT7e3GXC2eMVDwEMIUYJIFqVG72XpOv673UuuAq6LhA==",
|
||||
"requires": {
|
||||
"classnames": "^2.2.5",
|
||||
"prop-types": "^15.6.0"
|
||||
}
|
||||
},
|
||||
"react-dropzone": {
|
||||
"version": "10.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-10.2.1.tgz",
|
||||
@@ -13263,11 +13429,40 @@
|
||||
"@babel/runtime": "^7.4.5"
|
||||
}
|
||||
},
|
||||
"react-icon-base": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-icon-base/-/react-icon-base-2.1.0.tgz",
|
||||
"integrity": "sha1-oZbjP98eeqof2jrvu2i9rZ6Cp50="
|
||||
},
|
||||
"react-icons": {
|
||||
"version": "2.2.7",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-2.2.7.tgz",
|
||||
"integrity": "sha512-0n4lcGqzJFcIQLoQytLdJCE0DKSA9dkwEZRYoGrIDJZFvIT6Hbajx5mv9geqhqFiNjUgtxg8kPyDfjlhymbGFg==",
|
||||
"requires": {
|
||||
"react-icon-base": "2.1.0"
|
||||
}
|
||||
},
|
||||
"react-is": {
|
||||
"version": "16.12.0",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.12.0.tgz",
|
||||
"integrity": "sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q=="
|
||||
},
|
||||
"react-jinke-music-player": {
|
||||
"version": "4.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-jinke-music-player/-/react-jinke-music-player-4.7.2.tgz",
|
||||
"integrity": "sha512-r2P1gf7nsOBBXqVaKbN73POomWXAYiHuOq5q6AIiUPCVvKx19pCiOsVqwN0vB3kN5tK3Vypm1tO0GkFBVVK11Q==",
|
||||
"requires": {
|
||||
"classnames": "^2.2.6",
|
||||
"downloadjs": "^1.4.7",
|
||||
"is-mobile": "^2.1.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"rc-slider": "^8.7.1",
|
||||
"rc-switch": "^1.9.0",
|
||||
"react-drag-listview": "^0.1.6",
|
||||
"react-draggable": "^3.3.2",
|
||||
"react-icons": "^2.2.5"
|
||||
}
|
||||
},
|
||||
"react-lifecycles-compat": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
||||
@@ -14262,6 +14457,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"shallowequal": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
|
||||
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="
|
||||
},
|
||||
"shebang-command": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
|
||||
|
||||
@@ -6,12 +6,16 @@
|
||||
"@testing-library/jest-dom": "^5.0.2",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
"@testing-library/user-event": "^8.0.4",
|
||||
"deepmerge": "^4.2.2",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"md5-hex": "^3.0.1",
|
||||
"prop-types": "^15.7.2",
|
||||
"ra-data-json-server": "^3.1.2",
|
||||
"react": "^16.12.0",
|
||||
"react-admin": "^3.1.2",
|
||||
"react-dom": "^16.12.0",
|
||||
"react-jinke-music-player": "^4.7.2",
|
||||
"react-redux": "^7.1.0",
|
||||
"react-scripts": "3.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>Navidrome</title>
|
||||
<script>
|
||||
window.__APP_CONFIG__ = "{{.AppConfig}}"
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
@@ -1,31 +1,54 @@
|
||||
// in src/App.js
|
||||
import React from 'react'
|
||||
import { Admin, Resource } from 'react-admin'
|
||||
import { Admin, resolveBrowserLocale, Resource } from 'react-admin'
|
||||
import dataProvider from './dataProvider'
|
||||
import authProvider from './authProvider'
|
||||
import { Login, Layout, DarkTheme } from './layout'
|
||||
import polyglotI18nProvider from 'ra-i18n-polyglot'
|
||||
import messages from './i18n'
|
||||
import { DarkTheme, Layout, Login } from './layout'
|
||||
import user from './user'
|
||||
import song from './song'
|
||||
import album from './album'
|
||||
import artist from './artist'
|
||||
import { createMuiTheme } from '@material-ui/core/styles'
|
||||
import { Player, playQueueReducer } from './player'
|
||||
|
||||
const theme = createMuiTheme(DarkTheme)
|
||||
|
||||
const App = () => (
|
||||
<Admin
|
||||
theme={theme}
|
||||
dataProvider={dataProvider}
|
||||
authProvider={authProvider}
|
||||
layout={Layout}
|
||||
loginPage={Login}
|
||||
>
|
||||
{(permissions) => [
|
||||
<Resource name="artist" {...artist} options={{ subMenu: 'library' }} />,
|
||||
<Resource name="album" {...album} options={{ subMenu: 'library' }} />,
|
||||
<Resource name="song" {...song} options={{ subMenu: 'library' }} />,
|
||||
permissions === 'admin' ? <Resource name="user" {...user} /> : null
|
||||
]}
|
||||
</Admin>
|
||||
const i18nProvider = polyglotI18nProvider(
|
||||
(locale) => (messages[locale] ? messages[locale] : messages.en),
|
||||
resolveBrowserLocale()
|
||||
)
|
||||
|
||||
const App = () => {
|
||||
try {
|
||||
const appConfig = JSON.parse(window.__APP_CONFIG__)
|
||||
|
||||
// This flags to the login process that it should create the first account instead
|
||||
if (appConfig.firstTime) {
|
||||
localStorage.setItem('initialAccountCreation', 'true')
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
return (
|
||||
<Admin
|
||||
theme={theme}
|
||||
customReducers={{ queue: playQueueReducer }}
|
||||
dataProvider={dataProvider}
|
||||
authProvider={authProvider}
|
||||
i18nProvider={i18nProvider}
|
||||
layout={Layout}
|
||||
loginPage={Login}
|
||||
>
|
||||
{(permissions) => [
|
||||
<Resource name="artist" {...artist} options={{ subMenu: 'library' }} />,
|
||||
<Resource name="album" {...album} options={{ subMenu: 'library' }} />,
|
||||
<Resource name="song" {...song} options={{ subMenu: 'library' }} />,
|
||||
<Resource name="albumSong" />,
|
||||
permissions === 'admin' ? <Resource name="user" {...user} /> : null,
|
||||
<Player />
|
||||
]}
|
||||
</Admin>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
64
ui/src/album/AlbumActions.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Button,
|
||||
sanitizeListRestProps,
|
||||
TopToolbar,
|
||||
useTranslate
|
||||
} from 'react-admin'
|
||||
import PlayArrowIcon from '@material-ui/icons/PlayArrow'
|
||||
import ShuffleIcon from '@material-ui/icons/Shuffle'
|
||||
import React from 'react'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { playAlbum } from '../player'
|
||||
|
||||
export const AlbumActions = ({
|
||||
className,
|
||||
ids,
|
||||
data,
|
||||
exporter,
|
||||
permanentFilter,
|
||||
...rest
|
||||
}) => {
|
||||
const dispatch = useDispatch()
|
||||
const translate = useTranslate()
|
||||
|
||||
const shuffle = (data) => {
|
||||
const ids = Object.keys(data)
|
||||
for (let i = ids.length - 1; i > 0; i--) {
|
||||
let j = Math.floor(Math.random() * (i + 1))
|
||||
;[ids[i], ids[j]] = [ids[j], ids[i]]
|
||||
}
|
||||
const shuffled = {}
|
||||
ids.forEach((id) => (shuffled[id] = data[id]))
|
||||
return shuffled
|
||||
}
|
||||
|
||||
return (
|
||||
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
|
||||
<Button
|
||||
color={'secondary'}
|
||||
onClick={() => {
|
||||
dispatch(playAlbum(ids[0], data))
|
||||
}}
|
||||
label={translate('resources.album.actions.playAll')}
|
||||
>
|
||||
<PlayArrowIcon />
|
||||
</Button>
|
||||
<Button
|
||||
color={'secondary'}
|
||||
onClick={() => {
|
||||
const shuffled = shuffle(data)
|
||||
const firstId = Object.keys(shuffled)[0]
|
||||
dispatch(playAlbum(firstId, shuffled))
|
||||
}}
|
||||
label={translate('resources.album.actions.shuffle')}
|
||||
>
|
||||
<ShuffleIcon />
|
||||
</Button>
|
||||
</TopToolbar>
|
||||
)
|
||||
}
|
||||
|
||||
AlbumActions.defaultProps = {
|
||||
selectedIds: [],
|
||||
onUnselectItems: () => null
|
||||
}
|
||||
46
ui/src/album/AlbumDetails.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react'
|
||||
import { Card, CardContent, CardMedia, Typography } from '@material-ui/core'
|
||||
import { useTranslate } from 'react-admin'
|
||||
import { subsonicUrl } from '../subsonic'
|
||||
import { DurationField } from '../common'
|
||||
|
||||
const AlbumDetails = ({ classes, record }) => {
|
||||
const translate = useTranslate()
|
||||
const genreYear = (record) => {
|
||||
let genreDateLine = []
|
||||
if (record.genre) {
|
||||
genreDateLine.push(record.genre)
|
||||
}
|
||||
if (record.year) {
|
||||
genreDateLine.push(record.year)
|
||||
}
|
||||
return genreDateLine.join(' · ')
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={classes.container}>
|
||||
<CardMedia
|
||||
image={subsonicUrl('getCoverArt', record.coverArtId || 'not_found', {
|
||||
size: 500
|
||||
})}
|
||||
className={classes.albumCover}
|
||||
/>
|
||||
<CardContent className={classes.albumDetails}>
|
||||
<Typography variant="h5" className={classes.albumTitle}>
|
||||
{record.name}
|
||||
</Typography>
|
||||
<Typography component="h6">
|
||||
{record.albumArtist || record.artist}
|
||||
</Typography>
|
||||
<Typography component="p">{genreYear(record)}</Typography>
|
||||
<Typography component="p">
|
||||
{record.songCount}{' '}
|
||||
{translate('resources.song.name', { smart_count: record.songCount })}{' '}
|
||||
· <DurationField record={record} source={'duration'} />
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default AlbumDetails
|
||||
@@ -6,13 +6,15 @@ import {
|
||||
Filter,
|
||||
List,
|
||||
NumberField,
|
||||
FunctionField,
|
||||
SearchInput,
|
||||
TextInput,
|
||||
Show,
|
||||
SimpleShowLayout,
|
||||
TextField
|
||||
} from 'react-admin'
|
||||
import { DurationField, Title } from '../common'
|
||||
import { DurationField, Pagination, Title } from '../common'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
|
||||
const AlbumFilter = (props) => (
|
||||
<Filter {...props}>
|
||||
@@ -25,7 +27,7 @@ const AlbumDetails = (props) => {
|
||||
return (
|
||||
<Show {...props} title=" ">
|
||||
<SimpleShowLayout>
|
||||
<TextField label="Album Artist" source="albumArtist" />
|
||||
<TextField source="albumArtist" />
|
||||
<TextField source="genre" />
|
||||
<BooleanField source="compilation" />
|
||||
<DateField source="updatedAt" showTime />
|
||||
@@ -34,32 +36,30 @@ const AlbumDetails = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
const albumRowClick = (id, basePath, record) => {
|
||||
const filter = { album: record.name, album_id: id }
|
||||
if (!record.compilation) {
|
||||
filter.artist = record.artist
|
||||
}
|
||||
return `/song?filter=${JSON.stringify(filter)}&order=ASC&sort=trackNumber`
|
||||
const AlbumList = (props) => {
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
|
||||
return (
|
||||
<List
|
||||
{...props}
|
||||
title={<Title subTitle={'Albums'} />}
|
||||
sort={{ field: 'name', order: 'ASC' }}
|
||||
exporter={false}
|
||||
bulkActionButtons={false}
|
||||
filters={<AlbumFilter />}
|
||||
perPage={15}
|
||||
pagination={<Pagination />}
|
||||
>
|
||||
<Datagrid expand={<AlbumDetails />} rowClick={'show'}>
|
||||
<TextField source="name" />
|
||||
<FunctionField
|
||||
source="artist"
|
||||
render={(r) => (r.albumArtist ? r.albumArtist : r.artist)}
|
||||
/>
|
||||
{isDesktop && <NumberField source="songCount" />}
|
||||
<TextField source="year" />
|
||||
{isDesktop && <DurationField source="duration" />}
|
||||
</Datagrid>
|
||||
</List>
|
||||
)
|
||||
}
|
||||
|
||||
const AlbumList = (props) => (
|
||||
<List
|
||||
{...props}
|
||||
title={<Title subTitle={'Albums'} />}
|
||||
sort={{ field: 'name', order: 'ASC' }}
|
||||
exporter={false}
|
||||
bulkActionButtons={false}
|
||||
filters={<AlbumFilter />}
|
||||
perPage={15}
|
||||
>
|
||||
<Datagrid expand={<AlbumDetails />} rowClick={albumRowClick}>
|
||||
<TextField source="name" />
|
||||
<TextField source="artist" />
|
||||
<NumberField source="songCount" />
|
||||
<TextField source="year" />
|
||||
<DurationField label="Time" source="duration" />
|
||||
</Datagrid>
|
||||
</List>
|
||||
)
|
||||
|
||||
export default AlbumList
|
||||
|
||||
76
ui/src/album/AlbumShow.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Datagrid,
|
||||
FunctionField,
|
||||
List,
|
||||
Loading,
|
||||
TextField,
|
||||
useGetOne
|
||||
} from 'react-admin'
|
||||
import AlbumDetails from './AlbumDetails'
|
||||
import { DurationField, Title } from '../common'
|
||||
import { useStyles } from './styles'
|
||||
import { AlbumActions } from './AlbumActions'
|
||||
import { AlbumSongBulkActions } from './AlbumSongBulkActions'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { setTrack } from '../player'
|
||||
import { useDispatch } from 'react-redux'
|
||||
|
||||
const AlbumShow = (props) => {
|
||||
const dispatch = useDispatch()
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
|
||||
const classes = useStyles()
|
||||
const { data: record, loading, error } = useGetOne('album', props.id)
|
||||
|
||||
if (loading) {
|
||||
return <Loading />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <p>ERROR: {error}</p>
|
||||
}
|
||||
|
||||
const trackName = (r) => {
|
||||
const name = r.title
|
||||
if (r.trackNumber) {
|
||||
return r.trackNumber.toString().padStart(2, '0') + ' ' + name
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlbumDetails {...props} classes={classes} record={record} />
|
||||
<List
|
||||
{...props}
|
||||
title={<Title subTitle={record.name} />}
|
||||
actions={<AlbumActions />}
|
||||
filter={{ album_id: props.id }}
|
||||
resource={'albumSong'}
|
||||
exporter={false}
|
||||
perPage={1000}
|
||||
pagination={null}
|
||||
sort={{ field: 'discNumber asc, trackNumber asc', order: 'ASC' }}
|
||||
bulkActionButtons={<AlbumSongBulkActions />}
|
||||
>
|
||||
<Datagrid
|
||||
rowClick={(id, basePath, record) => dispatch(setTrack(record))}
|
||||
>
|
||||
{isDesktop && (
|
||||
<TextField
|
||||
source="trackNumber"
|
||||
sortBy="discNumber asc, trackNumber asc"
|
||||
label="#"
|
||||
/>
|
||||
)}
|
||||
{isDesktop && <TextField source="title" />}
|
||||
{!isDesktop && <FunctionField source="title" render={trackName} />}
|
||||
{record.compilation && <TextField source="artist" />}
|
||||
<DurationField source="duration" />
|
||||
</Datagrid>
|
||||
</List>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AlbumShow
|
||||
16
ui/src/album/AlbumSongBulkActions.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import React, { Fragment, useEffect } from 'react'
|
||||
import { useUnselectAll } from 'react-admin'
|
||||
import AddToQueueButton from '../song/AddToQueueButton'
|
||||
|
||||
export const AlbumSongBulkActions = (props) => {
|
||||
const unselectAll = useUnselectAll()
|
||||
useEffect(() => {
|
||||
unselectAll('albumSong')
|
||||
// eslint-disable-next-line
|
||||
}, [])
|
||||
return (
|
||||
<Fragment>
|
||||
<AddToQueueButton {...props} />
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import AlbumIcon from '@material-ui/icons/Album'
|
||||
import AlbumList from './AlbumList'
|
||||
import AlbumShow from './AlbumShow'
|
||||
|
||||
export default {
|
||||
list: AlbumList,
|
||||
show: AlbumShow,
|
||||
icon: AlbumIcon
|
||||
}
|
||||
|
||||
47
ui/src/album/styles.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
|
||||
export const useStyles = makeStyles((theme) => ({
|
||||
container: {
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
padding: '0.7em',
|
||||
minWidth: '24em'
|
||||
},
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
padding: '1em',
|
||||
minWidth: '32em'
|
||||
}
|
||||
},
|
||||
albumCover: {
|
||||
display: 'inline-block',
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
height: '8em',
|
||||
width: '8em'
|
||||
},
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
height: '10em',
|
||||
width: '10em'
|
||||
},
|
||||
[theme.breakpoints.up('lg')]: {
|
||||
height: '15em',
|
||||
width: '15em'
|
||||
}
|
||||
},
|
||||
albumDetails: {
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'top',
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
width: '14em'
|
||||
},
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
width: '26em'
|
||||
},
|
||||
[theme.breakpoints.up('lg')]: {
|
||||
width: '38em'
|
||||
}
|
||||
},
|
||||
albumTitle: {
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}
|
||||
}))
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
SearchInput,
|
||||
TextField
|
||||
} from 'react-admin'
|
||||
import { Title } from '../common'
|
||||
import { Pagination, Title } from '../common'
|
||||
|
||||
const ArtistFilter = (props) => (
|
||||
<Filter {...props}>
|
||||
@@ -29,6 +29,7 @@ const ArtistList = (props) => (
|
||||
bulkActionButtons={false}
|
||||
filters={<ArtistFilter />}
|
||||
perPage={15}
|
||||
pagination={<Pagination />}
|
||||
>
|
||||
<Datagrid rowClick={artistRowClick}>
|
||||
<TextField source="name" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import jwtDecode from 'jwt-decode'
|
||||
import md5 from 'md5-hex'
|
||||
|
||||
const authProvider = {
|
||||
login: ({ username, password }) => {
|
||||
@@ -27,6 +28,12 @@ const authProvider = {
|
||||
localStorage.setItem('name', response.name)
|
||||
localStorage.setItem('username', response.username)
|
||||
localStorage.setItem('role', response.isAdmin ? 'admin' : 'regular')
|
||||
const salt = new Date().getTime().toString()
|
||||
localStorage.setItem('subsonic-salt', salt)
|
||||
localStorage.setItem(
|
||||
'subsonic-token',
|
||||
generateSubsonicToken(password, salt)
|
||||
)
|
||||
return response
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -49,11 +56,7 @@ const authProvider = {
|
||||
checkAuth: () =>
|
||||
localStorage.getItem('token') ? Promise.resolve() : Promise.reject(),
|
||||
|
||||
checkError: (error) => {
|
||||
const { status, message } = error
|
||||
if (message === 'no users created') {
|
||||
localStorage.setItem('initialAccountCreation', 'true')
|
||||
}
|
||||
checkError: ({ status }) => {
|
||||
if (status === 401 || status === 403) {
|
||||
removeItems()
|
||||
return Promise.reject()
|
||||
@@ -73,6 +76,12 @@ const removeItems = () => {
|
||||
localStorage.removeItem('username')
|
||||
localStorage.removeItem('role')
|
||||
localStorage.removeItem('version')
|
||||
localStorage.removeItem('subsonic-salt')
|
||||
localStorage.removeItem('subsonic-token')
|
||||
}
|
||||
|
||||
const generateSubsonicToken = (password, salt) => {
|
||||
return md5(password + salt)
|
||||
}
|
||||
|
||||
export default authProvider
|
||||
|
||||
8
ui/src/common/Pagination.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react'
|
||||
import { Pagination as RAPagination } from 'react-admin'
|
||||
|
||||
const Pagination = (props) => (
|
||||
<RAPagination rowsPerPageOptions={[15, 25, 50]} {...props} />
|
||||
)
|
||||
|
||||
export default Pagination
|
||||
30
ui/src/common/PlayButton.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import PlayArrowIcon from '@material-ui/icons/PlayArrow'
|
||||
import { IconButton } from '@material-ui/core'
|
||||
import { useDispatch } from 'react-redux'
|
||||
|
||||
const defaultIcon = <PlayArrowIcon fontSize="small" />
|
||||
|
||||
const PlayButton = ({ icon = defaultIcon, action, ...rest }) => {
|
||||
const dispatch = useDispatch()
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
dispatch(action)
|
||||
}}
|
||||
{...rest}
|
||||
size={'small'}
|
||||
>
|
||||
{icon}
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
|
||||
PlayButton.propTypes = {
|
||||
icon: PropTypes.element,
|
||||
action: PropTypes.object
|
||||
}
|
||||
export default PlayButton
|
||||
149
ui/src/common/SimpleList.js
Normal file
@@ -0,0 +1,149 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Avatar from '@material-ui/core/Avatar'
|
||||
import List from '@material-ui/core/List'
|
||||
import ListItem from '@material-ui/core/ListItem'
|
||||
import ListItemAvatar from '@material-ui/core/ListItemAvatar'
|
||||
import ListItemIcon from '@material-ui/core/ListItemIcon'
|
||||
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'
|
||||
import ListItemText from '@material-ui/core/ListItemText'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { linkToRecord, sanitizeListRestProps } from 'ra-core'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
{
|
||||
link: {
|
||||
textDecoration: 'none',
|
||||
color: 'inherit'
|
||||
},
|
||||
tertiary: { float: 'right', opacity: 0.541176 }
|
||||
},
|
||||
{ name: 'RaSimpleList' }
|
||||
)
|
||||
|
||||
const LinkOrNot = ({
|
||||
classes: classesOverride,
|
||||
linkType,
|
||||
basePath,
|
||||
id,
|
||||
record,
|
||||
children
|
||||
}) => {
|
||||
const classes = useStyles({ classes: classesOverride })
|
||||
return linkType === 'edit' || linkType === true ? (
|
||||
<Link to={linkToRecord(basePath, id)} className={classes.link}>
|
||||
{children}
|
||||
</Link>
|
||||
) : linkType === 'show' ? (
|
||||
<Link to={`${linkToRecord(basePath, id)}/show`} className={classes.link}>
|
||||
{children}
|
||||
</Link>
|
||||
) : typeof linkType === 'function' ? (
|
||||
<span onClick={() => linkType(id, basePath, record)}>{children}</span>
|
||||
) : (
|
||||
<span>{children}</span>
|
||||
)
|
||||
}
|
||||
|
||||
const SimpleList = ({
|
||||
basePath,
|
||||
className,
|
||||
classes: classesOverride,
|
||||
data,
|
||||
hasBulkActions,
|
||||
ids,
|
||||
loading,
|
||||
leftAvatar,
|
||||
leftIcon,
|
||||
linkType,
|
||||
onToggleItem,
|
||||
primaryText,
|
||||
rightAvatar,
|
||||
rightIcon,
|
||||
secondaryText,
|
||||
selectedIds,
|
||||
tertiaryText,
|
||||
total,
|
||||
...rest
|
||||
}) => {
|
||||
const classes = useStyles({ classes: classesOverride })
|
||||
return (
|
||||
(loading || total > 0) && (
|
||||
<List className={className} {...sanitizeListRestProps(rest)}>
|
||||
{ids.map((id) => (
|
||||
<LinkOrNot
|
||||
linkType={linkType}
|
||||
basePath={basePath}
|
||||
id={id}
|
||||
key={id}
|
||||
record={data[id]}
|
||||
>
|
||||
<ListItem button={!!linkType}>
|
||||
{leftIcon && (
|
||||
<ListItemIcon>{leftIcon(data[id], id)}</ListItemIcon>
|
||||
)}
|
||||
{leftAvatar && (
|
||||
<ListItemAvatar>
|
||||
<Avatar>{leftAvatar(data[id], id)}</Avatar>
|
||||
</ListItemAvatar>
|
||||
)}
|
||||
<ListItemText
|
||||
primary={
|
||||
<div>
|
||||
{primaryText(data[id], id)}
|
||||
{tertiaryText && (
|
||||
<span className={classes.tertiary}>
|
||||
{tertiaryText(data[id], id)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
secondary={secondaryText && secondaryText(data[id], id)}
|
||||
/>
|
||||
{(rightAvatar || rightIcon) && (
|
||||
<ListItemSecondaryAction>
|
||||
{rightAvatar && <Avatar>{rightAvatar(data[id], id)}</Avatar>}
|
||||
{rightIcon && (
|
||||
<ListItemIcon>{rightIcon(data[id], id)}</ListItemIcon>
|
||||
)}
|
||||
</ListItemSecondaryAction>
|
||||
)}
|
||||
</ListItem>
|
||||
</LinkOrNot>
|
||||
))}
|
||||
</List>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
SimpleList.propTypes = {
|
||||
basePath: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
classes: PropTypes.object,
|
||||
data: PropTypes.object,
|
||||
hasBulkActions: PropTypes.bool.isRequired,
|
||||
ids: PropTypes.array,
|
||||
leftAvatar: PropTypes.func,
|
||||
leftIcon: PropTypes.func,
|
||||
linkType: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.bool,
|
||||
PropTypes.func
|
||||
]).isRequired,
|
||||
onToggleItem: PropTypes.func,
|
||||
primaryText: PropTypes.func,
|
||||
rightAvatar: PropTypes.func,
|
||||
rightIcon: PropTypes.func,
|
||||
secondaryText: PropTypes.func,
|
||||
selectedIds: PropTypes.arrayOf(PropTypes.any).isRequired,
|
||||
tertiaryText: PropTypes.func
|
||||
}
|
||||
|
||||
SimpleList.defaultProps = {
|
||||
linkType: 'edit',
|
||||
hasBulkActions: false,
|
||||
selectedIds: []
|
||||
}
|
||||
|
||||
export default SimpleList
|
||||
@@ -1,7 +1,13 @@
|
||||
import React from 'react'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
|
||||
const Title = ({ subTitle }) => {
|
||||
return <span>Navidrome {subTitle ? ` - ${subTitle}` : ''}</span>
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
|
||||
|
||||
if (isDesktop) {
|
||||
return <span>Navidrome {subTitle ? ` - ${subTitle}` : ''}</span>
|
||||
}
|
||||
return <span>{subTitle ? subTitle : 'Navidrome'}</span>
|
||||
}
|
||||
|
||||
export default Title
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import Title from './Title'
|
||||
import DurationField from './DurationField'
|
||||
import BitrateField from './BitrateField'
|
||||
import Pagination from './Pagination'
|
||||
import PlayButton from './PlayButton'
|
||||
import SimpleList from './SimpleList'
|
||||
|
||||
export { Title, DurationField, BitrateField }
|
||||
export {
|
||||
Title,
|
||||
DurationField,
|
||||
BitrateField,
|
||||
Pagination,
|
||||
PlayButton,
|
||||
SimpleList
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { fetchUtils } from 'react-admin'
|
||||
import jsonServerProvider from 'ra-data-json-server'
|
||||
|
||||
const baseUrl = '/app/api'
|
||||
|
||||
const httpClient = (url, options = {}) => {
|
||||
url = url.replace(baseUrl + '/albumSong', baseUrl + '/song')
|
||||
if (!options.headers) {
|
||||
options.headers = new Headers({ Accept: 'application/json' })
|
||||
}
|
||||
@@ -19,6 +22,6 @@ const httpClient = (url, options = {}) => {
|
||||
})
|
||||
}
|
||||
|
||||
const dataProvider = jsonServerProvider('/app/api', httpClient)
|
||||
const dataProvider = jsonServerProvider(baseUrl, httpClient)
|
||||
|
||||
export default dataProvider
|
||||
|
||||
54
ui/src/i18n/en.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import deepmerge from 'deepmerge'
|
||||
import englishMessages from 'ra-language-english'
|
||||
|
||||
export default deepmerge(englishMessages, {
|
||||
resources: {
|
||||
song: {
|
||||
name: 'Song |||| Songs',
|
||||
fields: {
|
||||
albumArtist: 'Album Artist',
|
||||
duration: 'Time',
|
||||
trackNumber: 'Track #'
|
||||
},
|
||||
bulk: {
|
||||
addToQueue: 'Play Later'
|
||||
}
|
||||
},
|
||||
album: {
|
||||
fields: {
|
||||
albumArtist: 'Album Artist',
|
||||
duration: 'Time'
|
||||
},
|
||||
actions: {
|
||||
playAll: 'Play',
|
||||
playNext: 'Play Next',
|
||||
addToQueue: 'Play Later',
|
||||
shuffle: 'Shuffle'
|
||||
}
|
||||
}
|
||||
},
|
||||
ra: {
|
||||
auth: {
|
||||
welcome1: 'Thanks for installing Navidrome!',
|
||||
welcome2: 'To start, create an admin user',
|
||||
confirmPassword: 'Confirm Password',
|
||||
buttonCreateAdmin: 'Create Admin'
|
||||
},
|
||||
validation: {
|
||||
invalidChars: 'Please only use letter and numbers',
|
||||
passwordDoesNotMatch: 'Password does not match'
|
||||
}
|
||||
},
|
||||
menu: {
|
||||
library: 'Library'
|
||||
},
|
||||
player: {
|
||||
panelTitle: 'Play Queue',
|
||||
playModeText: {
|
||||
order: 'In order',
|
||||
orderLoop: 'Repeat',
|
||||
singleLoop: 'Repeat One',
|
||||
shufflePlay: 'Shuffle'
|
||||
}
|
||||
}
|
||||
})
|
||||
3
ui/src/i18n/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import en from './en'
|
||||
|
||||
export default { en }
|
||||
@@ -149,10 +149,10 @@ const FormSignUp = ({ loading, handleSubmit, validate }) => {
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className={classes.systemName}>
|
||||
Thanks for installing Navidrome!
|
||||
{translate('ra.auth.welcome1')}
|
||||
</div>
|
||||
<div className={classes.systemName}>
|
||||
To start, create an admin user
|
||||
{translate('ra.auth.welcome2')}
|
||||
</div>
|
||||
<div className={classes.form}>
|
||||
<div className={classes.input}>
|
||||
@@ -160,7 +160,7 @@ const FormSignUp = ({ loading, handleSubmit, validate }) => {
|
||||
autoFocus
|
||||
name="username"
|
||||
component={renderInput}
|
||||
label={'Admin Username'}
|
||||
label={translate('ra.auth.username')}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
@@ -177,7 +177,7 @@ const FormSignUp = ({ loading, handleSubmit, validate }) => {
|
||||
<Field
|
||||
name="confirmPassword"
|
||||
component={renderInput}
|
||||
label={'Confirm Password'}
|
||||
label={translate('ra.auth.confirmPassword')}
|
||||
type="password"
|
||||
disabled={loading}
|
||||
/>
|
||||
@@ -193,7 +193,7 @@ const FormSignUp = ({ loading, handleSubmit, validate }) => {
|
||||
fullWidth
|
||||
>
|
||||
{loading && <CircularProgress size={25} thickness={2} />}
|
||||
{translate('Create Admin')}
|
||||
{translate('ra.auth.buttonCreateAdmin')}
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
@@ -242,13 +242,13 @@ const Login = ({ location }) => {
|
||||
const errors = validateLogin(values)
|
||||
const regex = /^\w+$/g
|
||||
if (values.username && !values.username.match(regex)) {
|
||||
errors.username = translate('Please only use letter and numbers')
|
||||
errors.username = translate('ra.validation.invalidChars')
|
||||
}
|
||||
if (!values.confirmPassword) {
|
||||
errors.confirmPassword = translate('ra.validation.required')
|
||||
}
|
||||
if (values.confirmPassword !== values.password) {
|
||||
errors.confirmPassword = 'Password does not match'
|
||||
errors.confirmPassword = translate('ra.validation.passwordDoesNotMatch')
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// in src/Menu.js
|
||||
import React, { useState, createElement } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
@@ -58,7 +57,7 @@ const Menu = ({ onMenuClick, dense, logout }) => {
|
||||
handleToggle={() => handleToggle('menuLibrary')}
|
||||
isOpen={state.menuLibrary}
|
||||
sidebarIsOpen={open}
|
||||
name="Library"
|
||||
name="menu.library"
|
||||
icon={<LibraryMusicIcon />}
|
||||
dense={dense}
|
||||
>
|
||||
|
||||
91
ui/src/player/Player.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import React from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import {
|
||||
fetchUtils,
|
||||
useAuthState,
|
||||
useDataProvider,
|
||||
useTranslate
|
||||
} from 'react-admin'
|
||||
import ReactJkMusicPlayer from 'react-jinke-music-player'
|
||||
import 'react-jinke-music-player/assets/index.css'
|
||||
import { scrobble, syncQueue } from './queue'
|
||||
|
||||
const Player = () => {
|
||||
const translate = useTranslate()
|
||||
|
||||
const defaultOptions = {
|
||||
bounds: 'body',
|
||||
mode: 'full',
|
||||
autoPlay: true,
|
||||
preload: true,
|
||||
autoPlayInitLoadPlayList: true,
|
||||
clearPriorAudioLists: false,
|
||||
showDownload: false,
|
||||
showReload: false,
|
||||
glassBg: false,
|
||||
showThemeSwitch: false,
|
||||
playModeText: {
|
||||
order: translate('player.playModeText.order'),
|
||||
orderLoop: translate('player.playModeText.orderLoop'),
|
||||
singleLoop: translate('player.playModeText.singleLoop'),
|
||||
shufflePlay: translate('player.playModeText.shufflePlay')
|
||||
},
|
||||
panelTitle: translate('player.panelTitle'),
|
||||
defaultPosition: {
|
||||
top: 300,
|
||||
left: 120
|
||||
}
|
||||
}
|
||||
|
||||
const addQueueToOptions = (queue) => {
|
||||
return {
|
||||
...defaultOptions,
|
||||
autoPlay: true,
|
||||
clearPriorAudioLists: queue.clear,
|
||||
audioLists: queue.queue.map((item) => item)
|
||||
}
|
||||
}
|
||||
|
||||
const dataProvider = useDataProvider()
|
||||
const dispatch = useDispatch()
|
||||
const queue = useSelector((state) => state.queue)
|
||||
const options = addQueueToOptions(queue)
|
||||
const { authenticated } = useAuthState()
|
||||
|
||||
const OnAudioListsChange = (currentPlayIndex, audioLists) => {
|
||||
dispatch(syncQueue(audioLists))
|
||||
}
|
||||
|
||||
const OnAudioProgress = (info) => {
|
||||
const progress = (info.currentTime / info.duration) * 100
|
||||
if (isNaN(info.duration) || progress < 90) {
|
||||
return
|
||||
}
|
||||
const item = queue.queue.find((item) => item.id === info.id)
|
||||
if (item && !item.scrobbled) {
|
||||
dispatch(scrobble(info.id))
|
||||
fetchUtils.fetchJson(info.scrobble(true))
|
||||
}
|
||||
}
|
||||
|
||||
const OnAudioPlay = (info) => {
|
||||
if (info.duration) {
|
||||
fetchUtils.fetchJson(info.scrobble(false))
|
||||
dataProvider.getOne('keepalive', { id: info.id })
|
||||
}
|
||||
}
|
||||
|
||||
if (authenticated && options.audioLists.length > 0) {
|
||||
return (
|
||||
<ReactJkMusicPlayer
|
||||
{...options}
|
||||
onAudioListsChange={OnAudioListsChange}
|
||||
onAudioProgress={OnAudioProgress}
|
||||
onAudioPlay={OnAudioPlay}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <div />
|
||||
}
|
||||
|
||||
export default Player
|
||||
4
ui/src/player/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import Player from './Player'
|
||||
import { addTrack, setTrack, playQueueReducer, playAlbum } from './queue'
|
||||
|
||||
export { Player, addTrack, setTrack, playAlbum, playQueueReducer }
|
||||
84
ui/src/player/queue.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'react-jinke-music-player/assets/index.css'
|
||||
import { subsonicUrl } from '../subsonic'
|
||||
|
||||
const PLAYER_ADD_TRACK = 'PLAYER_ADD_TRACK'
|
||||
const PLAYER_SET_TRACK = 'PLAYER_SET_TRACK'
|
||||
const PLAYER_SYNC_QUEUE = 'PLAYER_SYNC_QUEUE'
|
||||
const PLAYER_SCROBBLE = 'PLAYER_SCROBBLE'
|
||||
const PLAYER_PLAY_ALBUM = 'PLAYER_PLAY_ALBUM'
|
||||
|
||||
const mapToAudioLists = (item) => ({
|
||||
name: item.title,
|
||||
singer: item.artist,
|
||||
cover: subsonicUrl('getCoverArt', item.id, { size: 300 }),
|
||||
musicSrc: subsonicUrl('stream', item.id, { ts: true }),
|
||||
scrobble: (submit) => subsonicUrl('scrobble', item.id, { submission: submit })
|
||||
})
|
||||
|
||||
const addTrack = (data) => ({
|
||||
type: PLAYER_ADD_TRACK,
|
||||
data
|
||||
})
|
||||
|
||||
const setTrack = (data) => ({
|
||||
type: PLAYER_SET_TRACK,
|
||||
data
|
||||
})
|
||||
|
||||
const playAlbum = (id, data) => ({
|
||||
type: PLAYER_PLAY_ALBUM,
|
||||
data,
|
||||
id
|
||||
})
|
||||
|
||||
const syncQueue = (data) => ({
|
||||
type: PLAYER_SYNC_QUEUE,
|
||||
data
|
||||
})
|
||||
|
||||
const scrobble = (id) => ({
|
||||
type: PLAYER_SCROBBLE,
|
||||
data: id
|
||||
})
|
||||
|
||||
const playQueueReducer = (
|
||||
previousState = { queue: [], clear: true },
|
||||
payload
|
||||
) => {
|
||||
let queue
|
||||
const { type, data } = payload
|
||||
switch (type) {
|
||||
case PLAYER_ADD_TRACK:
|
||||
queue = previousState.queue
|
||||
queue.push(mapToAudioLists(data))
|
||||
return { queue, clear: false }
|
||||
case PLAYER_SET_TRACK:
|
||||
return { queue: [mapToAudioLists(data)], clear: true }
|
||||
case PLAYER_SYNC_QUEUE:
|
||||
return { queue: data, clear: false }
|
||||
case PLAYER_SCROBBLE:
|
||||
const newQueue = previousState.queue.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
scrobbled: item.scrobbled || item.id === data
|
||||
}
|
||||
})
|
||||
return { queue: newQueue, clear: false }
|
||||
case PLAYER_PLAY_ALBUM:
|
||||
queue = []
|
||||
let match = false
|
||||
Object.keys(data).forEach((id) => {
|
||||
if (id === payload.id) {
|
||||
match = true
|
||||
}
|
||||
if (match) {
|
||||
queue.push(mapToAudioLists(data[id]))
|
||||
}
|
||||
})
|
||||
return { queue, clear: true }
|
||||
default:
|
||||
return previousState
|
||||
}
|
||||
}
|
||||
|
||||
export { addTrack, setTrack, playAlbum, syncQueue, scrobble, playQueueReducer }
|
||||
37
ui/src/song/AddToQueueButton.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Button,
|
||||
useDataProvider,
|
||||
useTranslate,
|
||||
useUnselectAll
|
||||
} from 'react-admin'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { addTrack } from '../player'
|
||||
import AddToQueueIcon from '@material-ui/icons/AddToQueue'
|
||||
|
||||
const AddToQueueButton = ({ selectedIds }) => {
|
||||
const dispatch = useDispatch()
|
||||
const translate = useTranslate()
|
||||
const dataProvider = useDataProvider()
|
||||
const unselectAll = useUnselectAll()
|
||||
const addToQueue = () => {
|
||||
selectedIds.forEach((id) => {
|
||||
dataProvider.getOne('song', { id }).then((response) => {
|
||||
dispatch(addTrack(response.data))
|
||||
})
|
||||
})
|
||||
unselectAll('song')
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={addToQueue}
|
||||
label={translate('resources.song.bulk.addToQueue')}
|
||||
>
|
||||
<AddToQueueIcon />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddToQueueButton
|
||||
10
ui/src/song/SongBulkActions.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import React, { Fragment } from 'react'
|
||||
import AddToQueueButton from './AddToQueueButton'
|
||||
|
||||
export const SongBulkActions = (props) => {
|
||||
return (
|
||||
<Fragment>
|
||||
<AddToQueueButton {...props} />
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
@@ -7,12 +7,24 @@ import {
|
||||
List,
|
||||
NumberField,
|
||||
SearchInput,
|
||||
TextInput,
|
||||
Show,
|
||||
SimpleShowLayout,
|
||||
TextField
|
||||
TextField,
|
||||
TextInput
|
||||
} from 'react-admin'
|
||||
import { BitrateField, DurationField, Title } from '../common'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import {
|
||||
BitrateField,
|
||||
DurationField,
|
||||
Pagination,
|
||||
PlayButton,
|
||||
SimpleList,
|
||||
Title
|
||||
} from '../common'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { addTrack, setTrack } from '../player'
|
||||
import AddIcon from '@material-ui/icons/Add'
|
||||
import { SongBulkActions } from './SongBulkActions'
|
||||
|
||||
const SongFilter = (props) => (
|
||||
<Filter {...props}>
|
||||
@@ -27,7 +39,7 @@ const SongDetails = (props) => {
|
||||
<Show {...props} title=" ">
|
||||
<SimpleShowLayout>
|
||||
<TextField source="path" />
|
||||
<TextField label="Album Artist" source="albumArtist" />
|
||||
<TextField source="albumArtist" />
|
||||
<TextField source="genre" />
|
||||
<BooleanField source="compilation" />
|
||||
<BitrateField source="bitRate" />
|
||||
@@ -37,26 +49,49 @@ const SongDetails = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
const SongList = (props) => (
|
||||
<List
|
||||
{...props}
|
||||
title={<Title subTitle={'Songs'} />}
|
||||
sort={{ field: 'title', order: 'ASC' }}
|
||||
exporter={false}
|
||||
bulkActionButtons={false}
|
||||
filters={<SongFilter />}
|
||||
perPage={15}
|
||||
>
|
||||
<Datagrid expand={<SongDetails />}>
|
||||
<TextField source="title" />
|
||||
<TextField source="album" />
|
||||
<TextField source="artist" />
|
||||
<NumberField label="Track #" source="trackNumber" />
|
||||
<NumberField label="Disc #" source="discNumber" />
|
||||
<TextField source="year" />
|
||||
<DurationField label="Time" source="duration" />
|
||||
</Datagrid>
|
||||
</List>
|
||||
)
|
||||
const SongList = (props) => {
|
||||
const dispatch = useDispatch()
|
||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
|
||||
return (
|
||||
<List
|
||||
{...props}
|
||||
title={<Title subTitle={'Songs'} />}
|
||||
sort={{ field: 'title', order: 'ASC' }}
|
||||
exporter={false}
|
||||
bulkActionButtons={<SongBulkActions />}
|
||||
filters={<SongFilter />}
|
||||
perPage={isXsmall ? 50 : 15}
|
||||
pagination={<Pagination />}
|
||||
>
|
||||
{isXsmall ? (
|
||||
<SimpleList
|
||||
primaryText={(r) => (
|
||||
<>
|
||||
<PlayButton action={setTrack(r)} />
|
||||
<PlayButton action={addTrack(r)} icon={<AddIcon />} />
|
||||
{r.title}
|
||||
</>
|
||||
)}
|
||||
secondaryText={(r) => r.artist}
|
||||
tertiaryText={(r) => <DurationField record={r} source={'duration'} />}
|
||||
linkType={(id, basePath, record) => dispatch(setTrack(record))}
|
||||
/>
|
||||
) : (
|
||||
<Datagrid
|
||||
expand={<SongDetails />}
|
||||
rowClick={(id, basePath, record) => dispatch(setTrack(record))}
|
||||
>
|
||||
<TextField source="title" />
|
||||
{isDesktop && <TextField source="album" />}
|
||||
<TextField source="artist" />
|
||||
{isDesktop && <NumberField source="trackNumber" />}
|
||||
{isDesktop && <TextField source="year" />}
|
||||
<DurationField source="duration" />
|
||||
</Datagrid>
|
||||
)}
|
||||
</List>
|
||||
)
|
||||
}
|
||||
|
||||
export default SongList
|
||||
|
||||
22
ui/src/subsonic/index.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const subsonicUrl = (command, id, options) => {
|
||||
const params = new URLSearchParams()
|
||||
params.append('u', localStorage.getItem('username'))
|
||||
params.append('t', localStorage.getItem('subsonic-token'))
|
||||
params.append('s', localStorage.getItem('subsonic-salt'))
|
||||
params.append('f', 'json')
|
||||
params.append('v', '1.8.0')
|
||||
params.append('c', 'NavidromeUI')
|
||||
params.append('id', id)
|
||||
if (options) {
|
||||
if (options.ts) {
|
||||
options['_'] = new Date().getTime()
|
||||
delete options.ts
|
||||
}
|
||||
Object.keys(options).forEach((k) => {
|
||||
params.append(k, options[k])
|
||||
})
|
||||
}
|
||||
return `rest/${command}?${params.toString()}`
|
||||
}
|
||||
|
||||
export { subsonicUrl }
|
||||
72
utils/request_helpers.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ParamString(r *http.Request, param string) string {
|
||||
return r.URL.Query().Get(param)
|
||||
}
|
||||
|
||||
func ParamStrings(r *http.Request, param string) []string {
|
||||
return r.URL.Query()[param]
|
||||
}
|
||||
|
||||
func ParamTimes(r *http.Request, param string) []time.Time {
|
||||
pStr := ParamStrings(r, param)
|
||||
times := make([]time.Time, len(pStr))
|
||||
for i, t := range pStr {
|
||||
ti, err := strconv.ParseInt(t, 10, 64)
|
||||
if err == nil {
|
||||
times[i] = ToTime(ti)
|
||||
}
|
||||
}
|
||||
return times
|
||||
}
|
||||
|
||||
func ParamTime(r *http.Request, param string, def time.Time) time.Time {
|
||||
v := ParamString(r, param)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
value, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return ToTime(value)
|
||||
}
|
||||
|
||||
func ParamInt(r *http.Request, param string, def int) int {
|
||||
v := ParamString(r, param)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
value, err := strconv.ParseInt(v, 10, 32)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return int(value)
|
||||
}
|
||||
|
||||
func ParamInts(r *http.Request, param string) []int {
|
||||
pStr := ParamStrings(r, param)
|
||||
ints := make([]int, 0, len(pStr))
|
||||
for _, s := range pStr {
|
||||
i, err := strconv.ParseInt(s, 10, 32)
|
||||
if err == nil {
|
||||
ints = append(ints, int(i))
|
||||
}
|
||||
}
|
||||
return ints
|
||||
}
|
||||
|
||||
func ParamBool(r *http.Request, param string, def bool) bool {
|
||||
p := ParamString(r, param)
|
||||
if p == "" {
|
||||
return def
|
||||
}
|
||||
return strings.Index("/true/on/1/", "/"+p+"/") != -1
|
||||
}
|
||||