Compare commits

...

126 Commits

Author SHA1 Message Date
Deluan
db4479e720 Allow cache image to be disabled (workaround for #177) 2020-04-14 19:28:54 -04:00
Deluan
66275d3b94 Make song details table dense 2020-04-14 17:09:47 -04:00
Deluan
57f2c3f823 Better layout for Song Details 2020-04-14 16:21:59 -04:00
Deluan
afba4c9915 Add size and play count/date to Song Details 2020-04-14 15:23:11 -04:00
Deluan
f0d18d2cb3 Add Song Details to Album view 2020-04-14 14:59:16 -04:00
Deluan
da45bcf448 Make player theme configurable from Navidrome's theme 2020-04-14 11:54:49 -04:00
Deluan
3a54246b15 Change default sort for albums view to alphabetically (list) or most recent (grid) 2020-04-14 09:26:59 -04:00
Deluan
2b06f20f41 Close the sidebar menu when clicking "Personal" in mobile screens 2020-04-14 08:52:26 -04:00
Deluan
88f44b2e77 Upgrade React Player to 4.11.2, fix to MediaSession "close" action 2020-04-14 01:42:07 -04:00
Deluan
4dff067e0b Upgrade React Player to 4.11.1, enabled MediaSession 2020-04-13 14:24:50 -04:00
Deluan
d81bf8a518 Update github.com/go-chi/cors 2020-04-13 10:50:18 -04:00
Deluan
adfaf39489 Mark more endpoints as "gone" (won't be implemented) 2020-04-12 23:12:28 -04:00
Deluan
f6a15905d7 Move Album View toolbar to left 2020-04-12 20:43:51 -04:00
jvoisin
52b8c5f151 Correctly handle error in migration 2020-04-12 14:58:08 -04:00
Deluan
c4eab5db86 Update dhowden/tag library, to fix extracting images from Ogg files
see https://github.com/dhowden/tag/issues/64
2020-04-11 23:40:35 -04:00
Deluan
4b1c76e307 Keep the order of the playlist when adding new songs. Also allow adding a song more than once 2020-04-11 21:24:15 -04:00
Deluan
e476a5f6f1 Make fields songCount, duration, created and changed mandatory in playlists responses (fixes #164) 2020-04-11 19:15:15 -04:00
Deluan
9fb4f5ef52 Removed Playlist.GetWithTracks, not needed anymore 2020-04-11 19:05:51 -04:00
Deluan
e232c5c561 Add created and changed fields to playlists responses 2020-04-11 18:58:43 -04:00
Deluan
803a5776ae Update link to Subsonic API compatibility doc 2020-04-11 13:19:58 -04:00
Deluan
a6dfcafdab Update themes doc, link to documentation site 2020-04-11 13:13:53 -04:00
Deluan
8f2c7b7913 go mod tidy 2020-04-11 13:10:54 -04:00
jvoisin
2ab647efe1 Add a test 2020-04-11 13:08:21 -04:00
jvoisin
04eb421186 Refactor a bit how ffmpeg is used to get metadata
- createProbeCommand returns a []string instead of (string, string[])
- Simplify the loop of createProbeCommand
2020-04-11 13:08:21 -04:00
Deluan
6a3a66975c Update dhowden/tag library, to fix extracting images from some id3v4 tags
See https://github.com/dhowden/tag/issues/62
2020-04-10 23:42:06 -04:00
jvoisin
1ef4fa970f Simplify a bit ffmpeg's transcoder
- Remove the useless "format" parameter
- createTranscodeCommand now returns a list of string, instead of (string, string[])
2020-04-10 13:00:29 -04:00
jvoisin
b34523e196 Warn if ffmpeg can't be found 2020-04-10 10:56:58 -04:00
Deluan
09985453aa Show a Datagrid placeholder while loading 2020-04-09 22:38:40 -04:00
jvoisin
159a6e1cad Simplify the openrc unit 2020-04-09 19:21:23 -04:00
Deluan
b429949dd9 Keep optimistic rendering when changing the sort order for the current album 2020-04-09 18:53:44 -04:00
Deluan
b9f601dfb4 Remove unused import 2020-04-09 18:31:37 -04:00
Deluan
5b488b72b1 Add a custom AlbumSongs list component, to disable the optimistic rendering (should fix #158) 2020-04-09 18:28:47 -04:00
Deluan
03044bcb68 Ignore data folder when watching for changes in folders (when in dev mode) 2020-04-09 16:48:04 -04:00
Deluan
7bc3dace4c Revert "Improve ffmpeg's error diagnostic"
This reverts commit 4fc88f23
2020-04-09 14:26:42 -04:00
Deluan
c2ec142ce3 More tests 2020-04-09 13:36:05 -04:00
Deluan
2d39a6df8d Remove duplicated fscache creation 2020-04-09 13:15:01 -04:00
Deluan
5265d0234f Fix tests for Cover service 2020-04-09 12:13:54 -04:00
jvoisin
4fc88f23e9 Improve ffmpeg's error diagnostic
This should close #155
2020-04-09 10:40:16 -04:00
Deluan
5412bb2dc8 Fine tune album grid for mobile view 2020-04-09 09:53:53 -04:00
Deluan
b661d52477 Force full scan to enable search by tracks' artists in albums 2020-04-09 00:24:26 -04:00
Deluan
43ce81af67 Add all individual artists from album in searchable full text field. Should fix #94 2020-04-08 23:54:54 -04:00
Deluan
b8d1185f7f Remove duplicated words and extra spaces from full text searchable fields 2020-04-08 23:29:28 -04:00
Deluan
0fa8290ed3 Don't transcode if original format/bitrate is the same as the selected ones 2020-04-08 19:10:55 -04:00
Deluan
519e3f014d Re-stage files after formatting 2020-04-08 13:23:39 -04:00
Deluan
d38f8544d5 Remove unused localStorage config 2020-04-08 13:20:02 -04:00
Deluan
089a92157f Pass version to UI through AppConfig, instead of login payload.master
This makes the version info updated with a browser refresh (no need to logout and login again)
2020-04-08 11:00:30 -04:00
Deluan
db246900a6 Introduce a new configuration to select the login background image URL 2020-04-08 09:07:15 -04:00
Deluan
a0f389fc3e Consolidate UI configuration in one place, allowing it to be overridden from the server 2020-04-08 09:07:15 -04:00
Deluan
d0188db4f9 Fine tune album grid 2020-04-07 21:25:06 -04:00
Deluan
f537984bbf Use trackId instead of simply id, as it seems to conflict with internal id generated by the player. fixes #153 2020-04-07 11:55:45 -04:00
Deluan
7e6c0e3894 Less noisy logs for scrobble 2020-04-06 19:42:35 -04:00
Deluan
b930c7253a Fix tests in pipeline 2020-04-06 17:01:48 -04:00
Deluan
c1afe70d98 Fix: also pass the custom authorization header in all requests 2020-04-06 16:23:47 -04:00
Deluan
3f9ddb915e Use a custom authorization header, to avoid conflicts with proxies using basic auth (fixes #146) 2020-04-06 16:03:20 -04:00
Deluan
c3edc7f449 Add test for ServeIndex 2020-04-06 15:37:15 -04:00
Deluan
9b272c8021 Small log tweak 2020-04-06 14:02:50 -04:00
Deluan
6d1221164b Download and install latest Jamstash when calling make Jamstash-master 2020-04-06 00:40:51 -04:00
Deluan
647132625c Logs new stream sessions 2020-04-06 00:26:51 -04:00
Deluan
a17a98a75f Log API requests and responses at Debug level 2020-04-05 23:57:04 -04:00
Deluan
59707b3a8f Detect embedded art in ogg containers 2020-04-05 23:41:10 -04:00
Deluan
fa378ab4e4 Add tracing log to Cover service 2020-04-05 22:48:07 -04:00
Deluan
05ffb1acad Cache cover arts. closes #19 2020-04-05 22:02:06 -04:00
Deluan
a1ba5c59b2 Returns default cover on any error (not found, encoding, or unknown)
Only returns error if it cannot read the default image
2020-04-05 22:02:06 -04:00
Deluan
1bc68c20fc Create and configure image cache 2020-04-05 22:02:06 -04:00
Deluan
d308e7ca46 Fix typo 2020-04-05 17:49:14 -04:00
jvoisin
2b5433dc6e Add an openrc unit file 2020-04-05 13:07:00 -04:00
Deluan
86a23f9b14 Add more indexes to MediaFile table 2020-04-04 21:56:22 -04:00
Deluan
0ba5840a65 Don't set a playerId cookie it cannot register the player 2020-04-04 20:26:36 -04:00
Deluan
93646b964e More logging tests 2020-04-04 19:11:21 -04:00
Deluan
13be8d297c Converted last GoConvey tests to Ginkgo
Removed GoConvey dependency
2020-04-04 18:54:12 -04:00
Deluan
a4b97121ab Changes when pipelines are triggered:
- Build now on new Pull Requests
- Release only on new pushed tags
2020-04-04 16:39:43 -04:00
Deluan
660f9c205b Rename dist target to snapshot 2020-04-04 14:36:23 -04:00
Deluan
28852ce7d7 go mod tidy 2020-04-03 22:57:59 -04:00
Deluan
656ca1f3b5 Fix colour of album actions 2020-04-03 22:35:55 -04:00
Deluan
b8f7715a74 Fix ReactAdmin console warnings 2020-04-03 21:03:34 -04:00
Deluan
096ed396c8 Add link to all artist's albums from an album 2020-04-03 20:51:15 -04:00
Deluan
3b6d0b3d15 Add a catchall route to redirect everything to app/index.html 2020-04-03 19:45:35 -04:00
Deluan
75cd21da1f Add BaseURL configuration (fixes #103) 2020-04-03 19:05:38 -04:00
Deluan
b8eb22d162 Add git hooks on check_env 2020-04-03 16:00:17 -04:00
Deluan
9b461735f4 Add comments to createXxxxCommand functions to clarify about the filepaths arguments being absolute paths 2020-04-03 14:49:35 -04:00
Deluan
63bf49b3c4 Add lefthook for handling git hooks 2020-04-03 14:48:14 -04:00
Deluan
559848299c Fix default mp3 encoding ffmpeg command 2020-04-03 00:26:41 -04:00
Deluan
8510273216 Send estimated content length if requested 2020-04-03 00:24:40 -04:00
Deluan
2392060bc1 Don't try to transcode a file if the requested format is the same and the client is not requesting to downsample 2020-04-02 22:17:52 -04:00
Deluan
2d7998de59 Return cover from album even if client does not prefix the id with al-. Fixes #46 2020-04-02 22:03:27 -04:00
Deluan
40638688b2 Remove React warnings by omit properties not used downstream 2020-04-02 19:58:34 -04:00
Deluan
ea22b2fc6d Bump react-admin to 3.3.3 2020-04-02 19:47:10 -04:00
Deluan
1182218787 Upgrade Node to 13.12 2020-04-02 19:41:10 -04:00
Deluan
14f7c5610e Bump @testing-library/jest-dom, @testing-library/user-event and react-dom 2020-04-02 19:31:49 -04:00
Deluan
27579b99a3 Removed album list selection, for now 2020-04-02 19:20:39 -04:00
Deluan
c58021e645 Make Personal settings form more consistent with the rest of the App 2020-04-02 18:46:09 -04:00
Deluan
1810cc7ac7 Simplify album lists tabs handling 2020-04-02 18:18:52 -04:00
Deluan
86f73eecca Only add padding to layout if the player is visible 2020-04-02 18:09:02 -04:00
Deluan
3d6ce8a77f Skip calling ffmpeg if there are no files to probe 2020-04-02 17:38:20 -04:00
Deluan
670be29d7b Revert "Pause the player with <space>"
This reverts commit 6e6cfdd0
2020-04-02 16:52:46 -04:00
dependabot-preview[bot]
2b3e506583 build(deps): bump github.com/go-chi/chi
Bumps [github.com/go-chi/chi](https://github.com/go-chi/chi) from 4.0.4+incompatible to 4.1.0+incompatible.
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v4.0.4...v4.1.0)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-04-02 08:53:28 -04:00
jvoisin
6e6cfdd02b Pause the player with <space> 2020-04-01 17:14:09 -04:00
Deeparghya Dutta Barua
a18093e255 Fix systemd unit to allow FFmpeg execution 2020-04-01 15:15:59 -04:00
Deluan
a35636999d feat: fine tune album art image size. better, but still not ideal 2020-04-01 09:09:51 -04:00
Deluan
13a3d38e4f fix: Personal view title and menu tooltip 2020-03-31 21:40:06 -04:00
Deluan
9f00fb0f05 feat: move Configuration menu to Personal settings 2020-03-31 21:28:50 -04:00
Deluan
6cddcd6f0d docs: update README 2020-03-31 20:42:59 -04:00
Deluan Quintão
c6d1cfeceb docs: update theme's README 2020-03-31 20:07:11 -04:00
Deluan
de43c27b3c docs: basic documentation on creating themes.
#129
2020-03-31 19:54:38 -04:00
Deluan
747b5ea25e refactor: rename theme name attribute to themeName 2020-03-31 19:36:45 -04:00
Deluan
dd2e98fca2 feat: make theme select input longer 2020-03-31 18:43:54 -04:00
Deluan
eb621be646 feat: load themes dynamically 2020-03-31 18:31:14 -04:00
Deluan
d223a4f4db docs: mentions our Subreddit in the README 2020-03-31 15:11:33 -04:00
Deluan
7aa182e33d fix: add padding at the bottom of the layout, to accommodate the audio player (relates to #132) 2020-03-31 14:52:54 -04:00
Deluan
7fec503b72 feat: persist the queue in the localStorage 2020-03-31 14:34:08 -04:00
Deluan
083a11a563 feat: store state in localStorage 2020-03-31 14:07:33 -04:00
Deluan
944f3695c4 fix: disable click on version menu item 2020-03-31 13:04:04 -04:00
Deluan
dfc8691262 fix: add "Version" message to translations 2020-03-31 11:17:11 -04:00
Deluan
395b598bb1 fix: don't show tooltips in profile menu items 2020-03-31 11:07:45 -04:00
Deluan
d04b434d96 fix: profile menu items colors 2020-03-31 10:49:47 -04:00
Deluan
f041503a85 feat: simple theme selector. only works with hardcoded light and dark for now 2020-03-31 09:35:44 -04:00
Deluan
500207f7b8 refactor: extract themes to their own folder 2020-03-31 09:05:46 -04:00
Deluan
1e0a79ebb7 fix: "Recent" should sort by play_date, not starred_at 2020-03-30 19:34:44 -04:00
Deluan
301fa2a957 fix: sort by album in songs view 2020-03-30 19:34:00 -04:00
Deluan
46f4f63212 feat: initial implementation of album lists 2020-03-29 00:01:08 -04:00
Deluan
fec8b5f731 feat: add playcounts to album and songs
(fix year in song list)
2020-03-28 20:38:41 -04:00
Deluan
777231ea79 feat: expose album, song and artist annotations in the RESTful API 2020-03-28 19:22:55 -04:00
Deluan
0e36ed35a3 fix: typo 2020-03-28 18:50:18 -04:00
Deluan
f1af646cee feat: option to display albums as a grid 2020-03-28 16:25:55 -04:00
Deluan
fc0621646b feat: add link to album from Songs view 2020-03-28 00:34:09 -04:00
Deluan
575800dcff docs: add badge with link to subreddit 2020-03-27 21:51:24 -04:00
125 changed files with 2625 additions and 896 deletions

View File

@@ -1,5 +1,5 @@
name: Build
on: [push]
on: [push, pull_request]
jobs:
go:
name: Test Server on ${{ matrix.os }}

View File

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

2
.nvmrc
View File

@@ -1 +1 @@
v13.11.0
v13.12.0

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,19 @@
# Navidrome Music Streamer
[![Build](https://img.shields.io/github/workflow/status/deluan/navidrome/Build?style=for-the-badge)](https://github.com/deluan/navidrome/actions)
[![Last Release](https://img.shields.io/github/v/release/deluan/navidrome?label=latest&style=for-the-badge)](https://github.com/deluan/navidrome/releases)
[![Docker Pulls](https://img.shields.io/docker/pulls/deluan/navidrome?style=for-the-badge)](https://hub.docker.com/r/deluan/navidrome)
[![Join the Chat](https://img.shields.io/discord/671335427726114836?style=for-the-badge)](https://discord.gg/xh7j7yF)
[![Last Release](https://img.shields.io/github/v/release/deluan/navidrome?label=latest&style=flat-square)](https://github.com/deluan/navidrome/releases)
[![Build](https://img.shields.io/github/workflow/status/deluan/navidrome/Build?style=flat-square)](https://github.com/deluan/navidrome/actions)
[![Docker Pulls](https://img.shields.io/docker/pulls/deluan/navidrome?style=flat-square)](https://hub.docker.com/r/deluan/navidrome)
[![Dev Chat](https://img.shields.io/discord/671335427726114836?label=chat&style=flat-square)](https://discord.gg/xh7j7yF)
[![Subreddit](https://img.shields.io/reddit/subreddit-subscribers/navidrome?style=flat-square)](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.

View File

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

View File

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

View File

@@ -31,8 +31,5 @@ PrivateDevices=yes
ProtectSystem=full
ProtectHome=true
MemoryDenyWriteExecute=yes
[Install]
WantedBy=multi-user.target

View File

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

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

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ services:
ND_PORT: 4533
ND_TRANSCODINGCACHESIZE: 100MB
ND_SESSIONTIMEOUT: 30m
ND_BASEURL: ""
volumes:
- "./data:/data"
- "./music:/music"

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

10
go.mod
View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

File diff suppressed because one or more lines are too long

View File

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

16
tests/fixtures/index.html vendored Normal file
View 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
View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View 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

View 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

View File

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

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

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

View File

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

View File

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

View File

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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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