Compare commits

...

58 Commits

Author SHA1 Message Date
Deluan
400fa65326 feat: better scanner logging when level = info 2020-02-08 23:36:09 -05:00
Deluan
ab10719d27 fix: use a regex to match year in ffmpeg date field. close #63 2020-02-08 23:17:12 -05:00
Deluan
029290f304 fix: set default play_count to 0
IncPlayCount was not incrementing when the annotation already existed with play_count = null
2020-02-08 22:55:05 -05:00
Deluan
2c146ea1fe feat: add option to auto-create admin user on first start-up
Useful for development purposes
2020-02-08 14:50:33 -05:00
Deluan
10ead1f5f2 feat: better way to detect initial account creation 2020-02-08 14:32:55 -05:00
Deluan
730722cfe3 feat: better track number formatting 2020-02-08 11:50:11 -05:00
Deluan
dc352834b9 fix: workaround to force check for initial setup 2020-02-08 00:11:15 -05:00
Deluan
313a3342a0 fix: remove unused import 2020-02-07 22:35:04 -05:00
Deluan
0f13bbdbd0 docs: update screenshots 2020-02-07 18:21:51 -05:00
Deluan
4310f2c94f docs: update README 2020-02-07 18:02:44 -05:00
Deluan
6ce4811460 feat: add the remainder of the album to the queue when clicking on an album's track 2020-02-07 17:36:50 -05:00
Deluan
52cd17963f feat: limit size of cover art 2020-02-07 16:51:14 -05:00
Deluan
8f0c07d29f refactor: simplify PlayButton usage 2020-02-07 16:38:01 -05:00
Deluan
a50735a94c feat: custom SimpleList, to allow onClick handle 2020-02-07 16:08:53 -05:00
Deluan
f0e7f3ef25 feat: responsive album view 2020-02-07 16:08:52 -05:00
Deluan
2ca98d8e81 feat: optimized for small screens (only) 2020-02-07 13:50:25 -05:00
Deluan
81e1a7088f feat: new album view (initial implementation) 2020-02-07 11:49:26 -05:00
Deluan
d37351610a feat: initial support for i18n 2020-02-07 10:12:32 -05:00
Deluan
99361c0d9f fix: create a subsonic token on login, to use for subsonic API calls 2020-02-06 20:57:00 -05:00
Deluan
8673533cd4 refactor: move request param extractors to utils 2020-02-06 18:55:38 -05:00
Deluan
d9dd9fe587 refactor: put all subsonic client URLs together 2020-02-06 18:41:34 -05:00
Deluan
abb99a8501 feat: add authentication via JWT token 2020-02-06 18:41:34 -05:00
Deluan
690f92a671 feat: make song list more responsive 2020-02-06 18:41:34 -05:00
Deluan
c57007db52 feat: song list xsmall view 2020-02-06 18:41:34 -05:00
Deluan
cc229dcee6 chore: add direct dependency to react-redux 2020-02-06 18:41:34 -05:00
Deluan
7aab82c246 feat: enable overriding sql sorting 2020-02-06 18:41:34 -05:00
Deluan
989deb1200 feat: change pagination options 2020-02-06 18:41:34 -05:00
Deluan
6aaee4342e feat: smaller play button 2020-02-06 18:41:34 -05:00
Deluan
b5dadf55f4 feat: add an authenticated keepalive, to keep the UI session alive while playing songs 2020-02-06 18:41:34 -05:00
Deluan
18c7397709 feat: scrobbling 2020-02-06 18:41:34 -05:00
Deluan
4a82a6cb02 feat: initial integration of react-jinke-music-player 2020-02-06 18:41:33 -05:00
Deluan
220ffd5324 chore: removed unused code 2020-02-06 18:41:16 -05:00
Deluan
e33d2305a1 feat: support multiple year formats in the date tag (#63) 2020-02-06 14:44:50 -05:00
Deluan
7815b57920 fix: remove docker-compose.override.yml from repo 2020-02-06 12:14:10 -05:00
Deluan
18cbb153f3 chore: add a docker-compose.override.yml file, to support local testing 2020-02-06 12:12:10 -05:00
Deluan
9f086b5f7b docs: fix typo 2020-02-06 09:19:32 -05:00
Deluan
c8d6f2d506 feat: add m4b to mime-type list. fix #62 2020-02-06 08:48:02 -05:00
Deluan
6619b0986a chore: go mod tidy 2020-02-05 23:15:19 -05:00
Deluan
2dbd645292 feat: show server version in User Menu 2020-02-05 23:08:04 -05:00
Deluan
6978790e96 feat: allow regular users to login to the UI 2020-02-05 22:22:44 -05:00
Deluan
e0308acef3 feat: add lapsed time to SQL logger, to help detect SQL bottlenecks 2020-02-05 08:47:32 -05:00
Deluan
5fbde33b97 docs: update README 2020-02-05 08:40:15 -05:00
Deluan
19fb29e520 docs: add Discord invite button 2020-02-05 08:33:07 -05:00
Deluan
e5e35516d7 fix: initialize mimetypes for tests 2020-02-04 20:44:54 -05:00
Deluan
28bad95e66 test: removed unused file property 2020-02-04 19:59:04 -05:00
Deluan
9260957271 docs: update README 2020-02-04 15:17:10 -05:00
Deluan
79b0f1f57b docs: add link to ffmpeg static binaries download 2020-02-04 15:13:37 -05:00
Deluan
4dffcb7b46 fix: removed invalid make rule 2020-02-04 15:02:43 -05:00
Deluan
d1f8d39866 refactor: move banner to consts, closer to version 2020-02-04 10:14:53 -05:00
Deluan
0996272943 refactor: more reliable stream seek implementation 2020-02-04 10:01:31 -05:00
Deluan
d093191659 test: createTranscodeCommand 2020-02-04 09:34:26 -05:00
Deluan
998323b364 docs: update README re: transcoding 2020-02-04 09:09:14 -05:00
Deluan
6dfe56c1c4 feat: transcoding info in responses, to enable Jamstash to play transcoded FLAC. hardcoded for now 2020-02-04 09:01:22 -05:00
dependabot-preview[bot]
fd5548f890 build(deps): bump github.com/go-chi/jwtauth
Bumps [github.com/go-chi/jwtauth](https://github.com/go-chi/jwtauth) from 4.0.3+incompatible to 4.0.4+incompatible.
- [Release notes](https://github.com/go-chi/jwtauth/releases)
- [Commits](https://github.com/go-chi/jwtauth/compare/v4.0.3...v4.0.4)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-02-04 07:00:20 -05:00
Deluan
6e2454f6cc refactor: add -i to ffmpeg ProbeCommand. make it more consistent with the DownsampleCommand 2020-02-03 23:04:58 -05:00
Deluan
8372dee000 feat: experimental downsampling support 2020-02-03 22:53:57 -05:00
Deluan
41fd5862b8 chore: try to make goreleaser add all changes to changelog 2020-02-03 20:13:32 -05:00
Deluan
a6b5be7b0a ci: use latest ci-goreleaser 2020-02-03 18:24:14 -05:00
93 changed files with 2095 additions and 655 deletions

View File

@@ -6,7 +6,6 @@ Dockerfile
data
*.db
testDB
*_test.go
navidrome
navidrome.db
navidrome.toml

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

View File

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

Before

Width:  |  Height:  |  Size: 709 KiB

After

Width:  |  Height:  |  Size: 709 KiB

BIN
.github/screenshots/ss-mobile-player.png vendored Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

View File

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -24,7 +24,8 @@ jobs:
- name: Fetch tags
run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
- name: Run GoReleaser
uses: docker://bepsays/ci-goreleaser:1.13-4
uses: docker://bepsays/ci-goreleaser:latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:

1
.gitignore vendored
View File

@@ -19,3 +19,4 @@ navidrome.db
*_gen.go
dist
music
docker-compose.override.yml

View File

@@ -83,6 +83,3 @@ changelog:
filters:
exclude:
- '^docs:'
- '^test:'
- '^chore:'
- '^ci:'

View File

@@ -63,7 +63,7 @@ build: check_go_env
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=master"
.PHONY: buildall
buildall: check_env assets/embedded_gen.go
buildall: check_env
@(cd ./ui && npm run build)
go-bindata -fs -prefix "ui/build" -tags embed -nocompress -pkg assets -o assets/embedded_gen.go ui/build/...
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=master" -tags=embed
@@ -72,11 +72,10 @@ buildall: check_env assets/embedded_gen.go
release:
@if [[ ! "${V}" =~ ^[0-9]+\.[0-9]+\.[0-9]+.*$$ ]]; then echo "Usage: make release V=X.X.X"; exit 1; fi
go mod tidy
make test
@if [ -n "`git status -s`" ]; then echo "\n\nThere are pending changes. Please commit or stash first"; exit 1; fi
make test
git tag v${V}
git push origin v${V}
git push origin master
.PHONY: dist
dist:

View File

@@ -3,11 +3,10 @@
[![Build](https://img.shields.io/github/workflow/status/deluan/navidrome/Build?style=for-the-badge)](https://github.com/deluan/navidrome/actions)
[![Last Release](https://img.shields.io/github/v/release/deluan/navidrome?label=latest&style=for-the-badge)](https://github.com/deluan/navidrome/releases)
[![Docker Pulls](https://img.shields.io/docker/pulls/deluan/navidrome?style=for-the-badge)](https://hub.docker.com/r/deluan/navidrome)
[![Join the Chat](https://img.shields.io/discord/671335427726114836?style=for-the-badge)](https://discord.gg/xh7j7yF)
Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your
music collection from any browser or mobile device.
This is a fully functional _alpha quality_ software. Expect some changes in the feature set and the way it works.
music collection from any browser or mobile device. It's like your personal Spotify!
__Any feedback is welcome!__ If you need/want a new feature, find a bug or think of any way to improve Navidrome,
please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join the chat in our [Discord server](https://discord.gg/xh7j7yF)
@@ -26,31 +25,34 @@ please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join
- Compatible with the huge selection of clients for [Subsonic](http://www.subsonic.org),
[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:
- Transcoding/Downsampling on-the-fly
- Last.FM integration
- Integrated music player
- Pre-build binaries for Raspberry Pi
- Smart/dynamic playlists (similar to iTunes)
- Support for audiobooks (bookmarking)
- Jukebox mode
- Sharing links to albums/songs/playlists
- Podcasts
## Installation
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).
Remember to install [ffmpeg](https://ffmpeg.org/download.html) in your system, a requirement for Navidrome to work properly.
You may find the latest static build for your platform here: https://johnvansickle.com/ffmpeg/
If you have any issues with these binaries, or need a binary for a different platform, please
[open an issue](https://github.com/deluan/navidrome/issues)
@@ -77,9 +79,11 @@ 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`
### Build from source
You will need to install [Go 1.13](https://golang.org/dl/) and [Node 13.7.0](http://nodejs.org).
@@ -95,7 +99,7 @@ $ make setup # Install tools required for Navidrome's development
$ make buildall # Build UI and server, generates a single executable
```
This will generate the `navidrome` binary executable in the project's root folder.
This will generate the `navidrome` executable binary in the project's root folder.
### Running for the first time
@@ -114,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>

View File

@@ -22,14 +22,16 @@ type nd struct {
IgnoredArticles string `default:"The El La Los Las Le Les Os As O A"`
IndexGroups string `default:"A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)"`
DisableDownsampling bool `default:"false"`
DownsampleCommand string `default:"ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -"`
ProbeCommand string `default:"ffmpeg %s -f ffmetadata"`
ScanInterval string `default:"1m"`
EnableDownsampling bool `default:"false"`
MaxBitRate int `default:"0"`
DownsampleCommand string `default:"ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -"`
ProbeCommand string `default:"ffmpeg -i %s -f ffmetadata"`
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{}

View File

@@ -1,10 +1,9 @@
package main
package consts
import (
"fmt"
"strings"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/static"
)
@@ -13,8 +12,8 @@ func getBanner() string {
return strings.TrimSuffix(string(data), "\n")
}
func ShowBanner() {
version := "Version: " + consts.Version()
func Banner() string {
version := "Version: " + Version()
padding := strings.Repeat(" ", 52-len(version))
fmt.Printf("%s%s%s\n\n", getBanner(), padding, version)
return fmt.Sprintf("%s%s%s\n", getBanner(), padding, version)
}

View File

@@ -13,4 +13,7 @@ const (
JWTTokenExpiration = 30 * time.Minute
UIAssetsLocalPath = "ui/build"
DevInitialUserName = "admin"
DevInitialName = "Dev Admin"
)

View File

@@ -1,8 +1,8 @@
package server
package consts
import "mime"
func initMimeTypes() {
func init() {
mt := map[string]string{
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
@@ -11,6 +11,7 @@ func initMimeTypes() {
".ogx": "application/ogg",
".aac": "audio/mp4",
".m4a": "audio/mp4",
".m4b": "audio/mp4",
".flac": "audio/flac",
".wav": "audio/x-wav",
".wma": "audio/x-ms-wma",

View File

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

View File

@@ -1,4 +1,4 @@
package migrations
package migration
import (
"database/sql"

View File

@@ -1,4 +1,4 @@
package migrations
package migration
import (
"database/sql"

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

View File

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

View File

@@ -4,11 +4,13 @@ import (
"testing"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/tests"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
func TestEngine(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelCritical)
RegisterFailHandler(Fail)
RunSpecs(t, "Engine Suite")

220
engine/media_streamer.go Normal file
View File

@@ -0,0 +1,220 @@
package engine
import (
"context"
"io"
"io/ioutil"
"mime"
"os"
"os/exec"
"strconv"
"strings"
"time"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/utils"
)
type MediaStreamer interface {
NewStream(ctx context.Context, id string, maxBitRate int, format string) (mediaStream, error)
}
func NewMediaStreamer(ds model.DataStore) MediaStreamer {
return &mediaStreamer{ds: ds}
}
type mediaStream interface {
io.ReadSeeker
ContentType() string
Name() string
ModTime() time.Time
Close() error
}
type mediaStreamer struct {
ds model.DataStore
}
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, maxBitRate int, format string) (mediaStream, error) {
mf, err := ms.ds.MediaFile(ctx).Get(id)
if err != nil {
return nil, err
}
var bitRate int
if format == "raw" || !conf.Server.EnableDownsampling {
bitRate = mf.BitRate
format = mf.Suffix
} else {
if maxBitRate == 0 {
bitRate = mf.BitRate
} else {
bitRate = utils.MinInt(mf.BitRate, maxBitRate)
}
format = mf.Suffix
}
if conf.Server.MaxBitRate != 0 {
bitRate = utils.MinInt(bitRate, conf.Server.MaxBitRate)
}
var stream mediaStream
if bitRate == mf.BitRate && mime.TypeByExtension("."+format) == mf.ContentType() {
log.Debug(ctx, "Streaming raw file", "id", mf.ID, "path", mf.Path,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
f, err := os.Open(mf.Path)
if err != nil {
return nil, err
}
stream = &rawMediaStream{ctx: ctx, mf: mf, file: f}
return stream, nil
}
log.Debug(ctx, "Streaming transcoded file", "id", mf.ID, "path", mf.Path,
"requestBitrate", bitRate, "requestFormat", format,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
f := &transcodedMediaStream{ctx: ctx, mf: mf, bitRate: bitRate, format: format}
return f, err
}
type rawMediaStream struct {
file *os.File
ctx context.Context
mf *model.MediaFile
}
func (m *rawMediaStream) Read(p []byte) (n int, err error) {
return m.file.Read(p)
}
func (m *rawMediaStream) Seek(offset int64, whence int) (int64, error) {
return m.file.Seek(offset, whence)
}
func (m *rawMediaStream) ContentType() string {
return m.mf.ContentType()
}
func (m *rawMediaStream) Name() string {
return m.mf.Path
}
func (m *rawMediaStream) ModTime() time.Time {
return m.mf.UpdatedAt
}
func (m *rawMediaStream) Close() error {
log.Trace(m.ctx, "Closing file", "id", m.mf.ID, "path", m.mf.Path)
return m.file.Close()
}
type transcodedMediaStream struct {
ctx context.Context
mf *model.MediaFile
pipe io.ReadCloser
bitRate int
format string
skip int64
pos int64
}
func (m *transcodedMediaStream) Read(p []byte) (n int, err error) {
// Open the pipe and optionally skip a initial chunk of the stream (to simulate a Seek)
if m.pipe == nil {
m.pipe, err = newTranscode(m.ctx, m.mf.Path, m.bitRate, m.format)
if err != nil {
return 0, err
}
if m.skip > 0 {
_, err := io.CopyN(ioutil.Discard, m.pipe, m.skip)
m.pos = m.skip
if err != nil {
return 0, err
}
}
}
n, err = m.pipe.Read(p)
m.pos += int64(n)
if err == io.EOF {
m.Close()
}
return
}
// This is an attempt to make a pipe seekable. It is very wasteful, restarting the stream every time
// 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 transcoded stream", "path", m.mf.Path, "offset", offset, "whence", whence, "size", size)
switch whence {
case io.SeekEnd:
m.skip = size - offset
offset = size
case io.SeekStart:
m.skip = offset
case io.SeekCurrent:
io.CopyN(ioutil.Discard, m.pipe, offset)
m.pos += offset
offset = m.pos
}
// If need to Seek to a previous position, close the pipe (will be restarted on next Read)
var err error
if whence != io.SeekCurrent {
if m.pipe != nil {
err = m.Close()
}
}
return offset, err
}
func (m *transcodedMediaStream) ContentType() string {
return mime.TypeByExtension(".mp3")
}
func (m *transcodedMediaStream) Name() string {
return m.mf.Path
}
func (m *transcodedMediaStream) ModTime() time.Time {
return m.mf.UpdatedAt
}
func (m *transcodedMediaStream) Close() error {
log.Trace(m.ctx, "Closing stream", "id", m.mf.ID, "path", m.mf.Path)
err := m.pipe.Close()
m.pipe = nil
m.pos = 0
return err
}
func newTranscode(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
cmdLine, args := createTranscodeCommand(path, maxBitRate, format)
log.Trace(ctx, "Executing ffmpeg command", "arg0", cmdLine, "args", args)
cmd := exec.Command(cmdLine, args...)
cmd.Stderr = os.Stderr
if f, err = cmd.StdoutPipe(); err != nil {
return f, err
}
return f, cmd.Start()
}
func createTranscodeCommand(path string, maxBitRate int, format string) (string, []string) {
cmd := conf.Server.DownsampleCommand
split := strings.Split(cmd, " ")
for i, s := range split {
s = strings.Replace(s, "%s", path, -1)
s = strings.Replace(s, "%b", strconv.Itoa(maxBitRate), -1)
split[i] = s
}
return split[0], split[1:]
}

View File

@@ -0,0 +1,75 @@
package engine
import (
"time"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/persistence"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("MediaStreamer", func() {
var streamer MediaStreamer
var ds model.DataStore
ctx := log.NewContext(nil)
BeforeEach(func() {
conf.Server.EnableDownsampling = true
ds = &persistence.MockDataStore{}
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "bitRate": 128}]`, 1)
streamer = NewMediaStreamer(ds)
})
Context("NewStream", func() {
It("returns a rawMediaStream if format is 'raw'", func() {
Expect(streamer.NewStream(ctx, "123", 0, "raw")).To(BeAssignableToTypeOf(&rawMediaStream{}))
})
It("returns a rawMediaStream if maxBitRate is 0", func() {
Expect(streamer.NewStream(ctx, "123", 0, "mp3")).To(BeAssignableToTypeOf(&rawMediaStream{}))
})
It("returns a rawMediaStream if maxBitRate is higher than file bitRate", func() {
Expect(streamer.NewStream(ctx, "123", 256, "mp3")).To(BeAssignableToTypeOf(&rawMediaStream{}))
})
It("returns a transcodedMediaStream if maxBitRate is lower than file bitRate", func() {
s, err := streamer.NewStream(ctx, "123", 64, "mp3")
Expect(err).To(BeNil())
Expect(s).To(BeAssignableToTypeOf(&transcodedMediaStream{}))
Expect(s.(*transcodedMediaStream).bitRate).To(Equal(64))
})
})
Context("rawMediaStream", func() {
var rawStream mediaStream
var modTime time.Time
BeforeEach(func() {
modTime = time.Now()
mf := &model.MediaFile{ID: "123", Path: "test.mp3", UpdatedAt: modTime, Suffix: "mp3"}
rawStream = &rawMediaStream{mf: mf, ctx: ctx}
})
It("returns the ContentType", func() {
Expect(rawStream.ContentType()).To(Equal("audio/mpeg"))
})
It("returns the ModTime", func() {
Expect(rawStream.ModTime()).To(Equal(modTime))
})
})
Context("createTranscodeCommand", func() {
BeforeEach(func() {
conf.Server.DownsampleCommand = "ffmpeg -i %s -b:a %bk mp3 -"
})
It("creates a valid command line", func() {
cmd, args := createTranscodeCommand("/music library/file.mp3", 123, "")
Expect(cmd).To(Equal("ffmpeg"))
Expect(args).To(Equal([]string{"-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
})
})
})

View File

@@ -1,59 +0,0 @@
package engine
import (
"context"
"io"
"os"
"os/exec"
"strconv"
"strings"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/log"
)
// TODO Encapsulate as a io.Reader
func Stream(ctx context.Context, path string, bitRate int, maxBitRate int, w io.Writer) error {
var f io.Reader
var err error
enabled := !conf.Server.DisableDownsampling
if enabled && maxBitRate > 0 && bitRate > maxBitRate {
f, err = downsample(ctx, path, maxBitRate)
} else {
f, err = os.Open(path)
}
if err != nil {
log.Error(ctx, "Error opening file", "path", path, err)
return err
}
if _, err = io.Copy(w, f); err != nil {
log.Error(ctx, "Error copying file", "path", path, err)
return err
}
return err
}
func downsample(ctx context.Context, path string, maxBitRate int) (f io.Reader, err error) {
cmdLine, args := createDownsamplingCommand(path, maxBitRate)
log.Debug(ctx, "Executing command", "cmdLine", cmdLine, "args", args)
cmd := exec.Command(cmdLine, args...)
cmd.Stderr = os.Stderr
if f, err = cmd.StdoutPipe(); err != nil {
return f, err
}
return f, cmd.Start()
}
func createDownsamplingCommand(path string, maxBitRate int) (string, []string) {
cmd := conf.Server.DownsampleCommand
split := strings.Split(cmd, " ")
for i, s := range split {
s = strings.Replace(s, "%s", path, -1)
s = strings.Replace(s, "%b", strconv.Itoa(maxBitRate), -1)
split[i] = s
}
return split[0], split[1:]
}

View File

@@ -1,30 +0,0 @@
package engine
import (
"testing"
. "github.com/deluan/navidrome/tests"
. "github.com/smartystreets/goconvey/convey"
)
func TestDownsampling(t *testing.T) {
Init(t, false)
Convey("Subject: createDownsamplingCommand", t, func() {
Convey("It should create a valid command line", func() {
cmd, args := createDownsamplingCommand("/music library/file.mp3", 128)
So(cmd, ShouldEqual, "ffmpeg")
So(args[0], ShouldEqual, "-i")
So(args[1], ShouldEqual, "/music library/file.mp3")
So(args[2], ShouldEqual, "-b:a")
So(args[3], ShouldEqual, "128k")
So(args[4], ShouldEqual, "mp3")
So(args[5], ShouldEqual, "-")
})
})
}

View File

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

View File

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

View File

@@ -12,4 +12,5 @@ var Set = wire.NewSet(
NewSearch,
NewNowPlayingRepository,
NewUsers,
NewMediaStreamer,
)

2
go.mod
View File

@@ -14,7 +14,7 @@ require (
github.com/fatih/structs v1.0.0 // indirect
github.com/go-chi/chi v4.0.3+incompatible
github.com/go-chi/cors v1.0.0
github.com/go-chi/jwtauth v4.0.3+incompatible
github.com/go-chi/jwtauth v4.0.4+incompatible
github.com/go-sql-driver/mysql v1.5.0 // indirect
github.com/golang/protobuf v1.3.1 // indirect
github.com/google/uuid v1.1.1

4
go.sum
View File

@@ -41,8 +41,8 @@ github.com/go-chi/chi v4.0.3+incompatible h1:gakN3pDJnzZN5jqFV2TEdF66rTfKeITyR8q
github.com/go-chi/chi v4.0.3+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/cors v1.0.0 h1:e6x8k7uWbUwYs+aXDoiUzeQFT6l0cygBYyNhD7/1Tg0=
github.com/go-chi/cors v1.0.0/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
github.com/go-chi/jwtauth v4.0.3+incompatible h1:hPhobLUgh7fMpA1qUDdId14u2Z93M22fCNPMVLNWeHU=
github.com/go-chi/jwtauth v4.0.3+incompatible/go.mod h1:Q5EIArY/QnD6BdS+IyDw7B2m6iNbnPxtfd6/BcmtWbs=
github.com/go-chi/jwtauth v4.0.4+incompatible h1:LGIxg6YfvSBzxU2BljXbrzVc1fMlgqSKBQgKOGAVtPY=
github.com/go-chi/jwtauth v4.0.4+incompatible/go.mod h1:Q5EIArY/QnD6BdS+IyDw7B2m6iNbnPxtfd6/BcmtWbs=
github.com/go-redis/redis v6.14.2+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=

View File

@@ -2,12 +2,13 @@ package main
import (
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/db"
)
func main() {
if !conf.Server.DevDisableBanner {
ShowBanner()
println(consts.Banner())
}
conf.Load()

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"strings"
"text/scanner"
"time"
. "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
@@ -14,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"
@@ -30,6 +33,14 @@ func userId(ctx context.Context) string {
return usr.ID
}
func loggedUser(ctx context.Context) *model.User {
user := ctx.Value("user")
if user == nil {
return &model.User{}
}
return user.(*model.User)
}
func (r sqlRepository) newSelect(options ...model.QueryOptions) SelectBuilder {
sq := Select().From(r.tableName)
sq = r.applyOptions(sq, options...)
@@ -46,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
@@ -68,9 +98,10 @@ func (r sqlRepository) executeSQL(sq Sqlizer) (int64, error) {
if err != nil {
return 0, err
}
start := time.Now()
res, err := r.ormer.Raw(query, args...).Exec()
c, _ := res.RowsAffected()
r.logSQL(query, args, err, c)
r.logSQL(query, args, err, c, start)
if err != nil {
if err.Error() != "LastInsertId is not supported by this driver" {
return 0, err
@@ -84,12 +115,13 @@ func (r sqlRepository) queryOne(sq Sqlizer, response interface{}) error {
if err != nil {
return err
}
start := time.Now()
err = r.ormer.Raw(query, args...).QueryRow(response)
if err == orm.ErrNoRows {
r.logSQL(query, args, nil, 1)
r.logSQL(query, args, nil, 1, start)
return model.ErrNotFound
}
r.logSQL(query, args, err, 1)
r.logSQL(query, args, err, 1, start)
return err
}
@@ -98,12 +130,13 @@ func (r sqlRepository) queryAll(sq Sqlizer, response interface{}) error {
if err != nil {
return err
}
start := time.Now()
c, err := r.ormer.Raw(query, args...).QueryRows(response)
if err == orm.ErrNoRows {
r.logSQL(query, args, nil, c)
r.logSQL(query, args, nil, c, start)
return model.ErrNotFound
}
r.logSQL(query, args, nil, c)
r.logSQL(query, args, nil, c, start)
return err
}
@@ -154,7 +187,8 @@ func (r sqlRepository) delete(cond Sqlizer) error {
return err
}
func (r sqlRepository) logSQL(sql string, args []interface{}, err error, rowsAffected int64) {
func (r sqlRepository) logSQL(sql string, args []interface{}, err error, rowsAffected int64, start time.Time) {
lapsed := time.Since(start)
var fmtArgs []string
for i := range args {
var f string
@@ -167,9 +201,9 @@ func (r sqlRepository) logSQL(sql string, args []interface{}, err error, rowsAff
fmtArgs = append(fmtArgs, f)
}
if err != nil {
log.Error(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, err)
log.Error(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, "lapsedTime", lapsed, err)
} else {
log.Trace(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected)
log.Trace(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, "lapsedTime", lapsed)
}
}
@@ -177,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 {

View File

@@ -85,10 +85,18 @@ func (r *userRepository) UpdateLastAccessAt(id string) error {
}
func (r *userRepository) Count(options ...rest.QueryOptions) (int64, error) {
usr := loggedUser(r.ctx)
if !usr.IsAdmin {
return 0, rest.ErrPermissionDenied
}
return r.CountAll(r.parseRestOptions(options...))
}
func (r *userRepository) Read(id string) (interface{}, error) {
usr := loggedUser(r.ctx)
if !usr.IsAdmin && usr.ID != id {
return nil, rest.ErrPermissionDenied
}
usr, err := r.Get(id)
if err == model.ErrNotFound {
return nil, rest.ErrNotFound
@@ -97,6 +105,10 @@ func (r *userRepository) Read(id string) (interface{}, error) {
}
func (r *userRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
usr := loggedUser(r.ctx)
if !usr.IsAdmin {
return nil, rest.ErrPermissionDenied
}
return r.GetAll(r.parseRestOptions(options...))
}
@@ -109,17 +121,25 @@ func (r *userRepository) NewInstance() interface{} {
}
func (r *userRepository) Save(entity interface{}) (string, error) {
usr := entity.(*model.User)
err := r.Put(usr)
usr := loggedUser(r.ctx)
if !usr.IsAdmin {
return "", rest.ErrPermissionDenied
}
u := entity.(*model.User)
err := r.Put(u)
if err != nil {
return "", err
}
return usr.ID, err
return u.ID, err
}
func (r *userRepository) Update(entity interface{}, cols ...string) error {
usr := entity.(*model.User)
err := r.Put(usr)
u := entity.(*model.User)
usr := loggedUser(r.ctx)
if !usr.IsAdmin && usr.ID != u.ID {
return rest.ErrPermissionDenied
}
err := r.Put(u)
if err == model.ErrNotFound {
return rest.ErrNotFound
}
@@ -127,7 +147,11 @@ func (r *userRepository) Update(entity interface{}, cols ...string) error {
}
func (r *userRepository) Delete(id string) error {
err := r.Delete(id)
usr := loggedUser(r.ctx)
if !usr.IsAdmin && usr.ID != id {
return rest.ErrPermissionDenied
}
err := r.delete(Eq{"id": id})
if err == model.ErrNotFound {
return rest.ErrNotFound
}

View File

@@ -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" }
@@ -74,7 +74,7 @@ func ExtractAllMetadata(dirPath string) (map[string]*Metadata, error) {
func probe(inputs []string) (map[string]*Metadata, error) {
cmdLine, args := createProbeCommand(inputs)
log.Trace("Executing command", "cmdLine", cmdLine, "args", args)
log.Trace("Executing command", "arg0", cmdLine, "args", args)
cmd := exec.Command(cmdLine, args...)
output, _ := cmd.CombinedOutput()
mds := map[string]*Metadata{}
@@ -192,6 +192,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, "/")
@@ -233,10 +257,15 @@ func createProbeCommand(inputs []string) (string, []string) {
split := strings.Split(cmd, " ")
args := make([]string, 0)
first := true
for _, s := range split {
if s == "%s" {
for _, inp := range inputs {
args = append(args, "-i", inp)
if !first {
args = append(args, "-i")
}
args = append(args, inp)
first = false
}
continue
}

View File

@@ -61,21 +61,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 +72,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 +85,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 +103,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 +111,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 +123,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 +141,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))
})
})
})

View File

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

View File

@@ -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
@@ -185,12 +185,13 @@ func (s *TagScanner) processChangedDir(ctx context.Context, dir string, updatedA
}
}
log.Debug("Finished processing changed folder", "dir", dir, "updated", numUpdatedTracks, "purged", numPurgedTracks, "elapsed", time.Since(start))
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,6 +202,7 @@ 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)
}

View File

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

View File

@@ -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
@@ -63,6 +62,8 @@ func handleLogin(ds model.DataStore, username string, password string, w http.Re
"token": tokenString,
"name": user.Name,
"username": username,
"isAdmin": user.IsAdmin,
"version": consts.Version(),
})
}
@@ -80,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)
@@ -127,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 {
@@ -148,10 +139,6 @@ func validateLogin(userRepo model.UserRepository, userName, password string) (*m
if u.Password != password {
return nil, nil
}
if !u.IsAdmin {
log.Warn("Non-admin user tried to login", "user", userName)
return nil, nil
}
err = userRepo.UpdateLastLoginAt(u.ID)
if err != nil {
log.Error("Could not update LastLoginAt", "user", userName)
@@ -159,28 +146,10 @@ 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
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 userFrom(claims jwt.MapClaims) *model.User {
user := &model.User{
UserName: claims["sub"].(string),
}
return user
func contextWithUser(ctx context.Context, ds model.DataStore, claims jwt.MapClaims) context.Context {
userName := claims["sub"].(string)
user, _ := ds.User(ctx).FindByUsername(userName)
return context.WithValue(ctx, "user", user)
}
func getToken(ds model.DataStore, ctx context.Context) (*jwt.Token, error) {
@@ -201,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) {
@@ -217,8 +186,8 @@ func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
claims := token.Claims.(jwt.MapClaims)
newCtx := context.WithValue(r.Context(), "loggedUser", userFrom(claims))
newTokenString, err := touchToken(token)
newCtx := contextWithUser(r.Context(), ds, claims)
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
View 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)
}
}
}

View File

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

View File

@@ -23,7 +23,6 @@ type Server struct {
func New(scanner *scanner.Scanner, ds model.DataStore) *Server {
a := &Server{Scanner: scanner, ds: ds}
initMimeTypes()
initialSetup(ds)
a.initRoutes()
a.initScanner()

View File

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

View File

@@ -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"
)
@@ -25,15 +26,17 @@ type Router struct {
Scrobbler engine.Scrobbler
Search engine.Search
Users engine.Users
Streamer engine.MediaStreamer
mux http.Handler
}
func New(browser engine.Browser, cover engine.Cover, listGenerator engine.ListGenerator, users engine.Users,
playlists engine.Playlists, ratings engine.Ratings, scrobbler engine.Scrobbler, search engine.Search) *Router {
playlists engine.Playlists, ratings engine.Ratings, scrobbler engine.Scrobbler, search engine.Search,
streamer engine.MediaStreamer) *Router {
r := &Router{Browser: browser, Cover: cover, ListGenerator: listGenerator, Playlists: playlists,
Ratings: ratings, Scrobbler: scrobbler, Search: search, Users: users}
Ratings: ratings, Scrobbler: scrobbler, Search: search, Users: users, Streamer: streamer}
r.mux = r.routes()
return r
}
@@ -161,7 +164,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":
@@ -170,7 +173,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))

View File

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

View File

@@ -2,10 +2,9 @@ package subsonic
import (
"fmt"
"mime"
"net/http"
"strconv"
"strings"
"time"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/engine"
@@ -19,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)
}
@@ -27,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 {
@@ -200,6 +135,9 @@ func ToChild(entry engine.Entry) responses.Child {
child.Type = entry.Type
child.UserRating = entry.UserRating
child.SongCount = entry.SongCount
// TODO Must be dynamic, based on player/transcoding config
child.TranscodedSuffix = "mp3"
child.TranscodedContentType = mime.TypeByExtension(".mp3")
return child
}

View File

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

View File

@@ -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,7 +37,7 @@ 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)
err = c.cover.Get(r.Context(), id, size, w)

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,92 +2,52 @@ package subsonic
import (
"net/http"
"strconv"
"github.com/deluan/navidrome/engine"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/server/subsonic/responses"
"github.com/deluan/navidrome/utils"
)
type StreamController struct {
browser engine.Browser
streamer engine.MediaStreamer
}
func NewStreamController(browser engine.Browser) *StreamController {
return &StreamController{browser: browser}
func NewStreamController(streamer engine.MediaStreamer) *StreamController {
return &StreamController{streamer: streamer}
}
func (c *StreamController) getMediaFile(r *http.Request) (mf *engine.Entry, err error) {
func (c *StreamController) Stream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
id, err := RequiredParamString(r, "id", "id parameter required")
if err != nil {
return nil, err
}
maxBitRate := utils.ParamInt(r, "maxBitRate", 0)
format := utils.ParamString(r, "format")
ms, err := c.streamer.NewStream(r.Context(), id, maxBitRate, format)
if err != nil {
return nil, err
}
// Override Content-Type detected by http.FileServer
w.Header().Set("Content-Type", ms.ContentType())
http.ServeContent(w, r, ms.Name(), ms.ModTime(), ms)
return nil, nil
}
func (c *StreamController) Download(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
id, err := RequiredParamString(r, "id", "id parameter required")
if err != nil {
return nil, err
}
mf, err = c.browser.GetSong(r.Context(), id)
switch {
case err == model.ErrNotFound:
log.Error(r, "Mediafile not found", "id", id)
return nil, NewError(responses.ErrorDataNotFound)
case err != nil:
log.Error(r, "Error reading mediafile from DB", "id", id, err)
return nil, NewError(responses.ErrorGeneric, "Internal error")
}
return
}
// TODO Still getting the "Conn.Write wrote more than the declared Content-Length" error.
// Don't know if this causes any issues
func (c *StreamController) Stream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
mf, err := c.getMediaFile(r)
ms, err := c.streamer.NewStream(r.Context(), id, 0, "raw")
if err != nil {
return nil, err
}
maxBitRate := ParamInt(r, "maxBitRate", 0)
maxBitRate = utils.MinInt(mf.BitRate, maxBitRate)
log.Debug(r, "Streaming file", "id", mf.Id, "path", mf.AbsolutePath, "bitrate", mf.BitRate, "maxBitRate", maxBitRate)
// TODO Send proper estimated content-length
//contentLength := mf.Size
//if maxBitRate > 0 {
// contentLength = strconv.Itoa((mf.Duration + 1) * maxBitRate * 1000 / 8)
//}
h := w.Header()
h.Set("Content-Length", strconv.Itoa(mf.Size))
h.Set("Content-Type", "audio/mpeg")
h.Set("Expires", "0")
h.Set("Cache-Control", "must-revalidate")
h.Set("Pragma", "public")
if r.Method == "HEAD" {
log.Debug(r, "Just a HEAD. Not streaming", "path", mf.AbsolutePath)
return nil, nil
}
err = engine.Stream(r.Context(), mf.AbsolutePath, mf.BitRate, maxBitRate, w)
if err != nil {
log.Error(r, "Error streaming file", "id", mf.Id, err)
}
log.Debug(r, "Finished streaming", "path", mf.AbsolutePath)
return nil, nil
}
func (c *StreamController) Download(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
mf, err := c.getMediaFile(r)
if err != nil {
return nil, err
}
log.Debug(r, "Sending file", "path", mf.AbsolutePath)
err = engine.Stream(r.Context(), mf.AbsolutePath, 0, 0, w)
if err != nil {
log.Error(r, "Error downloading file", "path", mf.AbsolutePath, err)
}
log.Debug(r, "Finished sending", "path", mf.AbsolutePath)
// Override Content-Type detected by http.FileServer
w.Header().Set("Content-Type", ms.ContentType())
http.ServeContent(w, r, ms.Name(), ms.ModTime(), ms)
return nil, nil
}

View File

@@ -59,8 +59,8 @@ func initMediaRetrievalController(router *Router) *MediaRetrievalController {
}
func initStreamController(router *Router) *StreamController {
browser := router.Browser
streamController := NewStreamController(browser)
mediaStreamer := router.Streamer
streamController := NewStreamController(mediaStreamer)
return streamController
}
@@ -75,5 +75,5 @@ var allProviders = wire.NewSet(
NewSearchingController,
NewUsersController,
NewMediaRetrievalController,
NewStreamController, wire.FieldsOf(new(*Router), "Browser", "Cover", "ListGenerator", "Playlists", "Ratings", "Scrobbler", "Search"),
NewStreamController, wire.FieldsOf(new(*Router), "Browser", "Cover", "ListGenerator", "Playlists", "Ratings", "Scrobbler", "Search", "Streamer"),
)

View File

@@ -16,7 +16,7 @@ var allProviders = wire.NewSet(
NewUsersController,
NewMediaRetrievalController,
NewStreamController,
wire.FieldsOf(new(*Router), "Browser", "Cover", "ListGenerator", "Playlists", "Ratings", "Scrobbler", "Search"),
wire.FieldsOf(new(*Router), "Browser", "Cover", "ListGenerator", "Playlists", "Ratings", "Scrobbler", "Search", "Streamer"),
)
func initSystemController(router *Router) *SystemController {

View File

@@ -1,68 +0,0 @@
package tests
import (
"bytes"
"crypto/md5"
"encoding/json"
"encoding/xml"
"fmt"
"github.com/deluan/navidrome/server/subsonic/responses"
"github.com/smartystreets/goconvey/convey"
)
func ShouldMatchXML(actual interface{}, expected ...interface{}) string {
xml, err := xml.Marshal(actual)
if err != nil {
return fmt.Sprintf("Malformed XML: %v", err)
}
return convey.ShouldEqual(string(xml), expected[0].(string))
}
func ShouldMatchJSON(actual interface{}, expected ...interface{}) string {
json, err := json.Marshal(actual)
if err != nil {
return fmt.Sprintf("Malformed JSON: %v", err)
}
s := UnindentJSON(json)
return convey.ShouldEqual(s, expected[0].(string))
}
func ShouldContainJSON(actual interface{}, expected ...interface{}) string {
a := UnindentJSON(actual.(*bytes.Buffer).Bytes())
return convey.ShouldContainSubstring(a, expected[0].(string))
}
func ShouldReceiveError(actual interface{}, expected ...interface{}) string {
v := responses.Subsonic{}
err := xml.Unmarshal(actual.(*bytes.Buffer).Bytes(), &v)
if err != nil {
return fmt.Sprintf("Malformed XML: %v", err)
}
return convey.ShouldEqual(v.Error.Code, expected[0].(int))
}
func ShouldMatchMD5(actual interface{}, expected ...interface{}) string {
a := fmt.Sprintf("%x", md5.Sum(actual.([]byte)))
return convey.ShouldEqual(a, expected[0].(string))
}
func ShouldBeAValid(actual interface{}, expected ...interface{}) string {
v := responses.Subsonic{}
err := json.Unmarshal(actual.(*bytes.Buffer).Bytes(), &v)
if err != nil {
return fmt.Sprintf("Malformed response: %v", err)
}
return ""
}
func UnindentJSON(j []byte) string {
var m = make(map[string]interface{})
json.Unmarshal(j, &m)
s, _ := json.Marshal(m)
return string(s)
}

200
ui/package-lock.json generated
View File

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

View File

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

View File

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

View File

@@ -1,29 +1,53 @@
// 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}
>
<Resource name="artist" {...artist} options={{ subMenu: 'library' }} />
<Resource name="album" {...album} options={{ subMenu: 'library' }} />
<Resource name="song" {...song} options={{ subMenu: 'library' }} />
<Resource name="user" {...user} />
</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' }} />,
permissions === 'admin' ? <Resource name="user" {...user} /> : null,
<Player />
]}
</Admin>
)
}
export default App

View File

@@ -0,0 +1,51 @@
import React from 'react'
import { Loading, useGetOne } from 'react-admin'
import { Card, CardContent, CardMedia, Typography } from '@material-ui/core'
import { subsonicUrl } from '../subsonic'
const AlbumDetails = ({ id, classes }) => {
const { data, loading, error } = useGetOne('album', id)
if (loading) {
return <Loading />
}
if (error) {
return <p>ERROR: {error}</p>
}
const genreYear = (data) => {
let genreDateLine = []
if (data.genre) {
genreDateLine.push(data.genre)
}
if (data.year) {
genreDateLine.push(data.year)
}
return genreDateLine.join(' - ')
}
return (
<Card className={classes.container}>
<CardMedia
image={subsonicUrl(
'getCoverArt',
data.coverArtId || 'not_found',
'size=500'
)}
className={classes.albumCover}
/>
<CardContent className={classes.albumDetails}>
<Typography variant="h5" className={classes.albumTitle}>
{data.name}
</Typography>
<Typography component="h6">
{data.albumArtist || data.artist}
</Typography>
<Typography component="p">{genreYear(data)}</Typography>
</CardContent>
</Card>
)
}
export default AlbumDetails

View File

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

70
ui/src/album/AlbumShow.js Normal file
View File

@@ -0,0 +1,70 @@
import React from 'react'
import { Show } from 'react-admin'
import { Title } from '../common'
import { makeStyles } from '@material-ui/core/styles'
import AlbumSongList from './AlbumSongList'
import AlbumDetails from './AlbumDetails'
const AlbumTitle = ({ record }) => {
return <Title subTitle={record ? record.name : ''} />
}
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: '15em',
width: '15em'
},
[theme.breakpoints.up('lg')]: {
height: '20em',
width: '20em'
}
},
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'
}
}))
const AlbumShow = (props) => {
const classes = useStyles()
return (
<>
<AlbumDetails classes={classes} {...props} />
<Show title={<AlbumTitle />} {...props}>
<AlbumSongList {...props} />
</Show>
</>
)
}
export default AlbumShow

View File

@@ -0,0 +1,54 @@
import React from 'react'
import { useGetList } from 'react-admin'
import { DurationField, PlayButton, SimpleList } from '../common'
import { addTrack } from '../player'
import AddIcon from '@material-ui/icons/Add'
import { useDispatch } from 'react-redux'
import { playAlbum } from '../player/queue'
const AlbumSongList = (props) => {
const dispatch = useDispatch()
const { record } = props
const { data, total, loading, error } = useGetList(
'song',
{ page: 0, perPage: 100 },
{ field: 'album', order: 'ASC' },
{ album_id: record.id }
)
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 (
<SimpleList
data={data}
ids={Object.keys(data)}
loading={loading}
total={total}
primaryText={(r) => (
<>
<PlayButton action={playAlbum(r.id, data)} />
<PlayButton action={addTrack(r)} icon={<AddIcon />} />
{trackName(r)}
</>
)}
secondaryText={(r) =>
r.albumArtist && r.artist !== r.albumArtist ? r.artist : ''
}
tertiaryText={(r) => <DurationField record={r} source={'duration'} />}
linkType={(id) => dispatch(playAlbum(id, data))}
/>
)
}
export default AlbumSongList

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import jwtDecode from 'jwt-decode'
import md5 from 'md5-hex'
const authProvider = {
login: ({ username, password }) => {
@@ -23,8 +24,16 @@ const authProvider = {
jwtDecode(response.token)
localStorage.removeItem('initialAccountCreation')
localStorage.setItem('token', response.token)
localStorage.setItem('version', response.version)
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) => {
@@ -47,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()
@@ -59,13 +64,24 @@ const authProvider = {
return Promise.resolve()
},
getPermissions: (params) => Promise.resolve()
getPermissions: () => {
const role = localStorage.getItem('role')
return role ? Promise.resolve(role) : Promise.reject()
}
}
const removeItems = () => {
localStorage.removeItem('token')
localStorage.removeItem('name')
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

View 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

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

View File

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

View File

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

47
ui/src/i18n/en.js Normal file
View File

@@ -0,0 +1,47 @@
import deepmerge from 'deepmerge'
import englishMessages from 'ra-language-english'
export default deepmerge(englishMessages, {
resources: {
song: {
fields: {
albumArtist: 'Album Artist',
duration: 'Time',
trackNumber: 'Track #'
},
bulk: {
addToQueue: 'Play Later'
}
},
album: {
fields: {
albumArtist: 'Album Artist',
duration: 'Time'
}
}
},
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
View File

@@ -0,0 +1,3 @@
import en from './en'
export default { en }

23
ui/src/layout/AppBar.js Normal file
View File

@@ -0,0 +1,23 @@
import React, { forwardRef } from 'react';
import { AppBar as RAAppBar, UserMenu, MenuItemLink } from 'react-admin'
import InfoIcon from '@material-ui/icons/Info';
const ConfigurationMenu = forwardRef(({ onClick }, ref) => (
<MenuItemLink
ref={ref}
to=""
primaryText={"Version " + localStorage.getItem("version") }
leftIcon={<InfoIcon />}
onClick={onClick}
/>
))
const CustomUserMenu = (props) => (
<UserMenu {...props}>
<ConfigurationMenu />
</UserMenu>
)
const AppBar = (props) => <RAAppBar {...props} userMenu={<CustomUserMenu />} />
export default AppBar

View File

@@ -1,5 +1,6 @@
import React from 'react'
import { Layout } from 'react-admin'
import Menu from './Menu'
import AppBar from './AppBar'
export default (props) => <Layout {...props} menu={Menu} />
export default (props) => <Layout {...props} menu={Menu} appBar={AppBar} />

View File

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

View File

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

@@ -0,0 +1,4 @@
import Player from './Player'
import { addTrack, setTrack, playQueueReducer } from './queue'
export { Player, addTrack, setTrack, playQueueReducer }

84
ui/src/player/queue.js Normal file
View 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),
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 }

View File

@@ -0,0 +1,40 @@
import React from 'react'
import {
Button,
useDataProvider,
useUnselectAll,
useTranslate
} from 'react-admin'
import { useDispatch } from 'react-redux'
import { addTrack } from '../player'
import AddToQueueIcon from '@material-ui/icons/AddToQueue'
import Tooltip from '@material-ui/core/Tooltip'
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}>
<Tooltip
title={translate('resources.song.bulk.addToQueue')}
placement="right"
>
<AddToQueueIcon />
</Tooltip>
</Button>
)
}
export default AddToQueueButton

View File

@@ -1,4 +1,4 @@
import React from 'react'
import React, { Fragment } from 'react'
import {
BooleanField,
Datagrid,
@@ -7,12 +7,18 @@ 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, Title } from '../common'
import AddToQueueButton from './AddToQueueButton'
import { PlayButton, SimpleList } from '../common'
import { useDispatch } from 'react-redux'
import { setTrack, addTrack } from '../player'
import AddIcon from '@material-ui/icons/Add'
const SongFilter = (props) => (
<Filter {...props}>
@@ -22,12 +28,18 @@ const SongFilter = (props) => (
</Filter>
)
const SongBulkActionButtons = (props) => (
<Fragment>
<AddToQueueButton {...props} />
</Fragment>
)
const SongDetails = (props) => {
return (
<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={<SongBulkActionButtons />}
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

13
ui/src/subsonic/index.js Normal file
View File

@@ -0,0 +1,13 @@
const subsonicUrl = (command, id, options) => {
const username = localStorage.getItem('username')
const token = localStorage.getItem('subsonic-token')
const salt = localStorage.getItem('subsonic-salt')
const timeStamp = new Date().getTime()
const url = `rest/${command}?u=${username}&f=json&v=1.8.0&c=NavidromeUI&t=${token}&s=${salt}&id=${id}&_=${timeStamp}`
if (options) {
return url + '&' + options
}
return url
}
export { subsonicUrl }

72
utils/request_helpers.go Normal file
View 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
}

View File

@@ -41,7 +41,8 @@ func CreateSubsonicAPIRouter() *subsonic.Router {
ratings := engine.NewRatings(dataStore)
scrobbler := engine.NewScrobbler(dataStore, nowPlayingRepository)
search := engine.NewSearch(dataStore)
router := subsonic.New(browser, cover, listGenerator, users, playlists, ratings, scrobbler, search)
mediaStreamer := engine.NewMediaStreamer(dataStore)
router := subsonic.New(browser, cover, listGenerator, users, playlists, ratings, scrobbler, search, mediaStreamer)
return router
}