mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-06 05:48:09 -05:00
Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db4479e720 | ||
|
|
66275d3b94 | ||
|
|
57f2c3f823 | ||
|
|
afba4c9915 | ||
|
|
f0d18d2cb3 | ||
|
|
da45bcf448 | ||
|
|
3a54246b15 | ||
|
|
2b06f20f41 | ||
|
|
88f44b2e77 | ||
|
|
4dff067e0b | ||
|
|
d81bf8a518 | ||
|
|
adfaf39489 | ||
|
|
f6a15905d7 | ||
|
|
52b8c5f151 | ||
|
|
c4eab5db86 | ||
|
|
4b1c76e307 | ||
|
|
e476a5f6f1 | ||
|
|
9fb4f5ef52 | ||
|
|
e232c5c561 | ||
|
|
803a5776ae | ||
|
|
a6dfcafdab | ||
|
|
8f2c7b7913 | ||
|
|
2ab647efe1 | ||
|
|
04eb421186 | ||
|
|
6a3a66975c | ||
|
|
1ef4fa970f | ||
|
|
b34523e196 | ||
|
|
09985453aa | ||
|
|
159a6e1cad | ||
|
|
b429949dd9 | ||
|
|
b9f601dfb4 | ||
|
|
5b488b72b1 | ||
|
|
03044bcb68 | ||
|
|
7bc3dace4c | ||
|
|
c2ec142ce3 | ||
|
|
2d39a6df8d | ||
|
|
5265d0234f | ||
|
|
4fc88f23e9 | ||
|
|
5412bb2dc8 | ||
|
|
b661d52477 | ||
|
|
43ce81af67 | ||
|
|
b8d1185f7f | ||
|
|
0fa8290ed3 | ||
|
|
519e3f014d | ||
|
|
d38f8544d5 | ||
|
|
089a92157f | ||
|
|
db246900a6 | ||
|
|
a0f389fc3e | ||
|
|
d0188db4f9 | ||
|
|
f537984bbf | ||
|
|
7e6c0e3894 | ||
|
|
b930c7253a | ||
|
|
c1afe70d98 | ||
|
|
3f9ddb915e | ||
|
|
c3edc7f449 | ||
|
|
9b272c8021 | ||
|
|
6d1221164b | ||
|
|
647132625c | ||
|
|
a17a98a75f | ||
|
|
59707b3a8f | ||
|
|
fa378ab4e4 | ||
|
|
05ffb1acad | ||
|
|
a1ba5c59b2 | ||
|
|
1bc68c20fc | ||
|
|
d308e7ca46 | ||
|
|
2b5433dc6e | ||
|
|
86a23f9b14 | ||
|
|
0ba5840a65 | ||
|
|
93646b964e | ||
|
|
13be8d297c | ||
|
|
a4b97121ab | ||
|
|
660f9c205b | ||
|
|
28852ce7d7 | ||
|
|
656ca1f3b5 | ||
|
|
b8f7715a74 | ||
|
|
096ed396c8 | ||
|
|
3b6d0b3d15 | ||
|
|
75cd21da1f | ||
|
|
b8eb22d162 | ||
|
|
9b461735f4 | ||
|
|
63bf49b3c4 | ||
|
|
559848299c | ||
|
|
8510273216 | ||
|
|
2392060bc1 | ||
|
|
2d7998de59 | ||
|
|
40638688b2 | ||
|
|
ea22b2fc6d | ||
|
|
1182218787 | ||
|
|
14f7c5610e | ||
|
|
27579b99a3 | ||
|
|
c58021e645 | ||
|
|
1810cc7ac7 | ||
|
|
86f73eecca | ||
|
|
3d6ce8a77f | ||
|
|
670be29d7b | ||
|
|
2b3e506583 | ||
|
|
6e6cfdd02b | ||
|
|
a18093e255 | ||
|
|
a35636999d | ||
|
|
13a3d38e4f | ||
|
|
9f00fb0f05 | ||
|
|
6cddcd6f0d | ||
|
|
c6d1cfeceb | ||
|
|
de43c27b3c | ||
|
|
747b5ea25e | ||
|
|
dd2e98fca2 | ||
|
|
eb621be646 | ||
|
|
d223a4f4db | ||
|
|
7aa182e33d | ||
|
|
7fec503b72 | ||
|
|
083a11a563 | ||
|
|
944f3695c4 | ||
|
|
dfc8691262 | ||
|
|
395b598bb1 | ||
|
|
d04b434d96 | ||
|
|
f041503a85 | ||
|
|
500207f7b8 | ||
|
|
1e0a79ebb7 | ||
|
|
301fa2a957 | ||
|
|
46f4f63212 | ||
|
|
fec8b5f731 | ||
|
|
777231ea79 | ||
|
|
0e36ed35a3 | ||
|
|
f1af646cee | ||
|
|
fc0621646b | ||
|
|
575800dcff |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: Build
|
||||
on: [push]
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
go:
|
||||
name: Test Server on ${{ matrix.os }}
|
||||
|
||||
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
@@ -1,9 +1,8 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
create:
|
||||
push:
|
||||
tags:
|
||||
- v*.*.*
|
||||
- '*'
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
@@ -15,7 +14,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 13.11
|
||||
node-version: 13.12
|
||||
- name: Build UI
|
||||
run: |
|
||||
cd ui
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
|
||||
### Supported Subsonic API endpoints
|
||||
|
||||
Navidrome is currently compatible with [Subsonic API](http://www.subsonic.org/pages/api.jsp) v1.8.0, with some exceptions.
|
||||
|
||||
This is an (almost) up to date list of all Subsonic API endpoints implemented by Navidrome.
|
||||
Check the "Notes" column for limitations/missing behaviour. Also keep in mind these differences between
|
||||
Navidrome and Subsonic:
|
||||
|
||||
* Right now, Navidrome only works with a single Music Library (Music Folder)
|
||||
* Navidrome does not mark songs as played by calls to `stream`, only when
|
||||
`scrobble` is called with `submission=true`
|
||||
* Next features to be implemented: Last.FM integration, Jukebox, Sharing, Bookmarks, Podcasts, Internet Radio.
|
||||
|
||||
Navidrome is actively being tested with:
|
||||
[DSub](http://www.subsonic.org/pages/apps.jsp#dsub),
|
||||
[Music Stash](https://play.google.com/store/apps/details?id=com.ghenry22.mymusicstash) and
|
||||
[Jamstash](http://www.subsonic.org/pages/apps.jsp#jamstash))
|
||||
|
||||
|
||||
| ENDPOINT | NOTES |
|
||||
|------------------------|-------|
|
||||
| _SYSTEM_ ||
|
||||
| `ping` | |
|
||||
| `getLicense` | Always valid ;) |
|
||||
| ||
|
||||
| _BROWSING_ ||
|
||||
| `getMusicFolders` | Hardcoded to just one, set with ND_MUSICFOLDER configuration |
|
||||
| `getIndexes` | Doesn't support shortcuts, nor direct children |
|
||||
| `getMusicDirectory` | |
|
||||
| `getSong` | |
|
||||
| `getArtists` | |
|
||||
| `getArtist` | |
|
||||
| `getAlbum` | |
|
||||
| `getGenres` | |
|
||||
| ||
|
||||
| _ALBUM/SONGS LISTS_ ||
|
||||
| `getAlbumList` | `byYear` and `byGenre` are not implemented |
|
||||
| `getAlbumList2` | `byYear` and `byGenre` are not implemented |
|
||||
| `getStarred` | |
|
||||
| `getStarred2` | |
|
||||
| `getNowPlaying` | |
|
||||
| `getRandomSongs` | Ignores `fromYear` and `toYear` parameters |
|
||||
| ||
|
||||
| _SEARCHING_ ||
|
||||
| `search2` | Doesn't support Lucene queries, only simple auto complete queries |
|
||||
| `search3` | Doesn't support Lucene queries, only simple auto complete queries |
|
||||
| ||
|
||||
| _PLAYLISTS_ ||
|
||||
| `getPlaylists` | `username` parameter is not implemented |
|
||||
| `getPlaylist` | |
|
||||
| `createPlaylist` | Return empty response on success |
|
||||
| `updatePlaylist` | `comment` and `public` are not implemented. All playlists are public |
|
||||
| `deletePlaylist` | |
|
||||
| ||
|
||||
| _MEDIA RETRIEVAL_ ||
|
||||
| `stream` | |
|
||||
| `download` | |
|
||||
| `getCoverArt` | Only gets embedded artwork |
|
||||
| `getAvatar` | Always returns the same image |
|
||||
| ||
|
||||
| _MEDIA ANNOTATION_ ||
|
||||
| `star` | |
|
||||
| `unstar` | |
|
||||
| `setRating` | |
|
||||
| `scrobble` | No Last.FM support yet. It is used to update play count and last played |
|
||||
| ||
|
||||
| _USER MANAGEMENT_ ||
|
||||
| `getUser` | Hardcoded all roles, ignores `username` parameter|
|
||||
@@ -1,6 +1,6 @@
|
||||
#####################################################
|
||||
### Build UI bundles
|
||||
FROM node:13.11-alpine AS jsbuilder
|
||||
FROM node:13.12-alpine AS jsbuilder
|
||||
WORKDIR /src
|
||||
COPY ui/package.json ui/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
20
Makefile
20
Makefile
@@ -16,6 +16,10 @@ server: check_go_env
|
||||
@reflex -d none -c reflex.conf
|
||||
.PHONY: server
|
||||
|
||||
wire: check_go_env
|
||||
wire ./...
|
||||
.PHONY: wire
|
||||
|
||||
watch: check_go_env
|
||||
ginkgo watch -notify ./...
|
||||
.PHONY: watch
|
||||
@@ -30,13 +34,14 @@ testall: check_go_env test
|
||||
.PHONY: testall
|
||||
|
||||
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)
|
||||
@which go-bindata || (echo "Installing BinData" && GO111MODULE=off go get -u github.com/go-bindata/go-bindata/...)
|
||||
@which reflex || (echo "Installing Reflex" && GO111MODULE=off go get -u github.com/cespare/reflex)
|
||||
@which goreman || (echo "Installing Goreman" && GO111MODULE=off go get -u github.com/mattn/goreman)
|
||||
@which ginkgo || (echo "Installing Ginkgo" && GO111MODULE=off go get -u github.com/onsi/ginkgo/ginkgo)
|
||||
@which goose || (echo "Installing Goose" && GO111MODULE=off go get -u github.com/pressly/goose/cmd/goose)
|
||||
@which lefthook || (echo "Installing Lefthook" && GO111MODULE=off go get -u github.com/Arkweid/lefthook)
|
||||
@lefthook install
|
||||
go mod download
|
||||
@(cd ./ui && npm ci)
|
||||
.PHONY: setup
|
||||
@@ -49,10 +54,17 @@ Jamstash-master:
|
||||
wget -N https://github.com/tsquillario/Jamstash/archive/master.zip
|
||||
unzip -o master.zip
|
||||
rm master.zip
|
||||
(cd Jamstash-master && npm ci && npx bower install && npx grunt build)
|
||||
rm -rf Jamstash-master/node_modules Jamstash-master/bower_components
|
||||
|
||||
check_env: check_go_env check_node_env
|
||||
.PHONE: check_env
|
||||
|
||||
check_hooks:
|
||||
@lefthook add pre-commit
|
||||
@lefthook add pre-push
|
||||
.PHONE: check_hooks
|
||||
|
||||
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)
|
||||
@@ -82,6 +94,6 @@ release:
|
||||
git push origin v${V}
|
||||
.PHONY: release
|
||||
|
||||
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
|
||||
snapshot:
|
||||
docker run -it -v $(PWD):/workspace -w /workspace bepsays/ci-goreleaser:1.14-1 goreleaser release --rm-dist --skip-publish --snapshot
|
||||
.PHONY: snapshot
|
||||
|
||||
24
README.md
24
README.md
@@ -1,16 +1,19 @@
|
||||
# Navidrome Music Streamer
|
||||
|
||||
[](https://github.com/deluan/navidrome/actions)
|
||||
[](https://github.com/deluan/navidrome/releases)
|
||||
[](https://hub.docker.com/r/deluan/navidrome)
|
||||
[](https://discord.gg/xh7j7yF)
|
||||
[](https://github.com/deluan/navidrome/releases)
|
||||
[](https://github.com/deluan/navidrome/actions)
|
||||
[](https://hub.docker.com/r/deluan/navidrome)
|
||||
[](https://discord.gg/xh7j7yF)
|
||||
[](https://www.reddit.com/r/navidrome/)
|
||||
|
||||
Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your
|
||||
music collection from any browser or mobile device. 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 discussion in our
|
||||
[Subreddit](https://www.reddit.com/r/navidrome/). If you want to contribute to the project in any other way
|
||||
(ui/backend dev, translations, [themes](ui/src/themes/README.md)), please join the chat in our
|
||||
[Discord server](https://discord.gg/xh7j7yF).
|
||||
|
||||
|
||||
## Features
|
||||
@@ -23,7 +26,8 @@ our [Discord server](https://discord.gg/xh7j7yF)
|
||||
- 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
|
||||
- [Themeable](ui/src/themes/README.md), modern and responsive Web interface based on Material UI, to manage users and
|
||||
browse your library
|
||||
- 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)
|
||||
@@ -48,6 +52,7 @@ trouble with the client of your choice.
|
||||
This project is being actively worked on. Expect a more polished experience and new features/releases
|
||||
on a frequent basis. Some upcoming features planned:
|
||||
|
||||
- Complete WebUI, to browse and listen to your library
|
||||
- Last.FM integration
|
||||
- Smart/dynamic playlists (similar to iTunes)
|
||||
- Support for audiobooks (bookmarking)
|
||||
@@ -95,6 +100,7 @@ services:
|
||||
ND_PORT: 4533
|
||||
ND_TRANSCODINGCACHESIZE: 100MB
|
||||
ND_SESSIONTIMEOUT: 30m
|
||||
ND_BASEURL: ""
|
||||
volumes:
|
||||
- "./data:/data"
|
||||
- "/path/to/your/music/folder:/music:ro"
|
||||
@@ -104,7 +110,7 @@ To get the cutting-edge, latest version from master, use the image `deluan/navid
|
||||
|
||||
### Build from source
|
||||
|
||||
You will need to install [Go 1.14](https://golang.org/dl/) and [Node 13.11.0](http://nodejs.org).
|
||||
You will need to install [Go 1.14](https://golang.org/dl/) and [Node 13.12.0](http://nodejs.org).
|
||||
You'll also need [ffmpeg](https://ffmpeg.org) installed in your system. The setup is very strict, and
|
||||
the steps bellow only work with these specific versions (enforced in the Makefile)
|
||||
|
||||
@@ -152,5 +158,5 @@ folder for startup files for your init system.
|
||||
|
||||
## Subsonic API Version Compatibility
|
||||
|
||||
Check the up to date [compatibility table](https://github.com/deluan/navidrome/blob/master/API_COMPATIBILITY.md)
|
||||
Check the up to date [compatibility table](https://www.navidrome.org/docs/developers/subsonic-api)
|
||||
for the latest Subsonic features available.
|
||||
|
||||
@@ -20,12 +20,16 @@ type nd struct {
|
||||
DbPath string ``
|
||||
LogLevel string `default:"info"`
|
||||
SessionTimeout string `default:"30m"`
|
||||
BaseURL string `default:""`
|
||||
|
||||
UILoginBackgroundURL string `default:"https://source.unsplash.com/random/1600x900?music"`
|
||||
|
||||
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]([)"`
|
||||
|
||||
TranscodingCacheSize string `default:"100MB"` // in MB
|
||||
ProbeCommand string `default:"ffmpeg -i %s -f ffmetadata"`
|
||||
ImageCacheSize string `default:"100MB"` // in MB
|
||||
ProbeCommand string `default:"ffmpeg %s -f ffmetadata"`
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
DevLogSourceLine bool `default:"false"`
|
||||
|
||||
@@ -14,19 +14,30 @@ const (
|
||||
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL"
|
||||
InitialSetupFlagKey = "InitialSetup"
|
||||
|
||||
UIAuthorizationHeader = "X-ND-Authorization"
|
||||
JWTSecretKey = "JWTSecret"
|
||||
JWTIssuer = "ND"
|
||||
DefaultSessionTimeout = 30 * time.Minute
|
||||
|
||||
UIAssetsLocalPath = "ui/build"
|
||||
|
||||
CacheDir = "cache"
|
||||
DefaultTranscodingCacheSize = 100 * 1024 * 1024 // 100MB
|
||||
DefaultTranscodingCacheMaxItems = 0 // Unlimited
|
||||
DefaultTranscodingCachePurgeInterval = 10 * time.Minute
|
||||
|
||||
DevInitialUserName = "admin"
|
||||
DevInitialName = "Dev Admin"
|
||||
|
||||
URLPathUI = "/app"
|
||||
URLPathSubsonicAPI = "/rest"
|
||||
)
|
||||
|
||||
// Cache options
|
||||
const (
|
||||
TranscodingCacheDir = "cache/transcoding"
|
||||
DefaultTranscodingCacheMaxItems = 0 // Unlimited
|
||||
|
||||
ImageCacheDir = "cache/images"
|
||||
DefaultImageCacheMaxItems = 0 // Unlimited
|
||||
|
||||
DefaultCacheSize = 100 * 1024 * 1024 // 100MB
|
||||
DefaultCacheCleanUpInterval = 10 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -35,7 +46,7 @@ var (
|
||||
"name": "mp3 audio",
|
||||
"targetFormat": "mp3",
|
||||
"defaultBitRate": 192,
|
||||
"command": "ffmpeg -i %s -ab %bk -v 0 -f mp3 -",
|
||||
"command": "ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -",
|
||||
},
|
||||
{
|
||||
"name": "opus audio",
|
||||
|
||||
15
contrib/navidrome
Normal file
15
contrib/navidrome
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/sbin/openrc-run
|
||||
|
||||
name=$RC_SVCNAME
|
||||
command="/opt/navidrome/${RC_SVCNAME}"
|
||||
command_args="-datafolder /opt/navidrome"
|
||||
command_user="${RC_SVCNAME}"
|
||||
pidfile="/var/run/${RC_SVCNAME}.pid"
|
||||
output_log="/opt/navidrome/${RC_SVCNAME}.log"
|
||||
error_log="/opt/navidrome/${RC_SVCNAME}.err"
|
||||
command_background="yes"
|
||||
|
||||
depend() {
|
||||
need net
|
||||
}
|
||||
|
||||
@@ -31,8 +31,5 @@ PrivateDevices=yes
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
|
||||
MemoryDenyWriteExecute=yes
|
||||
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -37,7 +37,7 @@ update annotation set item_type = 'media_file' where item_type = 'mediaFile';
|
||||
}
|
||||
|
||||
func Down20200131183653(tx *sql.Tx) error {
|
||||
tx.Exec(`
|
||||
_, err := tx.Exec(`
|
||||
create table search_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
@@ -59,5 +59,5 @@ create index search_table
|
||||
|
||||
update annotation set item_type = 'mediaFile' where item_type = 'media_file';
|
||||
`)
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
30
db/migration/20200404214704_add_indexes.go
Normal file
30
db/migration/20200404214704_add_indexes.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200404214704, Down20200404214704)
|
||||
}
|
||||
|
||||
func Up20200404214704(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create index if not exists media_file_year
|
||||
on media_file (year);
|
||||
|
||||
create index if not exists media_file_duration
|
||||
on media_file (duration);
|
||||
|
||||
create index if not exists media_file_track_number
|
||||
on media_file (disc_number, track_number);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200404214704(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200409002249, Down20200409002249)
|
||||
}
|
||||
|
||||
func Up20200409002249(tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to enable search by individual Artist in an Album!")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200409002249(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200411164603, Down20200411164603)
|
||||
}
|
||||
|
||||
func Up20200411164603(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table playlist
|
||||
add created_at datetime;
|
||||
alter table playlist
|
||||
add updated_at datetime;
|
||||
update playlist
|
||||
set created_at = datetime('now'), updated_at = datetime('now');
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200411164603(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@@ -15,6 +15,7 @@ services:
|
||||
ND_PORT: 4533
|
||||
ND_TRANSCODINGCACHESIZE: 100MB
|
||||
ND_SESSIONTIMEOUT: 30m
|
||||
ND_BASEURL: ""
|
||||
volumes:
|
||||
- "./data:/data"
|
||||
- "./music:/music"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -153,3 +154,12 @@ func FromArtists(ars model.Artists) Entries {
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func userName(ctx context.Context) string {
|
||||
user := ctx.Value("user")
|
||||
if user == nil {
|
||||
return "UNKNOWN"
|
||||
}
|
||||
usr := user.(model.User)
|
||||
return usr.UserName
|
||||
}
|
||||
|
||||
182
engine/cover.go
182
engine/cover.go
@@ -4,95 +4,153 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/static"
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/nfnt/resize"
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/djherbis/fscache"
|
||||
)
|
||||
|
||||
type Cover interface {
|
||||
Get(ctx context.Context, id string, size int, out io.Writer) error
|
||||
}
|
||||
|
||||
type ImageCache fscache.Cache
|
||||
|
||||
func NewCover(ds model.DataStore, cache ImageCache) Cover {
|
||||
return &cover{ds: ds, cache: cache}
|
||||
}
|
||||
|
||||
type cover struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func NewCover(ds model.DataStore) Cover {
|
||||
return &cover{ds}
|
||||
}
|
||||
|
||||
func (c *cover) getCoverPath(ctx context.Context, id string) (string, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(id, "al-"):
|
||||
id = id[3:]
|
||||
al, err := c.ds.Album(ctx).Get(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return al.CoverArtPath, nil
|
||||
default:
|
||||
mf, err := c.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if mf.HasCoverArt {
|
||||
return mf.Path, nil
|
||||
}
|
||||
}
|
||||
return "", model.ErrNotFound
|
||||
ds model.DataStore
|
||||
cache fscache.Cache
|
||||
}
|
||||
|
||||
func (c *cover) Get(ctx context.Context, id string, size int, out io.Writer) error {
|
||||
path, err := c.getCoverPath(ctx, id)
|
||||
id = strings.TrimPrefix(id, "al-")
|
||||
path, lastUpdate, err := c.getCoverPath(ctx, id)
|
||||
if err != nil && err != model.ErrNotFound {
|
||||
return err
|
||||
}
|
||||
|
||||
var reader io.Reader
|
||||
|
||||
if err != model.ErrNotFound {
|
||||
reader, err = readFromTag(path)
|
||||
} else {
|
||||
var f http.File
|
||||
f, err = static.AssetFile().Open("default_cover.jpg")
|
||||
if err == nil {
|
||||
defer f.Close()
|
||||
reader = f
|
||||
// If cache is disabled, just read the coverart directly from file
|
||||
if c.cache == nil {
|
||||
log.Trace(ctx, "Retrieving cover art from file", "path", path, "size", size, err)
|
||||
reader, err := c.getCover(ctx, path, size)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading cover art", "path", path, "size", size, err)
|
||||
} else {
|
||||
_, err = io.Copy(out, reader)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
|
||||
if size > 0 {
|
||||
return resizeImage(reader, size, out)
|
||||
}
|
||||
_, err = io.Copy(out, reader)
|
||||
return err
|
||||
}
|
||||
|
||||
func resizeImage(reader io.Reader, size int, out io.Writer) error {
|
||||
img, _, err := image.Decode(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m := resize.Resize(uint(size), 0, img, resize.NearestNeighbor)
|
||||
return jpeg.Encode(out, m, &jpeg.Options{Quality: 75})
|
||||
cacheKey := imageCacheKey(path, size, lastUpdate)
|
||||
r, w, err := c.cache.Get(cacheKey)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error reading from image cache", "path", path, "size", size, err)
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
if w != nil {
|
||||
log.Trace(ctx, "Image cache miss", "path", path, "size", size, "lastUpdate", lastUpdate)
|
||||
go func() {
|
||||
defer w.Close()
|
||||
reader, err := c.getCover(ctx, path, size)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading cover art", "path", path, "size", size, err)
|
||||
return
|
||||
}
|
||||
io.Copy(w, reader)
|
||||
}()
|
||||
} else {
|
||||
log.Trace(ctx, "Loading image from cache", "path", path, "size", size, "lastUpdate", lastUpdate)
|
||||
}
|
||||
|
||||
_, err = io.Copy(out, r)
|
||||
return err
|
||||
}
|
||||
|
||||
func readFromTag(path string) (io.Reader, error) {
|
||||
func (c *cover) getCoverPath(ctx context.Context, id string) (path string, lastUpdated time.Time, err error) {
|
||||
var found bool
|
||||
if found, err = c.ds.Album(ctx).Exists(id); err != nil {
|
||||
return
|
||||
}
|
||||
if found {
|
||||
var al *model.Album
|
||||
al, err = c.ds.Album(ctx).Get(id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if al.CoverArtId == "" {
|
||||
err = model.ErrNotFound
|
||||
return
|
||||
}
|
||||
id = al.CoverArtId
|
||||
}
|
||||
var mf *model.MediaFile
|
||||
mf, err = c.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if mf.HasCoverArt {
|
||||
return mf.Path, mf.UpdatedAt, nil
|
||||
}
|
||||
return "", time.Time{}, model.ErrNotFound
|
||||
}
|
||||
|
||||
func imageCacheKey(path string, size int, lastUpdate time.Time) string {
|
||||
return fmt.Sprintf("%s.%d.%s", path, size, lastUpdate.Format(time.RFC3339Nano))
|
||||
}
|
||||
|
||||
func (c *cover) getCover(ctx context.Context, path string, size int) (reader io.Reader, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error extracting image", "path", path, "size", size, err)
|
||||
reader, err = static.AssetFile().Open("navidrome-310x310.png")
|
||||
}
|
||||
}()
|
||||
var data []byte
|
||||
data, err = readFromTag(path)
|
||||
|
||||
if err == nil && size > 0 {
|
||||
data, err = resizeImage(bytes.NewReader(data), size)
|
||||
}
|
||||
|
||||
// Confirm the image is valid. Costly, but necessary
|
||||
_, _, err = image.Decode(bytes.NewReader(data))
|
||||
if err == nil {
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func resizeImage(reader io.Reader, size int) ([]byte, error) {
|
||||
img, _, err := image.Decode(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := imaging.Resize(img, size, size, imaging.Lanczos)
|
||||
buf := new(bytes.Buffer)
|
||||
err = jpeg.Encode(buf, m, &jpeg.Options{Quality: 75})
|
||||
return buf.Bytes(), err
|
||||
}
|
||||
|
||||
func readFromTag(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -106,7 +164,11 @@ func readFromTag(path string) (io.Reader, error) {
|
||||
|
||||
picture := m.Picture()
|
||||
if picture == nil {
|
||||
return nil, errors.New("error extracting art from file " + path)
|
||||
return nil, errors.New("file does not contain embedded art")
|
||||
}
|
||||
return bytes.NewReader(picture.Data), nil
|
||||
return picture.Data, nil
|
||||
}
|
||||
|
||||
func NewImageCache() (ImageCache, error) {
|
||||
return newFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems)
|
||||
}
|
||||
|
||||
125
engine/cover_test.go
Normal file
125
engine/cover_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
|
||||
"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("Cover", func() {
|
||||
var cover Cover
|
||||
var ds model.DataStore
|
||||
ctx := log.NewContext(nil)
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &persistence.MockDataStore{MockedTranscoding: &mockTranscodingRepository{}}
|
||||
ds.Album(ctx).(*persistence.MockAlbum).SetData(`[{"id": "222", "coverArtId": "123"}, {"id": "333", "coverArtId": ""}]`)
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "hasCoverArt": true, "updatedAt":"2020-04-02T21:29:31.6377Z"}]`)
|
||||
})
|
||||
|
||||
Context("Cache is configured", func() {
|
||||
BeforeEach(func() {
|
||||
cover = NewCover(ds, testCache)
|
||||
})
|
||||
|
||||
It("retrieves the original cover art from an album", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "222", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
|
||||
It("accepts albumIds with 'al-' prefix", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "al-222", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
|
||||
It("returns the default cover if album does not have cover", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "333", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("png"))
|
||||
})
|
||||
|
||||
It("returns the default cover if album is not found", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "444", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("png"))
|
||||
})
|
||||
|
||||
It("retrieves the original cover art from a media_file", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 0, buf)).To(BeNil())
|
||||
|
||||
img, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(600))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(600))
|
||||
})
|
||||
|
||||
It("resized cover art as requested", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 200, buf)).To(BeNil())
|
||||
|
||||
img, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
|
||||
Context("Errors", func() {
|
||||
It("returns err if gets error from album table", func() {
|
||||
ds.Album(ctx).(*persistence.MockAlbum).SetError(true)
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "222", 0, buf)).To(MatchError("Error!"))
|
||||
})
|
||||
|
||||
It("returns err if gets error from media_file table", func() {
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetError(true)
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 0, buf)).To(MatchError("Error!"))
|
||||
})
|
||||
})
|
||||
})
|
||||
Context("Cache is NOT configured", func() {
|
||||
BeforeEach(func() {
|
||||
cover = NewCover(ds, nil)
|
||||
})
|
||||
|
||||
It("retrieves the original cover art from an album", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "222", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,13 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/tests"
|
||||
"github.com/djherbis/fscache"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -15,3 +18,18 @@ func TestEngine(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Engine Suite")
|
||||
}
|
||||
|
||||
var testCache fscache.Cache
|
||||
var testCacheDir string
|
||||
|
||||
var _ = Describe("Engine Suite Setup", func() {
|
||||
BeforeSuite(func() {
|
||||
testCacheDir, _ = ioutil.TempDir("", "engine_test_cache")
|
||||
fs, _ := fscache.NewFs(testCacheDir, 0755)
|
||||
testCache, _ = fscache.NewCache(fs, nil)
|
||||
})
|
||||
|
||||
AfterSuite(func() {
|
||||
os.RemoveAll(testCacheDir)
|
||||
})
|
||||
})
|
||||
|
||||
32
engine/file_caches.go
Normal file
32
engine/file_caches.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/djherbis/fscache"
|
||||
"github.com/dustin/go-humanize"
|
||||
)
|
||||
|
||||
func newFileCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cache, error) {
|
||||
if cacheSize == "0" {
|
||||
log.Warn(fmt.Sprintf("%s cache disabled", name))
|
||||
return nil, nil
|
||||
}
|
||||
size, err := humanize.ParseBytes(cacheSize)
|
||||
if err != nil {
|
||||
size = consts.DefaultCacheSize
|
||||
}
|
||||
lru := fscache.NewLRUHaunter(maxItems, int64(size), consts.DefaultCacheCleanUpInterval)
|
||||
h := fscache.NewLRUHaunterStrategy(lru)
|
||||
cacheFolder = filepath.Join(conf.Server.DataFolder, cacheFolder)
|
||||
log.Info(fmt.Sprintf("Creating %s cache", name), "path", cacheFolder, "maxSize", humanize.Bytes(size))
|
||||
fs, err := fscache.NewFs(cacheFolder, 0755)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fscache.NewCacheWithHaunter(fs, h)
|
||||
}
|
||||
37
engine/file_caches_test.go
Normal file
37
engine/file_caches_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("File Caches", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DataFolder, _ = ioutil.TempDir("", "file_caches")
|
||||
})
|
||||
AfterEach(func() {
|
||||
os.RemoveAll(conf.Server.DataFolder)
|
||||
})
|
||||
|
||||
Describe("newFileCache", func() {
|
||||
It("creates the cache folder", func() {
|
||||
Expect(newFileCache("test", "1k", "test", 10)).ToNot(BeNil())
|
||||
|
||||
_, err := os.Stat(filepath.Join(conf.Server.DataFolder, "test"))
|
||||
Expect(os.IsNotExist(err)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("creates the cache folder with invalid size", func() {
|
||||
Expect(newFileCache("test", "abc", "test", 10)).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("returns empty if cache size is '0'", func() {
|
||||
Expect(newFileCache("test", "0", "test", 10)).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"io"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
@@ -15,14 +14,15 @@ import (
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/djherbis/fscache"
|
||||
"github.com/dustin/go-humanize"
|
||||
)
|
||||
|
||||
type MediaStreamer interface {
|
||||
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error)
|
||||
}
|
||||
|
||||
func NewMediaStreamer(ds model.DataStore, ffm transcoder.Transcoder, cache fscache.Cache) MediaStreamer {
|
||||
type TranscodingCache fscache.Cache
|
||||
|
||||
func NewMediaStreamer(ds model.DataStore, ffm transcoder.Transcoder, cache TranscodingCache) MediaStreamer {
|
||||
return &mediaStreamer{ds: ds, ffm: ffm, cache: cache}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,14 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat str
|
||||
return nil, err
|
||||
}
|
||||
|
||||
format, bitRate := selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate)
|
||||
var format string
|
||||
var bitRate int
|
||||
defer func() {
|
||||
log.Info("Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format,
|
||||
"bitRate", bitRate, "user", userName(ctx), "transcoding", format != "raw", "originalFormat", mf.Suffix)
|
||||
}()
|
||||
|
||||
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,
|
||||
@@ -76,7 +83,7 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat str
|
||||
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)
|
||||
out, err := ms.ffm.Start(ctx, t.Command, mf.Path, bitRate)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error starting transcoder", "id", mf.ID, err)
|
||||
return nil, os.ErrInvalid
|
||||
@@ -138,8 +145,11 @@ type Stream struct {
|
||||
func (s *Stream) Seekable() bool { return s.Seeker != nil }
|
||||
func (s *Stream) Duration() float32 { return s.mf.Duration }
|
||||
func (s *Stream) ContentType() string { return mime.TypeByExtension("." + s.format) }
|
||||
func (s *Stream) Name() string { return s.mf.Path }
|
||||
func (s *Stream) Name() string { return s.mf.Title + "." + s.format }
|
||||
func (s *Stream) ModTime() time.Time { return s.mf.UpdatedAt }
|
||||
func (s *Stream) EstimatedContentLength() int {
|
||||
return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -147,6 +157,10 @@ func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model
|
||||
if reqFormat == "raw" {
|
||||
return
|
||||
}
|
||||
if reqFormat == mf.Suffix && reqBitRate == 0 {
|
||||
bitRate = mf.BitRate
|
||||
return
|
||||
}
|
||||
trc, hasDefault := ctx.Value("transcoding").(model.Transcoding)
|
||||
var cFormat string
|
||||
var cBitRate int
|
||||
@@ -176,7 +190,7 @@ func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model
|
||||
bitRate = t.DefaultBitRate
|
||||
}
|
||||
}
|
||||
if format == mf.Suffix && bitRate > mf.BitRate {
|
||||
if format == mf.Suffix && bitRate >= mf.BitRate {
|
||||
format = "raw"
|
||||
bitRate = 0
|
||||
}
|
||||
@@ -198,19 +212,6 @@ func getFinalCachedSize(r fscache.ReadAtCloser) int64 {
|
||||
return -1
|
||||
}
|
||||
|
||||
func NewTranscodingCache() (fscache.Cache, error) {
|
||||
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
|
||||
}
|
||||
return fscache.NewCacheWithHaunter(fs, h)
|
||||
func NewTranscodingCache() (TranscodingCache, error) {
|
||||
return newFileCache("Transcoding", conf.Server.TranscodingCacheSize, consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems)
|
||||
}
|
||||
|
||||
@@ -3,14 +3,11 @@ package engine
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
"github.com/djherbis/fscache"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -18,25 +15,13 @@ import (
|
||||
var _ = Describe("MediaStreamer", func() {
|
||||
var streamer MediaStreamer
|
||||
var ds model.DataStore
|
||||
var cache fscache.Cache
|
||||
var tempDir string
|
||||
ffmpeg := &fakeFFmpeg{Data: "fake data"}
|
||||
ctx := log.NewContext(nil)
|
||||
|
||||
BeforeSuite(func() {
|
||||
tempDir, _ = ioutil.TempDir("", "stream_tests")
|
||||
fs, _ := fscache.NewFs(tempDir, 0755)
|
||||
cache, _ = fscache.NewCache(fs, nil)
|
||||
})
|
||||
|
||||
BeforeEach(func() {
|
||||
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)
|
||||
})
|
||||
|
||||
AfterSuite(func() {
|
||||
os.RemoveAll(tempDir)
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "suffix": "mp3", "bitRate": 128, "duration": 257.0}]`)
|
||||
streamer = NewMediaStreamer(ds, ffmpeg, testCache)
|
||||
})
|
||||
|
||||
Context("NewStream", func() {
|
||||
@@ -104,6 +89,13 @@ var _ = Describe("MediaStreamer", func() {
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(192))
|
||||
})
|
||||
It("returns raw if requested format is the same as the original, but requested BitRate is 0", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 320
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
Expect(bitRate).To(Equal(320))
|
||||
})
|
||||
})
|
||||
|
||||
Context("player has format configured", func() {
|
||||
@@ -138,6 +130,13 @@ var _ = Describe("MediaStreamer", func() {
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(80))
|
||||
})
|
||||
It("returns raw if selected bitrate and format is the same as original", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 192
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
|
||||
Expect(format).To(Equal("raw"))
|
||||
Expect(bitRate).To(Equal(0))
|
||||
})
|
||||
})
|
||||
Context("player has maxBitRate configured", func() {
|
||||
BeforeEach(func() {
|
||||
@@ -183,7 +182,7 @@ type fakeFFmpeg struct {
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (ff *fakeFFmpeg) Start(ctx context.Context, cmd, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
|
||||
func (ff *fakeFFmpeg) Start(ctx context.Context, cmd, path string, maxBitRate int) (f io.ReadCloser, err error) {
|
||||
ff.r = strings.NewReader(ff.Data)
|
||||
return ff, nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
@@ -118,10 +119,12 @@ type PlaylistInfo struct {
|
||||
Public bool
|
||||
Owner string
|
||||
Comment string
|
||||
Created time.Time
|
||||
Changed time.Time
|
||||
}
|
||||
|
||||
func (p *playlists) Get(ctx context.Context, id string) (*PlaylistInfo, error) {
|
||||
pl, err := p.ds.Playlist(ctx).GetWithTracks(id)
|
||||
pl, err := p.ds.Playlist(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -135,6 +138,8 @@ func (p *playlists) Get(ctx context.Context, id string) (*PlaylistInfo, error) {
|
||||
Public: pl.Public,
|
||||
Owner: pl.Owner,
|
||||
Comment: pl.Comment,
|
||||
Changed: pl.UpdatedAt,
|
||||
Created: pl.CreatedAt,
|
||||
}
|
||||
|
||||
plsInfo.Entries = FromMediaFiles(pl.Tracks)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
@@ -42,6 +43,9 @@ func (s *scrobbler) Register(ctx context.Context, playerId int, trackId string,
|
||||
err = s.ds.Artist(ctx).IncPlayCount(mf.ArtistID, playTime)
|
||||
return err
|
||||
})
|
||||
|
||||
log.Info("Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", userName(ctx))
|
||||
|
||||
return mf, err
|
||||
}
|
||||
|
||||
@@ -56,6 +60,8 @@ func (s *scrobbler) NowPlaying(ctx context.Context, playerId int, playerName, tr
|
||||
return nil, errors.New(fmt.Sprintf(`ID "%s" not found`, trackId))
|
||||
}
|
||||
|
||||
log.Info("Now Playing", "title", mf.Title, "artist", mf.Artist, "user", userName(ctx))
|
||||
|
||||
info := &NowPlayingInfo{TrackID: trackId, Username: username, Start: time.Now(), PlayerId: playerId, PlayerName: playerName}
|
||||
return mf, s.npRepo.Enqueue(info)
|
||||
}
|
||||
|
||||
@@ -12,20 +12,25 @@ import (
|
||||
)
|
||||
|
||||
type Transcoder interface {
|
||||
Start(ctx context.Context, command, path string, maxBitRate int, format string) (f io.ReadCloser, err error)
|
||||
Start(ctx context.Context, command, path string, maxBitRate int) (f io.ReadCloser, err error)
|
||||
}
|
||||
|
||||
func New() Transcoder {
|
||||
path, err := exec.LookPath("ffmpeg")
|
||||
if err != nil {
|
||||
log.Error("Unable to find ffmpeg", err)
|
||||
}
|
||||
log.Debug("Found ffmpeg", "path", path)
|
||||
return &ffmpeg{}
|
||||
}
|
||||
|
||||
type ffmpeg struct{}
|
||||
|
||||
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)
|
||||
func (ff *ffmpeg) Start(ctx context.Context, command, path string, maxBitRate int) (f io.ReadCloser, err error) {
|
||||
args := createTranscodeCommand(command, path, maxBitRate)
|
||||
|
||||
log.Trace(ctx, "Executing ffmpeg command", "cmd", arg0, "args", args)
|
||||
cmd := exec.Command(arg0, args...)
|
||||
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd.Stderr = os.Stderr
|
||||
if f, err = cmd.StdoutPipe(); err != nil {
|
||||
return
|
||||
@@ -37,7 +42,8 @@ func (ff *ffmpeg) Start(ctx context.Context, command, path string, maxBitRate in
|
||||
return
|
||||
}
|
||||
|
||||
func createTranscodeCommand(cmd, path string, maxBitRate int, format string) (string, []string) {
|
||||
// Path will always be an absolute path
|
||||
func createTranscodeCommand(cmd, path string, maxBitRate int) []string {
|
||||
split := strings.Split(cmd, " ")
|
||||
for i, s := range split {
|
||||
s = strings.Replace(s, "%s", path, -1)
|
||||
@@ -45,5 +51,5 @@ func createTranscodeCommand(cmd, path string, maxBitRate int, format string) (st
|
||||
split[i] = s
|
||||
}
|
||||
|
||||
return split[0], split[1:]
|
||||
return split
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@ func TestTranscoder(t *testing.T) {
|
||||
|
||||
var _ = Describe("createTranscodeCommand", func() {
|
||||
It("creates a valid command line", func() {
|
||||
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", "-"}))
|
||||
args := createTranscodeCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,5 +18,6 @@ var Set = wire.NewSet(
|
||||
NewMediaStreamer,
|
||||
transcoder.New,
|
||||
NewTranscodingCache,
|
||||
NewImageCache,
|
||||
NewPlayers,
|
||||
)
|
||||
|
||||
10
go.mod
10
go.mod
@@ -9,13 +9,14 @@ require (
|
||||
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible
|
||||
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8
|
||||
github.com/dhowden/tag v0.0.0-20200412032933-5d76b8eaae27
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
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.4+incompatible
|
||||
github.com/go-chi/cors v1.0.1
|
||||
github.com/go-chi/chi v4.1.0+incompatible
|
||||
github.com/go-chi/cors v1.1.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
|
||||
@@ -26,14 +27,11 @@ require (
|
||||
github.com/kr/pretty v0.1.0 // indirect
|
||||
github.com/lib/pq v1.3.0 // indirect
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible
|
||||
github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5
|
||||
github.com/onsi/ginkgo v1.12.0
|
||||
github.com/onsi/gomega v1.9.0
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pressly/goose v2.6.0+incompatible
|
||||
github.com/sirupsen/logrus v1.5.0
|
||||
github.com/smartystreets/assertions v1.0.1 // indirect
|
||||
github.com/smartystreets/goconvey v1.6.4
|
||||
github.com/stretchr/testify v1.4.0 // indirect
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2 // indirect
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 // indirect
|
||||
|
||||
21
go.sum
21
go.sum
@@ -26,8 +26,10 @@ github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0 h1:qHX6TTBIsrpg0JkYNko
|
||||
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8 h1:nmFnmD8VZXkjDHIb1Gnfz50cgzUvGN72zLjPRXBW/hU=
|
||||
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8/go.mod h1:SniNVYuaD1jmdEEvi+7ywb1QFR7agjeTdGKyFb0p7Rw=
|
||||
github.com/dhowden/tag v0.0.0-20200412032933-5d76b8eaae27 h1:Z6xaGRBbqfLR797upHuzQ6w4zg33BLKfAKtVCcmMDgg=
|
||||
github.com/dhowden/tag v0.0.0-20200412032933-5d76b8eaae27/go.mod h1:SniNVYuaD1jmdEEvi+7ywb1QFR7agjeTdGKyFb0p7Rw=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
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=
|
||||
@@ -41,10 +43,10 @@ github.com/fatih/structs v1.0.0 h1:BrX964Rv5uQ3wwS+KRUAJCBBw5PQmgJfJ6v4yly5QwU=
|
||||
github.com/fatih/structs v1.0.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/go-chi/chi v4.0.4+incompatible h1:7fVnpr0gAXG15uDbtH+LwSeMztvIvlHrBNRkTzgphS0=
|
||||
github.com/go-chi/chi v4.0.4+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||
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/chi v4.1.0+incompatible h1:ETj3cggsVIY2Xao5ExCu6YhEh5MD6JTfcBzS37R260w=
|
||||
github.com/go-chi/chi v4.1.0+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||
github.com/go-chi/cors v1.1.1 h1:eHuqxsIw89iXcWnWUN8R72JMibABJTN/4IOYI5WERvw=
|
||||
github.com/go-chi/cors v1.1.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=
|
||||
@@ -93,8 +95,6 @@ github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5 h1:BvoENQQU+fZ9uukda/RzCAL/191HHwJA5b13R6diVlY=
|
||||
github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.0 h1:Iw5WCbBcaAAd0fpRb1c9r5YCylv4XDoCSigm1zLevwU=
|
||||
github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
|
||||
@@ -123,8 +123,6 @@ github.com/sirupsen/logrus v1.5.0 h1:1N5EYkVAPEywqZRJd7cwnRtCb6xJx7NH3T3WUTF980Q
|
||||
github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w=
|
||||
github.com/smartystreets/assertions v1.0.1/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM=
|
||||
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/ssdb/gossdb v0.0.0-20180723034631-88f6b59b84ec/go.mod h1:QBvMkMya+gXctz3kmljlUCu/yB3GZ6oee+dUozsezQE=
|
||||
@@ -139,6 +137,8 @@ github.com/wendal/errors v0.0.0-20130201093226-f66c77a7882b/go.mod h1:Q12BUT7DqI
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628=
|
||||
@@ -168,6 +168,7 @@ golang.org/x/tools v0.0.0-20190328211700-ab21143f2384 h1:TFlARGu6Czu1z7q93HTxcP1
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b h1:NVD8gBK33xpdqCaZVVtd6OFJp+3dxkXuz7+U7KaVN6s=
|
||||
golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20200117065230-39095c1d176c h1:FodBYPZKH5tAN2O60HlglMwXGAeV/4k+NKbli79M/2c=
|
||||
golang.org/x/tools v0.0.0-20200117065230-39095c1d176c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
13
lefthook.yml
Normal file
13
lefthook.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
pre-push:
|
||||
commands:
|
||||
unit-tests:
|
||||
tags: tests
|
||||
run: go test ./...
|
||||
|
||||
pre-commit:
|
||||
parallel: false
|
||||
commands:
|
||||
gofmt:
|
||||
tags: style
|
||||
glob: "*.go"
|
||||
run: gofmt -w {staged_files}; git add {staged_files}
|
||||
162
log/log_test.go
162
log/log_test.go
@@ -6,105 +6,175 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestLog(t *testing.T) {
|
||||
SetLevel(LevelInfo)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Log Suite")
|
||||
}
|
||||
|
||||
Convey("Test Logger", t, func() {
|
||||
l, hook := test.NewNullLogger()
|
||||
var _ = Describe("Logger", func() {
|
||||
var l *logrus.Logger
|
||||
var hook *test.Hook
|
||||
|
||||
BeforeEach(func() {
|
||||
l, hook = test.NewNullLogger()
|
||||
SetLevel(LevelInfo)
|
||||
SetDefaultLogger(l)
|
||||
})
|
||||
|
||||
Convey("Plain message", func() {
|
||||
Context("Logging", func() {
|
||||
It("logs a simple message", func() {
|
||||
Error("Simple Message")
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Simple Message")
|
||||
So(hook.LastEntry().Data, ShouldBeEmpty)
|
||||
Expect(hook.LastEntry().Message).To(Equal("Simple Message"))
|
||||
Expect(hook.LastEntry().Data).To(BeEmpty())
|
||||
})
|
||||
|
||||
Convey("Passing nil as context", func() {
|
||||
It("logs a message when context is nil", func() {
|
||||
Error(nil, "Simple Message")
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Simple Message")
|
||||
So(hook.LastEntry().Data, ShouldBeEmpty)
|
||||
Expect(hook.LastEntry().Message).To(Equal("Simple Message"))
|
||||
Expect(hook.LastEntry().Data).To(BeEmpty())
|
||||
})
|
||||
|
||||
SkipConvey("Empty context", func() {
|
||||
XIt("Empty context", func() {
|
||||
Error(context.Background(), "Simple Message")
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Simple Message")
|
||||
So(hook.LastEntry().Data, ShouldBeEmpty)
|
||||
Expect(hook.LastEntry().Message).To(Equal("Simple Message"))
|
||||
Expect(hook.LastEntry().Data).To(BeEmpty())
|
||||
})
|
||||
|
||||
Convey("Message with two kv pairs", func() {
|
||||
It("logs messages with two kv pairs", func() {
|
||||
Error("Simple Message", "key1", "value1", "key2", "value2")
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Simple Message")
|
||||
So(hook.LastEntry().Data["key1"], ShouldEqual, "value1")
|
||||
So(hook.LastEntry().Data["key2"], ShouldEqual, "value2")
|
||||
So(hook.LastEntry().Data, ShouldHaveLength, 2)
|
||||
Expect(hook.LastEntry().Message).To(Equal("Simple Message"))
|
||||
Expect(hook.LastEntry().Data["key1"]).To(Equal("value1"))
|
||||
Expect(hook.LastEntry().Data["key2"]).To(Equal("value2"))
|
||||
Expect(hook.LastEntry().Data).To(HaveLen(2))
|
||||
})
|
||||
|
||||
Convey("Only error", func() {
|
||||
It("logs error objects as simple messages", func() {
|
||||
Error(errors.New("error test"))
|
||||
So(hook.LastEntry().Message, ShouldEqual, "error test")
|
||||
So(hook.LastEntry().Data, ShouldBeEmpty)
|
||||
Expect(hook.LastEntry().Message).To(Equal("error test"))
|
||||
Expect(hook.LastEntry().Data).To(BeEmpty())
|
||||
})
|
||||
|
||||
Convey("Error as last argument", func() {
|
||||
It("logs errors passed as last argument", func() {
|
||||
Error("Error scrobbling track", "id", 1, errors.New("some issue"))
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Error scrobbling track")
|
||||
So(hook.LastEntry().Data["id"], ShouldEqual, 1)
|
||||
So(hook.LastEntry().Data["error"], ShouldEqual, "some issue")
|
||||
So(hook.LastEntry().Data, ShouldHaveLength, 2)
|
||||
Expect(hook.LastEntry().Message).To(Equal("Error scrobbling track"))
|
||||
Expect(hook.LastEntry().Data["id"]).To(Equal(1))
|
||||
Expect(hook.LastEntry().Data["error"]).To(Equal("some issue"))
|
||||
Expect(hook.LastEntry().Data).To(HaveLen(2))
|
||||
})
|
||||
|
||||
Convey("Passing a request", func() {
|
||||
It("can get data from the request's context", func() {
|
||||
ctx := NewContext(nil, "foo", "bar")
|
||||
req := httptest.NewRequest("get", "/", nil).WithContext(ctx)
|
||||
|
||||
Error(req, "Simple Message", "key1", "value1")
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Simple Message")
|
||||
So(hook.LastEntry().Data["foo"], ShouldEqual, "bar")
|
||||
So(hook.LastEntry().Data["key1"], ShouldEqual, "value1")
|
||||
So(hook.LastEntry().Data, ShouldHaveLength, 2)
|
||||
|
||||
Expect(hook.LastEntry().Message).To(Equal("Simple Message"))
|
||||
Expect(hook.LastEntry().Data["foo"]).To(Equal("bar"))
|
||||
Expect(hook.LastEntry().Data["key1"]).To(Equal("value1"))
|
||||
Expect(hook.LastEntry().Data).To(HaveLen(2))
|
||||
})
|
||||
|
||||
Convey("Skip if level is lower", func() {
|
||||
It("does not log anything if level is lower", func() {
|
||||
SetLevel(LevelError)
|
||||
Info("Simple Message")
|
||||
So(hook.LastEntry(), ShouldBeNil)
|
||||
Expect(hook.LastEntry()).To(BeNil())
|
||||
})
|
||||
|
||||
It("logs source file and line number, if requested", func() {
|
||||
SetLogSourceLine(true)
|
||||
Error("A crash happened")
|
||||
Expect(hook.LastEntry().Message).To(Equal("A crash happened"))
|
||||
// NOTE: This assertions breaks if the line number changes
|
||||
Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:92"))
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Test extractLogger", t, func() {
|
||||
Convey("It returns an error if the context is nil", func() {
|
||||
Context("Levels", func() {
|
||||
BeforeEach(func() {
|
||||
SetLevel(LevelTrace)
|
||||
})
|
||||
It("logs error messages", func() {
|
||||
Error("msg")
|
||||
Expect(hook.LastEntry().Level).To(Equal(logrus.ErrorLevel))
|
||||
})
|
||||
It("logs warn messages", func() {
|
||||
Warn("msg")
|
||||
Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel))
|
||||
})
|
||||
It("logs info messages", func() {
|
||||
Info("msg")
|
||||
Expect(hook.LastEntry().Level).To(Equal(logrus.InfoLevel))
|
||||
})
|
||||
It("logs debug messages", func() {
|
||||
Debug("msg")
|
||||
Expect(hook.LastEntry().Level).To(Equal(logrus.DebugLevel))
|
||||
})
|
||||
It("logs info messages", func() {
|
||||
Trace("msg")
|
||||
Expect(hook.LastEntry().Level).To(Equal(logrus.TraceLevel))
|
||||
})
|
||||
})
|
||||
|
||||
Context("extractLogger", func() {
|
||||
It("returns an error if the context is nil", func() {
|
||||
_, err := extractLogger(nil)
|
||||
So(err, ShouldNotBeNil)
|
||||
Expect(err).ToNot(BeNil())
|
||||
})
|
||||
|
||||
Convey("It returns an error if the context is a string", func() {
|
||||
It("returns an error if the context is a string", func() {
|
||||
_, err := extractLogger("any msg")
|
||||
So(err, ShouldNotBeNil)
|
||||
Expect(err).ToNot(BeNil())
|
||||
})
|
||||
|
||||
Convey("It returns the logger from context if it has one", func() {
|
||||
It("returns the logger from context if it has one", func() {
|
||||
logger := logrus.NewEntry(logrus.New())
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, "logger", logger)
|
||||
|
||||
l, err := extractLogger(ctx)
|
||||
So(err, ShouldBeNil)
|
||||
So(l, ShouldEqual, logger)
|
||||
Expect(extractLogger(ctx)).To(Equal(logger))
|
||||
})
|
||||
|
||||
Convey("It returns the logger from request's context if it has one", func() {
|
||||
It("returns the logger from request's context if it has one", func() {
|
||||
logger := logrus.NewEntry(logrus.New())
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, "logger", logger)
|
||||
req := httptest.NewRequest("get", "/", nil).WithContext(ctx)
|
||||
l, err := extractLogger(req)
|
||||
So(err, ShouldBeNil)
|
||||
So(l, ShouldEqual, logger)
|
||||
|
||||
Expect(extractLogger(req)).To(Equal(logger))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Context("SetLevelString", func() {
|
||||
It("converts Critical level", func() {
|
||||
SetLevelString("Critical")
|
||||
Expect(CurrentLevel()).To(Equal(LevelCritical))
|
||||
})
|
||||
It("converts Error level", func() {
|
||||
SetLevelString("ERROR")
|
||||
Expect(CurrentLevel()).To(Equal(LevelError))
|
||||
})
|
||||
It("converts Warn level", func() {
|
||||
SetLevelString("warn")
|
||||
Expect(CurrentLevel()).To(Equal(LevelWarn))
|
||||
})
|
||||
It("converts Info level", func() {
|
||||
SetLevelString("info")
|
||||
Expect(CurrentLevel()).To(Equal(LevelInfo))
|
||||
})
|
||||
It("converts Debug level", func() {
|
||||
SetLevelString("debug")
|
||||
Expect(CurrentLevel()).To(Equal(LevelDebug))
|
||||
})
|
||||
It("converts Trace level", func() {
|
||||
SetLevelString("trace")
|
||||
Expect(CurrentLevel()).To(Equal(LevelTrace))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
4
main.go
4
main.go
@@ -19,7 +19,7 @@ func main() {
|
||||
panic(fmt.Sprintf("Could not create the Subsonic API router. Aborting! err=%v", err))
|
||||
}
|
||||
a := CreateServer(conf.Server.MusicFolder)
|
||||
a.MountRouter("/rest", subsonic)
|
||||
a.MountRouter("/app", CreateAppRouter("/app"))
|
||||
a.MountRouter(consts.URLPathSubsonicAPI, subsonic)
|
||||
a.MountRouter(consts.URLPathUI, CreateAppRouter())
|
||||
a.Run(":" + conf.Server.Port)
|
||||
}
|
||||
|
||||
@@ -22,11 +22,11 @@ type Album struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
// Annotations
|
||||
PlayCount int `json:"-" orm:"-"`
|
||||
PlayDate time.Time `json:"-" orm:"-"`
|
||||
Rating int `json:"-" orm:"-"`
|
||||
Starred bool `json:"-" orm:"-"`
|
||||
StarredAt time.Time `json:"-" orm:"-"`
|
||||
PlayCount int `json:"playCount" orm:"-"`
|
||||
PlayDate time.Time `json:"playDate" orm:"-"`
|
||||
Rating int `json:"rating" orm:"-"`
|
||||
Starred bool `json:"starred" orm:"-"`
|
||||
StarredAt time.Time `json:"starredAt" orm:"-"`
|
||||
}
|
||||
|
||||
type Albums []Album
|
||||
@@ -34,7 +34,6 @@ type Albums []Album
|
||||
type AlbumRepository interface {
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
Exists(id string) (bool, error)
|
||||
Put(m *Album) error
|
||||
Get(id string) (*Album, error)
|
||||
FindByArtist(albumArtistId string) (Albums, error)
|
||||
GetAll(...QueryOptions) (Albums, error)
|
||||
|
||||
@@ -7,3 +7,7 @@ type AnnotatedRepository interface {
|
||||
SetStar(starred bool, itemIDs ...string) error
|
||||
SetRating(rating int, itemID string) error
|
||||
}
|
||||
|
||||
// While I can't find a better way to make these fields optional in the models, I keep this list here
|
||||
// to be used in other packages
|
||||
var AnnotationFields = []string{"playCount", "playDate", "rating", "starred", "starredAt"}
|
||||
|
||||
@@ -9,11 +9,11 @@ type Artist struct {
|
||||
FullText string `json:"fullText"`
|
||||
|
||||
// Annotations
|
||||
PlayCount int `json:"-" orm:"-"`
|
||||
PlayDate time.Time `json:"-" orm:"-"`
|
||||
Rating int `json:"-" orm:"-"`
|
||||
Starred bool `json:"-" orm:"-"`
|
||||
StarredAt time.Time `json:"-" orm:"-"`
|
||||
PlayCount int `json:"playCount" orm:"-"`
|
||||
PlayDate time.Time `json:"playDate" orm:"-"`
|
||||
Rating int `json:"rating" orm:"-"`
|
||||
Starred bool `json:"starred" orm:"-"`
|
||||
StarredAt time.Time `json:"starredAt" orm:"-"`
|
||||
}
|
||||
|
||||
type Artists []Artist
|
||||
|
||||
@@ -30,11 +30,11 @@ type MediaFile struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
// Annotations
|
||||
PlayCount int `json:"-" orm:"-"`
|
||||
PlayDate time.Time `json:"-" orm:"-"`
|
||||
Rating int `json:"-" orm:"-"`
|
||||
Starred bool `json:"-" orm:"-"`
|
||||
StarredAt time.Time `json:"-" orm:"-"`
|
||||
PlayCount int `json:"playCount" orm:"-"`
|
||||
PlayDate time.Time `json:"playDate" orm:"-"`
|
||||
Rating int `json:"rating" orm:"-"`
|
||||
Starred bool `json:"starred" orm:"-"`
|
||||
StarredAt time.Time `json:"starredAt" orm:"-"`
|
||||
}
|
||||
|
||||
func (mf *MediaFile) ContentType() string {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Playlist struct {
|
||||
ID string
|
||||
Name string
|
||||
Comment string
|
||||
Duration float32
|
||||
Owner string
|
||||
Public bool
|
||||
Tracks MediaFiles
|
||||
ID string
|
||||
Name string
|
||||
Comment string
|
||||
Duration float32
|
||||
Owner string
|
||||
Public bool
|
||||
Tracks MediaFiles
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type PlaylistRepository interface {
|
||||
@@ -15,7 +19,6 @@ type PlaylistRepository interface {
|
||||
Exists(id string) (bool, error)
|
||||
Put(pls *Playlist) error
|
||||
Get(id string) (*Playlist, error)
|
||||
GetWithTracks(id string) (*Playlist, error)
|
||||
GetAll(options ...QueryOptions) (Playlists, error)
|
||||
Delete(id string) error
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ func NewAlbumRepository(ctx context.Context, o orm.Ormer) model.AlbumRepository
|
||||
r.tableName = "album"
|
||||
r.sortMappings = map[string]string{
|
||||
"artist": "compilation asc, album_artist asc, name asc",
|
||||
"random": "RANDOM()",
|
||||
}
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"name": fullTextFilter,
|
||||
@@ -64,12 +65,6 @@ func (r *albumRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(Select().Where(Eq{"id": id}))
|
||||
}
|
||||
|
||||
func (r *albumRepository) Put(a *model.Album) error {
|
||||
a.FullText = r.getFullText(a.Name, a.Artist, a.AlbumArtist)
|
||||
_, err := r.put(a.ID, a)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder {
|
||||
return r.newSelectWithAnnotation("album.id", options...).Columns("*")
|
||||
}
|
||||
@@ -112,11 +107,13 @@ func (r *albumRepository) Refresh(ids ...string) error {
|
||||
model.Album
|
||||
CurrentId string
|
||||
HasCoverArt bool
|
||||
SongArtists string
|
||||
}
|
||||
var albums []refreshAlbum
|
||||
sel := Select(`album_id as id, album as name, f.artist, f.album_artist, f.artist_id, f.album_artist_id,
|
||||
f.compilation, f.genre, max(f.year) as max_year, min(f.year) as min_year, sum(f.duration) as duration,
|
||||
count(*) as song_count, a.id as current_id, f.id as cover_art_id, f.path as cover_art_path, f.has_cover_art`).
|
||||
count(*) as song_count, a.id as current_id, f.id as cover_art_id, f.path as cover_art_path,
|
||||
group_concat(f.artist, ' ') as song_artists, f.has_cover_art`).
|
||||
From("media_file f").
|
||||
LeftJoin("album a on f.album_id = a.id").
|
||||
Where(Eq{"f.album_id": ids}).GroupBy("album_id").OrderBy("f.id")
|
||||
@@ -146,7 +143,8 @@ func (r *albumRepository) Refresh(ids ...string) error {
|
||||
toInsert++
|
||||
al.CreatedAt = time.Now()
|
||||
}
|
||||
err := r.Put(&al.Album)
|
||||
al.FullText = r.getFullText(al.Name, al.Artist, al.AlbumArtist, al.SongArtists)
|
||||
_, err := r.put(al.ID, al.Album)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
func toSqlArgs(rec interface{}) (map[string]interface{}, error) {
|
||||
@@ -21,7 +23,9 @@ func toSqlArgs(rec interface{}) (map[string]interface{}, error) {
|
||||
err = json.Unmarshal(b, &m)
|
||||
r := make(map[string]interface{}, len(m))
|
||||
for f, v := range m {
|
||||
r[toSnakeCase(f)] = v
|
||||
if !utils.StringInSlice(f, model.AnnotationFields) {
|
||||
r[toSnakeCase(f)] = v
|
||||
}
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ func CreateMockAlbumRepo() *MockAlbum {
|
||||
|
||||
type MockAlbum struct {
|
||||
model.AlbumRepository
|
||||
data map[string]*model.Album
|
||||
data map[string]model.Album
|
||||
all model.Albums
|
||||
err bool
|
||||
Options model.QueryOptions
|
||||
@@ -24,19 +24,22 @@ func (m *MockAlbum) SetError(err bool) {
|
||||
m.err = err
|
||||
}
|
||||
|
||||
func (m *MockAlbum) SetData(j string, size int) {
|
||||
m.data = make(map[string]*model.Album)
|
||||
m.all = make(model.Albums, size)
|
||||
func (m *MockAlbum) SetData(j string) {
|
||||
m.data = make(map[string]model.Album)
|
||||
m.all = model.Albums{}
|
||||
err := json.Unmarshal([]byte(j), &m.all)
|
||||
if err != nil {
|
||||
fmt.Println("ERROR: ", err)
|
||||
}
|
||||
for _, a := range m.all {
|
||||
m.data[a.ID] = &a
|
||||
m.data[a.ID] = a
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockAlbum) Exists(id string) (bool, error) {
|
||||
if m.err {
|
||||
return false, errors.New("Error!")
|
||||
}
|
||||
_, found := m.data[id]
|
||||
return found, nil
|
||||
}
|
||||
@@ -46,7 +49,7 @@ func (m *MockAlbum) Get(id string) (*model.Album, error) {
|
||||
return nil, errors.New("Error!")
|
||||
}
|
||||
if d, ok := m.data[id]; ok {
|
||||
return d, nil
|
||||
return &d, nil
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
@@ -69,7 +72,7 @@ func (m *MockAlbum) FindByArtist(artistId string) (model.Albums, error) {
|
||||
i := 0
|
||||
for _, a := range m.data {
|
||||
if a.AlbumArtistID == artistId {
|
||||
res[i] = *a
|
||||
res[i] = a
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ func CreateMockArtistRepo() *MockArtist {
|
||||
|
||||
type MockArtist struct {
|
||||
model.ArtistRepository
|
||||
data map[string]*model.Artist
|
||||
data map[string]model.Artist
|
||||
err bool
|
||||
}
|
||||
|
||||
@@ -22,19 +22,22 @@ func (m *MockArtist) SetError(err bool) {
|
||||
m.err = err
|
||||
}
|
||||
|
||||
func (m *MockArtist) SetData(j string, size int) {
|
||||
m.data = make(map[string]*model.Artist)
|
||||
var l = make([]model.Artist, size)
|
||||
func (m *MockArtist) SetData(j string) {
|
||||
m.data = make(map[string]model.Artist)
|
||||
var l = model.Artists{}
|
||||
err := json.Unmarshal([]byte(j), &l)
|
||||
if err != nil {
|
||||
fmt.Println("ERROR: ", err)
|
||||
}
|
||||
for _, a := range l {
|
||||
m.data[a.ID] = &a
|
||||
m.data[a.ID] = a
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockArtist) Exists(id string) (bool, error) {
|
||||
if m.err {
|
||||
return false, errors.New("Error!")
|
||||
}
|
||||
_, found := m.data[id]
|
||||
return found, nil
|
||||
}
|
||||
@@ -44,7 +47,7 @@ func (m *MockArtist) Get(id string) (*model.Artist, error) {
|
||||
return nil, errors.New("Error!")
|
||||
}
|
||||
if d, ok := m.data[id]; ok {
|
||||
return d, nil
|
||||
return &d, nil
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
@@ -22,9 +22,9 @@ func (m *MockMediaFile) SetError(err bool) {
|
||||
m.err = err
|
||||
}
|
||||
|
||||
func (m *MockMediaFile) SetData(j string, size int) {
|
||||
func (m *MockMediaFile) SetData(j string) {
|
||||
m.data = make(map[string]model.MediaFile)
|
||||
var l = make(model.MediaFiles, size)
|
||||
var l = model.MediaFiles{}
|
||||
err := json.Unmarshal([]byte(j), &l)
|
||||
if err != nil {
|
||||
fmt.Println("ERROR: ", err)
|
||||
|
||||
@@ -73,7 +73,7 @@ func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRe
|
||||
case model.MediaFile:
|
||||
return s.MediaFile(ctx).(model.ResourceRepository)
|
||||
}
|
||||
log.Error("Resource no implemented", "model", reflect.TypeOf(m).Name())
|
||||
log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name())
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestPersistence(t *testing.T) {
|
||||
|
||||
var (
|
||||
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1, FullText: "kraftwerk"}
|
||||
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2, FullText: "the beatles"}
|
||||
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2, FullText: "beatles the"}
|
||||
testArtists = model.Artists{
|
||||
artistKraftwerk,
|
||||
artistBeatles,
|
||||
@@ -40,9 +40,9 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
albumSgtPeppers = model.Album{ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", AlbumArtistID: "3", Genre: "Rock", CoverArtId: "1", CoverArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967, FullText: "sgt peppers the beatles"}
|
||||
albumAbbeyRoad = model.Album{ID: "2", Name: "Abbey Road", Artist: "The Beatles", AlbumArtistID: "3", Genre: "Rock", CoverArtId: "2", CoverArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969, FullText: "abbey road the beatles"}
|
||||
albumRadioactivity = model.Album{ID: "3", Name: "Radioactivity", Artist: "Kraftwerk", AlbumArtistID: "2", Genre: "Electronic", CoverArtId: "3", CoverArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2, FullText: "radioactivity kraftwerk"}
|
||||
albumSgtPeppers = model.Album{ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", AlbumArtistID: "3", Genre: "Rock", CoverArtId: "1", CoverArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967, FullText: "beatles peppers sgt the"}
|
||||
albumAbbeyRoad = model.Album{ID: "2", Name: "Abbey Road", Artist: "The Beatles", AlbumArtistID: "3", Genre: "Rock", CoverArtId: "2", CoverArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969, FullText: "abbey beatles road the"}
|
||||
albumRadioactivity = model.Album{ID: "3", Name: "Radioactivity", Artist: "Kraftwerk", AlbumArtistID: "2", Genre: "Electronic", CoverArtId: "3", CoverArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2, FullText: "kraftwerk radioactivity"}
|
||||
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"), 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"}
|
||||
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 beatles day in life peppers sgt the"}
|
||||
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: "abbey beatles come road the together"}
|
||||
songRadioactivity = model.MediaFile{ID: "3", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Album: "Radioactivity", Genre: "Electronic", Path: P("/kraft/radio/radio.mp3"), FullText: "kraftwerk radioactivity"}
|
||||
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,
|
||||
@@ -65,13 +65,12 @@ var (
|
||||
|
||||
var (
|
||||
plsBest = model.Playlist{
|
||||
ID: "10",
|
||||
Name: "Best",
|
||||
Comment: "No Comments",
|
||||
Duration: 10,
|
||||
Owner: "userid",
|
||||
Public: true,
|
||||
Tracks: model.MediaFiles{{ID: "1"}, {ID: "3"}},
|
||||
ID: "10",
|
||||
Name: "Best",
|
||||
Comment: "No Comments",
|
||||
Owner: "userid",
|
||||
Public: true,
|
||||
Tracks: model.MediaFiles{{ID: "1"}, {ID: "3"}},
|
||||
}
|
||||
plsCool = model.Playlist{ID: "11", Name: "Cool", Tracks: model.MediaFiles{{ID: "4"}}}
|
||||
testPlaylists = model.Playlists{plsBest, plsCool}
|
||||
@@ -95,9 +94,9 @@ var _ = Describe("Initialize test DB", func() {
|
||||
}
|
||||
}
|
||||
|
||||
alr := NewAlbumRepository(ctx, o)
|
||||
alr := NewAlbumRepository(ctx, o).(*albumRepository)
|
||||
for _, a := range testAlbums {
|
||||
err := alr.Put(&a)
|
||||
_, err := alr.put(a.ID, &a)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
@@ -3,20 +3,24 @@ package persistence
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
type playlist struct {
|
||||
ID string `orm:"column(id)"`
|
||||
Name string
|
||||
Comment string
|
||||
Duration float32
|
||||
Owner string
|
||||
Public bool
|
||||
Tracks string
|
||||
ID string `orm:"column(id)"`
|
||||
Name string
|
||||
Comment string
|
||||
Duration float32
|
||||
Owner string
|
||||
Public bool
|
||||
Tracks string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type playlistRepository struct {
|
||||
@@ -44,6 +48,10 @@ func (r *playlistRepository) Delete(id string) error {
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Put(p *model.Playlist) error {
|
||||
if p.ID == "" {
|
||||
p.CreatedAt = time.Now()
|
||||
}
|
||||
p.UpdatedAt = time.Now()
|
||||
pls := r.fromModel(p)
|
||||
_, err := r.put(pls.ID, pls)
|
||||
return err
|
||||
@@ -57,26 +65,6 @@ func (r *playlistRepository) Get(id string) (*model.Playlist, error) {
|
||||
return &pls, err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) GetWithTracks(id string) (*model.Playlist, error) {
|
||||
pls, err := r.Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mfRepo := NewMediaFileRepository(r.ctx, r.ormer)
|
||||
pls.Duration = 0
|
||||
newTracks := model.MediaFiles{}
|
||||
for _, t := range pls.Tracks {
|
||||
mf, err := mfRepo.Get(t.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
pls.Duration += mf.Duration
|
||||
newTracks = append(newTracks, *mf)
|
||||
}
|
||||
pls.Tracks = newTracks
|
||||
return pls, err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playlists, error) {
|
||||
sel := r.newSelect(options...).Columns("*")
|
||||
var res []playlist
|
||||
@@ -94,12 +82,14 @@ func (r *playlistRepository) toModels(all []playlist) model.Playlists {
|
||||
|
||||
func (r *playlistRepository) toModel(p *playlist) model.Playlist {
|
||||
pls := model.Playlist{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Comment: p.Comment,
|
||||
Duration: p.Duration,
|
||||
Owner: p.Owner,
|
||||
Public: p.Public,
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Comment: p.Comment,
|
||||
Duration: p.Duration,
|
||||
Owner: p.Owner,
|
||||
Public: p.Public,
|
||||
CreatedAt: p.CreatedAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
}
|
||||
if strings.TrimSpace(p.Tracks) != "" {
|
||||
tracks := strings.Split(p.Tracks, ",")
|
||||
@@ -107,24 +97,74 @@ func (r *playlistRepository) toModel(p *playlist) model.Playlist {
|
||||
pls.Tracks = append(pls.Tracks, model.MediaFile{ID: t})
|
||||
}
|
||||
}
|
||||
pls.Tracks = r.loadTracks(&pls)
|
||||
return pls
|
||||
}
|
||||
|
||||
func (r *playlistRepository) fromModel(p *model.Playlist) playlist {
|
||||
pls := playlist{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Comment: p.Comment,
|
||||
Duration: p.Duration,
|
||||
Owner: p.Owner,
|
||||
Public: p.Public,
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Comment: p.Comment,
|
||||
Owner: p.Owner,
|
||||
Public: p.Public,
|
||||
CreatedAt: p.CreatedAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
}
|
||||
p.Tracks = r.loadTracks(p)
|
||||
var newTracks []string
|
||||
for _, t := range p.Tracks {
|
||||
newTracks = append(newTracks, t.ID)
|
||||
pls.Duration += t.Duration
|
||||
}
|
||||
pls.Tracks = strings.Join(newTracks, ",")
|
||||
return pls
|
||||
}
|
||||
|
||||
// TODO: Introduce a relation table for Playlist <-> MediaFiles, and rewrite this method in pure SQL
|
||||
func (r *playlistRepository) loadTracks(p *model.Playlist) model.MediaFiles {
|
||||
if len(p.Tracks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect all ids
|
||||
ids := make([]string, len(p.Tracks))
|
||||
for i, t := range p.Tracks {
|
||||
ids[i] = t.ID
|
||||
}
|
||||
|
||||
// Break the list in chunks, up to 50 items, to avoid hitting SQLITE_MAX_FUNCTION_ARG limit
|
||||
const chunkSize = 50
|
||||
var chunks [][]string
|
||||
for i := 0; i < len(ids); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
|
||||
chunks = append(chunks, ids[i:end])
|
||||
}
|
||||
|
||||
// Query each chunk of media_file ids and store results in a map
|
||||
mfRepo := NewMediaFileRepository(r.ctx, r.ormer)
|
||||
trackMap := map[string]model.MediaFile{}
|
||||
for i := range chunks {
|
||||
idsFilter := Eq{"id": chunks[i]}
|
||||
tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: idsFilter})
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Could not load playlist's tracks", "playlistName", p.Name, "playlistId", p.ID, err)
|
||||
}
|
||||
for _, t := range tracks {
|
||||
trackMap[t.ID] = t
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new list of tracks with the same order as the original
|
||||
newTracks := make(model.MediaFiles, len(p.Tracks))
|
||||
for i, t := range p.Tracks {
|
||||
newTracks[i] = trackMap[t.ID]
|
||||
}
|
||||
return newTracks
|
||||
}
|
||||
|
||||
var _ model.PlaylistRepository = (*playlistRepository)(nil)
|
||||
|
||||
@@ -32,34 +32,25 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
|
||||
Describe("Get", func() {
|
||||
It("returns an existing playlist", func() {
|
||||
Expect(repo.Get("10")).To(Equal(&plsBest))
|
||||
p, err := repo.Get("10")
|
||||
Expect(err).To(BeNil())
|
||||
// Compare all but Tracks and timestamps
|
||||
p2 := *p
|
||||
p2.Tracks = plsBest.Tracks
|
||||
p2.UpdatedAt = plsBest.UpdatedAt
|
||||
p2.CreatedAt = plsBest.CreatedAt
|
||||
Expect(p2).To(Equal(plsBest))
|
||||
// Compare tracks
|
||||
for i := range p.Tracks {
|
||||
Expect(p.Tracks[i].ID).To(Equal(plsBest.Tracks[i].ID))
|
||||
}
|
||||
})
|
||||
It("returns ErrNotFound for a non-existing playlist", func() {
|
||||
_, err := repo.Get("666")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Put/Get/Delete", func() {
|
||||
newPls := model.Playlist{ID: "22", Name: "Great!", Tracks: model.MediaFiles{{ID: "4"}}}
|
||||
It("saves the playlist to the DB", func() {
|
||||
Expect(repo.Put(&newPls)).To(BeNil())
|
||||
})
|
||||
It("returns the newly created playlist", func() {
|
||||
Expect(repo.Get("22")).To(Equal(&newPls))
|
||||
})
|
||||
It("returns deletes the playlist", func() {
|
||||
Expect(repo.Delete("22")).To(BeNil())
|
||||
})
|
||||
It("returns error if tries to retrieve the deleted playlist", func() {
|
||||
_, err := repo.Get("22")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetWithTracks", func() {
|
||||
It("returns an existing playlist", func() {
|
||||
pls, err := repo.GetWithTracks("10")
|
||||
It("returns all tracks", func() {
|
||||
pls, err := repo.Get("10")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(pls.Name).To(Equal(plsBest.Name))
|
||||
Expect(pls.Tracks).To(Equal(model.MediaFiles{
|
||||
@@ -69,9 +60,40 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Put/Exists/Delete", func() {
|
||||
var newPls model.Playlist
|
||||
BeforeEach(func() {
|
||||
newPls = model.Playlist{ID: "22", Name: "Great!", Tracks: model.MediaFiles{{ID: "4"}, {ID: "3"}}}
|
||||
})
|
||||
It("saves the playlist to the DB", func() {
|
||||
Expect(repo.Put(&newPls)).To(BeNil())
|
||||
})
|
||||
It("adds repeated songs to a playlist and keeps the order", func() {
|
||||
newPls.Tracks = append(newPls.Tracks, model.MediaFile{ID: "4"})
|
||||
Expect(repo.Put(&newPls)).To(BeNil())
|
||||
saved, _ := repo.Get("22")
|
||||
Expect(saved.Tracks).To(HaveLen(3))
|
||||
Expect(saved.Tracks[0].ID).To(Equal("4"))
|
||||
Expect(saved.Tracks[1].ID).To(Equal("3"))
|
||||
Expect(saved.Tracks[2].ID).To(Equal("4"))
|
||||
})
|
||||
It("returns the newly created playlist", func() {
|
||||
Expect(repo.Exists("22")).To(BeTrue())
|
||||
})
|
||||
It("returns deletes the playlist", func() {
|
||||
Expect(repo.Delete("22")).To(BeNil())
|
||||
})
|
||||
It("returns error if tries to retrieve the deleted playlist", func() {
|
||||
Expect(repo.Exists("22")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetAll", func() {
|
||||
It("returns all playlists from DB", func() {
|
||||
Expect(repo.GetAll()).To(Equal(model.Playlists{plsBest, plsCool}))
|
||||
all, err := repo.GetAll()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(all[0].ID).To(Equal(plsBest.ID))
|
||||
Expect(all[1].ID).To(Equal(plsCool.ID))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -160,6 +160,7 @@ func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOpt
|
||||
|
||||
func (r sqlRepository) put(id string, m interface{}) (newId string, err error) {
|
||||
values, _ := toSqlArgs(m)
|
||||
// Remove created_at from args and save it for later, if needed fo insert
|
||||
createdAt := values["created_at"]
|
||||
delete(values, "created_at")
|
||||
if id != "" {
|
||||
@@ -178,6 +179,7 @@ func (r sqlRepository) put(id string, m interface{}) (newId string, err error) {
|
||||
id = rand.String()
|
||||
values["id"] = id
|
||||
}
|
||||
// It is a insert, if there was a created_at, add it back to args
|
||||
if createdAt != nil {
|
||||
values["created_at"] = createdAt
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
@@ -12,7 +13,16 @@ func (r sqlRepository) getFullText(text ...string) string {
|
||||
for _, txt := range text {
|
||||
sanitizedText.WriteString(strings.TrimSpace(sanitize.Accents(strings.ToLower(txt))) + " ")
|
||||
}
|
||||
return strings.TrimSpace(sanitizedText.String())
|
||||
words := make(map[string]struct{})
|
||||
for _, w := range strings.Fields(sanitizedText.String()) {
|
||||
words[w] = struct{}{}
|
||||
}
|
||||
var fullText []string
|
||||
for w := range words {
|
||||
fullText = append(fullText, w)
|
||||
}
|
||||
sort.Strings(fullText)
|
||||
return strings.Join(fullText, " ")
|
||||
}
|
||||
|
||||
func (r sqlRepository) doSearch(q string, offset, size int, results interface{}, orderBys ...string) error {
|
||||
|
||||
28
persistence/sql_search_test.go
Normal file
28
persistence/sql_search_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("sqlRepository", func() {
|
||||
var sqlRepository = &sqlRepository{}
|
||||
|
||||
Describe("getFullText", func() {
|
||||
It("returns all lowercase chars", func() {
|
||||
Expect(sqlRepository.getFullText("Some Text")).To(Equal("some text"))
|
||||
})
|
||||
|
||||
It("removes accents", func() {
|
||||
Expect(sqlRepository.getFullText("Quintão")).To(Equal("quintao"))
|
||||
})
|
||||
|
||||
It("remove extra spaces", func() {
|
||||
Expect(sqlRepository.getFullText(" some text ")).To(Equal("some text"))
|
||||
})
|
||||
|
||||
It("remove duplicated words", func() {
|
||||
Expect(sqlRepository.getFullText("legião urbana urbana legiÃo")).To(Equal("legiao urbana"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1 +1 @@
|
||||
-s -r "(\.go$$|navidrome.toml)" -R "(Jamstash-master|^ui)" -- go run .
|
||||
-s -r "(\.go$$|navidrome.toml)" -R "(Jamstash-master|^ui|^data)" -- go run .
|
||||
|
||||
@@ -33,7 +33,7 @@ func (m *Metadata) Genre() string { return m.getTag("genre") }
|
||||
func (m *Metadata) Year() int { return m.parseYear("date") }
|
||||
func (m *Metadata) TrackNumber() (int, int) { return m.parseTuple("track") }
|
||||
func (m *Metadata) DiscNumber() (int, int) { return m.parseTuple("tpa", "disc") }
|
||||
func (m *Metadata) HasPicture() bool { return m.getTag("has_picture") == "true" }
|
||||
func (m *Metadata) HasPicture() bool { return m.getTag("has_picture", "metadata_block_picture") != "" }
|
||||
func (m *Metadata) Comment() string { return m.getTag("comment") }
|
||||
func (m *Metadata) Compilation() bool { return m.parseBool("compilation") }
|
||||
func (m *Metadata) Duration() float32 { return m.parseDuration("duration") }
|
||||
@@ -74,10 +74,10 @@ func LoadAllAudioFiles(dirPath string) (map[string]os.FileInfo, error) {
|
||||
}
|
||||
|
||||
func ExtractAllMetadata(inputs []string) (map[string]*Metadata, error) {
|
||||
cmdLine, args := createProbeCommand(inputs)
|
||||
args := createProbeCommand(inputs)
|
||||
|
||||
log.Trace("Executing command", "arg0", cmdLine, "args", args)
|
||||
cmd := exec.Command(cmdLine, args...)
|
||||
log.Trace("Executing command", "args", args)
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
output, _ := cmd.CombinedOutput()
|
||||
mds := map[string]*Metadata{}
|
||||
if len(output) == 0 {
|
||||
@@ -99,7 +99,7 @@ var (
|
||||
inputRegex = regexp.MustCompile(`(?m)^Input #\d+,.*,\sfrom\s'(.*)'`)
|
||||
|
||||
// TITLE : Back In Black
|
||||
tagsRx = regexp.MustCompile(`(?i)^\s{4,6}(\w+)\s+:(.*)`)
|
||||
tagsRx = regexp.MustCompile(`(?i)^\s{4,6}(\w+)\s*:(.*)`)
|
||||
|
||||
// Duration: 00:04:16.00, start: 0.000000, bitrate: 995 kb/s`
|
||||
durationRx = regexp.MustCompile(`^\s\sDuration: ([\d.:]+).*bitrate: (\d+)`)
|
||||
@@ -268,25 +268,19 @@ func (m *Metadata) parseDuration(tagName string) float32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func createProbeCommand(inputs []string) (string, []string) {
|
||||
cmd := conf.Server.ProbeCommand
|
||||
|
||||
split := strings.Split(cmd, " ")
|
||||
// Inputs will always be absolute paths
|
||||
func createProbeCommand(inputs []string) []string {
|
||||
split := strings.Split(conf.Server.ProbeCommand, " ")
|
||||
args := make([]string, 0)
|
||||
first := true
|
||||
|
||||
for _, s := range split {
|
||||
if s == "%s" {
|
||||
for _, inp := range inputs {
|
||||
if !first {
|
||||
args = append(args, "-i")
|
||||
}
|
||||
args = append(args, inp)
|
||||
first = false
|
||||
args = append(args, "-i", inp)
|
||||
}
|
||||
continue
|
||||
} else {
|
||||
args = append(args, s)
|
||||
}
|
||||
args = append(args, s)
|
||||
}
|
||||
|
||||
return args[0], args[1:]
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -79,6 +79,19 @@ Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/
|
||||
Expect(md.HasPicture()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("detects embedded cover art in ogg containers", func() {
|
||||
const output = `
|
||||
Input #0, ogg, from '/Users/deluan/Music/iTunes/iTunes Media/Music/_Testes/Jamaican In New York/01-02 Jamaican In New York (Album Version).opus':
|
||||
Duration: 00:04:28.69, start: 0.007500, bitrate: 139 kb/s
|
||||
Stream #0:0(eng): Audio: opus, 48000 Hz, stereo, fltp
|
||||
Metadata:
|
||||
ALBUM : Jamaican In New York
|
||||
metadata_block_picture: AAAAAwAAAAppbWFnZS9qcGVnAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4Id/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQ
|
||||
TITLE : Jamaican In New York (Album Version)`
|
||||
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
|
||||
Expect(md.HasPicture()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("gets bitrate from the stream, if available", func() {
|
||||
const output = `
|
||||
Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3':
|
||||
@@ -215,4 +228,10 @@ Tracklist:
|
||||
Expect(md.Year()).To(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
It("creates a valid command line", func() {
|
||||
args := createProbeCommand([]string{"/music library/one.mp3", "/music library/two.mp3"})
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/one.mp3", "-i", "/music library/two.mp3", "-f", "ffmetadata" }))
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -169,33 +169,39 @@ func (s *TagScanner) processChangedDir(ctx context.Context, dir string, updatedA
|
||||
delete(currentTracks, filePath)
|
||||
}
|
||||
|
||||
// Load tracks Metadata from the folder
|
||||
newTracks, err := s.loadTracks(filesToUpdate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If track from folder is newer than the one in DB, update/insert in DB and delete from the current tracks
|
||||
log.Trace("Updating mediaFiles in DB", "dir", dir, "files", filesToUpdate, "numFiles", len(filesToUpdate))
|
||||
numUpdatedTracks := 0
|
||||
numPurgedTracks := 0
|
||||
for _, n := range newTracks {
|
||||
err := s.ds.MediaFile(ctx).Put(&n)
|
||||
updatedArtists[n.AlbumArtistID] = true
|
||||
updatedAlbums[n.AlbumID] = true
|
||||
numUpdatedTracks++
|
||||
|
||||
if len(filesToUpdate) > 0 {
|
||||
// Load tracks Metadata from the folder
|
||||
newTracks, err := s.loadTracks(filesToUpdate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If track from folder is newer than the one in DB, update/insert in DB and delete from the current tracks
|
||||
log.Trace("Updating mediaFiles in DB", "dir", dir, "files", filesToUpdate, "numFiles", len(filesToUpdate))
|
||||
for _, n := range newTracks {
|
||||
err := s.ds.MediaFile(ctx).Put(&n)
|
||||
updatedArtists[n.AlbumArtistID] = true
|
||||
updatedAlbums[n.AlbumID] = true
|
||||
numUpdatedTracks++
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remaining tracks from DB that are not in the folder are deleted
|
||||
for _, ct := range currentTracks {
|
||||
numPurgedTracks++
|
||||
updatedArtists[ct.AlbumArtistID] = true
|
||||
updatedAlbums[ct.AlbumID] = true
|
||||
if err := s.ds.MediaFile(ctx).Delete(ct.ID); err != nil {
|
||||
return err
|
||||
if len(currentTracks) > 0 {
|
||||
log.Trace("Deleting dangling tracks from DB", "dir", dir, "numTracks", len(currentTracks))
|
||||
// Remaining tracks from DB that are not in the folder are deleted
|
||||
for _, ct := range currentTracks {
|
||||
numPurgedTracks++
|
||||
updatedArtists[ct.AlbumArtistID] = true
|
||||
updatedAlbums[ct.AlbumID] = true
|
||||
if err := s.ds.MediaFile(ctx).Delete(ct.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +222,7 @@ func (s *TagScanner) processDeletedDir(ctx context.Context, dir string, updatedA
|
||||
updatedAlbums[t.AlbumID] = true
|
||||
}
|
||||
|
||||
log.Info("Finished processing deleted folder", "dir", dir, "deleted", len(ct), "elapsed", time.Since(start))
|
||||
log.Info("Finished processing deleted folder", "dir", dir, "purged", len(ct), "elapsed", time.Since(start))
|
||||
return s.ds.MediaFile(ctx).DeleteByPath(dir)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,30 +15,32 @@ import (
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
ds model.DataStore
|
||||
mux http.Handler
|
||||
path string
|
||||
ds model.DataStore
|
||||
mux http.Handler
|
||||
}
|
||||
|
||||
func New(ds model.DataStore, path string) *Router {
|
||||
r := &Router{ds: ds, path: path}
|
||||
r.mux = r.routes()
|
||||
return r
|
||||
func New(ds model.DataStore) *Router {
|
||||
return &Router{ds: ds}
|
||||
}
|
||||
|
||||
func (app *Router) Setup(path string) {
|
||||
app.mux = app.routes(path)
|
||||
}
|
||||
|
||||
func (app *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
app.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (app *Router) routes() http.Handler {
|
||||
func (app *Router) routes(path string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Post("/login", Login(app.ds))
|
||||
r.Post("/createAdmin", CreateAdmin(app.ds))
|
||||
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Use(mapAuthHeader())
|
||||
r.Use(jwtauth.Verifier(auth.TokenAuth))
|
||||
r.Use(Authenticator(app.ds))
|
||||
r.Use(authenticator(app.ds))
|
||||
app.R(r, "/user", model.User{})
|
||||
app.R(r, "/song", model.MediaFile{})
|
||||
app.R(r, "/album", model.Album{})
|
||||
@@ -51,8 +53,8 @@ func (app *Router) routes() http.Handler {
|
||||
})
|
||||
|
||||
// Serve UI app assets
|
||||
r.Handle("/", ServeIndex(app.ds))
|
||||
r.Handle("/*", http.StripPrefix(app.path, http.FileServer(assets.AssetFile())))
|
||||
r.Handle("/", ServeIndex(app.ds, assets.AssetFile()))
|
||||
r.Handle("/*", http.StripPrefix(path, http.FileServer(assets.AssetFile())))
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
17
server/app/app_suite_test.go
Normal file
17
server/app/app_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestApp(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelCritical)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "RESTful API Suite")
|
||||
}
|
||||
@@ -63,7 +63,6 @@ func handleLogin(ds model.DataStore, username string, password string, w http.Re
|
||||
"name": user.Name,
|
||||
"username": username,
|
||||
"isAdmin": user.IsAdmin,
|
||||
"version": consts.Version(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,7 +168,18 @@ func getToken(ds model.DataStore, ctx context.Context) (*jwt.Token, error) {
|
||||
return nil, errors.New("invalid authentication")
|
||||
}
|
||||
|
||||
func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
// This method maps the custom authorization header to the default 'Authorization', used by the jwtauth library
|
||||
func mapAuthHeader() func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
bearer := r.Header.Get(consts.UIAuthorizationHeader)
|
||||
r.Header.Set("Authorization", bearer)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
auth.InitTokenAuth(ds)
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
@@ -194,7 +204,7 @@ func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Authorization", newTokenString)
|
||||
w.Header().Set(consts.UIAuthorizationHeader, newTokenString)
|
||||
next.ServeHTTP(w, r.WithContext(newCtx))
|
||||
})
|
||||
}
|
||||
|
||||
27
server/app/auth_test.go
Normal file
27
server/app/auth_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Auth", func() {
|
||||
Describe("mapAuthHeader", func() {
|
||||
It("maps the custom header to Authorization header", func() {
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
r.Header.Set(consts.UIAuthorizationHeader, "test authorization bearer")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
mapAuthHeader()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Expect(r.Header.Get("Authorization")).To(Equal("test authorization bearer"))
|
||||
w.WriteHeader(200)
|
||||
})).ServeHTTP(w, r)
|
||||
|
||||
Expect(w.Code).To(Equal(200))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,34 +5,30 @@ import (
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/assets"
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
// Injects the `firstTime` config in the `index.html` template
|
||||
func ServeIndex(ds model.DataStore) http.HandlerFunc {
|
||||
func ServeIndex(ds model.DataStore, fs http.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := ds.User(r.Context()).CountAll()
|
||||
firstTime := c == 0 && err == nil
|
||||
|
||||
t := template.New("initial state")
|
||||
fs := assets.AssetFile()
|
||||
indexHtml, err := fs.Open("index.html")
|
||||
if err != nil {
|
||||
log.Error(r, "Could not find `index.html` template", err)
|
||||
}
|
||||
indexStr, err := ioutil.ReadAll(indexHtml)
|
||||
if err != nil {
|
||||
log.Error(r, "Could not read from `index.html`", err)
|
||||
}
|
||||
t, _ = t.Parse(string(indexStr))
|
||||
t := getIndexTemplate(r, fs)
|
||||
|
||||
appConfig := map[string]interface{}{
|
||||
"firstTime": firstTime,
|
||||
"version": consts.Version(),
|
||||
"firstTime": firstTime,
|
||||
"baseURL": strings.TrimSuffix(conf.Server.BaseURL, "/"),
|
||||
"loginBackgroundURL": conf.Server.UILoginBackgroundURL,
|
||||
}
|
||||
j, _ := json.Marshal(appConfig)
|
||||
|
||||
data := map[string]interface{}{
|
||||
"AppConfig": string(j),
|
||||
"Version": consts.Version(),
|
||||
@@ -43,3 +39,20 @@ func ServeIndex(ds model.DataStore) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getIndexTemplate(r *http.Request, fs http.FileSystem) *template.Template {
|
||||
t := template.New("initial state")
|
||||
indexHtml, err := fs.Open("index.html")
|
||||
if err != nil {
|
||||
log.Error(r, "Could not find `index.html` template", err)
|
||||
}
|
||||
indexStr, err := ioutil.ReadAll(indexHtml)
|
||||
if err != nil {
|
||||
log.Error(r, "Could not read from `index.html`", err)
|
||||
}
|
||||
t, err = t.Parse(string(indexStr))
|
||||
if err != nil {
|
||||
log.Error(r, "Error parsing `index.html`", err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
123
server/app/serve_index_test.go
Normal file
123
server/app/serve_index_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ServeIndex", func() {
|
||||
var ds model.DataStore
|
||||
mockUser := &mockedUserRepo{}
|
||||
fs := http.Dir("tests/fixtures")
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &persistence.MockDataStore{MockedUser: mockUser}
|
||||
conf.Server.UILoginBackgroundURL = ""
|
||||
})
|
||||
|
||||
It("adds app_config to index.html", func() {
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ServeIndex(ds, fs)(w, r)
|
||||
|
||||
Expect(w.Code).To(Equal(200))
|
||||
config := extractAppConfig(w.Body.String())
|
||||
Expect(config).To(BeAssignableToTypeOf(map[string]interface{}{}))
|
||||
})
|
||||
|
||||
It("sets firstTime = true when User table is empty", func() {
|
||||
mockUser.empty = true
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ServeIndex(ds, fs)(w, r)
|
||||
|
||||
config := extractAppConfig(w.Body.String())
|
||||
Expect(config).To(HaveKeyWithValue("firstTime", true))
|
||||
})
|
||||
|
||||
It("sets firstTime = false when User table is not empty", func() {
|
||||
mockUser.empty = false
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ServeIndex(ds, fs)(w, r)
|
||||
|
||||
config := extractAppConfig(w.Body.String())
|
||||
Expect(config).To(HaveKeyWithValue("firstTime", false))
|
||||
})
|
||||
|
||||
It("sets baseURL", func() {
|
||||
conf.Server.BaseURL = "base_url_test"
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ServeIndex(ds, fs)(w, r)
|
||||
|
||||
config := extractAppConfig(w.Body.String())
|
||||
Expect(config).To(HaveKeyWithValue("baseURL", "base_url_test"))
|
||||
})
|
||||
|
||||
It("sets the uiLoginBackgroundURL", func() {
|
||||
conf.Server.UILoginBackgroundURL = "my_background_url"
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ServeIndex(ds, fs)(w, r)
|
||||
|
||||
config := extractAppConfig(w.Body.String())
|
||||
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", "my_background_url"))
|
||||
})
|
||||
|
||||
It("sets the version", func() {
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ServeIndex(ds, fs)(w, r)
|
||||
|
||||
config := extractAppConfig(w.Body.String())
|
||||
Expect(config).To(HaveKeyWithValue("version", consts.Version()))
|
||||
})
|
||||
})
|
||||
|
||||
var appConfigRegex = regexp.MustCompile(`(?m)window.__APP_CONFIG__="([^"]*)`)
|
||||
|
||||
func extractAppConfig(body string) map[string]interface{} {
|
||||
config := make(map[string]interface{})
|
||||
match := appConfigRegex.FindStringSubmatch(body)
|
||||
if match == nil {
|
||||
return config
|
||||
}
|
||||
str, err := strconv.Unquote("\"" + match[1] + "\"")
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("%s: %s", match[1], err))
|
||||
}
|
||||
if err := json.Unmarshal([]byte(str), &config); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
type mockedUserRepo struct {
|
||||
model.UserRepository
|
||||
empty bool
|
||||
}
|
||||
|
||||
func (u *mockedUserRepo) CountAll(...model.QueryOptions) (int64, error) {
|
||||
if u.empty {
|
||||
return 0, nil
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
@@ -3,10 +3,12 @@ package server
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/scanner"
|
||||
@@ -15,6 +17,11 @@ import (
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
type Handler interface {
|
||||
http.Handler
|
||||
Setup(path string)
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Scanner *scanner.Scanner
|
||||
router *chi.Mux
|
||||
@@ -29,11 +36,13 @@ func New(scanner *scanner.Scanner, ds model.DataStore) *Server {
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *Server) MountRouter(path string, subRouter http.Handler) {
|
||||
log.Info("Mounting routes", "path", path)
|
||||
func (a *Server) MountRouter(urlPath string, subRouter Handler) {
|
||||
urlPath = path.Join(conf.Server.BaseURL, urlPath)
|
||||
log.Info("Mounting routes", "path", urlPath)
|
||||
subRouter.Setup(urlPath)
|
||||
a.router.Group(func(r chi.Router) {
|
||||
r.Use(RequestLogger)
|
||||
r.Mount(path, subRouter)
|
||||
r.Mount(urlPath, subRouter)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -45,7 +54,7 @@ func (a *Server) Run(addr string) {
|
||||
func (a *Server) initRoutes() {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(cors.Default().Handler)
|
||||
r.Use(cors.AllowAll().Handler)
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Recoverer)
|
||||
@@ -53,8 +62,9 @@ func (a *Server) initRoutes() {
|
||||
r.Use(middleware.Heartbeat("/ping"))
|
||||
r.Use(InjectLogger)
|
||||
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/app", 302)
|
||||
indexHtml := path.Join(conf.Server.BaseURL, consts.URLPathUI, "index.html")
|
||||
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, indexHtml, 302)
|
||||
})
|
||||
|
||||
workDir, _ := os.Getwd()
|
||||
|
||||
@@ -42,6 +42,8 @@ func New(browser engine.Browser, cover engine.Cover, listGenerator engine.ListGe
|
||||
return r
|
||||
}
|
||||
|
||||
func (api *Router) Setup(path string) {}
|
||||
|
||||
func (api *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
api.mux.ServeHTTP(w, r)
|
||||
}
|
||||
@@ -126,6 +128,9 @@ func (api *Router) routes() http.Handler {
|
||||
// Deprecated/Out of scope endpoints
|
||||
HGone(r, "getChatMessages")
|
||||
HGone(r, "addChatMessage")
|
||||
HGone(r, "getVideos")
|
||||
HGone(r, "getVideoInfo")
|
||||
HGone(r, "getCaptions")
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -188,9 +193,9 @@ func SendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub
|
||||
}
|
||||
if payload.Status == "ok" {
|
||||
if log.CurrentLevel() >= log.LevelTrace {
|
||||
log.Info(r.Context(), "API: Successful response", "status", "OK", "body", string(response))
|
||||
log.Debug(r.Context(), "API: Successful response", "status", "OK", "body", string(response))
|
||||
} else {
|
||||
log.Info(r.Context(), "API: Successful response", "status", "OK")
|
||||
log.Debug(r.Context(), "API: Successful response", "status", "OK")
|
||||
}
|
||||
} else {
|
||||
log.Warn(r.Context(), "API: Failed response", "error", payload.Error.Code, "message", payload.Error.Message)
|
||||
|
||||
@@ -125,19 +125,17 @@ func (c *MediaAnnotationController) Scrobble(w http.ResponseWriter, r *http.Requ
|
||||
t = time.Now()
|
||||
}
|
||||
if submission {
|
||||
mf, err := c.scrobbler.Register(r.Context(), playerId, id, t)
|
||||
_, err := c.scrobbler.Register(r.Context(), playerId, id, t)
|
||||
if err != nil {
|
||||
log.Error(r, "Error scrobbling track", "id", id, err)
|
||||
continue
|
||||
}
|
||||
log.Info(r, "Scrobbled", "id", id, "title", mf.Title, "timestamp", t)
|
||||
} else {
|
||||
mf, err := c.scrobbler.NowPlaying(r.Context(), playerId, playerName, id, username)
|
||||
_, err := c.scrobbler.NowPlaying(r.Context(), playerId, playerName, id, username)
|
||||
if err != nil {
|
||||
log.Error(r, "Error setting current song", "id", id, err)
|
||||
continue
|
||||
}
|
||||
log.Info(r, "Now Playing", "id", id, "title", mf.Title, "timestamp", t)
|
||||
}
|
||||
}
|
||||
return NewResponse(), nil
|
||||
|
||||
@@ -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("navidrone-310x310.png")
|
||||
f, err := static.AssetFile().Open("navidrome-310x310.png")
|
||||
if err != nil {
|
||||
log.Error(r, "Image not found", err)
|
||||
return nil, NewError(responses.ErrorDataNotFound, "Avatar image not found")
|
||||
|
||||
@@ -57,7 +57,7 @@ func checkRequiredParameters(next http.Handler) http.Handler {
|
||||
ctx = context.WithValue(ctx, "username", user)
|
||||
ctx = context.WithValue(ctx, "client", client)
|
||||
ctx = context.WithValue(ctx, "version", version)
|
||||
log.Info(ctx, "API: New request "+r.URL.Path, "username", user, "client", client, "version", version)
|
||||
log.Debug(ctx, "API: New request "+r.URL.Path, "username", user, "client", client, "version", version)
|
||||
|
||||
r = r.WithContext(ctx)
|
||||
next.ServeHTTP(w, r)
|
||||
@@ -112,16 +112,17 @@ func getPlayer(players engine.Players) func(next http.Handler) http.Handler {
|
||||
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)
|
||||
}
|
||||
|
||||
cookie := &http.Cookie{
|
||||
Name: playerIDCookieName(userName),
|
||||
Value: player.ID,
|
||||
MaxAge: cookieExpiry,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -156,6 +157,17 @@ var _ = Describe("Middlewares", func() {
|
||||
Expect(cookieStr).To(ContainSubstring(playerIDCookieName("someone")))
|
||||
})
|
||||
|
||||
It("does not add the cookie if there was an error", func() {
|
||||
ctx := context.WithValue(r.Context(), "client", "error")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
gp := getPlayer(mockedPlayers)(next)
|
||||
gp.ServeHTTP(w, r)
|
||||
|
||||
cookieStr := w.Header().Get("Set-Cookie")
|
||||
Expect(cookieStr).To(BeEmpty())
|
||||
})
|
||||
|
||||
Context("PlayerId specified in Cookies", func() {
|
||||
BeforeEach(func() {
|
||||
cookie := &http.Cookie{
|
||||
@@ -242,5 +254,8 @@ func (mp *mockPlayers) Get(ctx context.Context, playerId string) (*model.Player,
|
||||
}
|
||||
|
||||
func (mp *mockPlayers) Register(ctx context.Context, id, client, typ, ip string) (*model.Player, *model.Transcoding, error) {
|
||||
if client == "error" {
|
||||
return nil, nil, errors.New(client)
|
||||
}
|
||||
return &model.Player{ID: id}, mp.transcoding, nil
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ func (c *PlaylistsController) GetPlaylists(w http.ResponseWriter, r *http.Reques
|
||||
playlists[i].Duration = int(p.Duration)
|
||||
playlists[i].Owner = p.Owner
|
||||
playlists[i].Public = p.Public
|
||||
playlists[i].Created = p.CreatedAt
|
||||
playlists[i].Changed = p.UpdatedAt
|
||||
}
|
||||
response := NewResponse()
|
||||
response.Playlists = &responses.Playlists{Playlist: playlists}
|
||||
@@ -58,7 +60,7 @@ func (c *PlaylistsController) GetPlaylist(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response.Playlist = c.buildPlaylist(r.Context(), pinfo)
|
||||
response.Playlist = c.buildPlaylistWithSongs(r.Context(), pinfo)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -125,15 +127,24 @@ func (c *PlaylistsController) UpdatePlaylist(w http.ResponseWriter, r *http.Requ
|
||||
return NewResponse(), nil
|
||||
}
|
||||
|
||||
func (c *PlaylistsController) buildPlaylist(ctx context.Context, d *engine.PlaylistInfo) *responses.PlaylistWithSongs {
|
||||
pls := &responses.PlaylistWithSongs{}
|
||||
func (c *PlaylistsController) buildPlaylistWithSongs(ctx context.Context, d *engine.PlaylistInfo) *responses.PlaylistWithSongs {
|
||||
pls := &responses.PlaylistWithSongs{
|
||||
Playlist: *c.buildPlaylist(d),
|
||||
}
|
||||
pls.Entry = ToChildren(ctx, d.Entries)
|
||||
return pls
|
||||
}
|
||||
|
||||
func (c *PlaylistsController) buildPlaylist(d *engine.PlaylistInfo) *responses.Playlist {
|
||||
pls := &responses.Playlist{}
|
||||
pls.Id = d.Id
|
||||
pls.Name = d.Name
|
||||
pls.Comment = d.Comment
|
||||
pls.SongCount = d.SongCount
|
||||
pls.Owner = d.Owner
|
||||
pls.Duration = d.Duration
|
||||
pls.Public = d.Public
|
||||
|
||||
pls.Entry = ToChildren(ctx, d.Entries)
|
||||
pls.Created = d.Created
|
||||
pls.Changed = d.Changed
|
||||
return pls
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","playlists":{"playlist":[{"id":"111","name":"aaa"},{"id":"222","name":"bbb"}]}}
|
||||
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","playlists":{"playlist":[{"id":"111","name":"aaa","comment":"comment","songCount":2,"duration":120,"public":true,"owner":"admin","created":"0001-01-01T00:00:00Z","changed":"0001-01-01T00:00:00Z"},{"id":"222","name":"bbb","songCount":0,"duration":0,"created":"0001-01-01T00:00:00Z","changed":"0001-01-01T00:00:00Z"}]}}
|
||||
|
||||
@@ -1 +1 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><playlists><playlist id="111" name="aaa"></playlist><playlist id="222" name="bbb"></playlist></playlists></subsonic-response>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><playlists><playlist id="111" name="aaa" comment="comment" songCount="2" duration="120" public="true" owner="admin" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist><playlist id="222" name="bbb" songCount="0" duration="0" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist></playlists></subsonic-response>
|
||||
|
||||
@@ -188,22 +188,20 @@ type AlbumList struct {
|
||||
}
|
||||
|
||||
type Playlist struct {
|
||||
Id string `xml:"id,attr" json:"id"`
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"`
|
||||
SongCount int `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
|
||||
Duration int `xml:"duration,attr,omitempty" json:"duration,omitempty"`
|
||||
Public bool `xml:"public,attr,omitempty" json:"public,omitempty"`
|
||||
Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"`
|
||||
Id string `xml:"id,attr" json:"id"`
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"`
|
||||
SongCount int `xml:"songCount,attr" json:"songCount"`
|
||||
Duration int `xml:"duration,attr" json:"duration"`
|
||||
Public bool `xml:"public,attr,omitempty" json:"public,omitempty"`
|
||||
Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"`
|
||||
Created time.Time `xml:"created,attr" json:"created"`
|
||||
Changed time.Time `xml:"changed,attr" json:"changed"`
|
||||
/*
|
||||
<xs:sequence>
|
||||
<xs:element name="allowedUser" type="xs:string" minOccurs="0" maxOccurs="unbounded"/> <!--Added in 1.8.0-->
|
||||
</xs:sequence>
|
||||
<xs:attribute name="comment" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
|
||||
<xs:attribute name="created" type="xs:dateTime" use="required"/> <!--Added in 1.8.0-->
|
||||
<xs:attribute name="changed" type="xs:dateTime" use="required"/> <!--Added in 1.13.0-->
|
||||
<xs:attribute name="coverArt" type="xs:string" use="optional"/> <!--Added in 1.11.0-->
|
||||
|
||||
*/
|
||||
}
|
||||
|
||||
|
||||
@@ -235,9 +235,20 @@ var _ = Describe("Responses", func() {
|
||||
})
|
||||
|
||||
Context("with data", func() {
|
||||
timestamp, _ := time.Parse(time.RFC3339, "2020-04-11T16:43:00Z04:00")
|
||||
BeforeEach(func() {
|
||||
pls := make([]Playlist, 2)
|
||||
pls[0] = Playlist{Id: "111", Name: "aaa"}
|
||||
pls[0] = Playlist{
|
||||
Id: "111",
|
||||
Name: "aaa",
|
||||
Comment: "comment",
|
||||
SongCount: 2,
|
||||
Duration: 120,
|
||||
Public: true,
|
||||
Owner: "admin",
|
||||
Created: timestamp,
|
||||
Changed: timestamp,
|
||||
}
|
||||
pls[1] = Playlist{Id: "222", Name: "bbb"}
|
||||
response.Playlists.Playlist = pls
|
||||
})
|
||||
|
||||
@@ -26,6 +26,7 @@ func (c *StreamController) Stream(w http.ResponseWriter, r *http.Request) (*resp
|
||||
}
|
||||
maxBitRate := utils.ParamInt(r, "maxBitRate", 0)
|
||||
format := utils.ParamString(r, "format")
|
||||
estimateContentLength := utils.ParamBool(r, "estimateContentLength", false)
|
||||
|
||||
stream, err := c.streamer.NewStream(r.Context(), id, format, maxBitRate)
|
||||
if err != nil {
|
||||
@@ -46,6 +47,14 @@ func (c *StreamController) Stream(w http.ResponseWriter, r *http.Request) (*resp
|
||||
// If the stream doesn't provide a size (i.e. is not seekable), we can't support ranges/content-length
|
||||
w.Header().Set("Accept-Ranges", "none")
|
||||
w.Header().Set("Content-Type", stream.ContentType())
|
||||
|
||||
// if Client requests the estimated content-length, send it
|
||||
if estimateContentLength {
|
||||
length := strconv.Itoa(stream.EstimatedContentLength())
|
||||
log.Trace(r.Context(), "Estimated content-length", "contentLength", length)
|
||||
w.Header().Set("Content-Length", length)
|
||||
}
|
||||
|
||||
if c, err := io.Copy(w, stream); err != nil {
|
||||
log.Error(r.Context(), "Error sending transcoded file", "id", id, err)
|
||||
} else {
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
16
tests/fixtures/index.html
vendored
Normal file
16
tests/fixtures/index.html
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta
|
||||
name="description"
|
||||
content="Navidrome Music Server - {{.Version}}"
|
||||
/>
|
||||
<title>Navidrome</title>
|
||||
<script>
|
||||
window.__APP_CONFIG__="{{.AppConfig}}"
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
||||
359
ui/package-lock.json
generated
359
ui/package-lock.json
generated
@@ -1651,9 +1651,9 @@
|
||||
}
|
||||
},
|
||||
"@material-ui/core": {
|
||||
"version": "4.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.9.7.tgz",
|
||||
"integrity": "sha512-RTRibZgq572GHEskMAG4sP+bt3P3XyIkv3pOTR8grZAW2rSUd6JoGZLRM4S2HkuO7wS7cAU5SpU2s1EsmTgWog==",
|
||||
"version": "4.9.8",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.9.8.tgz",
|
||||
"integrity": "sha512-4cslpG6oLoPWUfwPkX+hvbak4hAGiOfgXOu/UIYeeMrtsTEebC0Mirjoby7zhS4ny86YI3rXEFW6EZDmlj5n5w==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.4.4",
|
||||
"@material-ui/styles": "^4.9.6",
|
||||
@@ -1944,20 +1944,34 @@
|
||||
}
|
||||
},
|
||||
"@testing-library/jest-dom": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.1.1.tgz",
|
||||
"integrity": "sha512-7xnmBFcUmmUVAUhFiZ/u3CxFh1e46THAwra4SiiKNCW4By26RedCRwEk0rtleFPZG0wlTSNOKDvJjWYy93dp0w==",
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.3.0.tgz",
|
||||
"integrity": "sha512-Cdhpc3BHL888X55qBNyra9eM0UG63LCm/FqCWTa1Ou/0MpsUbQTM9vW1NU6/jBQFoSLgkFfDG5XVpm2V0dOm/A==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.8.3",
|
||||
"@types/testing-library__jest-dom": "^5.0.0",
|
||||
"@babel/runtime": "^7.9.2",
|
||||
"@types/testing-library__jest-dom": "^5.0.2",
|
||||
"chalk": "^3.0.0",
|
||||
"css": "^2.2.4",
|
||||
"css.escape": "^1.5.1",
|
||||
"jest-diff": "^25.1.0",
|
||||
"jest-matcher-utils": "^25.1.0",
|
||||
"lodash": "^4.17.15",
|
||||
"pretty-format": "^25.1.0",
|
||||
"redent": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": {
|
||||
"version": "7.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.2.tgz",
|
||||
"integrity": "sha512-NE2DtOdufG7R5vnfQUTehdTfNycfUANEtCa9PssN9O/xmTzP4E08UI797ixaei6hBEVL9BI/PsdJS5x7mWoB9Q==",
|
||||
"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/react": {
|
||||
@@ -1986,9 +2000,9 @@
|
||||
}
|
||||
},
|
||||
"@testing-library/user-event": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-10.0.0.tgz",
|
||||
"integrity": "sha512-ygQ1SaX3AzWDGPer5e2LF7FvWwLPG+XYViHvpW4ObseOkqmJI2ruawp9iLmEwxQW88jNCCExvonh0jBAwwiYZw=="
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-10.0.1.tgz",
|
||||
"integrity": "sha512-M63ftowo1QpAGMnWyz7df0ygqnu4XyF68Sty7mivMAz2HLcY1uLoN3qcen6WMobdY0MoZUi4+BLsziSDAP62Vg=="
|
||||
},
|
||||
"@types/babel__core": {
|
||||
"version": "7.1.6",
|
||||
@@ -2075,12 +2089,25 @@
|
||||
}
|
||||
},
|
||||
"@types/jest": {
|
||||
"version": "25.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-25.1.3.tgz",
|
||||
"integrity": "sha512-jqargqzyJWgWAJCXX96LBGR/Ei7wQcZBvRv0PLEu9ZByMfcs23keUJrKv9FMR6YZf9YCbfqDqgmY+JUBsnqhrg==",
|
||||
"version": "25.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-25.1.5.tgz",
|
||||
"integrity": "sha512-FBmb9YZHoEOH56Xo/PIYtfuyTL0IzJLM3Hy0Sqc82nn5eqqXgefKcl/eMgChM8eSGVfoDee8cdlj7K74T8a6Yg==",
|
||||
"requires": {
|
||||
"jest-diff": "^25.1.0",
|
||||
"pretty-format": "^25.1.0"
|
||||
"jest-diff": "25.1.0",
|
||||
"pretty-format": "25.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"jest-diff": {
|
||||
"version": "25.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-25.1.0.tgz",
|
||||
"integrity": "sha512-nepXgajT+h017APJTreSieh4zCqnSHEJ1iT8HDlewu630lSJ4Kjjr9KNzm+kzGwwcpsDE6Snx1GJGzzsefaEHw==",
|
||||
"requires": {
|
||||
"chalk": "^3.0.0",
|
||||
"diff-sequences": "^25.1.0",
|
||||
"jest-get-type": "^25.1.0",
|
||||
"pretty-format": "^25.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@types/json-schema": {
|
||||
@@ -2209,9 +2236,9 @@
|
||||
}
|
||||
},
|
||||
"@types/testing-library__jest-dom": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.0.1.tgz",
|
||||
"integrity": "sha512-GiPXQBVF9O4DG9cssD2d266vozBJvC5Tnv6aeH5ujgYJgys1DYm9AFCz7YC+STR5ksGxq3zCt+yP8T1wbk2DFg==",
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.0.2.tgz",
|
||||
"integrity": "sha512-dZP+/WHndgCSmdaImITy0KhjGAa9c0hlGGkzefbtrPFpnGEPZECDA0zyvfSp8RKhHECJJSKHFExjOwzo0rHyIA==",
|
||||
"requires": {
|
||||
"@types/jest": "*"
|
||||
}
|
||||
@@ -4909,9 +4936,9 @@
|
||||
"integrity": "sha1-/CqIe1pbwKCoVPthTHwvIJBh7gQ="
|
||||
},
|
||||
"diff-sequences": {
|
||||
"version": "25.1.0",
|
||||
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.1.0.tgz",
|
||||
"integrity": "sha512-nFIfVk5B/NStCsJ+zaPO4vYuLjlzQ6uFvPxzYyHlejNZ/UGa7G/n7peOXVrVNvRuyfstt+mZQYGpjxg9Z6N8Kw=="
|
||||
"version": "25.2.6",
|
||||
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.2.6.tgz",
|
||||
"integrity": "sha512-Hq8o7+6GaZeoFjtpgvRBUknSXNeJiCx7V9Fr94ZMljNiCr9n9L8H8aJqgWOQiDDGdyn29fRNcDdRVJ5fdyihfg=="
|
||||
},
|
||||
"diffie-hellman": {
|
||||
"version": "5.0.3",
|
||||
@@ -4968,9 +4995,9 @@
|
||||
"integrity": "sha512-PzwHEmsRP3IGY4gv/Ug+rMeaTIyTJvadCb+ujYXYeIylbHJezIyNToe8KfEgHTCEYyC+/bUghYOGg8yMGlZ6vA=="
|
||||
},
|
||||
"dom-align": {
|
||||
"version": "1.10.4",
|
||||
"resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.10.4.tgz",
|
||||
"integrity": "sha512-wytDzaru67AmqFOY4B9GUb/hrwWagezoYYK97D/vpK+ezg+cnuZO0Q2gltUPa7KfNmIqfRIYVCF8UhRDEHAmgQ=="
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.11.1.tgz",
|
||||
"integrity": "sha512-hN42DmUgtweBx0iBjDLO4WtKOMcK8yBmPx/fgdsgQadLuzPu/8co3oLdK5yMmeM/vnUd3yDyV6qV8/NzxBexQg=="
|
||||
},
|
||||
"dom-converter": {
|
||||
"version": "0.2.0",
|
||||
@@ -4981,12 +5008,27 @@
|
||||
}
|
||||
},
|
||||
"dom-helpers": {
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.1.3.tgz",
|
||||
"integrity": "sha512-nZD1OtwfWGRBWlpANxacBEZrEuLa16o1nh7YopFWeoF68Zt8GGEmzHu6Xv4F3XaFIC+YXtTLrzgqKxFgLEe4jw==",
|
||||
"version": "5.1.4",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.1.4.tgz",
|
||||
"integrity": "sha512-TjMyeVUvNEnOnhzs6uAn9Ya47GmMo3qq7m+Lr/3ON0Rs5kHvb8I+SQYjLUSYn7qhEm0QjW0yrBkvz9yOrwwz1A==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.6.3",
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^2.6.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": {
|
||||
"version": "7.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.2.tgz",
|
||||
"integrity": "sha512-NE2DtOdufG7R5vnfQUTehdTfNycfUANEtCa9PssN9O/xmTzP4E08UI797ixaei6hBEVL9BI/PsdJS5x7mWoB9Q==",
|
||||
"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=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"dom-serializer": {
|
||||
@@ -6412,9 +6454,9 @@
|
||||
}
|
||||
},
|
||||
"final-form": {
|
||||
"version": "4.18.7",
|
||||
"resolved": "https://registry.npmjs.org/final-form/-/final-form-4.18.7.tgz",
|
||||
"integrity": "sha512-XdlYYGDcoUcKKVzRJxLg8N/ZG3wVLZvhO7K7PKQWVMjCiIUWdmtBwApw2NFS4P7RJvg8OdF73qGXhhE3K5PuDQ==",
|
||||
"version": "4.19.1",
|
||||
"resolved": "https://registry.npmjs.org/final-form/-/final-form-4.19.1.tgz",
|
||||
"integrity": "sha512-C4RldRCUs8YZod91ydtrsT+TOeG3fwU4ip9oBDXhvbWdQ6iXl4cIrTAQkqpWijbnI3XFVA0akV7YTjSFJMJ2uw==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.8.3"
|
||||
}
|
||||
@@ -8076,14 +8118,38 @@
|
||||
}
|
||||
},
|
||||
"jest-diff": {
|
||||
"version": "25.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-25.1.0.tgz",
|
||||
"integrity": "sha512-nepXgajT+h017APJTreSieh4zCqnSHEJ1iT8HDlewu630lSJ4Kjjr9KNzm+kzGwwcpsDE6Snx1GJGzzsefaEHw==",
|
||||
"version": "25.2.6",
|
||||
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-25.2.6.tgz",
|
||||
"integrity": "sha512-KuadXImtRghTFga+/adnNrv9s61HudRMR7gVSbP35UKZdn4IK2/0N0PpGZIqtmllK9aUyye54I3nu28OYSnqOg==",
|
||||
"requires": {
|
||||
"chalk": "^3.0.0",
|
||||
"diff-sequences": "^25.1.0",
|
||||
"jest-get-type": "^25.1.0",
|
||||
"pretty-format": "^25.1.0"
|
||||
"diff-sequences": "^25.2.6",
|
||||
"jest-get-type": "^25.2.6",
|
||||
"pretty-format": "^25.2.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jest/types": {
|
||||
"version": "25.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@jest/types/-/types-25.2.6.tgz",
|
||||
"integrity": "sha512-myJTTV37bxK7+3NgKc4Y/DlQ5q92/NOwZsZ+Uch7OXdElxOg61QYc72fPYNAjlvbnJ2YvbXLamIsa9tj48BmyQ==",
|
||||
"requires": {
|
||||
"@types/istanbul-lib-coverage": "^2.0.0",
|
||||
"@types/istanbul-reports": "^1.1.1",
|
||||
"@types/yargs": "^15.0.0",
|
||||
"chalk": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"pretty-format": {
|
||||
"version": "25.2.6",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-25.2.6.tgz",
|
||||
"integrity": "sha512-DEiWxLBaCHneffrIT4B+TpMvkV9RNvvJrd3lY9ew1CEQobDzEXmYT1mg0hJhljZty7kCc10z13ohOFAE8jrUDg==",
|
||||
"requires": {
|
||||
"@jest/types": "^25.2.6",
|
||||
"ansi-regex": "^5.0.0",
|
||||
"ansi-styles": "^4.0.0",
|
||||
"react-is": "^16.12.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"jest-docblock": {
|
||||
@@ -8351,9 +8417,9 @@
|
||||
}
|
||||
},
|
||||
"jest-get-type": {
|
||||
"version": "25.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-25.1.0.tgz",
|
||||
"integrity": "sha512-yWkBnT+5tMr8ANB6V+OjmrIJufHtCAqI5ic2H40v+tRqxDmE0PGnIiTyvRWFOMtmVHYpwRqyazDbTnhpjsGvLw=="
|
||||
"version": "25.2.6",
|
||||
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-25.2.6.tgz",
|
||||
"integrity": "sha512-DxjtyzOHjObRM+sM1knti6or+eOgcGU4xVSb2HNP1TqO4ahsT+rqZg+nyqHWJSvWgKC5cG3QjGFBqxLghiF/Ig=="
|
||||
},
|
||||
"jest-haste-map": {
|
||||
"version": "24.9.0",
|
||||
@@ -9085,14 +9151,38 @@
|
||||
}
|
||||
},
|
||||
"jest-matcher-utils": {
|
||||
"version": "25.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-25.1.0.tgz",
|
||||
"integrity": "sha512-KGOAFcSFbclXIFE7bS4C53iYobKI20ZWleAdAFun4W1Wz1Kkej8Ng6RRbhL8leaEvIOjGXhGf/a1JjO8bkxIWQ==",
|
||||
"version": "25.2.6",
|
||||
"resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-25.2.6.tgz",
|
||||
"integrity": "sha512-+6IbC98ZBw3X7hsfUvt+7VIYBdI0FEvhSBjWo9XTHOc1KAAHDsrSHdeyHH/Su0r/pf4OEGuWRRLPnjkhS2S19A==",
|
||||
"requires": {
|
||||
"chalk": "^3.0.0",
|
||||
"jest-diff": "^25.1.0",
|
||||
"jest-get-type": "^25.1.0",
|
||||
"pretty-format": "^25.1.0"
|
||||
"jest-diff": "^25.2.6",
|
||||
"jest-get-type": "^25.2.6",
|
||||
"pretty-format": "^25.2.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jest/types": {
|
||||
"version": "25.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@jest/types/-/types-25.2.6.tgz",
|
||||
"integrity": "sha512-myJTTV37bxK7+3NgKc4Y/DlQ5q92/NOwZsZ+Uch7OXdElxOg61QYc72fPYNAjlvbnJ2YvbXLamIsa9tj48BmyQ==",
|
||||
"requires": {
|
||||
"@types/istanbul-lib-coverage": "^2.0.0",
|
||||
"@types/istanbul-reports": "^1.1.1",
|
||||
"@types/yargs": "^15.0.0",
|
||||
"chalk": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"pretty-format": {
|
||||
"version": "25.2.6",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-25.2.6.tgz",
|
||||
"integrity": "sha512-DEiWxLBaCHneffrIT4B+TpMvkV9RNvvJrd3lY9ew1CEQobDzEXmYT1mg0hJhljZty7kCc10z13ohOFAE8jrUDg==",
|
||||
"requires": {
|
||||
"@jest/types": "^25.2.6",
|
||||
"ansi-regex": "^5.0.0",
|
||||
"ansi-styles": "^4.0.0",
|
||||
"react-is": "^16.12.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"jest-message-util": {
|
||||
@@ -10436,6 +10526,11 @@
|
||||
"lodash._reinterpolate": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"lodash.throttle": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
|
||||
"integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ="
|
||||
},
|
||||
"lodash.uniq": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
|
||||
@@ -12915,9 +13010,9 @@
|
||||
"integrity": "sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA=="
|
||||
},
|
||||
"ra-core": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ra-core/-/ra-core-3.3.1.tgz",
|
||||
"integrity": "sha512-tAUSVqh3cZmyIhipa1pS2voK4E5G+7c8WTLR3cxhTR+6qzw3miVmPChk2F0Xh5wmbHJPZy2nZVoUIB16A4vVug==",
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/ra-core/-/ra-core-3.3.3.tgz",
|
||||
"integrity": "sha512-SwbKf/qnYfCSTrbjnRo0w6PM3cHcyA6iKNElSqf0OlV6FeXxVrTjuxE5lAbjRaxBKZBE62h7LtBj48z2TjYr/g==",
|
||||
"requires": {
|
||||
"@testing-library/react": "^8.0.7",
|
||||
"classnames": "~2.2.5",
|
||||
@@ -13010,21 +13105,21 @@
|
||||
}
|
||||
},
|
||||
"ra-data-json-server": {
|
||||
"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==",
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/ra-data-json-server/-/ra-data-json-server-3.3.3.tgz",
|
||||
"integrity": "sha512-iOUbrU5bhOa3iEldyRFgk2HarX0h9qgzts7F/zA2UWYKKhpSBVHVI9X3VvYU+lhIJXll1+OjqpEJft5cXQnLRg==",
|
||||
"requires": {
|
||||
"query-string": "^5.1.1",
|
||||
"ra-core": "^3.3.1"
|
||||
"ra-core": "^3.3.3"
|
||||
}
|
||||
},
|
||||
"ra-i18n-polyglot": {
|
||||
"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==",
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/ra-i18n-polyglot/-/ra-i18n-polyglot-3.3.3.tgz",
|
||||
"integrity": "sha512-dV00IZ5/gLLhTAbcmKeb4F5BsDE1anQMYRR1y6DeZobW4uMDjIX23HPUPGUGi4Cj6Na3M+j+lXqKsjpuQu6ZVg==",
|
||||
"requires": {
|
||||
"node-polyglot": "^2.2.2",
|
||||
"ra-core": "^3.3.1"
|
||||
"ra-core": "^3.3.3"
|
||||
}
|
||||
},
|
||||
"ra-language-english": {
|
||||
@@ -13033,9 +13128,9 @@
|
||||
"integrity": "sha512-/XmwYWoQoB4MBkkzBCbg/ykCuRGjHQOHLk2ik6n1aM10AWHxiiJNyRw2aoLzH7Vc5rcp4BBJQCuhT+DgfYIJ2Q=="
|
||||
},
|
||||
"ra-ui-materialui": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ra-ui-materialui/-/ra-ui-materialui-3.3.1.tgz",
|
||||
"integrity": "sha512-MHVTP6XG5ylwOH21MUQFl17+L1/Qe7335FhFscuhy6kEX7U3UQKaAQu9xD3ij30P6gAEJSb8EI02TR2FvaEWVg==",
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/ra-ui-materialui/-/ra-ui-materialui-3.3.3.tgz",
|
||||
"integrity": "sha512-qtJH16NQl+ebyNIyrCtYNHiR2IwyZx9XSyRILoJgPdPITiAr+j/cuz7DB6o1D5HQUl5/VOSu4IQIM3jlXjrYFQ==",
|
||||
"requires": {
|
||||
"autosuggest-highlight": "^3.1.1",
|
||||
"classnames": "~2.2.5",
|
||||
@@ -13122,14 +13217,14 @@
|
||||
}
|
||||
},
|
||||
"rc-align": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/rc-align/-/rc-align-2.4.5.tgz",
|
||||
"integrity": "sha512-nv9wYUYdfyfK+qskThf4BQUSIadeI/dCsfaMZfNEoxm9HwOIioQ+LyqmMK6jWHAZQgOzMLaqawhuBXlF63vgjw==",
|
||||
"version": "3.0.0-rc.1",
|
||||
"resolved": "https://registry.npmjs.org/rc-align/-/rc-align-3.0.0-rc.1.tgz",
|
||||
"integrity": "sha512-GbofumhCUb7SxP410j/fbtR2M9Zml+eoZSdaliZh6R3NhfEj5zP4jcO3HG3S9C9KIcXQQtd/cwVHkb9Y0KU7Hg==",
|
||||
"requires": {
|
||||
"babel-runtime": "^6.26.0",
|
||||
"classnames": "2.x",
|
||||
"dom-align": "^1.7.0",
|
||||
"prop-types": "^15.5.8",
|
||||
"rc-util": "^4.0.4"
|
||||
"rc-util": "^4.12.0",
|
||||
"resize-observer-polyfill": "^1.5.1"
|
||||
}
|
||||
},
|
||||
"rc-animate": {
|
||||
@@ -13147,16 +13242,14 @@
|
||||
}
|
||||
},
|
||||
"rc-slider": {
|
||||
"version": "8.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-8.7.1.tgz",
|
||||
"integrity": "sha512-WMT5mRFUEcrLWwTxsyS8jYmlaMsTVCZIGENLikHsNv+tE8ThU2lCoPfi/xFNUfJFNFSBFP3MwPez9ZsJmNp13g==",
|
||||
"version": "9.2.4",
|
||||
"resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-9.2.4.tgz",
|
||||
"integrity": "sha512-wSr7vz+WtzzGqsGU2rTQ4mmLz9fkuIDMPYMYm8ygYFvxQ2Rh4uRhOWHYI0R8krNK5k1bGycckYxmQqUIvLAh3w==",
|
||||
"requires": {
|
||||
"babel-runtime": "6.x",
|
||||
"classnames": "^2.2.5",
|
||||
"prop-types": "^15.5.4",
|
||||
"rc-tooltip": "^3.7.0",
|
||||
"rc-tooltip": "^4.0.0",
|
||||
"rc-util": "^4.0.4",
|
||||
"react-lifecycles-compat": "^3.0.4",
|
||||
"shallowequal": "^1.1.0",
|
||||
"warning": "^4.0.3"
|
||||
}
|
||||
@@ -13172,36 +13265,32 @@
|
||||
}
|
||||
},
|
||||
"rc-tooltip": {
|
||||
"version": "3.7.3",
|
||||
"resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-3.7.3.tgz",
|
||||
"integrity": "sha512-dE2ibukxxkrde7wH9W8ozHKUO4aQnPZ6qBHtrTH9LoO836PjDdiaWO73fgPB05VfJs9FbZdmGPVEbXCeOP99Ww==",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-4.0.3.tgz",
|
||||
"integrity": "sha512-HNyBh9/fPdds0DXja8JQX0XTIHmZapB3lLzbdn74aNSxXG1KUkt+GK4X1aOTRY5X9mqm4uUKdeFrn7j273H8gw==",
|
||||
"requires": {
|
||||
"babel-runtime": "6.x",
|
||||
"prop-types": "^15.5.8",
|
||||
"rc-trigger": "^2.2.2"
|
||||
"rc-trigger": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"rc-trigger": {
|
||||
"version": "2.6.5",
|
||||
"resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-2.6.5.tgz",
|
||||
"integrity": "sha512-m6Cts9hLeZWsTvWnuMm7oElhf+03GOjOLfTuU0QmdB9ZrW7jR2IpI5rpNM7i9MvAAlMAmTx5Zr7g3uu/aMvZAw==",
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-4.0.2.tgz",
|
||||
"integrity": "sha512-to5S1NhK10rWHIgQpoQdwIhuDc2Ok4R4/dh5NLrDt6C+gqkohsdBCYiPk97Z+NwGhRU8N+dbf251bivX8DkzQg==",
|
||||
"requires": {
|
||||
"babel-runtime": "6.x",
|
||||
"classnames": "^2.2.6",
|
||||
"prop-types": "15.x",
|
||||
"rc-align": "^2.4.0",
|
||||
"rc-animate": "2.x",
|
||||
"rc-util": "^4.4.0",
|
||||
"react-lifecycles-compat": "^3.0.4"
|
||||
"raf": "^3.4.1",
|
||||
"rc-align": "^3.0.0-rc.0",
|
||||
"rc-animate": "^2.10.2",
|
||||
"rc-util": "^4.20.0"
|
||||
}
|
||||
},
|
||||
"rc-util": {
|
||||
"version": "4.20.0",
|
||||
"resolved": "https://registry.npmjs.org/rc-util/-/rc-util-4.20.0.tgz",
|
||||
"integrity": "sha512-rUqk4RqtDe4OfTsSk2GpbvIQNVtfmmebw4Rn7ZAA1TO1zLMLfyOF78ZyrEKqs8RDwoE3S1aXp0AX0ogLfSxXrQ==",
|
||||
"version": "4.20.3",
|
||||
"resolved": "https://registry.npmjs.org/rc-util/-/rc-util-4.20.3.tgz",
|
||||
"integrity": "sha512-NBBc9Ad5yGAVTp4jV+pD7tXQGqHxGM2onPSZFyVoJ5fuvRF+ZgzSjZ6RXLPE0pVVISRJ07h+APgLJPBcAeZQlg==",
|
||||
"requires": {
|
||||
"add-dom-event-listener": "^1.1.0",
|
||||
"babel-runtime": "6.x",
|
||||
"prop-types": "^15.5.10",
|
||||
"react-is": "^16.12.0",
|
||||
"react-lifecycles-compat": "^3.0.4",
|
||||
@@ -13219,9 +13308,9 @@
|
||||
}
|
||||
},
|
||||
"react-admin": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-admin/-/react-admin-3.3.1.tgz",
|
||||
"integrity": "sha512-4tJRVhOmzqy6XGOoLzDDAxuFqx+y+W/Y+S9jpkIKAdG0cHRZtSSKvTiakuf3yCKYf6lBffLYQUmqifBpKupOCg==",
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/react-admin/-/react-admin-3.3.3.tgz",
|
||||
"integrity": "sha512-sUiwC/jaL+0RvJFuA/8dsKB7brmno0+d+++Y52G9coBeJceEmY41gEh9Q9w/GUQb4+9VstyJj9Aoq1ns2Qnteg==",
|
||||
"requires": {
|
||||
"@material-ui/core": "^4.3.3",
|
||||
"@material-ui/icons": "^4.2.1",
|
||||
@@ -13229,10 +13318,10 @@
|
||||
"connected-react-router": "^6.5.2",
|
||||
"final-form": "^4.18.5",
|
||||
"final-form-arrays": "^3.0.1",
|
||||
"ra-core": "^3.3.1",
|
||||
"ra-i18n-polyglot": "^3.3.1",
|
||||
"ra-core": "^3.3.3",
|
||||
"ra-i18n-polyglot": "^3.3.3",
|
||||
"ra-language-english": "^3.2.0",
|
||||
"ra-ui-materialui": "^3.3.1",
|
||||
"ra-ui-materialui": "^3.3.3",
|
||||
"react-final-form": "^6.3.3",
|
||||
"react-final-form-arrays": "^3.1.1",
|
||||
"react-redux": "^7.1.0",
|
||||
@@ -13510,14 +13599,14 @@
|
||||
}
|
||||
},
|
||||
"react-dom": {
|
||||
"version": "16.13.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.13.0.tgz",
|
||||
"integrity": "sha512-y09d2c4cG220DzdlFkPTnVvGTszVvNpC73v+AaLGLHbkpy3SSgvYq8x0rNwPJ/Rk/CicTNgk0hbHNw1gMEZAXg==",
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.13.1.tgz",
|
||||
"integrity": "sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag==",
|
||||
"requires": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"prop-types": "^15.6.2",
|
||||
"scheduler": "^0.19.0"
|
||||
"scheduler": "^0.19.1"
|
||||
}
|
||||
},
|
||||
"react-drag-listview": {
|
||||
@@ -13529,18 +13618,18 @@
|
||||
}
|
||||
},
|
||||
"react-draggable": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-3.3.2.tgz",
|
||||
"integrity": "sha512-oaz8a6enjbPtx5qb0oDWxtDNuybOylvto1QLydsXgKmwT7e3GXC2eMVDwEMIUYJIFqVG72XpOv673UuuAq6LhA==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.3.1.tgz",
|
||||
"integrity": "sha512-m8QeV+eIi7LhD5mXoLqDzLbokc6Ncwa0T34fF6uJzWSs4vc4fdZI/XGqHYoEn91T8S6qO+BSXslONh7Jz9VPQQ==",
|
||||
"requires": {
|
||||
"classnames": "^2.2.5",
|
||||
"prop-types": "^15.6.0"
|
||||
}
|
||||
},
|
||||
"react-dropzone": {
|
||||
"version": "10.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-10.2.1.tgz",
|
||||
"integrity": "sha512-Me5nOu8hK9/Xyg5easpdfJ6SajwUquqYR/2YTdMotsCUgJ1pHIIwNsv0n+qcIno0tWR2V2rVQtj2r/hXYs2TnQ==",
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-10.2.2.tgz",
|
||||
"integrity": "sha512-U5EKckXVt6IrEyhMMsgmHQiWTGLudhajPPG77KFSvgsMqNEHSyGpqWvOMc5+DhEah/vH4E1n+J5weBNLd5VtyA==",
|
||||
"requires": {
|
||||
"attr-accept": "^2.0.0",
|
||||
"file-selector": "^0.1.12",
|
||||
@@ -13553,12 +13642,27 @@
|
||||
"integrity": "sha512-TAv1KJFh3RhqxNvhzxj6LeT5NWklP6rDr2a0jaTfsZ5wSZWHOGeqQyejUp3xxLfPt2UpyJEcVQB/zyPcmonNFA=="
|
||||
},
|
||||
"react-final-form": {
|
||||
"version": "6.3.5",
|
||||
"resolved": "https://registry.npmjs.org/react-final-form/-/react-final-form-6.3.5.tgz",
|
||||
"integrity": "sha512-btqEp1+n1WO4bUDopBdvUoIuoGHf91n/EOJg0QU5YjhX9CK+4RIsBI0M41lmyT3H6hWv6NELdX5n5zBJyOIXoA==",
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/react-final-form/-/react-final-form-6.4.0.tgz",
|
||||
"integrity": "sha512-M7J7f0pnoj0o8sBq3iG6jsWJEh08pNUyl2D4wBC9SJvCNkGdol2UdyjMiEFYD3rz9LIFzQqFSG0kbRBCadqzhA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.8.3",
|
||||
"ts-essentials": "^5.0.0"
|
||||
"@babel/runtime": "^7.9.2",
|
||||
"ts-essentials": "^6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": {
|
||||
"version": "7.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.2.tgz",
|
||||
"integrity": "sha512-NE2DtOdufG7R5vnfQUTehdTfNycfUANEtCa9PssN9O/xmTzP4E08UI797ixaei6hBEVL9BI/PsdJS5x7mWoB9Q==",
|
||||
"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=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"react-final-form-arrays": {
|
||||
@@ -13588,18 +13692,18 @@
|
||||
"integrity": "sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q=="
|
||||
},
|
||||
"react-jinke-music-player": {
|
||||
"version": "4.10.1",
|
||||
"resolved": "https://registry.npmjs.org/react-jinke-music-player/-/react-jinke-music-player-4.10.1.tgz",
|
||||
"integrity": "sha512-5ji5OnIOf/3vHi5AL9QpQvuVTIf4kH/PoRNdHIzGN8OKC11nEvBR7PWSTXSdYRL/A4OwfcUEMW9yZr/hTm36Og==",
|
||||
"version": "4.11.2",
|
||||
"resolved": "https://registry.npmjs.org/react-jinke-music-player/-/react-jinke-music-player-4.11.2.tgz",
|
||||
"integrity": "sha512-AVUFRdva5vByBXy1VezsQG5Cr00fY956Rhe4cSNdPbs7JaFtJ/mDddgz2h75qmC6TJTumDSh52NBWhiiywN1Tw==",
|
||||
"requires": {
|
||||
"classnames": "^2.2.6",
|
||||
"downloadjs": "^1.4.7",
|
||||
"is-mobile": "^2.1.0",
|
||||
"is-mobile": "^2.2.1",
|
||||
"prop-types": "^15.7.2",
|
||||
"rc-slider": "^8.7.1",
|
||||
"rc-slider": "^9.2.4",
|
||||
"rc-switch": "^1.9.0",
|
||||
"react-drag-listview": "^0.1.6",
|
||||
"react-draggable": "^3.3.2",
|
||||
"react-draggable": "^4.2.0",
|
||||
"react-icons": "^2.2.5"
|
||||
}
|
||||
},
|
||||
@@ -14057,6 +14161,11 @@
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-3.0.1.tgz",
|
||||
"integrity": "sha1-79qpjqdFEyTQkrKyFjpqHXqaIUc="
|
||||
},
|
||||
"resize-observer-polyfill": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
|
||||
},
|
||||
"resolve": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.15.0.tgz",
|
||||
@@ -14380,9 +14489,9 @@
|
||||
}
|
||||
},
|
||||
"scheduler": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.0.tgz",
|
||||
"integrity": "sha512-xowbVaTPe9r7y7RUejcK73/j8tt2jfiyTednOvHbA8JoClvMYCp+r8QegLwK/n8zWQAtZb1fFnER4XLBZXrCxA==",
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz",
|
||||
"integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==",
|
||||
"requires": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"object-assign": "^4.1.1"
|
||||
@@ -15719,9 +15828,9 @@
|
||||
}
|
||||
},
|
||||
"ts-essentials": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-5.0.0.tgz",
|
||||
"integrity": "sha512-ftKWOm6Jq+/UCBekDfxUjLODEd5XGN2EM/+TIQV9LJ5xSV12je4GqdRyv7pXXGGYmEt/nQa6F00xTWYJ5PMjIQ=="
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-6.0.4.tgz",
|
||||
"integrity": "sha512-ZtU9zgSnn8DcAxDZY1DJF8rnxsen8M0IVkO7dVB5fTEOVs7o/0RA4V6i99PIg99bpX81Sgb0FCLjQqD5Ufz3rQ=="
|
||||
},
|
||||
"ts-pnp": {
|
||||
"version": "1.1.6",
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^5.1.1",
|
||||
"@testing-library/jest-dom": "^5.3.0",
|
||||
"@testing-library/react": "^10.0.1",
|
||||
"@testing-library/user-event": "^10.0.0",
|
||||
"@testing-library/user-event": "^10.0.1",
|
||||
"deepmerge": "^4.2.2",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"md5-hex": "^3.0.1",
|
||||
"prop-types": "^15.7.2",
|
||||
"ra-data-json-server": "^3.3.1",
|
||||
"ra-data-json-server": "^3.3.3",
|
||||
"react": "^16.13.1",
|
||||
"react-admin": "^3.3.1",
|
||||
"react-dom": "^16.13.0",
|
||||
"react-jinke-music-player": "^4.10.1",
|
||||
"react-admin": "^3.3.3",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-jinke-music-player": "^4.11.2",
|
||||
"react-redux": "^7.2.0",
|
||||
"react-scripts": "^3.4.1"
|
||||
},
|
||||
@@ -24,7 +25,7 @@
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"homepage": "https://localhost/app/",
|
||||
"homepage": ".",
|
||||
"proxy": "http://localhost:4633/",
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
|
||||
@@ -1,43 +1,50 @@
|
||||
import React from 'react'
|
||||
import { Provider } from 'react-redux'
|
||||
import { createHashHistory } from 'history'
|
||||
import { Admin, resolveBrowserLocale, Resource } from 'react-admin'
|
||||
import dataProvider from './dataProvider'
|
||||
import authProvider from './authProvider'
|
||||
import polyglotI18nProvider from 'ra-i18n-polyglot'
|
||||
import messages from './i18n'
|
||||
import { DarkTheme, Layout, Login } from './layout'
|
||||
import { 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 './audioplayer'
|
||||
|
||||
const theme = createMuiTheme(DarkTheme)
|
||||
import { albumViewReducer } from './album/albumState'
|
||||
import customRoutes from './routes'
|
||||
import themeReducer from './personal/themeReducer'
|
||||
import createAdminStore from './store/createAdminStore'
|
||||
|
||||
const i18nProvider = polyglotI18nProvider(
|
||||
(locale) => (messages[locale] ? messages[locale] : messages.en),
|
||||
resolveBrowserLocale()
|
||||
)
|
||||
|
||||
const App = () => {
|
||||
try {
|
||||
const appConfig = JSON.parse(window.__APP_CONFIG__)
|
||||
const history = createHashHistory()
|
||||
|
||||
// This flags to the login process that it should create the first account instead
|
||||
if (appConfig.firstTime) {
|
||||
localStorage.setItem('initialAccountCreation', 'true')
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
return (
|
||||
const App = () => (
|
||||
<Provider
|
||||
store={createAdminStore({
|
||||
authProvider,
|
||||
dataProvider,
|
||||
history,
|
||||
customReducers: {
|
||||
queue: playQueueReducer,
|
||||
albumView: albumViewReducer,
|
||||
theme: themeReducer
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Admin
|
||||
theme={theme}
|
||||
customReducers={{ queue: playQueueReducer }}
|
||||
dataProvider={dataProvider}
|
||||
authProvider={authProvider}
|
||||
i18nProvider={i18nProvider}
|
||||
customRoutes={customRoutes}
|
||||
history={history}
|
||||
layout={Layout}
|
||||
loginPage={Login}
|
||||
>
|
||||
@@ -66,7 +73,7 @@ const App = () => {
|
||||
<Player />
|
||||
]}
|
||||
</Admin>
|
||||
)
|
||||
}
|
||||
</Provider>
|
||||
)
|
||||
|
||||
export default App
|
||||
|
||||
@@ -42,7 +42,6 @@ export const AlbumActions = ({
|
||||
return (
|
||||
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
|
||||
<Button
|
||||
color={'secondary'}
|
||||
onClick={() => {
|
||||
dispatch(playAlbum(ids[0], filteredData))
|
||||
}}
|
||||
@@ -51,7 +50,6 @@ export const AlbumActions = ({
|
||||
<PlayArrowIcon />
|
||||
</Button>
|
||||
<Button
|
||||
color={'secondary'}
|
||||
onClick={() => {
|
||||
const shuffled = shuffle(filteredData)
|
||||
const firstId = Object.keys(shuffled)[0]
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react'
|
||||
import { Card, CardContent, CardMedia, Typography } from '@material-ui/core'
|
||||
import { useTranslate } from 'react-admin'
|
||||
import { subsonicUrl } from '../subsonic'
|
||||
import subsonic from '../subsonic'
|
||||
import { DurationField, formatRange } from '../common'
|
||||
import { ArtistLinkField } from './ArtistLinkField'
|
||||
|
||||
const AlbumDetails = ({ classes, record }) => {
|
||||
const translate = useTranslate()
|
||||
@@ -21,9 +22,7 @@ const AlbumDetails = ({ classes, record }) => {
|
||||
return (
|
||||
<Card className={classes.container}>
|
||||
<CardMedia
|
||||
image={subsonicUrl('getCoverArt', record.coverArtId || 'not_found', {
|
||||
size: 500
|
||||
})}
|
||||
image={subsonic.url('getCoverArt', record.coverArtId || 'not_found')}
|
||||
className={classes.albumCover}
|
||||
/>
|
||||
<CardContent className={classes.albumDetails}>
|
||||
@@ -31,7 +30,7 @@ const AlbumDetails = ({ classes, record }) => {
|
||||
{record.name}
|
||||
</Typography>
|
||||
<Typography component="h6">
|
||||
{record.albumArtist || record.artist}
|
||||
<ArtistLinkField record={record} />
|
||||
</Typography>
|
||||
<Typography component="p">{genreYear(record)}</Typography>
|
||||
<Typography component="p">
|
||||
|
||||
86
ui/src/album/AlbumGridView.js
Normal file
86
ui/src/album/AlbumGridView.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import React from 'react'
|
||||
import { GridList, GridListTile, GridListTileBar } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import withWidth from '@material-ui/core/withWidth'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { linkToRecord } from 'ra-core'
|
||||
import { Loading } from 'react-admin'
|
||||
import subsonic from '../subsonic'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
margin: '20px'
|
||||
},
|
||||
gridListTile: {
|
||||
minHeight: '180px',
|
||||
minWidth: '180px'
|
||||
},
|
||||
cover: {
|
||||
display: 'inline-block',
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
},
|
||||
tileBar: {
|
||||
textAlign: 'center',
|
||||
background:
|
||||
'linear-gradient(to top, rgba(0,0,0,0.8) 0%,rgba(0,0,0,0.4) 70%,rgba(0,0,0,0) 100%)'
|
||||
},
|
||||
albumArtistName: {
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
textAlign: 'center',
|
||||
fontSize: '1em'
|
||||
}
|
||||
}))
|
||||
|
||||
const getColsForWidth = (width) => {
|
||||
if (width === 'xs') return 2
|
||||
if (width === 'sm') return 4
|
||||
if (width === 'md') return 5
|
||||
if (width === 'lg') return 6
|
||||
return 7
|
||||
}
|
||||
|
||||
const LoadedAlbumGrid = ({ ids, data, basePath, width }) => {
|
||||
const classes = useStyles()
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<GridList cellHeight={'auto'} cols={getColsForWidth(width)} spacing={20}>
|
||||
{ids.map((id) => (
|
||||
<GridListTile
|
||||
className={classes.gridListTile}
|
||||
component={Link}
|
||||
key={id}
|
||||
to={linkToRecord(basePath, data[id].id, 'show')}
|
||||
>
|
||||
<img
|
||||
src={subsonic.url(
|
||||
'getCoverArt',
|
||||
data[id].coverArtId || 'not_found',
|
||||
{ size: 300 }
|
||||
)}
|
||||
alt={data[id].album}
|
||||
className={classes.cover}
|
||||
/>
|
||||
<GridListTileBar
|
||||
className={classes.tileBar}
|
||||
title={data[id].name}
|
||||
subtitle={
|
||||
<div className={classes.albumArtistName}>
|
||||
{data[id].albumArtist}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</GridListTile>
|
||||
))}
|
||||
</GridList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const AlbumGridView = ({ loading, ...props }) =>
|
||||
loading ? <Loading /> : <LoadedAlbumGrid {...props} />
|
||||
|
||||
export default withWidth()(AlbumGridView)
|
||||
@@ -1,23 +1,21 @@
|
||||
import React from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import {
|
||||
BooleanField,
|
||||
Datagrid,
|
||||
DateField,
|
||||
AutocompleteInput,
|
||||
Filter,
|
||||
List,
|
||||
NumberField,
|
||||
FunctionField,
|
||||
SearchInput,
|
||||
NumberInput,
|
||||
NullableBooleanInput,
|
||||
Show,
|
||||
SimpleShowLayout,
|
||||
NumberInput,
|
||||
ReferenceInput,
|
||||
AutocompleteInput,
|
||||
TextField
|
||||
SearchInput,
|
||||
Pagination
|
||||
} from 'react-admin'
|
||||
import { DurationField, Pagination, Title, RangeField } from '../common'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { Title } from '../common'
|
||||
import { withWidth } from '@material-ui/core'
|
||||
import AlbumListActions from './AlbumListActions'
|
||||
import AlbumListView from './AlbumListView'
|
||||
import AlbumGridView from './AlbumGridView'
|
||||
import { ALBUM_MODE_LIST } from './albumState'
|
||||
|
||||
const AlbumFilter = (props) => (
|
||||
<Filter {...props}>
|
||||
@@ -35,43 +33,51 @@ const AlbumFilter = (props) => (
|
||||
</Filter>
|
||||
)
|
||||
|
||||
const AlbumDetails = (props) => {
|
||||
return (
|
||||
<Show {...props} title=" ">
|
||||
<SimpleShowLayout>
|
||||
<TextField source="albumArtist" />
|
||||
<TextField source="genre" />
|
||||
<BooleanField source="compilation" />
|
||||
<DateField source="updatedAt" showTime />
|
||||
</SimpleShowLayout>
|
||||
</Show>
|
||||
)
|
||||
const getPerPage = (width) => {
|
||||
if (width === 'xs') return 12
|
||||
if (width === 'sm') return 12
|
||||
if (width === 'md') return 15
|
||||
if (width === 'lg') return 18
|
||||
return 21
|
||||
}
|
||||
|
||||
const getPerPageOptions = (width) => {
|
||||
const options = [3, 6, 12]
|
||||
if (width === 'xs') return [12]
|
||||
if (width === 'sm') return [12]
|
||||
if (width === 'md') return options.map((v) => v * 5)
|
||||
if (width === 'lg') return options.map((v) => v * 6)
|
||||
return options.map((v) => v * 7)
|
||||
}
|
||||
|
||||
const AlbumList = (props) => {
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
|
||||
const { width } = props
|
||||
const albumView = useSelector((state) => state.albumView)
|
||||
let sort
|
||||
if (albumView.mode === ALBUM_MODE_LIST) {
|
||||
sort = { field: 'name', order: 'ASC' }
|
||||
} else {
|
||||
sort = { field: 'created_at', order: 'DESC' }
|
||||
}
|
||||
return (
|
||||
<List
|
||||
{...props}
|
||||
title={<Title subTitle={'Albums'} />}
|
||||
sort={{ field: 'name', order: 'ASC' }}
|
||||
exporter={false}
|
||||
bulkActionButtons={false}
|
||||
actions={<AlbumListActions />}
|
||||
sort={sort}
|
||||
filters={<AlbumFilter />}
|
||||
perPage={15}
|
||||
pagination={<Pagination />}
|
||||
perPage={getPerPage(width)}
|
||||
pagination={<Pagination rowsPerPageOptions={getPerPageOptions(width)} />}
|
||||
>
|
||||
<Datagrid expand={<AlbumDetails />} rowClick={'show'}>
|
||||
<TextField source="name" />
|
||||
<FunctionField
|
||||
source="artist"
|
||||
render={(r) => (r.albumArtist ? r.albumArtist : r.artist)}
|
||||
/>
|
||||
{isDesktop && <NumberField source="songCount" />}
|
||||
<RangeField source={'year'} sortBy={'maxYear'} />
|
||||
{isDesktop && <DurationField source="duration" />}
|
||||
</Datagrid>
|
||||
{albumView.mode === ALBUM_MODE_LIST ? (
|
||||
<AlbumListView {...props} />
|
||||
) : (
|
||||
<AlbumGridView {...props} />
|
||||
)}
|
||||
</List>
|
||||
)
|
||||
}
|
||||
export default AlbumList
|
||||
|
||||
export default withWidth()(AlbumList)
|
||||
|
||||
69
ui/src/album/AlbumListActions.js
Normal file
69
ui/src/album/AlbumListActions.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import React, { cloneElement } from 'react'
|
||||
import { Button, sanitizeListRestProps, TopToolbar } from 'react-admin'
|
||||
import { ButtonGroup } from '@material-ui/core'
|
||||
import ViewHeadlineIcon from '@material-ui/icons/ViewHeadline'
|
||||
import ViewModuleIcon from '@material-ui/icons/ViewModule'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { ALBUM_MODE_GRID, ALBUM_MODE_LIST, selectViewMode } from './albumState'
|
||||
|
||||
const AlbumListActions = ({
|
||||
currentSort,
|
||||
className,
|
||||
resource,
|
||||
filters,
|
||||
displayedFilters,
|
||||
filterValues,
|
||||
permanentFilter,
|
||||
exporter,
|
||||
basePath,
|
||||
selectedIds,
|
||||
onUnselectItems,
|
||||
showFilter,
|
||||
maxResults,
|
||||
total,
|
||||
fullWidth,
|
||||
...rest
|
||||
}) => {
|
||||
const dispatch = useDispatch()
|
||||
const albumView = useSelector((state) => state.albumView)
|
||||
|
||||
return (
|
||||
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
|
||||
{filters &&
|
||||
cloneElement(filters, {
|
||||
resource,
|
||||
showFilter,
|
||||
displayedFilters,
|
||||
filterValues,
|
||||
context: 'button'
|
||||
})}
|
||||
<ButtonGroup
|
||||
variant="text"
|
||||
color="primary"
|
||||
aria-label="text primary button group"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
color={albumView.mode === ALBUM_MODE_LIST ? 'primary' : 'secondary'}
|
||||
onClick={() => dispatch(selectViewMode(ALBUM_MODE_LIST))}
|
||||
>
|
||||
<ViewHeadlineIcon fontSize="inherit" />
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color={albumView.mode === ALBUM_MODE_GRID ? 'primary' : 'secondary'}
|
||||
onClick={() => dispatch(selectViewMode(ALBUM_MODE_GRID))}
|
||||
>
|
||||
<ViewModuleIcon fontSize="inherit" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</TopToolbar>
|
||||
)
|
||||
}
|
||||
|
||||
AlbumListActions.defaultProps = {
|
||||
selectedIds: [],
|
||||
onUnselectItems: () => null
|
||||
}
|
||||
|
||||
export default AlbumListActions
|
||||
44
ui/src/album/AlbumListView.js
Normal file
44
ui/src/album/AlbumListView.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
BooleanField,
|
||||
Datagrid,
|
||||
DateField,
|
||||
NumberField,
|
||||
FunctionField,
|
||||
Show,
|
||||
SimpleShowLayout,
|
||||
TextField
|
||||
} from 'react-admin'
|
||||
import { DurationField, RangeField } from '../common'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
|
||||
const AlbumDetails = (props) => {
|
||||
return (
|
||||
<Show {...props} title=" ">
|
||||
<SimpleShowLayout>
|
||||
<TextField source="albumArtist" />
|
||||
<TextField source="genre" />
|
||||
<BooleanField source="compilation" />
|
||||
<DateField source="updatedAt" showTime />
|
||||
</SimpleShowLayout>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const AlbumListView = ({ hasShow, hasEdit, hasList, ...rest }) => {
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
|
||||
return (
|
||||
<Datagrid expand={<AlbumDetails />} rowClick={'show'} {...rest}>
|
||||
<TextField source="name" />
|
||||
<FunctionField
|
||||
source="artist"
|
||||
render={(r) => (r.albumArtist ? r.albumArtist : r.artist)}
|
||||
/>
|
||||
{isDesktop && <NumberField source="songCount" />}
|
||||
{isDesktop && <NumberField source="playCount" />}
|
||||
<RangeField source={'year'} sortBy={'maxYear'} />
|
||||
{isDesktop && <DurationField source="duration" />}
|
||||
</Datagrid>
|
||||
)
|
||||
}
|
||||
export default AlbumListView
|
||||
@@ -1,74 +1,40 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Datagrid,
|
||||
FunctionField,
|
||||
List,
|
||||
Loading,
|
||||
TextField,
|
||||
useGetOne
|
||||
} from 'react-admin'
|
||||
import { useGetOne } from 'react-admin'
|
||||
import AlbumDetails from './AlbumDetails'
|
||||
import { DurationField, Title } from '../common'
|
||||
import { Title } from '../common'
|
||||
import { useStyles } from './styles'
|
||||
import { AlbumActions } from './AlbumActions'
|
||||
import { AlbumSongBulkActions } from './AlbumSongBulkActions'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { setTrack } from '../audioplayer'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import AlbumSongs from './AlbumSongs'
|
||||
|
||||
const AlbumShow = (props) => {
|
||||
const dispatch = useDispatch()
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
|
||||
const classes = useStyles()
|
||||
const { data: record, loading, error } = useGetOne('album', props.id)
|
||||
|
||||
if (loading) {
|
||||
return <Loading />
|
||||
return null
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <p>ERROR: {error}</p>
|
||||
}
|
||||
|
||||
const trackName = (r) => {
|
||||
const name = r.title
|
||||
if (r.trackNumber) {
|
||||
return r.trackNumber.toString().padStart(2, '0') + ' ' + name
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlbumDetails {...props} classes={classes} record={record} />
|
||||
<List
|
||||
<AlbumSongs
|
||||
{...props}
|
||||
albumId={props.id}
|
||||
title={<Title subTitle={record.name} />}
|
||||
actions={<AlbumActions />}
|
||||
filter={{ album_id: props.id }}
|
||||
resource={'albumSong'}
|
||||
exporter={false}
|
||||
perPage={1000}
|
||||
perPage={-1}
|
||||
pagination={null}
|
||||
sort={{ field: 'discNumber asc, trackNumber asc', order: 'ASC' }}
|
||||
bulkActionButtons={<AlbumSongBulkActions />}
|
||||
>
|
||||
<Datagrid
|
||||
rowClick={(id, basePath, record) => dispatch(setTrack(record))}
|
||||
>
|
||||
{isDesktop && (
|
||||
<TextField
|
||||
source="trackNumber"
|
||||
sortBy="discNumber asc, trackNumber asc"
|
||||
label="#"
|
||||
/>
|
||||
)}
|
||||
{isDesktop && <TextField source="title" />}
|
||||
{!isDesktop && <FunctionField source="title" render={trackName} />}
|
||||
{isDesktop && <TextField source="artist" />}
|
||||
<DurationField source="duration" />
|
||||
</Datagrid>
|
||||
</List>
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
137
ui/src/album/AlbumSongs.js
Normal file
137
ui/src/album/AlbumSongs.js
Normal file
@@ -0,0 +1,137 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
BulkActionsToolbar,
|
||||
Datagrid,
|
||||
FunctionField,
|
||||
ListToolbar,
|
||||
TextField,
|
||||
useListController,
|
||||
DatagridLoading
|
||||
} from 'react-admin'
|
||||
import classnames from 'classnames'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { Card, useMediaQuery } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { setTrack } from '../audioplayer'
|
||||
import { DurationField } from '../common'
|
||||
import { SongDetails } from '../common'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
root: {},
|
||||
main: {
|
||||
display: 'flex'
|
||||
},
|
||||
content: {
|
||||
marginTop: 0,
|
||||
transition: theme.transitions.create('margin-top'),
|
||||
position: 'relative',
|
||||
flex: '1 1 auto',
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
boxShadow: 'none'
|
||||
},
|
||||
overflow: 'inherit'
|
||||
},
|
||||
bulkActionsDisplayed: {
|
||||
marginTop: -theme.spacing(8),
|
||||
transition: theme.transitions.create('margin-top')
|
||||
},
|
||||
actions: {
|
||||
zIndex: 2,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
flexWrap: 'wrap'
|
||||
},
|
||||
noResults: { padding: 20 }
|
||||
}),
|
||||
{ name: 'RaList' }
|
||||
)
|
||||
|
||||
const useStylesListToolbar = makeStyles({
|
||||
toolbar: {
|
||||
justifyContent: 'flex-start'
|
||||
}
|
||||
})
|
||||
|
||||
const trackName = (r) => {
|
||||
const name = r.title
|
||||
if (r.trackNumber) {
|
||||
return r.trackNumber.toString().padStart(2, '0') + ' ' + name
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
const AlbumSongs = (props) => {
|
||||
const classes = useStyles(props)
|
||||
const classesToolbar = useStylesListToolbar(props)
|
||||
const dispatch = useDispatch()
|
||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
|
||||
const controllerProps = useListController(props)
|
||||
const { bulkActionButtons, albumId, expand, className } = props
|
||||
const { data, ids, version } = controllerProps
|
||||
|
||||
const anySong = data[ids[0]]
|
||||
const showPlaceholder = !anySong || anySong.albumId !== albumId
|
||||
|
||||
const hasBulkActions = props.bulkActionButtons !== false
|
||||
return (
|
||||
<>
|
||||
<ListToolbar
|
||||
classes={classesToolbar}
|
||||
filters={props.filters}
|
||||
{...controllerProps}
|
||||
actions={props.actions}
|
||||
permanentFilter={props.filter}
|
||||
/>
|
||||
<div className={classes.main}>
|
||||
<Card
|
||||
className={classnames(classes.content, {
|
||||
[classes.bulkActionsDisplayed]:
|
||||
controllerProps.selectedIds.length > 0
|
||||
})}
|
||||
key={version}
|
||||
>
|
||||
{bulkActionButtons !== false && bulkActionButtons && (
|
||||
<BulkActionsToolbar {...controllerProps}>
|
||||
{bulkActionButtons}
|
||||
</BulkActionsToolbar>
|
||||
)}
|
||||
{showPlaceholder ? (
|
||||
<DatagridLoading
|
||||
classes={classes}
|
||||
className={className}
|
||||
expand={expand}
|
||||
hasBulkActions={hasBulkActions}
|
||||
nbChildren={3}
|
||||
size={'small'}
|
||||
/>
|
||||
) : (
|
||||
<Datagrid
|
||||
expand={!isXsmall && <SongDetails />}
|
||||
rowClick={(id, basePath, record) => dispatch(setTrack(record))}
|
||||
{...controllerProps}
|
||||
hasBulkActions={hasBulkActions}
|
||||
>
|
||||
{isDesktop && (
|
||||
<TextField
|
||||
source="trackNumber"
|
||||
sortBy="discNumber asc, trackNumber asc"
|
||||
label="#"
|
||||
/>
|
||||
)}
|
||||
{isDesktop && <TextField source="title" />}
|
||||
{!isDesktop && (
|
||||
<FunctionField source="title" render={trackName} />
|
||||
)}
|
||||
{isDesktop && <TextField source="artist" />}
|
||||
<DurationField source="duration" />
|
||||
</Datagrid>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AlbumSongs
|
||||
19
ui/src/album/ArtistLinkField.js
Normal file
19
ui/src/album/ArtistLinkField.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Link } from 'react-admin'
|
||||
import React from 'react'
|
||||
|
||||
export const ArtistLinkField = (props) => {
|
||||
const filter = { artist_id: props.record.albumArtistId }
|
||||
const url = `/album?filter=${JSON.stringify(
|
||||
filter
|
||||
)}&order=ASC&sort=maxYear&displayedFilters={"compilation":true}`
|
||||
return (
|
||||
<Link to={url} onClick={(e) => e.stopPropagation()}>
|
||||
{props.record.albumArtist}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
ArtistLinkField.defaultProps = {
|
||||
source: 'artistId',
|
||||
addLabel: true
|
||||
}
|
||||
58
ui/src/album/albumState.js
Normal file
58
ui/src/album/albumState.js
Normal file
@@ -0,0 +1,58 @@
|
||||
const ALBUM_MODE_GRID = 'ALBUM_GRID_MODE'
|
||||
const ALBUM_MODE_LIST = 'ALBUM_LIST_MODE'
|
||||
const selectViewMode = (mode) => ({ type: mode })
|
||||
|
||||
const ALBUM_LIST_ALL = 'ALBUM_LIST_ALL'
|
||||
const ALBUM_LIST_RANDOM = 'ALBUM_LIST_RANDOM'
|
||||
const ALBUM_LIST_NEWEST = 'ALBUM_LIST_NEWEST'
|
||||
const ALBUM_LIST_RECENT = 'ALBUM_LIST_RECENT'
|
||||
const ALBUM_LIST_STARRED = 'ALBUM_LIST_STARRED'
|
||||
|
||||
const albumListParams = {
|
||||
ALBUM_LIST_ALL: { sort: { field: 'name', order: 'ASC' } },
|
||||
ALBUM_LIST_RANDOM: { sort: { field: 'random' } },
|
||||
ALBUM_LIST_NEWEST: { sort: { field: 'created_at', order: 'DESC' } },
|
||||
ALBUM_LIST_RECENT: {
|
||||
sort: { field: 'play_date', order: 'DESC' },
|
||||
filter: { starred: true }
|
||||
}
|
||||
}
|
||||
|
||||
const selectAlbumList = (mode) => ({ type: mode })
|
||||
|
||||
const albumViewReducer = (
|
||||
previousState = {
|
||||
mode: ALBUM_MODE_LIST,
|
||||
list: ALBUM_LIST_ALL,
|
||||
params: { sort: {}, filter: {} }
|
||||
},
|
||||
payload
|
||||
) => {
|
||||
const { type } = payload
|
||||
switch (type) {
|
||||
case ALBUM_MODE_GRID:
|
||||
case ALBUM_MODE_LIST:
|
||||
return { ...previousState, mode: type }
|
||||
case ALBUM_LIST_ALL:
|
||||
case ALBUM_LIST_RANDOM:
|
||||
case ALBUM_LIST_NEWEST:
|
||||
case ALBUM_LIST_RECENT:
|
||||
case ALBUM_LIST_STARRED:
|
||||
return { ...previousState, list: type, params: albumListParams[type] }
|
||||
default:
|
||||
return previousState
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
ALBUM_MODE_LIST,
|
||||
ALBUM_MODE_GRID,
|
||||
ALBUM_LIST_ALL,
|
||||
ALBUM_LIST_RANDOM,
|
||||
ALBUM_LIST_NEWEST,
|
||||
ALBUM_LIST_RECENT,
|
||||
ALBUM_LIST_STARRED,
|
||||
albumViewReducer,
|
||||
selectViewMode,
|
||||
selectAlbumList
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
import React from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import {
|
||||
fetchUtils,
|
||||
useAuthState,
|
||||
useDataProvider,
|
||||
useTranslate
|
||||
} from 'react-admin'
|
||||
import { useAuthState, useDataProvider, useTranslate } from 'react-admin'
|
||||
import ReactJkMusicPlayer from 'react-jinke-music-player'
|
||||
import 'react-jinke-music-player/assets/index.css'
|
||||
import { scrobble, syncQueue } from './queue'
|
||||
import subsonic from '../subsonic'
|
||||
import { scrobbled, syncQueue } from './queue'
|
||||
import themes from '../themes'
|
||||
|
||||
const Player = () => {
|
||||
const translate = useTranslate()
|
||||
const currentTheme = useSelector((state) => state.theme)
|
||||
const theme = themes[currentTheme] || themes.DarkTheme
|
||||
const playerTheme = (theme.player && theme.player.theme) || 'dark'
|
||||
|
||||
const defaultOptions = {
|
||||
theme: playerTheme,
|
||||
bounds: 'body',
|
||||
mode: 'full',
|
||||
autoPlay: true,
|
||||
@@ -25,16 +26,19 @@ const Player = () => {
|
||||
showReload: false,
|
||||
glassBg: false,
|
||||
showThemeSwitch: false,
|
||||
playModeText: {
|
||||
order: translate('player.playModeText.order'),
|
||||
orderLoop: translate('player.playModeText.orderLoop'),
|
||||
singleLoop: translate('player.playModeText.singleLoop'),
|
||||
shufflePlay: translate('player.playModeText.shufflePlay')
|
||||
},
|
||||
showMediaSession: true,
|
||||
panelTitle: translate('player.panelTitle'),
|
||||
defaultPosition: {
|
||||
top: 300,
|
||||
left: 120
|
||||
},
|
||||
locale: {
|
||||
playModeText: {
|
||||
order: translate('player.playModeText.order'),
|
||||
orderLoop: translate('player.playModeText.orderLoop'),
|
||||
singleLoop: translate('player.playModeText.singleLoop'),
|
||||
shufflePlay: translate('player.playModeText.shufflePlay')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,17 +66,17 @@ const Player = () => {
|
||||
if (isNaN(info.duration) || progress < 90) {
|
||||
return
|
||||
}
|
||||
const item = queue.queue.find((item) => item.id === info.id)
|
||||
const item = queue.queue.find((item) => item.trackId === info.trackId)
|
||||
if (item && !item.scrobbled) {
|
||||
dispatch(scrobble(info.id))
|
||||
fetchUtils.fetchJson(info.scrobble(true))
|
||||
dispatch(scrobbled(info.trackId))
|
||||
subsonic.scrobble(info.trackId, true)
|
||||
}
|
||||
}
|
||||
|
||||
const OnAudioPlay = (info) => {
|
||||
if (info.duration) {
|
||||
fetchUtils.fetchJson(info.scrobble(false))
|
||||
dataProvider.getOne('keepalive', { id: info.id })
|
||||
subsonic.scrobble(info.trackId, false)
|
||||
dataProvider.getOne('keepalive', { id: info.trackId })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'react-jinke-music-player/assets/index.css'
|
||||
import { subsonicUrl } from '../subsonic'
|
||||
import subsonic from '../subsonic'
|
||||
|
||||
const PLAYER_ADD_TRACK = 'PLAYER_ADD_TRACK'
|
||||
const PLAYER_SET_TRACK = 'PLAYER_SET_TRACK'
|
||||
@@ -9,11 +9,11 @@ const PLAYER_PLAY_ALBUM = 'PLAYER_PLAY_ALBUM'
|
||||
|
||||
const mapToAudioLists = (item) => ({
|
||||
id: item.id,
|
||||
trackId: item.id,
|
||||
name: item.title,
|
||||
singer: item.artist,
|
||||
cover: subsonicUrl('getCoverArt', item.id, { size: 300 }),
|
||||
musicSrc: subsonicUrl('stream', item.id, { ts: true }),
|
||||
scrobble: (submit) => subsonicUrl('scrobble', item.id, { submission: submit })
|
||||
cover: subsonic.url('getCoverArt', item.id, { size: 300 }),
|
||||
musicSrc: subsonic.url('stream', item.id, { ts: true })
|
||||
})
|
||||
|
||||
const addTrack = (data) => ({
|
||||
@@ -37,7 +37,7 @@ const syncQueue = (data) => ({
|
||||
data
|
||||
})
|
||||
|
||||
const scrobble = (id) => ({
|
||||
const scrobbled = (id) => ({
|
||||
type: PLAYER_SCROBBLE,
|
||||
data: id
|
||||
})
|
||||
@@ -61,7 +61,7 @@ const playQueueReducer = (
|
||||
const newQueue = previousState.queue.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
scrobbled: item.scrobbled || item.id === data
|
||||
scrobbled: item.scrobbled || item.trackId === data
|
||||
}
|
||||
})
|
||||
return { queue: newQueue, clear: false }
|
||||
@@ -82,4 +82,4 @@ const playQueueReducer = (
|
||||
}
|
||||
}
|
||||
|
||||
export { addTrack, setTrack, playAlbum, syncQueue, scrobble, playQueueReducer }
|
||||
export { addTrack, setTrack, playAlbum, syncQueue, scrobbled, playQueueReducer }
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import jwtDecode from 'jwt-decode'
|
||||
import md5 from 'md5-hex'
|
||||
import baseUrl from './utils/baseUrl'
|
||||
import config from './config'
|
||||
|
||||
const authProvider = {
|
||||
login: ({ username, password }) => {
|
||||
let url = '/app/login'
|
||||
if (localStorage.getItem('initialAccountCreation')) {
|
||||
url = '/app/createAdmin'
|
||||
let url = baseUrl('/app/login')
|
||||
if (config.firstTime) {
|
||||
url = baseUrl('/app/createAdmin')
|
||||
}
|
||||
const request = new Request(url, {
|
||||
method: 'POST',
|
||||
@@ -22,9 +24,7 @@ const authProvider = {
|
||||
.then((response) => {
|
||||
// Validate token
|
||||
jwtDecode(response.token)
|
||||
localStorage.removeItem('initialAccountCreation')
|
||||
localStorage.setItem('token', response.token)
|
||||
localStorage.setItem('version', response.version)
|
||||
localStorage.setItem('name', response.name)
|
||||
localStorage.setItem('username', response.username)
|
||||
localStorage.setItem('role', response.isAdmin ? 'admin' : 'regular')
|
||||
@@ -34,6 +34,8 @@ const authProvider = {
|
||||
'subsonic-token',
|
||||
generateSubsonicToken(password, salt)
|
||||
)
|
||||
// Avoid going to create admin dialog after logout/login without a refresh
|
||||
config.firstTime = false
|
||||
return response
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -75,7 +77,6 @@ const removeItems = () => {
|
||||
localStorage.removeItem('name')
|
||||
localStorage.removeItem('username')
|
||||
localStorage.removeItem('role')
|
||||
localStorage.removeItem('version')
|
||||
localStorage.removeItem('subsonic-salt')
|
||||
localStorage.removeItem('subsonic-token')
|
||||
}
|
||||
|
||||
30
ui/src/common/SizeField.js
Normal file
30
ui/src/common/SizeField.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
const SizeField = ({ record = {}, source }) => {
|
||||
return <span>{formatBytes(record[source])}</span>
|
||||
}
|
||||
|
||||
function formatBytes(bytes, decimals = 2) {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
|
||||
const k = 1024
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
SizeField.propTypes = {
|
||||
label: PropTypes.string,
|
||||
record: PropTypes.object,
|
||||
source: PropTypes.string.isRequired
|
||||
}
|
||||
|
||||
SizeField.defaultProps = {
|
||||
addLabel: true
|
||||
}
|
||||
|
||||
export default SizeField
|
||||
51
ui/src/common/SongDetails.js
Normal file
51
ui/src/common/SongDetails.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react'
|
||||
import Paper from '@material-ui/core/Paper'
|
||||
import Table from '@material-ui/core/Table'
|
||||
import TableBody from '@material-ui/core/TableBody'
|
||||
import TableCell from '@material-ui/core/TableCell'
|
||||
import TableContainer from '@material-ui/core/TableContainer'
|
||||
import TableRow from '@material-ui/core/TableRow'
|
||||
import { BooleanField, DateField, TextField, useTranslate } from 'react-admin'
|
||||
import inflection from 'inflection'
|
||||
import { BitrateField, SizeField } from './index'
|
||||
|
||||
const SongDetails = (props) => {
|
||||
const translate = useTranslate()
|
||||
const { record } = props
|
||||
const data = {
|
||||
path: <TextField record={record} source="path" />,
|
||||
albumArtist: <TextField record={record} source="albumArtist" />,
|
||||
genre: <TextField record={record} source="genre" />,
|
||||
compilation: <BooleanField record={record} source="compilation" />,
|
||||
bitRate: <BitrateField record={record} source="bitRate" />,
|
||||
size: <SizeField record={record} source="size" />,
|
||||
updatedAt: <DateField record={record} source="updatedAt" showTime />,
|
||||
playCount: <TextField record={record} source="playCount" />
|
||||
}
|
||||
if (record.playCount > 0) {
|
||||
data.playDate = <DateField record={record} source="playDate" showTime />
|
||||
}
|
||||
return (
|
||||
<TableContainer component={Paper}>
|
||||
<Table aria-label="song details" size="small">
|
||||
<TableBody>
|
||||
{Object.keys(data).map((key) => {
|
||||
return (
|
||||
<TableRow key={record.id}>
|
||||
<TableCell component="th" scope="row">
|
||||
{translate(`resources.song.fields.${key}`, {
|
||||
_: inflection.humanize(inflection.underscore(key))
|
||||
})}
|
||||
:
|
||||
</TableCell>
|
||||
<TableCell align="left">{data[key]}</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default SongDetails
|
||||
@@ -5,14 +5,18 @@ import Pagination from './Pagination'
|
||||
import PlayButton from './PlayButton'
|
||||
import SimpleList from './SimpleList'
|
||||
import RangeField, { formatRange } from './RangeField'
|
||||
import SongDetails from './SongDetails'
|
||||
import SizeField from './SizeField'
|
||||
|
||||
export {
|
||||
Title,
|
||||
DurationField,
|
||||
SizeField,
|
||||
BitrateField,
|
||||
Pagination,
|
||||
PlayButton,
|
||||
SimpleList,
|
||||
RangeField,
|
||||
SongDetails,
|
||||
formatRange
|
||||
}
|
||||
|
||||
21
ui/src/config.js
Normal file
21
ui/src/config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const defaultConfig = {
|
||||
version: 'dev',
|
||||
firstTime: false,
|
||||
baseURL: '',
|
||||
loginBackgroundURL: 'https://source.unsplash.com/random/1600x900?music'
|
||||
}
|
||||
|
||||
let config
|
||||
|
||||
try {
|
||||
const appConfig = JSON.parse(window.__APP_CONFIG__)
|
||||
|
||||
config = {
|
||||
...defaultConfig,
|
||||
...appConfig
|
||||
}
|
||||
} catch (e) {
|
||||
config = defaultConfig
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -1,27 +1,32 @@
|
||||
import { fetchUtils } from 'react-admin'
|
||||
import jsonServerProvider from 'ra-data-json-server'
|
||||
import baseUrl from './utils/baseUrl'
|
||||
import config from './config'
|
||||
|
||||
const baseUrl = '/app/api'
|
||||
const restUrl = '/app/api'
|
||||
const customAuthorizationHeader = 'X-ND-Authorization'
|
||||
|
||||
const httpClient = (url, options = {}) => {
|
||||
url = url.replace(baseUrl + '/albumSong', baseUrl + '/song')
|
||||
url = baseUrl(url)
|
||||
url = url.replace(restUrl + '/albumSong', restUrl + '/song')
|
||||
if (!options.headers) {
|
||||
options.headers = new Headers({ Accept: 'application/json' })
|
||||
}
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
options.headers.set('Authorization', `Bearer ${token}`)
|
||||
options.headers.set(customAuthorizationHeader, `Bearer ${token}`)
|
||||
}
|
||||
return fetchUtils.fetchJson(url, options).then((response) => {
|
||||
const token = response.headers.get('authorization')
|
||||
const token = response.headers.get(customAuthorizationHeader)
|
||||
if (token) {
|
||||
localStorage.setItem('token', token)
|
||||
localStorage.removeItem('initialAccountCreation')
|
||||
// Avoid going to create admin dialog after logout/login without a refresh
|
||||
config.firstTime = false
|
||||
}
|
||||
return response
|
||||
})
|
||||
}
|
||||
|
||||
const dataProvider = jsonServerProvider(baseUrl, httpClient)
|
||||
const dataProvider = jsonServerProvider(restUrl, httpClient)
|
||||
|
||||
export default dataProvider
|
||||
|
||||
@@ -8,7 +8,8 @@ export default deepmerge(englishMessages, {
|
||||
fields: {
|
||||
albumArtist: 'Album Artist',
|
||||
duration: 'Time',
|
||||
trackNumber: 'Track #'
|
||||
trackNumber: 'Track #',
|
||||
playCount: 'Plays'
|
||||
},
|
||||
bulk: {
|
||||
addToQueue: 'Play Later'
|
||||
@@ -17,7 +18,9 @@ export default deepmerge(englishMessages, {
|
||||
album: {
|
||||
fields: {
|
||||
albumArtist: 'Album Artist',
|
||||
duration: 'Time'
|
||||
duration: 'Time',
|
||||
songCount: 'Songs',
|
||||
playCount: 'Plays'
|
||||
},
|
||||
actions: {
|
||||
playAll: 'Play',
|
||||
@@ -41,7 +44,10 @@ export default deepmerge(englishMessages, {
|
||||
},
|
||||
menu: {
|
||||
library: 'Library',
|
||||
settings: 'Settings'
|
||||
settings: 'Settings',
|
||||
personal: 'Personal',
|
||||
version: 'Version %{version}',
|
||||
theme: 'Theme'
|
||||
},
|
||||
player: {
|
||||
panelTitle: 'Play Queue',
|
||||
|
||||
@@ -1,20 +1,40 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { AppBar as RAAppBar, UserMenu, MenuItemLink } from 'react-admin'
|
||||
import InfoIcon from '@material-ui/icons/Info';
|
||||
import React, { forwardRef } from 'react'
|
||||
import {
|
||||
AppBar as RAAppBar,
|
||||
MenuItemLink,
|
||||
UserMenu,
|
||||
useTranslate
|
||||
} from 'react-admin'
|
||||
import { makeStyles } from '@material-ui/core'
|
||||
import InfoIcon from '@material-ui/icons/Info'
|
||||
import config from '../config'
|
||||
|
||||
const ConfigurationMenu = forwardRef(({ onClick }, ref) => (
|
||||
<MenuItemLink
|
||||
ref={ref}
|
||||
to=""
|
||||
primaryText={"Version " + localStorage.getItem("version") }
|
||||
leftIcon={<InfoIcon />}
|
||||
onClick={onClick}
|
||||
/>
|
||||
))
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
menuItem: {
|
||||
color: theme.palette.text.secondary
|
||||
}
|
||||
}))
|
||||
|
||||
const VersionMenu = forwardRef((props, ref) => {
|
||||
const translate = useTranslate()
|
||||
const classes = useStyles()
|
||||
return (
|
||||
<MenuItemLink
|
||||
ref={ref}
|
||||
to="#"
|
||||
primaryText={translate('menu.version', {
|
||||
version: config.version
|
||||
})}
|
||||
leftIcon={<InfoIcon />}
|
||||
className={classes.menuItem}
|
||||
sidebarIsOpen={true}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const CustomUserMenu = (props) => (
|
||||
<UserMenu {...props}>
|
||||
<ConfigurationMenu />
|
||||
<VersionMenu />
|
||||
</UserMenu>
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
import React from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Layout } from 'react-admin'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import Menu from './Menu'
|
||||
import AppBar from './AppBar'
|
||||
import themes from '../themes'
|
||||
|
||||
export default (props) => <Layout {...props} menu={Menu} appBar={AppBar} />
|
||||
const useStyles = makeStyles({
|
||||
root: { paddingBottom: (props) => (props.addPadding ? '80px' : 0) }
|
||||
})
|
||||
|
||||
export default (props) => {
|
||||
const theme = useSelector((state) => themes[state.theme] || themes.DarkTheme)
|
||||
const queue = useSelector((state) => state.queue)
|
||||
const classes = useStyles({ addPadding: queue.queue.length > 0 })
|
||||
|
||||
return (
|
||||
<Layout
|
||||
{...props}
|
||||
className={classes.root}
|
||||
menu={Menu}
|
||||
appBar={AppBar}
|
||||
theme={theme}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user