Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d8a2b310f | ||
|
|
3977575563 | ||
|
|
47244cb770 | ||
|
|
57aaf5a26b | ||
|
|
352d686d94 | ||
|
|
f6e448c1ba | ||
|
|
270b0ae74e | ||
|
|
8401d85f78 | ||
|
|
32fbf2e9eb | ||
|
|
8cdd4e317d | ||
|
|
97d95ea794 | ||
|
|
cbbebb3264 | ||
|
|
8b108905a3 | ||
|
|
5b40ec400e | ||
|
|
29e661e1fe | ||
|
|
b466ec75a4 | ||
|
|
c8cd755451 | ||
|
|
faac303eff | ||
|
|
ced87be57b | ||
|
|
811703ab60 | ||
|
|
bc1f767123 | ||
|
|
7055dc514b | ||
|
|
e02f3d3ec9 | ||
|
|
68a49befc8 | ||
|
|
c8b0d2bfae | ||
|
|
39993810b3 | ||
|
|
45180115a6 | ||
|
|
353c48d8d8 | ||
|
|
da36941252 | ||
|
|
8ec78900c5 | ||
|
|
a0e0fbad58 | ||
|
|
75e7ba8b1e | ||
|
|
74c30b5a66 | ||
|
|
e67bdbbc32 | ||
|
|
9554c8f783 | ||
|
|
e36a42f356 | ||
|
|
9d1960232c | ||
|
|
d3547544bf |
@@ -2,6 +2,8 @@
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- apt-get update
|
||||
- apt-get install -y gcc-arm-linux-gnueabi gcc-aarch64-linux-gnu
|
||||
- go get -u github.com/go-bindata/go-bindata/...
|
||||
- go-bindata -fs -prefix ui/build -tags embed -nocompress -pkg assets -o assets/embedded_gen.go ui/build/...
|
||||
- git checkout .
|
||||
@@ -21,7 +23,7 @@ builds:
|
||||
ldflags:
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
|
||||
|
||||
- id: navidrome_linux
|
||||
- id: navidrome_linux_amd64
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
goos:
|
||||
@@ -34,6 +36,38 @@ builds:
|
||||
- "-extldflags '-static'"
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
|
||||
|
||||
- id: navidrome_linux_arm
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=arm-linux-gnueabi-gcc
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- arm
|
||||
goarm:
|
||||
- 6
|
||||
- 7
|
||||
flags:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- "-extld=$CC"
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
|
||||
|
||||
- id: navidrome_linux_arm64
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=aarch64-linux-gnu-gcc
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- arm64
|
||||
flags:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
|
||||
|
||||
- id: navidrome_windows_i686
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
@@ -69,8 +103,15 @@ archives:
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
replacements:
|
||||
darwin: macOS
|
||||
linux: Linux
|
||||
windows: Windows
|
||||
386: i386
|
||||
amd64: x86_64
|
||||
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
name_template: '{{ .ProjectName }}_checksums.txt'
|
||||
|
||||
snapshot:
|
||||
name_template: "{{ .Tag }}-next"
|
||||
|
||||
@@ -54,7 +54,7 @@ Navidrome is actively being tested with:
|
||||
| `deletePlaylist` | |
|
||||
| ||
|
||||
| _MEDIA RETRIEVAL_ ||
|
||||
| `stream` | Experimental Transcoding/Downsampling support available |
|
||||
| `stream` | |
|
||||
| `download` | |
|
||||
| `getCoverArt` | Only gets embedded artwork |
|
||||
| `getAvatar` | Always returns the same image |
|
||||
|
||||
@@ -36,7 +36,7 @@ COPY --from=jsbuilder /src/build/* /src/ui/build/
|
||||
COPY --from=jsbuilder /src/build/static/css/* /src/ui/build/static/css/
|
||||
COPY --from=jsbuilder /src/build/static/js/* /src/ui/build/static/js/
|
||||
RUN rm -rf /src/build/css /src/build/js
|
||||
RUN GIT_TAG=$(git name-rev --name-only HEAD) && \
|
||||
RUN GIT_TAG=$(git describe --tags `git rev-list --tags --max-count=1`) && \
|
||||
GIT_TAG=${GIT_TAG#"tags/"} && \
|
||||
GIT_SHA=$(git rev-parse --short HEAD) && \
|
||||
echo "Building version: ${GIT_TAG} (${GIT_SHA})" && \
|
||||
|
||||
33
Makefile
@@ -3,28 +3,32 @@ NODE_VERSION=$(shell cat .nvmrc)
|
||||
|
||||
GIT_SHA=$(shell git rev-parse --short HEAD)
|
||||
|
||||
.PHONY: dev
|
||||
## Default target just build the Go project.
|
||||
default:
|
||||
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=master"
|
||||
.PHONY: default
|
||||
|
||||
dev: check_env
|
||||
@goreman -f Procfile.dev -b 4533 start
|
||||
.PHONY: dev
|
||||
|
||||
.PHONY: server
|
||||
server: check_go_env
|
||||
@reflex -d none -c reflex.conf
|
||||
.PHONY: server
|
||||
|
||||
.PHONY: watch
|
||||
watch: check_go_env
|
||||
ginkgo watch -notify ./...
|
||||
.PHONY: watch
|
||||
|
||||
.PHONY: test
|
||||
test: check_go_env
|
||||
go test ./... -v
|
||||
# @(cd ./ui && npm test -- --watchAll=false)
|
||||
.PHONY: test
|
||||
|
||||
.PHONY: testall
|
||||
testall: check_go_env test
|
||||
@(cd ./ui && npm test -- --watchAll=false)
|
||||
.PHONY: testall
|
||||
|
||||
.PHONY: setup
|
||||
setup: Jamstash-master
|
||||
@which goconvey || (echo "Installing GoConvey" && GO111MODULE=off go get -u github.com/smartystreets/goconvey)
|
||||
@which wire || (echo "Installing Wire" && GO111MODULE=off go get -u github.com/google/wire/cmd/wire)
|
||||
@@ -35,40 +39,40 @@ setup: Jamstash-master
|
||||
@which goose || (echo "Installing Goose" && GO111MODULE=off go get -u github.com/pressly/goose/cmd/goose)
|
||||
go mod download
|
||||
@(cd ./ui && npm ci)
|
||||
.PHONY: setup
|
||||
|
||||
.PHONY: static
|
||||
static:
|
||||
cd static && go-bindata -fs -prefix "static" -nocompress -ignore="\\\*.go" -pkg static .
|
||||
.PHONY: static
|
||||
|
||||
Jamstash-master:
|
||||
wget -N https://github.com/tsquillario/Jamstash/archive/master.zip
|
||||
unzip -o master.zip
|
||||
rm master.zip
|
||||
|
||||
.PHONE: check_env
|
||||
check_env: check_go_env check_node_env
|
||||
.PHONE: check_env
|
||||
|
||||
.PHONY: check_go_env
|
||||
check_go_env:
|
||||
@(hash go) || (echo "\nERROR: GO environment not setup properly!\n"; exit 1)
|
||||
@go version | grep -q $(GO_VERSION) || (echo "\nERROR: Please upgrade your GO version\nThis project requires version $(GO_VERSION)"; exit 1)
|
||||
.PHONY: check_go_env
|
||||
|
||||
.PHONY: check_node_env
|
||||
check_node_env:
|
||||
@(hash node) || (echo "\nERROR: Node environment not setup properly!\n"; exit 1)
|
||||
@node --version | grep -q $(NODE_VERSION) || (echo "\nERROR: Please check your Node version. Should be $(NODE_VERSION)\n"; exit 1)
|
||||
.PHONY: check_node_env
|
||||
|
||||
.PHONY: build
|
||||
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: build
|
||||
|
||||
.PHONY: buildall
|
||||
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
|
||||
.PHONY: buildall
|
||||
|
||||
.PHONY: release
|
||||
release:
|
||||
@if [[ ! "${V}" =~ ^[0-9]+\.[0-9]+\.[0-9]+.*$$ ]]; then echo "Usage: make release V=X.X.X"; exit 1; fi
|
||||
go mod tidy
|
||||
@@ -76,7 +80,8 @@ release:
|
||||
make test
|
||||
git tag v${V}
|
||||
git push origin v${V}
|
||||
.PHONY: release
|
||||
|
||||
.PHONY: dist
|
||||
dist:
|
||||
docker run -it -v $(PWD):/github/workspace -w /github/workspace bepsays/ci-goreleaser:1.14-1 goreleaser release --rm-dist --skip-publish --snapshot
|
||||
.PHONY: dist
|
||||
|
||||
43
README.md
@@ -9,7 +9,8 @@ Navidrome is an open source web-based music collection server and streamer. It g
|
||||
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)
|
||||
please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join the chat in
|
||||
our [Discord server](https://discord.gg/xh7j7yF)
|
||||
|
||||
|
||||
## Features
|
||||
@@ -20,21 +21,37 @@ please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join
|
||||
- Multi-user, each user has their own play counts, playlists, favourites, etc..
|
||||
- Very low resource usage: Ex: with a library of 300GB (~29000 songs), it uses less than 50MB of RAM
|
||||
- Multi-platform, runs on macOS, Linux and Windows. Docker images are also provided
|
||||
- Ready to use Raspberry Pi binaries available
|
||||
- Automatically monitors your library for changes, importing new files and reloading new metadata
|
||||
- Modern and responsive Web interface based on Material UI, to manage users and browse your library
|
||||
- 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)
|
||||
- Compatible with all Subsonic/Madsonic/Airsonic clients. See bellow for a list of tested clients
|
||||
- Transcoding/Downsampling on-the-fly. Can be set per user/player. Opus encoding is supported
|
||||
- Integrated music player (WIP)
|
||||
|
||||
Navidrome should be compatible with all Subsonic clients. The following clients are tested and confirmed to work properly:
|
||||
- Android:
|
||||
- [DSub](https://play.google.com/store/apps/details?id=github.daneren2005.dsub)
|
||||
- [Ultrasonic](https://play.google.com/store/apps/details?id=org.moire.ultrasonic)
|
||||
- [Music Stash](https://play.google.com/store/apps/details?id=com.ghenry22.mymusicstash)
|
||||
- iOS:
|
||||
- [play:Sub](http://michaelsapps.dk/playsubapp/)
|
||||
- Web:
|
||||
- [Jamstash](http://jamstash.com)
|
||||
- [Aurial](http://shrimpza.github.io/aurial/)
|
||||
- [Subfire](http://p.subfireplayer.net/)
|
||||
- [Subplayer](https://github.com/peguerosdc/subplayer)
|
||||
|
||||
For more options, look at the [list of clients](https://airsonic.github.io/docs/apps/) maintained by
|
||||
the Airsonic project. Please open an [issue](https://github.com/deluan/navidrome/issues) if you have any
|
||||
trouble with the client of your choice.
|
||||
|
||||
|
||||
## 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:
|
||||
|
||||
- Last.FM integration
|
||||
- Pre-build binaries for Raspberry Pi
|
||||
- Smart/dynamic playlists (similar to iTunes)
|
||||
- Support for audiobooks (bookmarking)
|
||||
- Jukebox mode
|
||||
@@ -49,17 +66,19 @@ Various options are available:
|
||||
### 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).
|
||||
platform. There are builds available for Linux (amd64 and arm), macOS and Windows (32 and 64 bits).
|
||||
For Raspberry Pi (tested with Raspbian Buster on Pi 4), use the Linux arm builds.
|
||||
|
||||
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/
|
||||
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)
|
||||
|
||||
### Docker
|
||||
|
||||
[Docker images](https://hub.docker.com/r/deluan/navidrome) are available. They include everything needed to run Navidrome. Example of usage:
|
||||
[Docker images](https://hub.docker.com/r/deluan/navidrome) are available. They include everything needed
|
||||
to run Navidrome. Example of usage:
|
||||
|
||||
```yaml
|
||||
# This is just an example. Customize it to your needs.
|
||||
@@ -77,9 +96,11 @@ services:
|
||||
ND_SCANINTERVAL: 1m
|
||||
ND_LOGLEVEL: info
|
||||
ND_PORT: 4533
|
||||
ND_TRANSCODINGCACHESIZE: 100MB
|
||||
ND_SESSIONTIMEOUT: 30m
|
||||
volumes:
|
||||
- "./data:/data"
|
||||
- "/path/to/your/music/folder:/music"
|
||||
- "/path/to/your/music/folder:/music:ro"
|
||||
```
|
||||
|
||||
To get the cutting-edge, latest version from master, use the image `deluan/navidrome:develop`
|
||||
|
||||
@@ -13,21 +13,19 @@ import (
|
||||
)
|
||||
|
||||
type nd struct {
|
||||
Port string `default:"4533"`
|
||||
MusicFolder string `default:"./music"`
|
||||
DataFolder string `default:"./"`
|
||||
ScanInterval string `default:"1m"`
|
||||
DbPath string
|
||||
LogLevel string `default:"info"`
|
||||
Port string `default:"4533"`
|
||||
MusicFolder string `default:"./music"`
|
||||
DataFolder string `default:"./"`
|
||||
ScanInterval string `default:"1m"`
|
||||
DbPath string ``
|
||||
LogLevel string `default:"info"`
|
||||
SessionTimeout string `default:"30m"`
|
||||
|
||||
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]([)"`
|
||||
|
||||
EnableDownsampling bool `default:"false"`
|
||||
MaxBitRate int `default:"0"`
|
||||
MaxTranscodingCacheSize int64 `default:"100"` // in MB
|
||||
DownsampleCommand string `default:"ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -"`
|
||||
ProbeCommand string `default:"ffmpeg -i %s -f ffmetadata"`
|
||||
TranscodingCacheSize string `default:"100MB"` // in MB
|
||||
ProbeCommand string `default:"ffmpeg -i %s -f ffmetadata"`
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
DevDisableBanner bool `default:"false"`
|
||||
|
||||
@@ -3,17 +3,18 @@ package consts
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/deluan/navidrome/static"
|
||||
)
|
||||
|
||||
func getBanner() string {
|
||||
data, _ := static.Asset("banner.txt")
|
||||
return strings.TrimSuffix(string(data), "\n")
|
||||
return strings.TrimRightFunc(string(data), unicode.IsSpace)
|
||||
}
|
||||
|
||||
func Banner() string {
|
||||
version := "Version: " + Version()
|
||||
padding := strings.Repeat(" ", 52-len(version))
|
||||
return fmt.Sprintf("%s%s%s\n", getBanner(), padding, version)
|
||||
return fmt.Sprintf("%s\n%s%s\n", getBanner(), padding, version)
|
||||
}
|
||||
|
||||
@@ -9,14 +9,34 @@ const (
|
||||
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL"
|
||||
InitialSetupFlagKey = "InitialSetup"
|
||||
|
||||
JWTSecretKey = "JWTSecret"
|
||||
JWTIssuer = "ND"
|
||||
JWTTokenExpiration = 30 * time.Minute
|
||||
JWTSecretKey = "JWTSecret"
|
||||
JWTIssuer = "ND"
|
||||
DefaultSessionTimeout = 30 * time.Minute
|
||||
|
||||
UIAssetsLocalPath = "ui/build"
|
||||
|
||||
CacheDir = "cache"
|
||||
CacheDir = "cache"
|
||||
DefaultTranscodingCacheSize = 100 * 1024 * 1024 // 100MB
|
||||
DefaultTranscodingCacheMaxItems = 0 // Unlimited
|
||||
DefaultTranscodingCachePurgeInterval = 10 * time.Minute
|
||||
|
||||
DevInitialUserName = "admin"
|
||||
DevInitialName = "Dev Admin"
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultTranscodings = []map[string]interface{}{
|
||||
{
|
||||
"name": "mp3 audio",
|
||||
"targetFormat": "mp3",
|
||||
"defaultBitRate": 192,
|
||||
"command": "ffmpeg -i %s -ab %bk -v 0 -f mp3 -",
|
||||
},
|
||||
{
|
||||
"name": "opus audio",
|
||||
"targetFormat": "oga",
|
||||
"defaultBitRate": 128,
|
||||
"command": "ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -c:a libopus -f opus -",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200310181627, Down20200310181627)
|
||||
}
|
||||
|
||||
func Up20200310181627(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table transcoding
|
||||
(
|
||||
id varchar(255) not null primary key,
|
||||
name varchar(255) not null,
|
||||
target_format varchar(255) not null,
|
||||
command varchar(255) default '' not null,
|
||||
default_bit_rate int default 192,
|
||||
unique (name),
|
||||
unique (target_format)
|
||||
);
|
||||
|
||||
create table player
|
||||
(
|
||||
id varchar(255) not null primary key,
|
||||
name varchar not null,
|
||||
type varchar,
|
||||
user_name varchar not null,
|
||||
client varchar not null,
|
||||
ip_address varchar,
|
||||
last_seen timestamp,
|
||||
max_bit_rate int default 0,
|
||||
transcoding_id varchar,
|
||||
unique (name),
|
||||
foreign key (transcoding_id)
|
||||
references transcoding(id)
|
||||
on update restrict
|
||||
on delete restrict
|
||||
);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200310181627(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
drop table transcoding;
|
||||
drop table player;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
42
db/migration/20200319211049_merge_search_into_main_tables.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200319211049, Down20200319211049)
|
||||
}
|
||||
|
||||
func Up20200319211049(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table media_file
|
||||
add full_text varchar(255) default '';
|
||||
create index if not exists media_file_full_text
|
||||
on media_file (full_text);
|
||||
|
||||
alter table album
|
||||
add full_text varchar(255) default '';
|
||||
create index if not exists album_full_text
|
||||
on album (full_text);
|
||||
|
||||
alter table artist
|
||||
add full_text varchar(255) default '';
|
||||
create index if not exists artist_full_text
|
||||
on artist (full_text);
|
||||
|
||||
drop table if exists search;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200319211049(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
@@ -13,9 +14,10 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
JwtSecret []byte
|
||||
TokenAuth *jwtauth.JWTAuth
|
||||
once sync.Once
|
||||
JwtSecret []byte
|
||||
TokenAuth *jwtauth.JWTAuth
|
||||
sessionTimeOut time.Duration
|
||||
)
|
||||
|
||||
func InitTokenAuth(ds model.DataStore) {
|
||||
@@ -39,8 +41,21 @@ func CreateToken(u *model.User) (string, error) {
|
||||
return TouchToken(token)
|
||||
}
|
||||
|
||||
func getSessionTimeOut() time.Duration {
|
||||
if sessionTimeOut == 0 {
|
||||
if to, err := time.ParseDuration(conf.Server.SessionTimeout); err != nil {
|
||||
sessionTimeOut = consts.DefaultSessionTimeout
|
||||
} else {
|
||||
sessionTimeOut = to
|
||||
}
|
||||
log.Info("Setting Session Timeout", "value", sessionTimeOut)
|
||||
}
|
||||
return sessionTimeOut
|
||||
}
|
||||
|
||||
func TouchToken(token *jwt.Token) (string, error) {
|
||||
expireIn := time.Now().Add(consts.JWTTokenExpiration).Unix()
|
||||
timeout := getSessionTimeOut()
|
||||
expireIn := time.Now().Add(timeout).Unix()
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims["exp"] = expireIn
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ func (b *browser) MediaFolders(ctx context.Context) (model.MediaFolders, error)
|
||||
|
||||
func (b *browser) Indexes(ctx context.Context, mediaFolderId string, ifModifiedSince time.Time) (model.ArtistIndexes, time.Time, error) {
|
||||
// TODO Proper handling of mediaFolderId param
|
||||
folder, err := b.ds.MediaFolder(ctx).Get(mediaFolderId)
|
||||
folder, _ := b.ds.MediaFolder(ctx).Get(mediaFolderId)
|
||||
|
||||
l, err := b.ds.Property(ctx).DefaultGet(model.PropLastScan+"-"+folder.Path, "-1")
|
||||
ms, _ := strconv.ParseInt(l, 10, 64)
|
||||
|
||||
@@ -14,12 +14,12 @@ import (
|
||||
"github.com/deluan/navidrome/engine/transcoder"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
"github.com/djherbis/fscache"
|
||||
"github.com/dustin/go-humanize"
|
||||
)
|
||||
|
||||
type MediaStreamer interface {
|
||||
NewStream(ctx context.Context, id string, maxBitRate int, format string) (*Stream, error)
|
||||
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error)
|
||||
}
|
||||
|
||||
func NewMediaStreamer(ds model.DataStore, ffm transcoder.Transcoder, cache fscache.Cache) MediaStreamer {
|
||||
@@ -32,18 +32,23 @@ type mediaStreamer struct {
|
||||
cache fscache.Cache
|
||||
}
|
||||
|
||||
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, maxBitRate int, reqFormat string) (*Stream, error) {
|
||||
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error) {
|
||||
mf, err := ms.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bitRate, format := selectTranscodingOptions(mf, maxBitRate, reqFormat)
|
||||
format, bitRate := selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate)
|
||||
log.Trace(ctx, "Selected transcoding options",
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
||||
"selectedBitrate", bitRate, "selectedFormat", format,
|
||||
)
|
||||
s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
|
||||
|
||||
if format == "raw" {
|
||||
log.Debug(ctx, "Streaming raw file", "id", mf.ID, "path", mf.Path,
|
||||
"requestBitrate", maxBitRate, "requestFormat", reqFormat,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
|
||||
f, err := os.Open(mf.Path)
|
||||
if err != nil {
|
||||
@@ -66,7 +71,12 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, maxBitRate in
|
||||
// If this is a brand new transcoding request, not in the cache, start transcoding
|
||||
if w != nil {
|
||||
log.Trace(ctx, "Cache miss. Starting new transcoding session", "id", mf.ID)
|
||||
out, err := ms.ffm.Start(ctx, mf.Path, bitRate, format)
|
||||
t, err := ms.ds.Transcoding(ctx).FindByFormat(format)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading transcoding command", "format", format, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
out, err := ms.ffm.Start(ctx, t.Command, mf.Path, bitRate, format)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error starting transcoder", "id", mf.ID, err)
|
||||
return nil, os.ErrInvalid
|
||||
@@ -79,7 +89,7 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, maxBitRate in
|
||||
size := getFinalCachedSize(r)
|
||||
if size > 0 {
|
||||
log.Debug(ctx, "Streaming cached file", "id", mf.ID, "path", mf.Path,
|
||||
"requestBitrate", maxBitRate, "requestFormat", reqFormat,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix, "size", size)
|
||||
sr := io.NewSectionReader(r, 0, size)
|
||||
s.Reader = sr
|
||||
@@ -91,7 +101,7 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, maxBitRate in
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Streaming transcoded file", "id", mf.ID, "path", mf.Path,
|
||||
"requestBitrate", maxBitRate, "requestFormat", reqFormat,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
|
||||
// All other cases, just return a ReadCloser, without Seek capabilities
|
||||
s.Reader = r
|
||||
@@ -131,27 +141,46 @@ func (s *Stream) ContentType() string { return mime.TypeByExtension("." + s.form
|
||||
func (s *Stream) Name() string { return s.mf.Path }
|
||||
func (s *Stream) ModTime() time.Time { return s.mf.UpdatedAt }
|
||||
|
||||
func selectTranscodingOptions(mf *model.MediaFile, maxBitRate int, format string) (int, string) {
|
||||
var bitRate int
|
||||
|
||||
if format == "raw" || !conf.Server.EnableDownsampling {
|
||||
return bitRate, "raw"
|
||||
// TODO This function deserves some love (refactoring)
|
||||
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) {
|
||||
format = "raw"
|
||||
if reqFormat == "raw" {
|
||||
return
|
||||
}
|
||||
trc, hasDefault := ctx.Value("transcoding").(model.Transcoding)
|
||||
var cFormat string
|
||||
var cBitRate int
|
||||
if reqFormat != "" {
|
||||
cFormat = reqFormat
|
||||
} else {
|
||||
if maxBitRate == 0 {
|
||||
bitRate = mf.BitRate
|
||||
} else {
|
||||
bitRate = utils.MinInt(mf.BitRate, maxBitRate)
|
||||
if hasDefault {
|
||||
cFormat = trc.TargetFormat
|
||||
cBitRate = trc.DefaultBitRate
|
||||
if p, ok := ctx.Value("player").(model.Player); ok {
|
||||
cBitRate = p.MaxBitRate
|
||||
}
|
||||
}
|
||||
format = "mp3" //mf.Suffix
|
||||
}
|
||||
if conf.Server.MaxBitRate != 0 {
|
||||
bitRate = utils.MinInt(bitRate, conf.Server.MaxBitRate)
|
||||
if reqBitRate > 0 {
|
||||
cBitRate = reqBitRate
|
||||
}
|
||||
|
||||
if bitRate == mf.BitRate {
|
||||
return bitRate, "raw"
|
||||
if cBitRate == 0 && cFormat == "" {
|
||||
return
|
||||
}
|
||||
return bitRate, format
|
||||
t, err := ds.Transcoding(ctx).FindByFormat(cFormat)
|
||||
if err == nil {
|
||||
format = t.TargetFormat
|
||||
if cBitRate != 0 {
|
||||
bitRate = cBitRate
|
||||
} else {
|
||||
bitRate = t.DefaultBitRate
|
||||
}
|
||||
}
|
||||
if format == mf.Suffix && bitRate > mf.BitRate {
|
||||
format = "raw"
|
||||
bitRate = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func cacheKey(id string, bitRate int, format string) string {
|
||||
@@ -170,9 +199,15 @@ func getFinalCachedSize(r fscache.ReadAtCloser) int64 {
|
||||
}
|
||||
|
||||
func NewTranscodingCache() (fscache.Cache, error) {
|
||||
lru := fscache.NewLRUHaunter(0, conf.Server.MaxTranscodingCacheSize*1024*1024, 10*time.Minute)
|
||||
cacheSize, err := humanize.ParseBytes(conf.Server.TranscodingCacheSize)
|
||||
if err != nil {
|
||||
cacheSize = consts.DefaultTranscodingCacheSize
|
||||
}
|
||||
lru := fscache.NewLRUHaunter(consts.DefaultTranscodingCacheMaxItems, int64(cacheSize), consts.DefaultTranscodingCachePurgeInterval)
|
||||
h := fscache.NewLRUHaunterStrategy(lru)
|
||||
cacheFolder := filepath.Join(conf.Server.DataFolder, consts.CacheDir)
|
||||
log.Info("Creating transcoding cache", "path", cacheFolder, "maxSize", humanize.Bytes(cacheSize),
|
||||
"cleanUpInterval", consts.DefaultTranscodingCachePurgeInterval)
|
||||
fs, err := fscache.NewFs(cacheFolder, 0755)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
@@ -31,9 +30,8 @@ var _ = Describe("MediaStreamer", func() {
|
||||
})
|
||||
|
||||
BeforeEach(func() {
|
||||
conf.Server.EnableDownsampling = true
|
||||
ds = &persistence.MockDataStore{}
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "bitRate": 128, "duration": 257.0}]`, 1)
|
||||
ds = &persistence.MockDataStore{MockedTranscoding: &mockTranscodingRepository{}}
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "suffix": "mp3", "bitRate": 128, "duration": 257.0}]`, 1)
|
||||
streamer = NewMediaStreamer(ds, ffmpeg, cache)
|
||||
})
|
||||
|
||||
@@ -43,33 +41,140 @@ var _ = Describe("MediaStreamer", func() {
|
||||
|
||||
Context("NewStream", func() {
|
||||
It("returns a seekable stream if format is 'raw'", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", 0, "raw")
|
||||
s, err := streamer.NewStream(ctx, "123", "raw", 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a seekable stream if maxBitRate is 0", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", 0, "mp3")
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a seekable stream if maxBitRate is higher than file bitRate", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", 320, "mp3")
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 320)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a NON seekable stream if transcode is required", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", 64, "mp3")
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 64)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s.Seekable()).To(BeFalse())
|
||||
Expect(s.Duration()).To(Equal(float32(257.0)))
|
||||
})
|
||||
It("returns a seekable stream if the file is complete in the cache", func() {
|
||||
Eventually(func() bool { return ffmpeg.closed }).Should(BeTrue())
|
||||
s, err := streamer.NewStream(ctx, "123", 64, "mp3")
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 64)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("selectTranscodingOptions", func() {
|
||||
mf := &model.MediaFile{}
|
||||
Context("player is not configured", func() {
|
||||
It("returns raw if raw is requested", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns raw if a transcoder does not exists", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns the requested format if a transcoder exists", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
||||
})
|
||||
It("returns raw if requested format is the same as the original and it is not necessary to downsample", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 112
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns the requested format if requested BitRate is lower than original", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 320
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(192))
|
||||
})
|
||||
})
|
||||
|
||||
Context("player has format configured", func() {
|
||||
BeforeEach(func() {
|
||||
t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
|
||||
ctx = context.WithValue(ctx, "transcoding", t)
|
||||
})
|
||||
It("returns raw if raw is requested", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns configured format/bitrate as default", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(96))
|
||||
})
|
||||
It("returns requested format", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
||||
})
|
||||
It("returns requested bitrate", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(80))
|
||||
})
|
||||
})
|
||||
Context("player has maxBitRate configured", func() {
|
||||
BeforeEach(func() {
|
||||
t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
|
||||
p := model.Player{ID: "player1", TranscodingId: t.ID, MaxBitRate: 80}
|
||||
ctx = context.WithValue(ctx, "transcoding", t)
|
||||
ctx = context.WithValue(ctx, "player", p)
|
||||
})
|
||||
It("returns raw if raw is requested", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns configured format/bitrate as default", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(80))
|
||||
})
|
||||
It("returns requested format", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
||||
})
|
||||
It("returns requested bitrate", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(80))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type fakeFFmpeg struct {
|
||||
@@ -78,7 +183,7 @@ type fakeFFmpeg struct {
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (ff *fakeFFmpeg) Start(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
|
||||
func (ff *fakeFFmpeg) Start(ctx context.Context, cmd, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
|
||||
ff.r = strings.NewReader(ff.Data)
|
||||
return ff, nil
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
func CreateMockNowPlayingRepo() *MockNowPlaying {
|
||||
return &MockNowPlaying{}
|
||||
}
|
||||
|
||||
type MockNowPlaying struct {
|
||||
NowPlayingRepository
|
||||
data []NowPlayingInfo
|
||||
t time.Time
|
||||
err bool
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) SetError(err bool) {
|
||||
m.err = err
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) Enqueue(info *NowPlayingInfo) error {
|
||||
if m.err {
|
||||
return errors.New("Error!")
|
||||
}
|
||||
|
||||
m.data = append(m.data, NowPlayingInfo{})
|
||||
copy(m.data[1:], m.data[0:])
|
||||
m.data[0] = *info
|
||||
|
||||
if !m.t.IsZero() {
|
||||
m.data[0].Start = m.t
|
||||
m.t = time.Time{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) Dequeue(playerId int) (*NowPlayingInfo, error) {
|
||||
if len(m.data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
l := len(m.data)
|
||||
info := m.data[l-1]
|
||||
m.data = m.data[:l-1]
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) Count(playerId int) (int64, error) {
|
||||
return int64(len(m.data)), nil
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) GetAll() ([]*NowPlayingInfo, error) {
|
||||
np, err := m.Head(1)
|
||||
if np == nil || err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []*NowPlayingInfo{np}, err
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) Head(playerId int) (*NowPlayingInfo, error) {
|
||||
if len(m.data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
info := m.data[0]
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) Tail(playerId int) (*NowPlayingInfo, error) {
|
||||
if len(m.data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
info := m.data[len(m.data)-1]
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) ClearAll() {
|
||||
m.data = make([]NowPlayingInfo, 0)
|
||||
m.err = false
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) OverrideNow(t time.Time) {
|
||||
m.t = t
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
func CreateMockPropertyRepo() *MockProperty {
|
||||
return &MockProperty{data: make(map[string]string)}
|
||||
}
|
||||
|
||||
type MockProperty struct {
|
||||
model.PropertyRepository
|
||||
data map[string]string
|
||||
err bool
|
||||
}
|
||||
|
||||
func (m *MockProperty) SetError(err bool) {
|
||||
m.err = err
|
||||
}
|
||||
|
||||
func (m *MockProperty) Put(id string, value string) error {
|
||||
if m.err {
|
||||
return errors.New("Error!")
|
||||
}
|
||||
m.data[id] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockProperty) Get(id string) (string, error) {
|
||||
if m.err {
|
||||
return "", errors.New("Error!")
|
||||
}
|
||||
return m.data[id], nil
|
||||
}
|
||||
|
||||
func (m *MockProperty) DefaultGet(id string, defaultValue string) (string, error) {
|
||||
v, err := m.Get(id)
|
||||
|
||||
if v == "" {
|
||||
v = defaultValue
|
||||
}
|
||||
|
||||
return v, err
|
||||
}
|
||||
22
engine/mock_transcoding_repo_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package engine
|
||||
|
||||
import "github.com/deluan/navidrome/model"
|
||||
|
||||
type mockTranscodingRepository struct {
|
||||
model.TranscodingRepository
|
||||
}
|
||||
|
||||
func (m *mockTranscodingRepository) Get(id string) (*model.Transcoding, error) {
|
||||
return &model.Transcoding{ID: id, TargetFormat: "mp3", DefaultBitRate: 160}, nil
|
||||
}
|
||||
|
||||
func (m *mockTranscodingRepository) FindByFormat(format string) (*model.Transcoding, error) {
|
||||
switch format {
|
||||
case "mp3":
|
||||
return &model.Transcoding{ID: "mp31", TargetFormat: "mp3", DefaultBitRate: 160}, nil
|
||||
case "oga":
|
||||
return &model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 128}, nil
|
||||
default:
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
}
|
||||
67
engine/players.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Players interface {
|
||||
Get(ctx context.Context, playerId string) (*model.Player, error)
|
||||
Register(ctx context.Context, id, client, typ, ip string) (*model.Player, *model.Transcoding, error)
|
||||
}
|
||||
|
||||
func NewPlayers(ds model.DataStore) Players {
|
||||
return &players{ds}
|
||||
}
|
||||
|
||||
type players struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func (p *players) Register(ctx context.Context, id, client, typ, ip string) (*model.Player, *model.Transcoding, error) {
|
||||
var plr *model.Player
|
||||
var trc *model.Transcoding
|
||||
var err error
|
||||
userName := ctx.Value("username").(string)
|
||||
if id != "" {
|
||||
plr, err = p.ds.Player(ctx).Get(id)
|
||||
if err == nil && plr.Client != client {
|
||||
id = ""
|
||||
}
|
||||
}
|
||||
if err != nil || id == "" {
|
||||
plr, err = p.ds.Player(ctx).FindByName(client, userName)
|
||||
if err == nil {
|
||||
log.Debug("Found player by name", "id", plr.ID, "client", client, "userName", userName)
|
||||
} else {
|
||||
r, _ := uuid.NewRandom()
|
||||
plr = &model.Player{
|
||||
ID: r.String(),
|
||||
Name: fmt.Sprintf("%s (%s)", client, userName),
|
||||
UserName: userName,
|
||||
Client: client,
|
||||
}
|
||||
log.Info("Registering new player", "id", plr.ID, "client", client, "userName", userName)
|
||||
}
|
||||
}
|
||||
plr.LastSeen = time.Now()
|
||||
plr.Type = typ
|
||||
plr.IPAddress = ip
|
||||
err = p.ds.Player(ctx).Put(plr)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if plr.TranscodingId != "" {
|
||||
trc, err = p.ds.Transcoding(ctx).Get(plr.TranscodingId)
|
||||
}
|
||||
return plr, trc, err
|
||||
}
|
||||
|
||||
func (p *players) Get(ctx context.Context, playerId string) (*model.Player, error) {
|
||||
return p.ds.Player(ctx).Get(playerId)
|
||||
}
|
||||
138
engine/players_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Players", func() {
|
||||
var players Players
|
||||
var repo *mockPlayerRepository
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid", UserName: "johndoe"})
|
||||
ctx = context.WithValue(ctx, "username", "johndoe")
|
||||
var beforeRegister time.Time
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = &mockPlayerRepository{}
|
||||
ds := &persistence.MockDataStore{MockedPlayer: repo, MockedTranscoding: &mockTranscodingRepository{}}
|
||||
players = NewPlayers(ds)
|
||||
beforeRegister = time.Now()
|
||||
})
|
||||
|
||||
Describe("Register", func() {
|
||||
It("creates a new player when no ID is specified", func() {
|
||||
p, trc, err := players.Register(ctx, "", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).ToNot(BeEmpty())
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(p.Client).To(Equal("client"))
|
||||
Expect(p.UserName).To(Equal("johndoe"))
|
||||
Expect(p.Type).To(Equal("chrome"))
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
Expect(trc).To(BeNil())
|
||||
})
|
||||
|
||||
It("creates a new player if it cannot find any matching player", func() {
|
||||
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).ToNot(BeEmpty())
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
Expect(trc).To(BeNil())
|
||||
})
|
||||
|
||||
It("creates a new player if client does not match the one in DB", func() {
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client1111", LastSeen: time.Time{}}
|
||||
repo.add(plr)
|
||||
p, trc, err := players.Register(ctx, "123", "client2222", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).ToNot(BeEmpty())
|
||||
Expect(p.ID).ToNot(Equal("123"))
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(p.Client).To(Equal("client2222"))
|
||||
Expect(trc).To(BeNil())
|
||||
})
|
||||
|
||||
It("finds players by ID", func() {
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}}
|
||||
repo.add(plr)
|
||||
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).To(Equal("123"))
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
Expect(trc).To(BeNil())
|
||||
})
|
||||
|
||||
It("finds player by client and user names when ID is not found", func() {
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserName: "johndoe", LastSeen: time.Time{}}
|
||||
repo.add(plr)
|
||||
p, _, err := players.Register(ctx, "999", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).To(Equal("123"))
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
})
|
||||
|
||||
It("finds player by client and user names when not ID is provided", func() {
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserName: "johndoe", LastSeen: time.Time{}}
|
||||
repo.add(plr)
|
||||
p, _, err := players.Register(ctx, "", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).To(Equal("123"))
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
})
|
||||
|
||||
It("finds player by ID and return its transcoding", func() {
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}, TranscodingId: "1"}
|
||||
repo.add(plr)
|
||||
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).To(Equal("123"))
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
Expect(trc.ID).To(Equal("1"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockPlayerRepository struct {
|
||||
model.PlayerRepository
|
||||
lastSaved *model.Player
|
||||
data map[string]model.Player
|
||||
}
|
||||
|
||||
func (m *mockPlayerRepository) add(p *model.Player) {
|
||||
if m.data == nil {
|
||||
m.data = make(map[string]model.Player)
|
||||
}
|
||||
m.data[p.ID] = *p
|
||||
}
|
||||
|
||||
func (m *mockPlayerRepository) Get(id string) (*model.Player, error) {
|
||||
if p, ok := m.data[id]; ok {
|
||||
return &p, nil
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockPlayerRepository) FindByName(client, userName string) (*model.Player, error) {
|
||||
for _, p := range m.data {
|
||||
if p.Client == client && p.UserName == userName {
|
||||
return &p, nil
|
||||
}
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockPlayerRepository) Put(p *model.Player) error {
|
||||
m.lastSaved = p
|
||||
return nil
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func (p *playlists) Create(ctx context.Context, playlistId, name string, ids []s
|
||||
}
|
||||
|
||||
func (p *playlists) getUser(ctx context.Context) string {
|
||||
user, ok := ctx.Value("user").(*model.User)
|
||||
user, ok := ctx.Value("user").(model.User)
|
||||
if ok {
|
||||
return user.UserName
|
||||
}
|
||||
@@ -73,15 +73,15 @@ func (p *playlists) Delete(ctx context.Context, playlistId string) error {
|
||||
|
||||
func (p *playlists) Update(ctx context.Context, playlistId string, name *string, idsToAdd []string, idxToRemove []int) error {
|
||||
pls, err := p.ds.Playlist(ctx).Get(playlistId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
owner := p.getUser(ctx)
|
||||
if owner != pls.Owner {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if name != nil {
|
||||
pls.Name = *name
|
||||
}
|
||||
|
||||
@@ -8,12 +8,11 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/log"
|
||||
)
|
||||
|
||||
type Transcoder interface {
|
||||
Start(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error)
|
||||
Start(ctx context.Context, command, path string, maxBitRate int, format string) (f io.ReadCloser, err error)
|
||||
}
|
||||
|
||||
func New() Transcoder {
|
||||
@@ -22,8 +21,8 @@ func New() Transcoder {
|
||||
|
||||
type ffmpeg struct{}
|
||||
|
||||
func (ff *ffmpeg) Start(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
|
||||
arg0, args := createTranscodeCommand(path, maxBitRate, format)
|
||||
func (ff *ffmpeg) Start(ctx context.Context, command, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
|
||||
arg0, args := createTranscodeCommand(command, path, maxBitRate, format)
|
||||
|
||||
log.Trace(ctx, "Executing ffmpeg command", "cmd", arg0, "args", args)
|
||||
cmd := exec.Command(arg0, args...)
|
||||
@@ -38,9 +37,7 @@ func (ff *ffmpeg) Start(ctx context.Context, path string, maxBitRate int, format
|
||||
return
|
||||
}
|
||||
|
||||
func createTranscodeCommand(path string, maxBitRate int, format string) (string, []string) {
|
||||
cmd := conf.Server.DownsampleCommand
|
||||
|
||||
func createTranscodeCommand(cmd, path string, maxBitRate int, format string) (string, []string) {
|
||||
split := strings.Split(cmd, " ")
|
||||
for i, s := range split {
|
||||
s = strings.Replace(s, "%s", path, -1)
|
||||
|
||||
@@ -3,7 +3,6 @@ package transcoder
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
@@ -18,11 +17,8 @@ func TestTranscoder(t *testing.T) {
|
||||
}
|
||||
|
||||
var _ = Describe("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, "")
|
||||
cmd, args := createTranscodeCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, "")
|
||||
Expect(cmd).To(Equal("ffmpeg"))
|
||||
Expect(args).To(Equal([]string{"-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
})
|
||||
|
||||
@@ -18,4 +18,5 @@ var Set = wire.NewSet(
|
||||
NewMediaStreamer,
|
||||
transcoder.New,
|
||||
NewTranscodingCache,
|
||||
NewPlayers,
|
||||
)
|
||||
|
||||
3
go.mod
@@ -11,10 +11,11 @@ require (
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8
|
||||
github.com/djherbis/fscache v0.10.0
|
||||
github.com/dustin/go-humanize v1.0.0
|
||||
github.com/fatih/camelcase v0.0.0-20160318181535-f6a740d52f96 // indirect
|
||||
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/cors v1.0.1
|
||||
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
|
||||
|
||||
6
go.sum
@@ -30,6 +30,8 @@ github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8 h1:nmFnmD8VZXkjDHIb1Gn
|
||||
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8/go.mod h1:SniNVYuaD1jmdEEvi+7ywb1QFR7agjeTdGKyFb0p7Rw=
|
||||
github.com/djherbis/fscache v0.10.0 h1:+O3s3LwKL1Jfz7txRHpgKOz8/krh9w+NzfzxtFdbsQg=
|
||||
github.com/djherbis/fscache v0.10.0/go.mod h1:yyPYtkNnnPXsW+81lAcQS6yab3G2CRfnPLotBvtbf0c=
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8=
|
||||
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
|
||||
github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
|
||||
@@ -41,8 +43,8 @@ github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/go-chi/chi v4.0.3+incompatible h1:gakN3pDJnzZN5jqFV2TEdF66rTfKeITyR8qu6ekICEY=
|
||||
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/cors v1.0.1 h1:56TT/uWGoLWZpnMI/AwAmCneikXr5eLsiIq27wrKecw=
|
||||
github.com/go-chi/cors v1.0.1/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
|
||||
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=
|
||||
|
||||
@@ -15,6 +15,7 @@ type Album struct {
|
||||
SongCount int `json:"songCount"`
|
||||
Duration float32 `json:"duration"`
|
||||
Genre string `json:"genre"`
|
||||
FullText string `json:"fullText"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ type Artist struct {
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
AlbumCount int `json:"albumCount" orm:"column(album_count)"`
|
||||
FullText string `json:"fullText"`
|
||||
|
||||
// Annotations
|
||||
PlayCount int `json:"-" orm:"-"`
|
||||
|
||||
@@ -28,6 +28,8 @@ type DataStore interface {
|
||||
Playlist(ctx context.Context) PlaylistRepository
|
||||
Property(ctx context.Context) PropertyRepository
|
||||
User(ctx context.Context) UserRepository
|
||||
Transcoding(ctx context.Context) TranscodingRepository
|
||||
Player(ctx context.Context) PlayerRepository
|
||||
|
||||
Resource(ctx context.Context, model interface{}) ResourceRepository
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ type MediaFile struct {
|
||||
Duration float32 `json:"duration"`
|
||||
BitRate int `json:"bitRate"`
|
||||
Genre string `json:"genre"`
|
||||
FullText string `json:"fullText"`
|
||||
Compilation bool `json:"compilation"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
25
model/player.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Player struct {
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
UserName string `json:"userName"`
|
||||
Client string `json:"client"`
|
||||
IPAddress string `json:"ipAddress"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
TranscodingId string `json:"transcodingId"`
|
||||
MaxBitRate int `json:"maxBitRate"`
|
||||
}
|
||||
|
||||
type Players []Player
|
||||
|
||||
type PlayerRepository interface {
|
||||
Get(id string) (*Player, error)
|
||||
FindByName(client, userName string) (*Player, error)
|
||||
Put(p *Player) error
|
||||
}
|
||||
18
model/transcoding.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package model
|
||||
|
||||
type Transcoding struct {
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
TargetFormat string `json:"targetFormat"`
|
||||
Command string `json:"command"`
|
||||
DefaultBitRate int `json:"defaultBitRate"`
|
||||
}
|
||||
|
||||
type Transcodings []Transcoding
|
||||
|
||||
type TranscodingRepository interface {
|
||||
Get(id string) (*Transcoding, error)
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
Put(*Transcoding) error
|
||||
FindByFormat(format string) (*Transcoding, error)
|
||||
}
|
||||
@@ -20,6 +20,14 @@ func NewAlbumRepository(ctx context.Context, o orm.Ormer) model.AlbumRepository
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
r.tableName = "album"
|
||||
r.sortMappings = map[string]string{
|
||||
"artist": "compilation asc, album_artist asc, name asc",
|
||||
}
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"name": fullTextFilter,
|
||||
"compilation": booleanFilter,
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -32,11 +40,9 @@ func (r *albumRepository) Exists(id string) (bool, error) {
|
||||
}
|
||||
|
||||
func (r *albumRepository) Put(a *model.Album) error {
|
||||
a.FullText = r.getFullText(a.Name, a.Artist, a.AlbumArtist)
|
||||
_, err := r.put(a.ID, a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.index(a.ID, a.Name, a.Artist, a.AlbumArtist)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder {
|
||||
|
||||
@@ -14,7 +14,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
var repo model.AlbumRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"})
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
|
||||
repo = NewAlbumRepository(ctx, orm.NewOrm())
|
||||
})
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ func NewArtistRepository(ctx context.Context, o orm.Ormer) model.ArtistRepositor
|
||||
r.ormer = o
|
||||
r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups)
|
||||
r.tableName = "artist"
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"name": fullTextFilter,
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -52,11 +55,9 @@ func (r *artistRepository) getIndexKey(a *model.Artist) string {
|
||||
}
|
||||
|
||||
func (r *artistRepository) Put(a *model.Artist) error {
|
||||
a.FullText = r.getFullText(a.Name)
|
||||
_, err := r.put(a.ID, a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.index(a.ID, a.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *artistRepository) Get(id string) (*model.Artist, error) {
|
||||
|
||||
@@ -14,7 +14,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
var repo model.ArtistRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"})
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
|
||||
repo = NewArtistRepository(ctx, orm.NewOrm())
|
||||
})
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ func NewMediaFileRepository(ctx context.Context, o orm.Ormer) *mediaFileReposito
|
||||
"artist": "artist asc, album asc, disc_number asc, track_number asc",
|
||||
"album": "album asc, disc_number asc, track_number asc",
|
||||
}
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"title": fullTextFilter,
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -37,11 +40,9 @@ func (r mediaFileRepository) Exists(id string) (bool, error) {
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) Put(m *model.MediaFile) error {
|
||||
m.FullText = r.getFullText(m.Title, m.Album, m.Artist, m.AlbumArtist)
|
||||
_, err := r.put(m.ID, m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.index(m.ID, m.Title, m.Album, m.Artist, m.AlbumArtist)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder {
|
||||
|
||||
@@ -16,7 +16,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
var mr model.MediaFileRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"})
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
|
||||
mr = NewMediaFileRepository(ctx, orm.NewOrm())
|
||||
})
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ import (
|
||||
)
|
||||
|
||||
type MockDataStore struct {
|
||||
MockedGenre model.GenreRepository
|
||||
MockedAlbum model.AlbumRepository
|
||||
MockedArtist model.ArtistRepository
|
||||
MockedMediaFile model.MediaFileRepository
|
||||
MockedUser model.UserRepository
|
||||
MockedGenre model.GenreRepository
|
||||
MockedAlbum model.AlbumRepository
|
||||
MockedArtist model.ArtistRepository
|
||||
MockedMediaFile model.MediaFileRepository
|
||||
MockedUser model.UserRepository
|
||||
MockedPlayer model.PlayerRepository
|
||||
MockedTranscoding model.TranscodingRepository
|
||||
}
|
||||
|
||||
func (db *MockDataStore) Album(context.Context) model.AlbumRepository {
|
||||
@@ -61,6 +63,20 @@ func (db *MockDataStore) User(context.Context) model.UserRepository {
|
||||
return db.MockedUser
|
||||
}
|
||||
|
||||
func (db *MockDataStore) Transcoding(context.Context) model.TranscodingRepository {
|
||||
if db.MockedTranscoding != nil {
|
||||
return db.MockedTranscoding
|
||||
}
|
||||
return struct{ model.TranscodingRepository }{}
|
||||
}
|
||||
|
||||
func (db *MockDataStore) Player(context.Context) model.PlayerRepository {
|
||||
if db.MockedPlayer != nil {
|
||||
return db.MockedPlayer
|
||||
}
|
||||
return struct{ model.PlayerRepository }{}
|
||||
}
|
||||
|
||||
func (db *MockDataStore) WithTx(block func(db model.DataStore) error) error {
|
||||
return block(db)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package persistence
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"sync"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/db"
|
||||
@@ -11,10 +10,6 @@ import (
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
type SQLStore struct {
|
||||
orm orm.Ormer
|
||||
}
|
||||
@@ -55,10 +50,22 @@ func (s *SQLStore) User(ctx context.Context) model.UserRepository {
|
||||
return NewUserRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (s *SQLStore) Transcoding(ctx context.Context) model.TranscodingRepository {
|
||||
return NewTranscodingRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (s *SQLStore) Player(ctx context.Context) model.PlayerRepository {
|
||||
return NewPlayerRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
|
||||
switch m.(type) {
|
||||
case model.User:
|
||||
return s.User(ctx).(model.ResourceRepository)
|
||||
case model.Transcoding:
|
||||
return s.Transcoding(ctx).(model.ResourceRepository)
|
||||
case model.Player:
|
||||
return s.Player(ctx).(model.ResourceRepository)
|
||||
case model.Artist:
|
||||
return s.Artist(ctx).(model.ResourceRepository)
|
||||
case model.Album:
|
||||
@@ -107,18 +114,6 @@ func (s *SQLStore) GC(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.MediaFile(ctx).(*mediaFileRepository).cleanSearchIndex()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.Album(ctx).(*albumRepository).cleanSearchIndex()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.Artist(ctx).(*artistRepository).cleanSearchIndex()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -31,8 +31,8 @@ func TestPersistence(t *testing.T) {
|
||||
}
|
||||
|
||||
var (
|
||||
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1}
|
||||
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2}
|
||||
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1, FullText: "kraftwerk"}
|
||||
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2, FullText: "the beatles"}
|
||||
testArtists = model.Artists{
|
||||
artistKraftwerk,
|
||||
artistBeatles,
|
||||
@@ -40,9 +40,9 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
albumSgtPeppers = model.Album{ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", ArtistID: "3", Genre: "Rock", CoverArtId: "1", CoverArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, Year: 1967}
|
||||
albumAbbeyRoad = model.Album{ID: "2", Name: "Abbey Road", Artist: "The Beatles", ArtistID: "3", Genre: "Rock", CoverArtId: "2", CoverArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, Year: 1969}
|
||||
albumRadioactivity = model.Album{ID: "3", Name: "Radioactivity", Artist: "Kraftwerk", ArtistID: "2", Genre: "Electronic", CoverArtId: "3", CoverArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2}
|
||||
albumSgtPeppers = model.Album{ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", ArtistID: "3", Genre: "Rock", CoverArtId: "1", CoverArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, Year: 1967, FullText: "sgt peppers the beatles"}
|
||||
albumAbbeyRoad = model.Album{ID: "2", Name: "Abbey Road", Artist: "The Beatles", ArtistID: "3", Genre: "Rock", CoverArtId: "2", CoverArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, Year: 1969, FullText: "abbey road the beatles"}
|
||||
albumRadioactivity = model.Album{ID: "3", Name: "Radioactivity", Artist: "Kraftwerk", ArtistID: "2", Genre: "Electronic", CoverArtId: "3", CoverArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2, FullText: "radioactivity kraftwerk"}
|
||||
testAlbums = model.Albums{
|
||||
albumSgtPeppers,
|
||||
albumAbbeyRoad,
|
||||
@@ -51,10 +51,10 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
songDayInALife = model.MediaFile{ID: "1", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "1", Album: "Sgt Peppers", Genre: "Rock", Path: P("/beatles/1/sgt/a day.mp3")}
|
||||
songComeTogether = model.MediaFile{ID: "2", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "2", Album: "Abbey Road", Genre: "Rock", Path: P("/beatles/1/come together.mp3")}
|
||||
songRadioactivity = model.MediaFile{ID: "3", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Album: "Radioactivity", Genre: "Electronic", Path: P("/kraft/radio/radio.mp3")}
|
||||
songAntenna = model.MediaFile{ID: "4", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Genre: "Electronic", Path: P("/kraft/radio/antenna.mp3")}
|
||||
songDayInALife = model.MediaFile{ID: "1", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "1", Album: "Sgt Peppers", Genre: "Rock", Path: P("/beatles/1/sgt/a day.mp3"), FullText: "a day in a life sgt peppers the beatles"}
|
||||
songComeTogether = model.MediaFile{ID: "2", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "2", Album: "Abbey Road", Genre: "Rock", Path: P("/beatles/1/come together.mp3"), FullText: "come together abbey road the beatles"}
|
||||
songRadioactivity = model.MediaFile{ID: "3", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Album: "Radioactivity", Genre: "Electronic", Path: P("/kraft/radio/radio.mp3"), FullText: "radioactivity radioactivity kraftwerk"}
|
||||
songAntenna = model.MediaFile{ID: "4", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Genre: "Electronic", Path: P("/kraft/radio/antenna.mp3"), FullText: "antenna kraftwerk"}
|
||||
testSongs = model.MediaFiles{
|
||||
songDayInALife,
|
||||
songComeTogether,
|
||||
@@ -86,7 +86,7 @@ var _ = Describe("Initialize test DB", func() {
|
||||
// TODO Load this data setup from file(s)
|
||||
BeforeSuite(func() {
|
||||
o := orm.NewOrm()
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"})
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
|
||||
mr := NewMediaFileRepository(ctx, o)
|
||||
for _, s := range testSongs {
|
||||
err := mr.Put(&s)
|
||||
|
||||
117
persistence/player_repository.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/rest"
|
||||
)
|
||||
|
||||
type playerRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewPlayerRepository(ctx context.Context, o orm.Ormer) model.PlayerRepository {
|
||||
r := &playerRepository{}
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
r.tableName = "player"
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *playerRepository) Put(p *model.Player) error {
|
||||
_, err := r.put(p.ID, p)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *playerRepository) Get(id string) (*model.Player, error) {
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"id": id})
|
||||
var res model.Player
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *playerRepository) FindByName(client, userName string) (*model.Player, error) {
|
||||
sel := r.newSelect().Columns("*").Where(And{Eq{"client": client}, Eq{"user_name": userName}})
|
||||
var res model.Player
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBuilder {
|
||||
s := r.newSelect(options...)
|
||||
u := loggedUser(r.ctx)
|
||||
if u.IsAdmin {
|
||||
return s
|
||||
}
|
||||
return s.Where(Eq{"user_name": u.UserName})
|
||||
}
|
||||
|
||||
func (r *playerRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.count(r.newRestSelect(), r.parseRestOptions(options...))
|
||||
}
|
||||
|
||||
func (r *playerRepository) Read(id string) (interface{}, error) {
|
||||
sel := r.newRestSelect().Columns("*").Where(Eq{"id": id})
|
||||
var res model.Player
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *playerRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
sel := r.newRestSelect(r.parseRestOptions(options...)).Columns("*")
|
||||
res := model.Players{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *playerRepository) EntityName() string {
|
||||
return "player"
|
||||
}
|
||||
|
||||
func (r *playerRepository) NewInstance() interface{} {
|
||||
return &model.Player{}
|
||||
}
|
||||
|
||||
func (r *playerRepository) isPermitted(p *model.Player) bool {
|
||||
u := loggedUser(r.ctx)
|
||||
return u.IsAdmin || p.UserName == u.UserName
|
||||
}
|
||||
|
||||
func (r *playerRepository) Save(entity interface{}) (string, error) {
|
||||
t := entity.(*model.Player)
|
||||
if !r.isPermitted(t) {
|
||||
return "", rest.ErrPermissionDenied
|
||||
}
|
||||
id, err := r.put(t.ID, t)
|
||||
if err == model.ErrNotFound {
|
||||
return "", rest.ErrNotFound
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (r *playerRepository) Update(entity interface{}, cols ...string) error {
|
||||
t := entity.(*model.Player)
|
||||
if !r.isPermitted(t) {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
_, err := r.put(t.ID, t)
|
||||
if err == model.ErrNotFound {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *playerRepository) Delete(id string) error {
|
||||
err := r.delete(And{Eq{"id": id}, Eq{"user_name": loggedUser(r.ctx).UserName}})
|
||||
if err == model.ErrNotFound {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var _ model.PlayerRepository = (*playerRepository)(nil)
|
||||
var _ rest.Repository = (*playerRepository)(nil)
|
||||
var _ rest.Persistable = (*playerRepository)(nil)
|
||||
@@ -71,7 +71,7 @@ func (r *playlistRepository) GetWithTracks(id string) (*model.Playlist, error) {
|
||||
continue
|
||||
}
|
||||
pls.Duration += mf.Duration
|
||||
newTracks = append(newTracks, model.MediaFile(*mf))
|
||||
newTracks = append(newTracks, *mf)
|
||||
}
|
||||
pls.Tracks = newTracks
|
||||
return pls, err
|
||||
|
||||
@@ -8,11 +8,6 @@ import (
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
type property struct {
|
||||
ID string `orm:"pk;column(id)"`
|
||||
Value string
|
||||
}
|
||||
|
||||
type propertyRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
@@ -10,18 +10,6 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type annotation struct {
|
||||
AnnID string `json:"annID" orm:"pk;column(ann_id)"`
|
||||
UserID string `json:"userID" orm:"pk;column(user_id)"`
|
||||
ItemID string `json:"itemID" orm:"pk;column(item_id)"`
|
||||
ItemType string `json:"itemType"`
|
||||
PlayCount int `json:"playCount"`
|
||||
PlayDate time.Time `json:"playDate"`
|
||||
Rating int `json:"rating"`
|
||||
Starred bool `json:"starred"`
|
||||
StarredAt time.Time `json:"starredAt"`
|
||||
}
|
||||
|
||||
const annotationTable = "annotation"
|
||||
|
||||
func (r sqlRepository) newSelectWithAnnotation(idField string, options ...model.QueryOptions) SelectBuilder {
|
||||
|
||||
@@ -13,13 +13,17 @@ import (
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/google/uuid"
|
||||
"github.com/kennygrant/sanitize"
|
||||
)
|
||||
|
||||
type filterFunc = func(field string, value interface{}) Sqlizer
|
||||
|
||||
type sqlRepository struct {
|
||||
ctx context.Context
|
||||
tableName string
|
||||
ormer orm.Ormer
|
||||
sortMappings map[string]string
|
||||
ctx context.Context
|
||||
tableName string
|
||||
ormer orm.Ormer
|
||||
sortMappings map[string]string
|
||||
filterMappings map[string]filterFunc
|
||||
}
|
||||
|
||||
const invalidUserId = "-1"
|
||||
@@ -29,7 +33,7 @@ func userId(ctx context.Context) string {
|
||||
if user == nil {
|
||||
return invalidUserId
|
||||
}
|
||||
usr := user.(*model.User)
|
||||
usr := user.(model.User)
|
||||
return usr.ID
|
||||
}
|
||||
|
||||
@@ -38,7 +42,8 @@ func loggedUser(ctx context.Context) *model.User {
|
||||
if user == nil {
|
||||
return &model.User{}
|
||||
}
|
||||
return user.(*model.User)
|
||||
u := user.(model.User)
|
||||
return &u
|
||||
}
|
||||
|
||||
func (r sqlRepository) newSelect(options ...model.QueryOptions) SelectBuilder {
|
||||
@@ -225,10 +230,37 @@ func (r sqlRepository) parseRestOptions(options ...rest.QueryOptions) model.Quer
|
||||
if len(options[0].Filters) > 0 {
|
||||
filters := And{}
|
||||
for f, v := range options[0].Filters {
|
||||
filters = append(filters, Like{f: fmt.Sprintf("%s%%", v)})
|
||||
if ff, ok := r.filterMappings[f]; ok {
|
||||
filters = append(filters, ff(f, v))
|
||||
} else {
|
||||
filters = append(filters, startsWithFilter(f, v))
|
||||
}
|
||||
}
|
||||
qo.Filters = filters
|
||||
}
|
||||
}
|
||||
return qo
|
||||
}
|
||||
|
||||
func startsWithFilter(field string, value interface{}) Like {
|
||||
return Like{field: fmt.Sprintf("%s%%", value)}
|
||||
}
|
||||
|
||||
func booleanFilter(field string, value interface{}) Sqlizer {
|
||||
v := strings.ToLower(value.(string))
|
||||
return Eq{field: strings.ToLower(v) == "true"}
|
||||
}
|
||||
|
||||
func fullTextFilter(field string, value interface{}) Sqlizer {
|
||||
q := value.(string)
|
||||
q = strings.TrimSpace(sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*"))))
|
||||
parts := strings.Split(q, " ")
|
||||
filters := And{}
|
||||
for _, part := range parts {
|
||||
filters = append(filters, Or{
|
||||
Like{"full_text": part + "%"},
|
||||
Like{"full_text": "%" + part + "%"},
|
||||
})
|
||||
}
|
||||
return filters
|
||||
}
|
||||
|
||||
@@ -4,34 +4,15 @@ import (
|
||||
"strings"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/kennygrant/sanitize"
|
||||
)
|
||||
|
||||
const searchTable = "search"
|
||||
|
||||
func (r sqlRepository) index(id string, text ...string) error {
|
||||
func (r sqlRepository) getFullText(text ...string) string {
|
||||
sanitizedText := strings.Builder{}
|
||||
for _, txt := range text {
|
||||
sanitizedText.WriteString(strings.TrimSpace(sanitize.Accents(strings.ToLower(txt))) + " ")
|
||||
}
|
||||
|
||||
values := map[string]interface{}{
|
||||
"id": id,
|
||||
"item_type": r.tableName,
|
||||
"full_text": strings.TrimSpace(sanitizedText.String()),
|
||||
}
|
||||
update := Update(searchTable).Where(Eq{"id": id}).SetMap(values)
|
||||
count, err := r.executeSQL(update)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
insert := Insert(searchTable).SetMap(values)
|
||||
_, err = r.executeSQL(insert)
|
||||
return err
|
||||
return strings.TrimSpace(sanitizedText.String())
|
||||
}
|
||||
|
||||
func (r sqlRepository) doSearch(q string, offset, size int, results interface{}, orderBys ...string) error {
|
||||
@@ -44,7 +25,6 @@ func (r sqlRepository) doSearch(q string, offset, size int, results interface{},
|
||||
if len(orderBys) > 0 {
|
||||
sq = sq.OrderBy(orderBys...)
|
||||
}
|
||||
sq = sq.Join("search").Where("search.id = " + r.tableName + ".id")
|
||||
parts := strings.Split(q, " ")
|
||||
for _, part := range parts {
|
||||
sq = sq.Where(Or{
|
||||
@@ -55,15 +35,3 @@ func (r sqlRepository) doSearch(q string, offset, size int, results interface{},
|
||||
err := r.queryAll(sq, results)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r sqlRepository) cleanSearchIndex() error {
|
||||
del := Delete(searchTable).Where(Eq{"item_type": r.tableName}).Where("id not in (select id from " + r.tableName + ")")
|
||||
c, err := r.executeSQL(del)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c > 0 {
|
||||
log.Debug(r.ctx, "Clean-up search index", "table", r.tableName, "totalDeleted", c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
98
persistence/transcoding_repository.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/rest"
|
||||
)
|
||||
|
||||
type transcodingRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewTranscodingRepository(ctx context.Context, o orm.Ormer) model.TranscodingRepository {
|
||||
r := &transcodingRepository{}
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
r.tableName = "transcoding"
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Get(id string) (*model.Transcoding, error) {
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"id": id})
|
||||
var res model.Transcoding
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) CountAll(qo ...model.QueryOptions) (int64, error) {
|
||||
return r.count(Select(), qo...)
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) FindByFormat(format string) (*model.Transcoding, error) {
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"target_format": format})
|
||||
var res model.Transcoding
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Put(t *model.Transcoding) error {
|
||||
_, err := r.put(t.ID, t)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.count(Select(), r.parseRestOptions(options...))
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Read(id string) (interface{}, error) {
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
sel := r.newSelect(r.parseRestOptions(options...)).Columns("*")
|
||||
res := model.Transcodings{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) EntityName() string {
|
||||
return "transcoding"
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) NewInstance() interface{} {
|
||||
return &model.Transcoding{}
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Save(entity interface{}) (string, error) {
|
||||
t := entity.(*model.Transcoding)
|
||||
id, err := r.put(t.ID, t)
|
||||
if err == model.ErrNotFound {
|
||||
return "", rest.ErrNotFound
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Update(entity interface{}, cols ...string) error {
|
||||
t := entity.(*model.Transcoding)
|
||||
_, err := r.put(t.ID, t)
|
||||
if err == model.ErrNotFound {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Delete(id string) error {
|
||||
err := r.delete(Eq{"id": id})
|
||||
if err == model.ErrNotFound {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var _ model.TranscodingRepository = (*transcodingRepository)(nil)
|
||||
var _ rest.Repository = (*transcodingRepository)(nil)
|
||||
var _ rest.Persistable = (*transcodingRepository)(nil)
|
||||
@@ -46,10 +46,10 @@ func (s *ChangeDetector) Scan(lastModifiedSince time.Time) (changed []string, de
|
||||
|
||||
func (s *ChangeDetector) loadDir(dirPath string) (children []string, lastUpdated time.Time, err error) {
|
||||
dir, err := os.Open(dirPath)
|
||||
defer dir.Close()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer dir.Close()
|
||||
dirInfo, err := os.Stat(dirPath)
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -43,6 +43,8 @@ func (app *Router) routes() http.Handler {
|
||||
app.R(r, "/song", model.MediaFile{})
|
||||
app.R(r, "/album", model.Album{})
|
||||
app.R(r, "/artist", model.Artist{})
|
||||
app.R(r, "/transcoding", model.Transcoding{})
|
||||
app.R(r, "/player", model.Player{})
|
||||
|
||||
// Keepalive endpoint to be used to keep the session valid (ex: while playing songs)
|
||||
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"response":"ok"}`)) })
|
||||
|
||||
@@ -149,7 +149,7 @@ func validateLogin(userRepo model.UserRepository, userName, password string) (*m
|
||||
func contextWithUser(ctx context.Context, ds model.DataStore, claims jwt.MapClaims) context.Context {
|
||||
userName := claims["sub"].(string)
|
||||
user, _ := ds.User(ctx).FindByUsername(userName)
|
||||
return context.WithValue(ctx, "user", user)
|
||||
return context.WithValue(ctx, "user", *user)
|
||||
}
|
||||
|
||||
func getToken(ds model.DataStore, ctx context.Context) (*jwt.Token, error) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +14,10 @@ import (
|
||||
|
||||
func initialSetup(ds model.DataStore) {
|
||||
_ = ds.WithTx(func(tx model.DataStore) error {
|
||||
if err := createDefaultTranscodings(ds); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := ds.Property(nil).Get(consts.InitialSetupFlagKey)
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -35,8 +39,7 @@ func initialSetup(ds model.DataStore) {
|
||||
}
|
||||
|
||||
func createInitialAdminUser(ds model.DataStore) error {
|
||||
ctx := context.Background()
|
||||
c, err := ds.User(ctx).CountAll()
|
||||
c, err := ds.User(nil).CountAll()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Could not access User table: %s", err))
|
||||
}
|
||||
@@ -56,7 +59,7 @@ func createInitialAdminUser(ds model.DataStore) error {
|
||||
Password: initialPassword,
|
||||
IsAdmin: true,
|
||||
}
|
||||
err := ds.User(ctx).Put(&initialUser)
|
||||
err := ds.User(nil).Put(&initialUser)
|
||||
if err != nil {
|
||||
log.Error("Could not create initial admin user", "user", initialUser, err)
|
||||
}
|
||||
@@ -77,3 +80,27 @@ func createJWTSecret(ds model.DataStore) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func createDefaultTranscodings(ds model.DataStore) error {
|
||||
repo := ds.Transcoding(nil)
|
||||
c, _ := repo.CountAll()
|
||||
if c != 0 {
|
||||
return nil
|
||||
}
|
||||
for _, d := range consts.DefaultTranscodings {
|
||||
var j []byte
|
||||
var err error
|
||||
if j, err = json.Marshal(d); err != nil {
|
||||
return err
|
||||
}
|
||||
var t model.Transcoding
|
||||
if err = json.Unmarshal(j, &t); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Info("Creating default transcoding config", "name", t.Name)
|
||||
if err = repo.Put(&t); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func (c *AlbumListController) GetAlbumList(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response.AlbumList = &responses.AlbumList{Album: ToChildren(albums)}
|
||||
response.AlbumList = &responses.AlbumList{Album: ToChildren(r.Context(), albums)}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ func (c *AlbumListController) GetAlbumList2(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response.AlbumList2 = &responses.AlbumList{Album: ToAlbums(albums)}
|
||||
response.AlbumList2 = &responses.AlbumList{Album: ToAlbums(r.Context(), albums)}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -91,8 +91,8 @@ func (c *AlbumListController) GetStarred(w http.ResponseWriter, r *http.Request)
|
||||
response := NewResponse()
|
||||
response.Starred = &responses.Starred{}
|
||||
response.Starred.Artist = ToArtists(artists)
|
||||
response.Starred.Album = ToChildren(albums)
|
||||
response.Starred.Song = ToChildren(mediaFiles)
|
||||
response.Starred.Album = ToChildren(r.Context(), albums)
|
||||
response.Starred.Song = ToChildren(r.Context(), mediaFiles)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -106,8 +106,8 @@ func (c *AlbumListController) GetStarred2(w http.ResponseWriter, r *http.Request
|
||||
response := NewResponse()
|
||||
response.Starred2 = &responses.Starred{}
|
||||
response.Starred2.Artist = ToArtists(artists)
|
||||
response.Starred2.Album = ToAlbums(albums)
|
||||
response.Starred2.Song = ToChildren(mediaFiles)
|
||||
response.Starred2.Album = ToAlbums(r.Context(), albums)
|
||||
response.Starred2.Song = ToChildren(r.Context(), mediaFiles)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ func (c *AlbumListController) GetNowPlaying(w http.ResponseWriter, r *http.Reque
|
||||
response.NowPlaying = &responses.NowPlaying{}
|
||||
response.NowPlaying.Entry = make([]responses.NowPlayingEntry, len(npInfos))
|
||||
for i, entry := range npInfos {
|
||||
response.NowPlaying.Entry[i].Child = ToChild(entry)
|
||||
response.NowPlaying.Entry[i].Child = ToChild(r.Context(), entry)
|
||||
response.NowPlaying.Entry[i].UserName = entry.UserName
|
||||
response.NowPlaying.Entry[i].MinutesAgo = entry.MinutesAgo
|
||||
response.NowPlaying.Entry[i].PlayerId = entry.PlayerId
|
||||
@@ -143,9 +143,6 @@ func (c *AlbumListController) GetRandomSongs(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
response := NewResponse()
|
||||
response.RandomSongs = &responses.Songs{}
|
||||
response.RandomSongs.Songs = make([]responses.Child, len(songs))
|
||||
for i, entry := range songs {
|
||||
response.RandomSongs.Songs[i] = ToChild(entry)
|
||||
}
|
||||
response.RandomSongs.Songs = ToChildren(r.Context(), songs)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -27,16 +27,17 @@ type Router struct {
|
||||
Search engine.Search
|
||||
Users engine.Users
|
||||
Streamer engine.MediaStreamer
|
||||
Players engine.Players
|
||||
|
||||
mux http.Handler
|
||||
}
|
||||
|
||||
func New(browser engine.Browser, cover engine.Cover, listGenerator engine.ListGenerator, users engine.Users,
|
||||
playlists engine.Playlists, ratings engine.Ratings, scrobbler engine.Scrobbler, search engine.Search,
|
||||
streamer engine.MediaStreamer) *Router {
|
||||
streamer engine.MediaStreamer, players engine.Players) *Router {
|
||||
|
||||
r := &Router{Browser: browser, Cover: cover, ListGenerator: listGenerator, Playlists: playlists,
|
||||
Ratings: ratings, Scrobbler: scrobbler, Search: search, Users: users, Streamer: streamer}
|
||||
Ratings: ratings, Scrobbler: scrobbler, Search: search, Users: users, Streamer: streamer, Players: players}
|
||||
r.mux = r.routes()
|
||||
return r
|
||||
}
|
||||
@@ -50,39 +51,39 @@ func (api *Router) routes() http.Handler {
|
||||
|
||||
r.Use(postFormToQueryParams)
|
||||
r.Use(checkRequiredParameters)
|
||||
|
||||
// Add validation middleware
|
||||
r.Use(authenticate(api.Users))
|
||||
// TODO Validate version
|
||||
|
||||
// Subsonic endpoints, grouped by controller
|
||||
r.Group(func(r chi.Router) {
|
||||
c := initSystemController(api)
|
||||
H(r, "ping", c.Ping)
|
||||
H(r, "getLicense", c.GetLicense)
|
||||
withPlayer := r.With(getPlayer(api.Players))
|
||||
H(withPlayer, "ping", c.Ping)
|
||||
H(withPlayer, "getLicense", c.GetLicense)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
c := initBrowsingController(api)
|
||||
H(r, "getMusicFolders", c.GetMusicFolders)
|
||||
H(r, "getMusicFolders", c.GetMusicFolders)
|
||||
H(r, "getIndexes", c.GetIndexes)
|
||||
H(r, "getArtists", c.GetArtists)
|
||||
H(r, "getGenres", c.GetGenres)
|
||||
H(r, "getMusicDirectory", c.GetMusicDirectory)
|
||||
H(r, "getArtist", c.GetArtist)
|
||||
H(r, "getAlbum", c.GetAlbum)
|
||||
H(r, "getSong", c.GetSong)
|
||||
H(r, "getArtistInfo", c.GetArtistInfo)
|
||||
H(r, "getArtistInfo2", c.GetArtistInfo2)
|
||||
withPlayer := r.With(getPlayer(api.Players))
|
||||
H(withPlayer, "getMusicFolders", c.GetMusicFolders)
|
||||
H(withPlayer, "getIndexes", c.GetIndexes)
|
||||
H(withPlayer, "getArtists", c.GetArtists)
|
||||
H(withPlayer, "getGenres", c.GetGenres)
|
||||
H(withPlayer, "getMusicDirectory", c.GetMusicDirectory)
|
||||
H(withPlayer, "getArtist", c.GetArtist)
|
||||
H(withPlayer, "getAlbum", c.GetAlbum)
|
||||
H(withPlayer, "getSong", c.GetSong)
|
||||
H(withPlayer, "getArtistInfo", c.GetArtistInfo)
|
||||
H(withPlayer, "getArtistInfo2", c.GetArtistInfo2)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
c := initAlbumListController(api)
|
||||
H(r, "getAlbumList", c.GetAlbumList)
|
||||
H(r, "getAlbumList2", c.GetAlbumList2)
|
||||
H(r, "getStarred", c.GetStarred)
|
||||
H(r, "getStarred2", c.GetStarred2)
|
||||
H(r, "getNowPlaying", c.GetNowPlaying)
|
||||
H(r, "getRandomSongs", c.GetRandomSongs)
|
||||
withPlayer := r.With(getPlayer(api.Players))
|
||||
H(withPlayer, "getAlbumList", c.GetAlbumList)
|
||||
H(withPlayer, "getAlbumList2", c.GetAlbumList2)
|
||||
H(withPlayer, "getStarred", c.GetStarred)
|
||||
H(withPlayer, "getStarred2", c.GetStarred2)
|
||||
H(withPlayer, "getNowPlaying", c.GetNowPlaying)
|
||||
H(withPlayer, "getRandomSongs", c.GetRandomSongs)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
c := initMediaAnnotationController(api)
|
||||
@@ -93,16 +94,18 @@ func (api *Router) routes() http.Handler {
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
c := initPlaylistsController(api)
|
||||
H(r, "getPlaylists", c.GetPlaylists)
|
||||
H(r, "getPlaylist", c.GetPlaylist)
|
||||
H(r, "createPlaylist", c.CreatePlaylist)
|
||||
H(r, "deletePlaylist", c.DeletePlaylist)
|
||||
H(r, "updatePlaylist", c.UpdatePlaylist)
|
||||
withPlayer := r.With(getPlayer(api.Players))
|
||||
H(withPlayer, "getPlaylists", c.GetPlaylists)
|
||||
H(withPlayer, "getPlaylist", c.GetPlaylist)
|
||||
H(withPlayer, "createPlaylist", c.CreatePlaylist)
|
||||
H(withPlayer, "deletePlaylist", c.DeletePlaylist)
|
||||
H(withPlayer, "updatePlaylist", c.UpdatePlaylist)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
c := initSearchingController(api)
|
||||
H(r, "search2", c.Search2)
|
||||
H(r, "search3", c.Search3)
|
||||
withPlayer := r.With(getPlayer(api.Players))
|
||||
H(withPlayer, "search2", c.Search2)
|
||||
H(withPlayer, "search3", c.Search3)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
c := initUsersController(api)
|
||||
@@ -115,8 +118,9 @@ func (api *Router) routes() http.Handler {
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
c := initStreamController(api)
|
||||
H(r, "stream", c.Stream)
|
||||
H(r, "download", c.Download)
|
||||
withPlayer := r.With(getPlayer(api.Players))
|
||||
H(withPlayer, "stream", c.Stream)
|
||||
H(withPlayer, "download", c.Download)
|
||||
})
|
||||
|
||||
// Deprecated/Out of scope endpoints
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -97,7 +98,7 @@ func (c *BrowsingController) GetMusicDirectory(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response.Directory = c.buildDirectory(dir)
|
||||
response.Directory = c.buildDirectory(r.Context(), dir)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -114,7 +115,7 @@ func (c *BrowsingController) GetArtist(w http.ResponseWriter, r *http.Request) (
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response.ArtistWithAlbumsID3 = c.buildArtist(dir)
|
||||
response.ArtistWithAlbumsID3 = c.buildArtist(r.Context(), dir)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -131,7 +132,7 @@ func (c *BrowsingController) GetAlbum(w http.ResponseWriter, r *http.Request) (*
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response.AlbumWithSongsID3 = c.buildAlbum(dir)
|
||||
response.AlbumWithSongsID3 = c.buildAlbum(r.Context(), dir)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -148,7 +149,7 @@ func (c *BrowsingController) GetSong(w http.ResponseWriter, r *http.Request) (*r
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
child := ToChild(*song)
|
||||
child := ToChild(r.Context(), *song)
|
||||
response.Song = &child
|
||||
return response, nil
|
||||
}
|
||||
@@ -189,7 +190,7 @@ func (c *BrowsingController) GetArtistInfo2(w http.ResponseWriter, r *http.Reque
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *BrowsingController) buildDirectory(d *engine.DirectoryInfo) *responses.Directory {
|
||||
func (c *BrowsingController) buildDirectory(ctx context.Context, d *engine.DirectoryInfo) *responses.Directory {
|
||||
dir := &responses.Directory{
|
||||
Id: d.Id,
|
||||
Name: d.Name,
|
||||
@@ -202,11 +203,11 @@ func (c *BrowsingController) buildDirectory(d *engine.DirectoryInfo) *responses.
|
||||
dir.Starred = &d.Starred
|
||||
}
|
||||
|
||||
dir.Child = ToChildren(d.Entries)
|
||||
dir.Child = ToChildren(ctx, d.Entries)
|
||||
return dir
|
||||
}
|
||||
|
||||
func (c *BrowsingController) buildArtist(d *engine.DirectoryInfo) *responses.ArtistWithAlbumsID3 {
|
||||
func (c *BrowsingController) buildArtist(ctx context.Context, d *engine.DirectoryInfo) *responses.ArtistWithAlbumsID3 {
|
||||
dir := &responses.ArtistWithAlbumsID3{}
|
||||
dir.Id = d.Id
|
||||
dir.Name = d.Name
|
||||
@@ -216,11 +217,11 @@ func (c *BrowsingController) buildArtist(d *engine.DirectoryInfo) *responses.Art
|
||||
dir.Starred = &d.Starred
|
||||
}
|
||||
|
||||
dir.Album = ToAlbums(d.Entries)
|
||||
dir.Album = ToAlbums(ctx, d.Entries)
|
||||
return dir
|
||||
}
|
||||
|
||||
func (c *BrowsingController) buildAlbum(d *engine.DirectoryInfo) *responses.AlbumWithSongsID3 {
|
||||
func (c *BrowsingController) buildAlbum(ctx context.Context, d *engine.DirectoryInfo) *responses.AlbumWithSongsID3 {
|
||||
dir := &responses.AlbumWithSongsID3{}
|
||||
dir.Id = d.Id
|
||||
dir.Name = d.Name
|
||||
@@ -239,6 +240,6 @@ func (c *BrowsingController) buildAlbum(d *engine.DirectoryInfo) *responses.Albu
|
||||
dir.Starred = &d.Starred
|
||||
}
|
||||
|
||||
dir.Song = ToChildren(d.Entries)
|
||||
dir.Song = ToChildren(ctx, d.Entries)
|
||||
return dir
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
@@ -63,16 +64,16 @@ func (e SubsonicError) Error() string {
|
||||
return msg
|
||||
}
|
||||
|
||||
func ToAlbums(entries engine.Entries) []responses.Child {
|
||||
func ToAlbums(ctx context.Context, entries engine.Entries) []responses.Child {
|
||||
children := make([]responses.Child, len(entries))
|
||||
for i, entry := range entries {
|
||||
children[i] = ToAlbum(entry)
|
||||
children[i] = ToAlbum(ctx, entry)
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
func ToAlbum(entry engine.Entry) responses.Child {
|
||||
album := ToChild(entry)
|
||||
func ToAlbum(ctx context.Context, entry engine.Entry) responses.Child {
|
||||
album := ToChild(ctx, entry)
|
||||
album.Name = album.Title
|
||||
album.Title = ""
|
||||
album.Parent = ""
|
||||
@@ -96,15 +97,15 @@ func ToArtists(entries engine.Entries) []responses.Artist {
|
||||
return artists
|
||||
}
|
||||
|
||||
func ToChildren(entries engine.Entries) []responses.Child {
|
||||
func ToChildren(ctx context.Context, entries engine.Entries) []responses.Child {
|
||||
children := make([]responses.Child, len(entries))
|
||||
for i, entry := range entries {
|
||||
children[i] = ToChild(entry)
|
||||
children[i] = ToChild(ctx, entry)
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
func ToChild(entry engine.Entry) responses.Child {
|
||||
func ToChild(ctx context.Context, entry engine.Entry) responses.Child {
|
||||
child := responses.Child{}
|
||||
child.Id = entry.Id
|
||||
child.Title = entry.Title
|
||||
@@ -136,9 +137,11 @@ func ToChild(entry engine.Entry) responses.Child {
|
||||
child.IsVideo = false
|
||||
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")
|
||||
format, _ := getTranscoding(ctx)
|
||||
if entry.Suffix != "" && format != "" && entry.Suffix != format {
|
||||
child.TranscodedSuffix = format
|
||||
child.TranscodedContentType = mime.TypeByExtension("." + format)
|
||||
}
|
||||
return child
|
||||
}
|
||||
|
||||
@@ -149,3 +152,13 @@ func ToGenres(genres model.Genres) *responses.Genres {
|
||||
}
|
||||
return &responses.Genres{Genre: response}
|
||||
}
|
||||
|
||||
func getTranscoding(ctx context.Context) (format string, bitRate int) {
|
||||
if trc, ok := ctx.Value("transcoding").(model.Transcoding); ok {
|
||||
format = trc.TargetFormat
|
||||
}
|
||||
if plr, ok := ctx.Value("player").(model.Player); ok {
|
||||
bitRate = plr.MaxBitRate
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ func NewMediaRetrievalController(cover engine.Cover) *MediaRetrievalController {
|
||||
}
|
||||
|
||||
func (c *MediaRetrievalController) GetAvatar(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
f, err := static.AssetFile().Open("itunes.png")
|
||||
f, err := static.AssetFile().Open("navidrone-310x310.png")
|
||||
if err != nil {
|
||||
log.Error(r, "Image not found", err)
|
||||
return nil, NewError(responses.ErrorDataNotFound, "Avatar image not found")
|
||||
|
||||
@@ -3,6 +3,7 @@ package subsonic
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -14,6 +15,10 @@ import (
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
cookieExpiry = 365 * 24 * 3600 // One year
|
||||
)
|
||||
|
||||
func postFormToQueryParams(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
@@ -82,10 +87,57 @@ func authenticate(users engine.Users) func(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, "user", usr)
|
||||
ctx = context.WithValue(ctx, "user", *usr)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func getPlayer(players engine.Players) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
userName := ctx.Value("username").(string)
|
||||
client := ctx.Value("client").(string)
|
||||
playerId := playerIDFromCookie(r, userName)
|
||||
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
player, trc, err := players.Register(ctx, playerId, client, r.Header.Get("user-agent"), ip)
|
||||
if err != nil {
|
||||
log.Error("Could not register player", "userName", userName, "client", client)
|
||||
} else {
|
||||
ctx = context.WithValue(ctx, "player", *player)
|
||||
if trc != nil {
|
||||
ctx = context.WithValue(ctx, "transcoding", *trc)
|
||||
}
|
||||
r = r.WithContext(ctx)
|
||||
}
|
||||
|
||||
cookie := &http.Cookie{
|
||||
Name: playerIDCookieName(userName),
|
||||
Value: player.ID,
|
||||
MaxAge: cookieExpiry,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func playerIDFromCookie(r *http.Request, userName string) string {
|
||||
cookieName := playerIDCookieName(userName)
|
||||
var playerId string
|
||||
if c, err := r.Cookie(cookieName); err == nil {
|
||||
playerId = c.Value
|
||||
log.Trace(r, "playerId found in cookies", "playerId", playerId)
|
||||
}
|
||||
return playerId
|
||||
}
|
||||
|
||||
func playerIDCookieName(userName string) string {
|
||||
cookieName := fmt.Sprintf("nd-player-%x", userName)
|
||||
return cookieName
|
||||
}
|
||||
|
||||
@@ -107,35 +107,102 @@ var _ = Describe("Middlewares", func() {
|
||||
})
|
||||
|
||||
Describe("Authenticate", func() {
|
||||
var mockedUser *mockUsers
|
||||
var mockedUsers *mockUsers
|
||||
BeforeEach(func() {
|
||||
mockedUser = &mockUsers{}
|
||||
mockedUsers = &mockUsers{}
|
||||
})
|
||||
|
||||
It("passes all parameters to users.Authenticate ", func() {
|
||||
r := newGetRequest("u=valid", "p=password", "t=token", "s=salt", "jwt=jwt")
|
||||
cp := authenticate(mockedUser)(next)
|
||||
cp := authenticate(mockedUsers)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(mockedUser.username).To(Equal("valid"))
|
||||
Expect(mockedUser.password).To(Equal("password"))
|
||||
Expect(mockedUser.token).To(Equal("token"))
|
||||
Expect(mockedUser.salt).To(Equal("salt"))
|
||||
Expect(mockedUser.jwt).To(Equal("jwt"))
|
||||
Expect(mockedUsers.username).To(Equal("valid"))
|
||||
Expect(mockedUsers.password).To(Equal("password"))
|
||||
Expect(mockedUsers.token).To(Equal("token"))
|
||||
Expect(mockedUsers.salt).To(Equal("salt"))
|
||||
Expect(mockedUsers.jwt).To(Equal("jwt"))
|
||||
Expect(next.called).To(BeTrue())
|
||||
user := next.req.Context().Value("user").(*model.User)
|
||||
user := next.req.Context().Value("user").(model.User)
|
||||
Expect(user.UserName).To(Equal("valid"))
|
||||
})
|
||||
|
||||
It("fails authentication with wrong password", func() {
|
||||
r := newGetRequest("u=invalid", "", "", "")
|
||||
cp := authenticate(mockedUser)(next)
|
||||
cp := authenticate(mockedUsers)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
||||
Expect(next.called).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetPlayer", func() {
|
||||
var mockedPlayers *mockPlayers
|
||||
var r *http.Request
|
||||
BeforeEach(func() {
|
||||
mockedPlayers = &mockPlayers{}
|
||||
r = newGetRequest()
|
||||
ctx := context.WithValue(r.Context(), "username", "someone")
|
||||
ctx = context.WithValue(ctx, "client", "client")
|
||||
r = r.WithContext(ctx)
|
||||
})
|
||||
|
||||
It("returns a new player in the cookies when none is specified", func() {
|
||||
gp := getPlayer(mockedPlayers)(next)
|
||||
gp.ServeHTTP(w, r)
|
||||
|
||||
cookieStr := w.Header().Get("Set-Cookie")
|
||||
Expect(cookieStr).To(ContainSubstring(playerIDCookieName("someone")))
|
||||
})
|
||||
|
||||
Context("PlayerId specified in Cookies", func() {
|
||||
BeforeEach(func() {
|
||||
cookie := &http.Cookie{
|
||||
Name: playerIDCookieName("someone"),
|
||||
Value: "123",
|
||||
MaxAge: cookieExpiry,
|
||||
}
|
||||
r.AddCookie(cookie)
|
||||
|
||||
gp := getPlayer(mockedPlayers)(next)
|
||||
gp.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
It("stores the player in the context", func() {
|
||||
Expect(next.called).To(BeTrue())
|
||||
player := next.req.Context().Value("player").(model.Player)
|
||||
Expect(player.ID).To(Equal("123"))
|
||||
Expect(next.req.Context().Value("transcoding")).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns the playerId in the cookie", func() {
|
||||
cookieStr := w.Header().Get("Set-Cookie")
|
||||
Expect(cookieStr).To(ContainSubstring(playerIDCookieName("someone") + "=123"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Player has transcoding configured", func() {
|
||||
BeforeEach(func() {
|
||||
cookie := &http.Cookie{
|
||||
Name: playerIDCookieName("someone"),
|
||||
Value: "123",
|
||||
MaxAge: cookieExpiry,
|
||||
}
|
||||
r.AddCookie(cookie)
|
||||
mockedPlayers.transcoding = &model.Transcoding{ID: "12"}
|
||||
gp := getPlayer(mockedPlayers)(next)
|
||||
gp.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
It("stores the player in the context", func() {
|
||||
player := next.req.Context().Value("player").(model.Player)
|
||||
Expect(player.ID).To(Equal("123"))
|
||||
transcoding := next.req.Context().Value("transcoding").(model.Transcoding)
|
||||
Expect(transcoding.ID).To(Equal("12"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockHandler struct {
|
||||
@@ -164,3 +231,16 @@ func (m *mockUsers) Authenticate(ctx context.Context, username, password, token,
|
||||
}
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
|
||||
type mockPlayers struct {
|
||||
engine.Players
|
||||
transcoding *model.Transcoding
|
||||
}
|
||||
|
||||
func (mp *mockPlayers) Get(ctx context.Context, playerId string) (*model.Player, error) {
|
||||
return &model.Player{ID: playerId}, nil
|
||||
}
|
||||
|
||||
func (mp *mockPlayers) Register(ctx context.Context, id, client, typ, ip string) (*model.Player, *model.Transcoding, error) {
|
||||
return &model.Player{ID: id}, mp.transcoding, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -57,7 +58,7 @@ func (c *PlaylistsController) GetPlaylist(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response.Playlist = c.buildPlaylist(pinfo)
|
||||
response.Playlist = c.buildPlaylist(r.Context(), pinfo)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -124,7 +125,7 @@ func (c *PlaylistsController) UpdatePlaylist(w http.ResponseWriter, r *http.Requ
|
||||
return NewResponse(), nil
|
||||
}
|
||||
|
||||
func (c *PlaylistsController) buildPlaylist(d *engine.PlaylistInfo) *responses.PlaylistWithSongs {
|
||||
func (c *PlaylistsController) buildPlaylist(ctx context.Context, d *engine.PlaylistInfo) *responses.PlaylistWithSongs {
|
||||
pls := &responses.PlaylistWithSongs{}
|
||||
pls.Id = d.Id
|
||||
pls.Name = d.Name
|
||||
@@ -133,6 +134,6 @@ func (c *PlaylistsController) buildPlaylist(d *engine.PlaylistInfo) *responses.P
|
||||
pls.Duration = d.Duration
|
||||
pls.Public = d.Public
|
||||
|
||||
pls.Entry = ToChildren(d.Entries)
|
||||
pls.Entry = ToChildren(ctx, d.Entries)
|
||||
return pls
|
||||
}
|
||||
|
||||
@@ -72,8 +72,8 @@ func (c *SearchingController) Search2(w http.ResponseWriter, r *http.Request) (*
|
||||
response := NewResponse()
|
||||
searchResult2 := &responses.SearchResult2{}
|
||||
searchResult2.Artist = ToArtists(as)
|
||||
searchResult2.Album = ToChildren(als)
|
||||
searchResult2.Song = ToChildren(mfs)
|
||||
searchResult2.Album = ToChildren(r.Context(), als)
|
||||
searchResult2.Song = ToChildren(r.Context(), mfs)
|
||||
response.SearchResult2 = searchResult2
|
||||
return response, nil
|
||||
}
|
||||
@@ -99,8 +99,8 @@ func (c *SearchingController) Search3(w http.ResponseWriter, r *http.Request) (*
|
||||
searchResult3.Artist[i].Starred = &e.Starred
|
||||
}
|
||||
}
|
||||
searchResult3.Album = ToAlbums(als)
|
||||
searchResult3.Song = ToChildren(mfs)
|
||||
searchResult3.Album = ToAlbums(r.Context(), als)
|
||||
searchResult3.Song = ToChildren(r.Context(), mfs)
|
||||
response.SearchResult3 = searchResult3
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func (c *StreamController) Stream(w http.ResponseWriter, r *http.Request) (*resp
|
||||
maxBitRate := utils.ParamInt(r, "maxBitRate", 0)
|
||||
format := utils.ParamString(r, "format")
|
||||
|
||||
stream, err := c.streamer.NewStream(r.Context(), id, maxBitRate, format)
|
||||
stream, err := c.streamer.NewStream(r.Context(), id, format, maxBitRate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func (c *StreamController) Download(w http.ResponseWriter, r *http.Request) (*re
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stream, err := c.streamer.NewStream(r.Context(), id, 0, "raw")
|
||||
stream, err := c.streamer.NewStream(r.Context(), id, "raw", 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 66 KiB |
BIN
static/navidrone-310x310.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
254
ui/package-lock.json
generated
@@ -1050,9 +1050,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"regenerator-runtime": {
|
||||
"version": "0.13.4",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.4.tgz",
|
||||
"integrity": "sha512-plpwicqEzfEyTQohIKktWigcLzmNStMGwbOUbykx51/29Z3JOGYldaaNGK7ngNXV+UcoqvIMmloZ48Sr74sd+g=="
|
||||
"version": "0.13.5",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz",
|
||||
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA=="
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1112,9 +1112,9 @@
|
||||
"integrity": "sha512-ij4wRiunFfaJxjB0BdrYHIH8FxBJpOwNPhhAcunlmPdXudL1WQV1qoP9un6JsEBAgQH+7UXyyjh0g7jTxXK6tg=="
|
||||
},
|
||||
"@emotion/hash": {
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.7.4.tgz",
|
||||
"integrity": "sha512-fxfMSBMX3tlIbKUdtGKxqB1fyrH6gVrX39Gsv3y8lRYKUqlgDt3UMqQyGnR1bQMa2B8aGnhLZokZgg8vT0Le+A=="
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
|
||||
"integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="
|
||||
},
|
||||
"@hapi/address": {
|
||||
"version": "2.1.4",
|
||||
@@ -1620,15 +1620,15 @@
|
||||
}
|
||||
},
|
||||
"@material-ui/core": {
|
||||
"version": "4.9.5",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.9.5.tgz",
|
||||
"integrity": "sha512-hVuUqw6847jcgRsUqzCiYCXcIJYhPUfM3gS9sNehTsbI0SF3tufLNO2B2Cgkuns8uOGy0nicD4p3L7JqhnEElg==",
|
||||
"version": "4.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.9.7.tgz",
|
||||
"integrity": "sha512-RTRibZgq572GHEskMAG4sP+bt3P3XyIkv3pOTR8grZAW2rSUd6JoGZLRM4S2HkuO7wS7cAU5SpU2s1EsmTgWog==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.4.4",
|
||||
"@material-ui/styles": "^4.9.0",
|
||||
"@material-ui/system": "^4.9.3",
|
||||
"@material-ui/styles": "^4.9.6",
|
||||
"@material-ui/system": "^4.9.6",
|
||||
"@material-ui/types": "^5.0.0",
|
||||
"@material-ui/utils": "^4.7.1",
|
||||
"@material-ui/utils": "^4.9.6",
|
||||
"@types/react-transition-group": "^4.2.0",
|
||||
"clsx": "^1.0.2",
|
||||
"hoist-non-react-statics": "^3.3.2",
|
||||
@@ -1657,17 +1657,17 @@
|
||||
}
|
||||
},
|
||||
"@material-ui/styles": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.9.0.tgz",
|
||||
"integrity": "sha512-nJHum4RqYBPWsjL/9JET8Z02FZ9gSizlg/7LWVFpIthNzpK6OQ5OSRR4T4x9/p+wK3t1qNn3b1uI4XpnZaPxOA==",
|
||||
"version": "4.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.9.6.tgz",
|
||||
"integrity": "sha512-ijgwStEkw1OZ6gCz18hkjycpr/3lKs1hYPi88O/AUn4vMuuGEGAIrqKVFq/lADmZUNF3DOFIk8LDkp7zmjPxtA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.4.4",
|
||||
"@emotion/hash": "^0.7.4",
|
||||
"@emotion/hash": "^0.8.0",
|
||||
"@material-ui/types": "^5.0.0",
|
||||
"@material-ui/utils": "^4.7.1",
|
||||
"@material-ui/utils": "^4.9.6",
|
||||
"clsx": "^1.0.2",
|
||||
"csstype": "^2.5.2",
|
||||
"hoist-non-react-statics": "^3.2.1",
|
||||
"hoist-non-react-statics": "^3.3.2",
|
||||
"jss": "^10.0.3",
|
||||
"jss-plugin-camel-case": "^10.0.3",
|
||||
"jss-plugin-default-unit": "^10.0.3",
|
||||
@@ -1690,12 +1690,12 @@
|
||||
}
|
||||
},
|
||||
"@material-ui/system": {
|
||||
"version": "4.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/system/-/system-4.9.3.tgz",
|
||||
"integrity": "sha512-DBGsTKYrLlFpHG8BUp0X6ZpvaOzef+GhSwn/8DwVTXUdHitphaPQoL9xucrI8X9MTBo//El+7nylko7lo7eJIw==",
|
||||
"version": "4.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/system/-/system-4.9.6.tgz",
|
||||
"integrity": "sha512-QtfoAePyqXoZ2HUVSwGb1Ro0kucMCvVjbI0CdYIR21t0Opgfm1Oer6ni9P5lfeXA39xSt0wCierw37j+YES48Q==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.4.4",
|
||||
"@material-ui/utils": "^4.7.1",
|
||||
"@material-ui/utils": "^4.9.6",
|
||||
"prop-types": "^15.7.2"
|
||||
}
|
||||
},
|
||||
@@ -1705,9 +1705,9 @@
|
||||
"integrity": "sha512-UeH2BuKkwDndtMSS0qgx1kCzSMw+ydtj0xx/XbFtxNSTlXydKwzs5gVW5ZKsFlAkwoOOQ9TIsyoCC8hq18tOwg=="
|
||||
},
|
||||
"@material-ui/utils": {
|
||||
"version": "4.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.7.1.tgz",
|
||||
"integrity": "sha512-+ux0SlLdlehvzCk2zdQ3KiS3/ylWvuo/JwAGhvb8dFVvwR21K28z0PU9OQW2PGogrMEdvX3miEI5tGxTwwWiwQ==",
|
||||
"version": "4.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.9.6.tgz",
|
||||
"integrity": "sha512-gqlBn0JPPTUZeAktn1rgMcy9Iczrr74ecx31tyZLVGdBGGzsxzM6PP6zeS7FuoLS6vG4hoZP7hWnOoHtkR0Kvw==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.4.4",
|
||||
"prop-types": "^15.7.2",
|
||||
@@ -1776,9 +1776,9 @@
|
||||
"integrity": "sha512-afmTuJrylUU/0OtqzaRkbyYFFNgCF73Bvel/sw90pvGrWIZ+vyoIJqA6eMSoA6+nb443kTmulmBtC9NerXboNg=="
|
||||
},
|
||||
"@sheerun/mutationobserver-shim": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@sheerun/mutationobserver-shim/-/mutationobserver-shim-0.3.2.tgz",
|
||||
"integrity": "sha512-vTCdPp/T/Q3oSqwHmZ5Kpa9oI7iLtGl3RQaA/NyLHikvcrPxACkkKVr/XzkSPJWXHRhKGzVvb0urJsbMlRxi1Q=="
|
||||
"version": "0.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@sheerun/mutationobserver-shim/-/mutationobserver-shim-0.3.3.tgz",
|
||||
"integrity": "sha512-DetpxZw1fzPD5xUBrIAoplLChO2VB8DlL5Gg+I1IR9b2wPqYIca2WSUxL5g1vLeR4MsQq1NeWriXAVffV+U1Fw=="
|
||||
},
|
||||
"@svgr/babel-plugin-add-jsx-attribute": {
|
||||
"version": "4.2.0",
|
||||
@@ -1890,17 +1890,15 @@
|
||||
}
|
||||
},
|
||||
"@testing-library/dom": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-6.15.0.tgz",
|
||||
"integrity": "sha512-8N24c4XwOigPicwc8n4ECgEoJW2/mMzRJBxu4Uo0zhLERZTbNzqpL5fyCigu7JGUXX+ITuiK4z9/lnHbYRHLwQ==",
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.0.4.tgz",
|
||||
"integrity": "sha512-+vrLcGDvopLPsBB7JgJhf8ZoOhBSeCsI44PKJL9YoKrP2AvCkqrTg+z77wEEZJ4tSNdxV0kymil7hSvsQQ7jMQ==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.8.4",
|
||||
"@sheerun/mutationobserver-shim": "^0.3.2",
|
||||
"@types/testing-library__dom": "^6.12.1",
|
||||
"aria-query": "^4.0.2",
|
||||
"dom-accessibility-api": "^0.3.0",
|
||||
"pretty-format": "^25.1.0",
|
||||
"wait-for-expect": "^3.0.2"
|
||||
"pretty-format": "^25.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"aria-query": {
|
||||
@@ -1932,13 +1930,28 @@
|
||||
}
|
||||
},
|
||||
"@testing-library/react": {
|
||||
"version": "9.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-9.5.0.tgz",
|
||||
"integrity": "sha512-di1b+D0p+rfeboHO5W7gTVeZDIK5+maEgstrZbWZSSvxDyfDRkkyBE1AJR5Psd6doNldluXlCWqXriUfqu/9Qg==",
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-10.0.1.tgz",
|
||||
"integrity": "sha512-sMHWud2dcymOzq2AhEniICSijEwKeTiBX+K0y36FYNY7wH2t0SIP1o732Bf5dDY0jYoMC2hj2UJSVpZC/rDsWg==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.8.4",
|
||||
"@testing-library/dom": "^6.15.0",
|
||||
"@types/testing-library__react": "^9.1.2"
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"@testing-library/dom": "^7.0.2",
|
||||
"@types/testing-library__react": "^9.1.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": {
|
||||
"version": "7.8.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.8.7.tgz",
|
||||
"integrity": "sha512-+AATMUFppJDw6aiR5NVPHqIQBlV/Pj8wY/EZH+lmvRdUo9xBaz/rF3alAwFJQavvKfeOlPE7oaaDHVbcySbCsg==",
|
||||
"requires": {
|
||||
"regenerator-runtime": "^0.13.4"
|
||||
}
|
||||
},
|
||||
"regenerator-runtime": {
|
||||
"version": "0.13.5",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz",
|
||||
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@testing-library/user-event": {
|
||||
@@ -2100,9 +2113,9 @@
|
||||
"integrity": "sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw=="
|
||||
},
|
||||
"@types/testing-library__dom": {
|
||||
"version": "6.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/testing-library__dom/-/testing-library__dom-6.12.1.tgz",
|
||||
"integrity": "sha512-cgqnEjxKk31tQt29j4baSWaZPNjQf3bHalj2gcHQTpW5SuHRal76gOpF0vypeEo6o+sS5inOvvNdzLY0B3FB2A==",
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/testing-library__dom/-/testing-library__dom-6.14.0.tgz",
|
||||
"integrity": "sha512-sMl7OSv0AvMOqn1UJ6j1unPMIHRXen0Ita1ujnMX912rrOcawe4f7wu0Zt9GIQhBhJvH2BaibqFgQ3lP+Pj2hA==",
|
||||
"requires": {
|
||||
"pretty-format": "^24.3.0"
|
||||
},
|
||||
@@ -2439,9 +2452,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"acorn": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.0.tgz",
|
||||
"integrity": "sha512-gac8OEcQ2Li1dxIEWGZzsp2BitJxwkwcOm0zHAJLcPJaVvm58FRnk6RkuLRpU1EujipU2ZFODv2P9DLMfnV8mw=="
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.1.tgz",
|
||||
"integrity": "sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA=="
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2767,9 +2780,9 @@
|
||||
"integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg=="
|
||||
},
|
||||
"attr-accept": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.0.0.tgz",
|
||||
"integrity": "sha512-I9SDP4Wvh2ItYYoafEg8hFpsBe96pfQ+eabceShXt3sw2fbIP96+Aoj9zZE0vkZNAkXXzHJATVRuWz+h9FxJxQ=="
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.1.0.tgz",
|
||||
"integrity": "sha512-sLzVM3zCCmmDtDNhI0i96k6PUztkotSOXqE4kDGQt/6iDi5M+H0srjeF+QC6jN581l4X/Zq3Zu/tgcErEssavg=="
|
||||
},
|
||||
"autoprefixer": {
|
||||
"version": "9.7.4",
|
||||
@@ -4212,9 +4225,9 @@
|
||||
"integrity": "sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg=="
|
||||
},
|
||||
"connected-react-router": {
|
||||
"version": "6.7.0",
|
||||
"resolved": "https://registry.npmjs.org/connected-react-router/-/connected-react-router-6.7.0.tgz",
|
||||
"integrity": "sha512-RDmcmiwSfUWQ3U7J7RVkc9cwNtek26fUn0DWpA8pS7JylC97VNeosrsIxjJ/3CGDrzZPqnc0Hr/kZxjh75JGlw==",
|
||||
"version": "6.8.0",
|
||||
"resolved": "https://registry.npmjs.org/connected-react-router/-/connected-react-router-6.8.0.tgz",
|
||||
"integrity": "sha512-E64/6krdJM3Ag3MMmh2nKPtMbH15s3JQDuaYJvOVXzu6MbHbDyIvuwLOyhQIuP4Om9zqEfZYiVyflROibSsONg==",
|
||||
"requires": {
|
||||
"prop-types": "^15.7.2"
|
||||
}
|
||||
@@ -8236,9 +8249,9 @@
|
||||
}
|
||||
},
|
||||
"acorn": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.0.tgz",
|
||||
"integrity": "sha512-gac8OEcQ2Li1dxIEWGZzsp2BitJxwkwcOm0zHAJLcPJaVvm58FRnk6RkuLRpU1EujipU2ZFODv2P9DLMfnV8mw=="
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.1.tgz",
|
||||
"integrity": "sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA=="
|
||||
},
|
||||
"jsdom": {
|
||||
"version": "14.1.0",
|
||||
@@ -10045,9 +10058,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"acorn": {
|
||||
"version": "5.7.3",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.3.tgz",
|
||||
"integrity": "sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw=="
|
||||
"version": "5.7.4",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.4.tgz",
|
||||
"integrity": "sha512-1D++VG7BhrtvQpNbBzovKNc1FLGGEE/oGe7b9xJm/RFHMBeUaUGpluV9RLjZa47YFdPcDAenEYuq9pQPcMdLJg=="
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -10132,9 +10145,9 @@
|
||||
}
|
||||
},
|
||||
"jss": {
|
||||
"version": "10.0.4",
|
||||
"resolved": "https://registry.npmjs.org/jss/-/jss-10.0.4.tgz",
|
||||
"integrity": "sha512-GqHmeDK83qbqMAVjxyPfN1qJVTKZne533a9bdCrllZukUM8npG/k+JumEPI86IIB5ifaZAHG2HAsUziyxOiooQ==",
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jss/-/jss-10.1.1.tgz",
|
||||
"integrity": "sha512-Xz3qgRUFlxbWk1czCZibUJqhVPObrZHxY3FPsjCXhDld4NOj1BgM14Ir5hVm+Qr6OLqVljjGvoMcCdXNOAbdkQ==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.3.1",
|
||||
"csstype": "^2.6.5",
|
||||
@@ -10143,69 +10156,69 @@
|
||||
}
|
||||
},
|
||||
"jss-plugin-camel-case": {
|
||||
"version": "10.0.4",
|
||||
"resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.0.4.tgz",
|
||||
"integrity": "sha512-+wnqxJsyfUnOn0LxVg3GgZBSjfBCrjxwx7LFxwVTUih0ceGaXKZoieheNOaTo5EM4w8bt1nbb8XonpQCj67C6A==",
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.1.1.tgz",
|
||||
"integrity": "sha512-MDIaw8FeD5uFz1seQBKz4pnvDLnj5vIKV5hXSVdMaAVq13xR6SVTVWkIV/keyTs5txxTvzGJ9hXoxgd1WTUlBw==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.3.1",
|
||||
"hyphenate-style-name": "^1.0.3",
|
||||
"jss": "10.0.4"
|
||||
"jss": "10.1.1"
|
||||
}
|
||||
},
|
||||
"jss-plugin-default-unit": {
|
||||
"version": "10.0.4",
|
||||
"resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.0.4.tgz",
|
||||
"integrity": "sha512-T0mhL/Ogp/quvod/jAHEqKvptLDxq7Cj3a+7zRuqK8HxUYkftptN89wJElZC3rshhNKiogkEYhCWenpJdFvTBg==",
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.1.1.tgz",
|
||||
"integrity": "sha512-UkeVCA/b3QEA4k0nIKS4uWXDCNmV73WLHdh2oDGZZc3GsQtlOCuiH3EkB/qI60v2MiCq356/SYWsDXt21yjwdg==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.3.1",
|
||||
"jss": "10.0.4"
|
||||
"jss": "10.1.1"
|
||||
}
|
||||
},
|
||||
"jss-plugin-global": {
|
||||
"version": "10.0.4",
|
||||
"resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.0.4.tgz",
|
||||
"integrity": "sha512-N8n9/GHENZce+sqE4UYiZiJtI+t+erT/BypHOrNYAfIoNEj7OYsOEKfIo2P0GpLB3QyDAYf5eo9XNdZ8veEkUA==",
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.1.1.tgz",
|
||||
"integrity": "sha512-VBG3wRyi3Z8S4kMhm8rZV6caYBegsk+QnQZSVmrWw6GVOT/Z4FA7eyMu5SdkorDlG/HVpHh91oFN56O4R9m2VA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.3.1",
|
||||
"jss": "10.0.4"
|
||||
"jss": "10.1.1"
|
||||
}
|
||||
},
|
||||
"jss-plugin-nested": {
|
||||
"version": "10.0.4",
|
||||
"resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.0.4.tgz",
|
||||
"integrity": "sha512-QM21BKVt8LDeoRfowvAMh/s+/89VYrreIIE6ch4pvw0oAXDWw1iorUPlqLZ7uCO3UL0uFtQhJq3QMLN6Lr1v0A==",
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.1.1.tgz",
|
||||
"integrity": "sha512-ozEu7ZBSVrMYxSDplPX3H82XHNQk2DQEJ9TEyo7OVTPJ1hEieqjDFiOQOxXEj9z3PMqkylnUbvWIZRDKCFYw5Q==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.3.1",
|
||||
"jss": "10.0.4",
|
||||
"jss": "10.1.1",
|
||||
"tiny-warning": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"jss-plugin-props-sort": {
|
||||
"version": "10.0.4",
|
||||
"resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.0.4.tgz",
|
||||
"integrity": "sha512-WoETdOCjGskuin/OMt2uEdDPLZF3vfQuHXF+XUHGJrq0BAapoyGQDcv37SeReDlkRAbVXkEZPsIMvYrgHSHFiA==",
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.1.1.tgz",
|
||||
"integrity": "sha512-g/joK3eTDZB4pkqpZB38257yD4LXB0X15jxtZAGbUzcKAVUHPl9Jb47Y7lYmiGsShiV4YmQRqG1p2DHMYoK91g==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.3.1",
|
||||
"jss": "10.0.4"
|
||||
"jss": "10.1.1"
|
||||
}
|
||||
},
|
||||
"jss-plugin-rule-value-function": {
|
||||
"version": "10.0.4",
|
||||
"resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.0.4.tgz",
|
||||
"integrity": "sha512-0hrzOSWRF5ABJGaHrlnHbYZjU877Ofzfh2id3uLtBvemGQLHI+ldoL8/+6iPSRa7M8z8Ngfg2vfYhKjUA5gA0g==",
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.1.1.tgz",
|
||||
"integrity": "sha512-ClV1lvJ3laU9la1CUzaDugEcwnpjPTuJ0yGy2YtcU+gG/w9HMInD5vEv7xKAz53Bk4WiJm5uLOElSEshHyhKNw==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.3.1",
|
||||
"jss": "10.0.4"
|
||||
"jss": "10.1.1"
|
||||
}
|
||||
},
|
||||
"jss-plugin-vendor-prefixer": {
|
||||
"version": "10.0.4",
|
||||
"resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.0.4.tgz",
|
||||
"integrity": "sha512-4JgEbcrdeMda1qvxTm1CnxFJAWVV++VLpP46HNTrfH7VhVlvUpihnUNs2gAlKuRT/XSBuiWeLAkrTqF4NVrPig==",
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.1.1.tgz",
|
||||
"integrity": "sha512-09MZpQ6onQrhaVSF6GHC4iYifQ7+4YC/tAP6D4ZWeZotvCMq1mHLqNKRIaqQ2lkgANjlEot2JnVi1ktu4+L4pw==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.3.1",
|
||||
"css-vendor": "^2.0.7",
|
||||
"jss": "10.0.4"
|
||||
"jss": "10.1.1"
|
||||
}
|
||||
},
|
||||
"jsx-ast-utils": {
|
||||
@@ -10745,9 +10758,9 @@
|
||||
}
|
||||
},
|
||||
"minimist": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
|
||||
"integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ="
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
|
||||
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
|
||||
},
|
||||
"minipass": {
|
||||
"version": "3.1.1",
|
||||
@@ -12908,9 +12921,9 @@
|
||||
"integrity": "sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA=="
|
||||
},
|
||||
"ra-core": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ra-core/-/ra-core-3.2.3.tgz",
|
||||
"integrity": "sha512-VXAjgpyuf8LEX7Xp0vmEYGM58D7vd5DVs08MNgSJI887ep0ZwYHwrRn/VP5pka1An8zN6D7AEVpmda8q2rkhpA==",
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ra-core/-/ra-core-3.3.1.tgz",
|
||||
"integrity": "sha512-tAUSVqh3cZmyIhipa1pS2voK4E5G+7c8WTLR3cxhTR+6qzw3miVmPChk2F0Xh5wmbHJPZy2nZVoUIB16A4vVug==",
|
||||
"requires": {
|
||||
"@testing-library/react": "^8.0.7",
|
||||
"classnames": "~2.2.5",
|
||||
@@ -12999,30 +13012,25 @@
|
||||
"ansi-styles": "^3.2.0",
|
||||
"react-is": "^16.8.4"
|
||||
}
|
||||
},
|
||||
"wait-for-expect": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/wait-for-expect/-/wait-for-expect-1.3.0.tgz",
|
||||
"integrity": "sha512-8fJU7jiA96HfGPt+P/UilelSAZfhMBJ52YhKzlmZQvKEZU2EcD1GQ0yqGB6liLdHjYtYAoGVigYwdxr5rktvzA=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra-data-json-server": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ra-data-json-server/-/ra-data-json-server-3.2.3.tgz",
|
||||
"integrity": "sha512-KtRKcEdln7eBHlLkN4WjxrQE26HUyDWQmWf61XT4UXYGTH3gZCxL2PXm9sEIaGLNXWm6ae6HZ5k/YiJVGIxGQw==",
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ra-data-json-server/-/ra-data-json-server-3.3.1.tgz",
|
||||
"integrity": "sha512-9ZRCQBiT3MWEMyvYTQfkx3/owHhbt/zUIPvZlsIWgoPvvMGe07p63EtoMC/OLUxtqqiBs9+M6hECCLZq5Ve9pA==",
|
||||
"requires": {
|
||||
"query-string": "^5.1.1",
|
||||
"ra-core": "^3.2.3"
|
||||
"ra-core": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"ra-i18n-polyglot": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ra-i18n-polyglot/-/ra-i18n-polyglot-3.2.3.tgz",
|
||||
"integrity": "sha512-IIEteIFuS08jJx7n4Q2fx2ikl67g0etPIaQcXxWIgRRtVrBLXTBE6XHGPWcNRwP/yAvcroLJ1O9A4M3BA6LWjg==",
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ra-i18n-polyglot/-/ra-i18n-polyglot-3.3.1.tgz",
|
||||
"integrity": "sha512-MTC5xndJ+IfPEJcvLjSuyKVA/4wueyc11oj6jv+CDBN6xlL9+4gQQNJ64Y9vOkCnDI2LCSEEPmeinXkRsfoW7Q==",
|
||||
"requires": {
|
||||
"node-polyglot": "^2.2.2",
|
||||
"ra-core": "^3.2.3"
|
||||
"ra-core": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"ra-language-english": {
|
||||
@@ -13031,9 +13039,9 @@
|
||||
"integrity": "sha512-/XmwYWoQoB4MBkkzBCbg/ykCuRGjHQOHLk2ik6n1aM10AWHxiiJNyRw2aoLzH7Vc5rcp4BBJQCuhT+DgfYIJ2Q=="
|
||||
},
|
||||
"ra-ui-materialui": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ra-ui-materialui/-/ra-ui-materialui-3.2.3.tgz",
|
||||
"integrity": "sha512-+O+34Onk8Eqncg8wo1EDPuARfuRgwpcphFFCoDmArY7xYQbBXlD5RP24rqQ+i3awk67JZ0xDIQiVkV77Wa0HfA==",
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ra-ui-materialui/-/ra-ui-materialui-3.3.1.tgz",
|
||||
"integrity": "sha512-MHVTP6XG5ylwOH21MUQFl17+L1/Qe7335FhFscuhy6kEX7U3UQKaAQu9xD3ij30P6gAEJSb8EI02TR2FvaEWVg==",
|
||||
"requires": {
|
||||
"autosuggest-highlight": "^3.1.1",
|
||||
"classnames": "~2.2.5",
|
||||
@@ -13217,9 +13225,9 @@
|
||||
}
|
||||
},
|
||||
"react-admin": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-admin/-/react-admin-3.2.3.tgz",
|
||||
"integrity": "sha512-Ts4Z4gdmUlltGHDnv/fdtKJxfnj+CDjwJg/fgMywZVYk9/BvoM4Q+NG7xN3mcvTmD7lKx7vi6DbAq1Xt+eoT4w==",
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-admin/-/react-admin-3.3.1.tgz",
|
||||
"integrity": "sha512-4tJRVhOmzqy6XGOoLzDDAxuFqx+y+W/Y+S9jpkIKAdG0cHRZtSSKvTiakuf3yCKYf6lBffLYQUmqifBpKupOCg==",
|
||||
"requires": {
|
||||
"@material-ui/core": "^4.3.3",
|
||||
"@material-ui/icons": "^4.2.1",
|
||||
@@ -13227,10 +13235,10 @@
|
||||
"connected-react-router": "^6.5.2",
|
||||
"final-form": "^4.18.5",
|
||||
"final-form-arrays": "^3.0.1",
|
||||
"ra-core": "^3.2.3",
|
||||
"ra-i18n-polyglot": "^3.2.3",
|
||||
"ra-core": "^3.3.1",
|
||||
"ra-i18n-polyglot": "^3.3.1",
|
||||
"ra-language-english": "^3.2.0",
|
||||
"ra-ui-materialui": "^3.2.3",
|
||||
"ra-ui-materialui": "^3.3.1",
|
||||
"react-final-form": "^6.3.3",
|
||||
"react-final-form-arrays": "^3.1.1",
|
||||
"react-redux": "^7.1.0",
|
||||
@@ -16075,9 +16083,9 @@
|
||||
}
|
||||
},
|
||||
"wait-for-expect": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wait-for-expect/-/wait-for-expect-3.0.2.tgz",
|
||||
"integrity": "sha512-cfS1+DZxuav1aBYbaO/kE06EOS8yRw7qOFoD3XtjTkYvCvh3zUvNST8DXK/nPaeqIzIv3P3kL3lRJn8iwOiSag=="
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/wait-for-expect/-/wait-for-expect-1.3.0.tgz",
|
||||
"integrity": "sha512-8fJU7jiA96HfGPt+P/UilelSAZfhMBJ52YhKzlmZQvKEZU2EcD1GQ0yqGB6liLdHjYtYAoGVigYwdxr5rktvzA=="
|
||||
},
|
||||
"walker": {
|
||||
"version": "1.0.7",
|
||||
@@ -16734,9 +16742,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"acorn": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.0.tgz",
|
||||
"integrity": "sha512-gac8OEcQ2Li1dxIEWGZzsp2BitJxwkwcOm0zHAJLcPJaVvm58FRnk6RkuLRpU1EujipU2ZFODv2P9DLMfnV8mw=="
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.1.tgz",
|
||||
"integrity": "sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA=="
|
||||
},
|
||||
"cacache": {
|
||||
"version": "12.0.3",
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^5.1.1",
|
||||
"@testing-library/react": "^9.5.0",
|
||||
"@testing-library/react": "^10.0.1",
|
||||
"@testing-library/user-event": "^10.0.0",
|
||||
"deepmerge": "^4.2.2",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"md5-hex": "^3.0.1",
|
||||
"prop-types": "^15.7.2",
|
||||
"ra-data-json-server": "^3.2.3",
|
||||
"ra-data-json-server": "^3.3.1",
|
||||
"react": "^16.13.0",
|
||||
"react-admin": "^3.2.3",
|
||||
"react-admin": "^3.3.1",
|
||||
"react-dom": "^16.13.0",
|
||||
"react-jinke-music-player": "^4.10.1",
|
||||
"react-redux": "^7.2.0",
|
||||
|
||||
BIN
ui/public/android-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
ui/public/android-icon-192x192.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
ui/public/android-icon-36x36.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
ui/public/android-icon-48x48.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
ui/public/android-icon-72x72.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
ui/public/android-icon-96x96.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
ui/public/apple-icon-114x114.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
ui/public/apple-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
ui/public/apple-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
ui/public/apple-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
ui/public/apple-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
ui/public/apple-icon-57x57.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
ui/public/apple-icon-60x60.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
ui/public/apple-icon-72x72.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
ui/public/apple-icon-76x76.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
ui/public/apple-icon-precomposed.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
ui/public/apple-icon.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
2
ui/public/browserconfig.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>
|
||||
BIN
ui/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
ui/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
ui/public/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -2,14 +2,30 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Navidrome Music Server"
|
||||
/>
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="%PUBLIC_URL%/apple-icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="%PUBLIC_URL%/apple-icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="%PUBLIC_URL%/apple-icon-72x72.png">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="%PUBLIC_URL%/apple-icon-76x76.png">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="%PUBLIC_URL%/apple-icon-114x114.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="%PUBLIC_URL%/apple-icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="%PUBLIC_URL%/apple-icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="%PUBLIC_URL%/apple-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="%PUBLIC_URL%/android-icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="%PUBLIC_URL%/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png">
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="%PUBLIC_URL%/ms-icon-144x144.png">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Navidrome Music Server"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
|
||||
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
@@ -1,25 +1,41 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
"name": "App",
|
||||
"icons": [
|
||||
{
|
||||
"src": "\/android-icon-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image\/png",
|
||||
"density": "0.75"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image\/png",
|
||||
"density": "1.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image\/png",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image\/png",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image\/png",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
ui/public/ms-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
ui/public/ms-icon-150x150.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
ui/public/ms-icon-310x310.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
ui/public/ms-icon-70x70.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
@@ -5,12 +5,14 @@ import authProvider from './authProvider'
|
||||
import polyglotI18nProvider from 'ra-i18n-polyglot'
|
||||
import messages from './i18n'
|
||||
import { DarkTheme, Layout, Login } from './layout'
|
||||
import transcoding from './transcoding'
|
||||
import player from './player'
|
||||
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'
|
||||
import { Player, playQueueReducer } from './audioplayer'
|
||||
|
||||
const theme = createMuiTheme(DarkTheme)
|
||||
|
||||
@@ -44,7 +46,23 @@ const App = () => {
|
||||
<Resource name="album" {...album} options={{ subMenu: 'library' }} />,
|
||||
<Resource name="song" {...song} options={{ subMenu: 'library' }} />,
|
||||
<Resource name="albumSong" />,
|
||||
permissions === 'admin' ? <Resource name="user" {...user} /> : null,
|
||||
permissions === 'admin' ? (
|
||||
<Resource name="user" {...user} options={{ subMenu: 'settings' }} />
|
||||
) : null,
|
||||
<Resource
|
||||
name="player"
|
||||
{...player}
|
||||
options={{ subMenu: 'settings' }}
|
||||
/>,
|
||||
permissions === 'admin' ? (
|
||||
<Resource
|
||||
name="transcoding"
|
||||
{...transcoding}
|
||||
options={{ subMenu: 'settings' }}
|
||||
/>
|
||||
) : (
|
||||
<Resource name="transcoding" />
|
||||
),
|
||||
<Player />
|
||||
]}
|
||||
</Admin>
|
||||
|
||||
@@ -8,7 +8,7 @@ import PlayArrowIcon from '@material-ui/icons/PlayArrow'
|
||||
import ShuffleIcon from '@material-ui/icons/Shuffle'
|
||||
import React from 'react'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { playAlbum } from '../player'
|
||||
import { playAlbum } from '../audioplayer'
|
||||
|
||||
export const AlbumActions = ({
|
||||
className,
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
NumberField,
|
||||
FunctionField,
|
||||
SearchInput,
|
||||
TextInput,
|
||||
NumberInput,
|
||||
BooleanInput,
|
||||
Show,
|
||||
SimpleShowLayout,
|
||||
TextField
|
||||
@@ -19,7 +20,8 @@ import { useMediaQuery } from '@material-ui/core'
|
||||
const AlbumFilter = (props) => (
|
||||
<Filter {...props}>
|
||||
<SearchInput source="name" alwaysOn />
|
||||
<TextInput source="artist" />
|
||||
<BooleanInput source="compilation" />
|
||||
<NumberInput source="year" />
|
||||
</Filter>
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useStyles } from './styles'
|
||||
import { AlbumActions } from './AlbumActions'
|
||||
import { AlbumSongBulkActions } from './AlbumSongBulkActions'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { setTrack } from '../player'
|
||||
import { setTrack } from '../audioplayer'
|
||||
import { useDispatch } from 'react-redux'
|
||||
|
||||
const AlbumShow = (props) => {
|
||||
|
||||