Compare commits

...

231 Commits

Author SHA1 Message Date
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
Deluan
0ca849a61a feat: show year range in album view and match ranges in year filter. #118 2020-03-27 21:11:06 -04:00
Deluan
53e8a92fed feat: rename year to max_year and add min_year to album. #118 2020-03-27 21:11:06 -04:00
Deluan
fc650cd127 chore: upgrade to Node 13.11 2020-03-27 19:23:52 -04:00
Deluan
b03519b09c fix: configured transcodings not appearing in players view 2020-03-27 19:12:11 -04:00
Deluan
39b9f818be feat: use ND_PORT env var in health check 2020-03-26 15:26:40 -04:00
Deluan
7febe05ed5 feat: add health check to docker image 2020-03-26 15:15:40 -04:00
Deluan
2c42e4e12e feat: add icons for playlists 2020-03-26 12:33:30 -04:00
Deluan
dcb3b3b5d1 fix: various album_artists <-> artists mismatches 2020-03-26 09:08:53 -04:00
Deluan
5331732236 fix: remove sql injection 2020-03-25 20:40:18 -04:00
Deluan
dc973ae670 refactor: remove unused code 2020-03-25 20:40:18 -04:00
Deluan
100db2bcfd feat: add artist filter to album view 2020-03-25 20:40:18 -04:00
dependabot-preview[bot]
c84a58ff7d build(deps): bump github.com/go-chi/chi
Bumps [github.com/go-chi/chi](https://github.com/go-chi/chi) from 4.0.3+incompatible to 4.0.4+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.3...v4.0.4)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-03-25 18:12:33 -04:00
Deluan
2d7fda1b2f docs: add default config vars to docker-compose.yml example 2020-03-24 12:34:31 -04:00
Deluan
3cba5f70fd chore: add tests for all utils, removed unused functions 2020-03-24 11:59:10 -04:00
Deluan
b4c7cac964 refactor: moved magic strings to consts 2020-03-24 11:59:10 -04:00
dependabot-preview[bot]
5ef80d2490 build(deps): bump github.com/sirupsen/logrus from 1.4.2 to 1.5.0
Bumps [github.com/sirupsen/logrus](https://github.com/sirupsen/logrus) from 1.4.2 to 1.5.0.
- [Release notes](https://github.com/sirupsen/logrus/releases)
- [Changelog](https://github.com/sirupsen/logrus/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sirupsen/logrus/compare/v1.4.2...v1.5.0)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-03-24 09:09:12 -04:00
dependabot-preview[bot]
3b798cf943 build(deps): bump react-scripts from 3.4.0 to 3.4.1 in /ui
Bumps [react-scripts](https://github.com/facebook/create-react-app/tree/HEAD/packages/react-scripts) from 3.4.0 to 3.4.1.
- [Release notes](https://github.com/facebook/create-react-app/releases)
- [Changelog](https://github.com/facebook/create-react-app/blob/master/CHANGELOG.md)
- [Commits](https://github.com/facebook/create-react-app/commits/react-scripts@3.4.1/packages/react-scripts)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-03-23 08:49:40 -04:00
dependabot-preview[bot]
50b7756159 build(deps): bump react from 16.13.0 to 16.13.1 in /ui
Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) from 16.13.0 to 16.13.1.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/master/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v16.13.1/packages/react)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-03-23 08:48:48 -04:00
Deluan
15606770ca chore: removed non-working config flag 2020-03-22 01:13:55 -04:00
Deluan
f403a8da34 feat: add version to index.html description meta tag 2020-03-22 01:04:10 -04:00
Deluan
20075ae68d refactor: extracted restful helpers into their own composable struct 2020-03-21 20:00:46 -04:00
Deluan
91a743623a feat: always show artist name in Album view 2020-03-21 19:15:39 -04:00
Deluan
e23a290812 fix: logging of scanner startup 2020-03-21 14:20:22 -04:00
Deluan
dee68559ab docs: uses less space for client list 2020-03-21 14:11:57 -04:00
Deluan
9f42e330b4 fix: change web requests log level to debug 2020-03-21 13:03:04 -04:00
jvoisin
ad63b8b1b4 Add a systemd startup unit 2020-03-21 12:47:05 -04:00
Deluan
0d8a2b310f fix: the default session timeout must be 30 minutes, not seconds! 2020-03-21 12:17:20 -04:00
Deluan
3977575563 build: add a simple build as default target, trying to make LGTM work 2020-03-20 12:21:41 -04:00
Deluan
47244cb770 refactor: remove unused static file 2020-03-20 12:00:14 -04:00
Deluan
57aaf5a26b refactor: remove unused property 2020-03-20 00:30:16 -04:00
Deluan
352d686d94 chore: upgrade react-admin to 3.3.1 2020-03-20 00:23:04 -04:00
Deluan
f6e448c1ba refactor: removed unused code, unnecessary typecasts and fixed small warnings 2020-03-20 00:07:36 -04:00
Deluan
270b0ae74e feat: add "Compilation" filter to albums 2020-03-19 23:25:40 -04:00
Deluan
8401d85f78 feat: search in WebUI now is more flexible, searching in all relevant fields in the current view 2020-03-19 22:26:18 -04:00
Deluan
32fbf2e9eb refactor: drop search table, integrated full_text into main tables 2020-03-19 21:44:48 -04:00
Deluan
8cdd4e317d feat: allow restful filter customization per field 2020-03-19 21:09:57 -04:00
Deluan
97d95ea794 fix: group compilations together in the restful API. fix #93 2020-03-19 15:02:11 -04:00
Deluan
cbbebb3264 fix: version position under banner 2020-03-18 23:21:01 -04:00
Deluan
8b108905a3 feat: use Navidrome's icon in getAvatar 2020-03-18 22:46:47 -04:00
Deluan
5b40ec400e build: go mod tidy 2020-03-18 21:35:15 -04:00
Deluan
29e661e1fe docs: update README 2020-03-18 21:23:45 -04:00
Deluan
b466ec75a4 build: always add latest tag to version 2020-03-18 21:05:17 -04:00
Deluan
c8cd755451 feat: use human readable sizes in cache size configuration 2020-03-18 20:39:10 -04:00
Deluan
faac303eff feat: allow session timeout to be configurable. closes #101 2020-03-18 20:16:18 -04:00
Deluan
ced87be57b fix: when searching player by id, create new player if client name does not match the one found 2020-03-17 19:10:09 -04:00
Deluan
811703ab60 fix: create default transcodings on existing installations 2020-03-17 16:49:37 -04:00
Deluan
bc1f767123 docs: Update README 2020-03-17 15:22:37 -04:00
Deluan
7055dc514b docs: update basic transcoding info 2020-03-17 15:20:35 -04:00
Deluan
e02f3d3ec9 refactor: clean up unused config options 2020-03-17 15:20:35 -04:00
Deluan
68a49befc8 feat: allow regular users to change their players' configuration 2020-03-17 15:20:35 -04:00
Deluan
c8b0d2bfae feat: select correct transcoding for streaming 2020-03-17 15:20:35 -04:00
Deluan
39993810b3 feat: add transcodedSuffix to Subsonic API responses 2020-03-17 15:20:35 -04:00
Deluan
45180115a6 feat: player CRUD 2020-03-17 15:20:35 -04:00
Deluan
353c48d8d8 refactor: rename player to audioplayer 2020-03-17 15:20:35 -04:00
Deluan
da36941252 feat: better getPlayer middleware setup 2020-03-17 15:20:35 -04:00
Deluan
8ec78900c5 feat: transcoding and player datastores and configuration 2020-03-17 15:20:35 -04:00
dependabot-preview[bot]
a0e0fbad58 build(deps): bump @testing-library/react from 9.5.0 to 10.0.1 in /ui
Bumps [@testing-library/react](https://github.com/testing-library/react-testing-library) from 9.5.0 to 10.0.1.
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/master/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v9.5.0...v10.0.1)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-03-16 10:06:23 -04:00
dependabot-preview[bot]
75e7ba8b1e build(deps): bump github.com/go-chi/cors from 1.0.0 to 1.0.1
Bumps [github.com/go-chi/cors](https://github.com/go-chi/cors) from 1.0.0 to 1.0.1.
- [Release notes](https://github.com/go-chi/cors/releases)
- [Commits](https://github.com/go-chi/cors/compare/v1.0.0...v1.0.1)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-03-16 10:04:16 -04:00
Deluan
74c30b5a66 docs: add list of tested clients 2020-03-15 13:26:48 -04:00
Deluan Quintão
e67bdbbc32 docs: add link to transcoding issue 2020-03-15 13:09:43 -04:00
Deluan
9554c8f783 build: rename generated archives 2020-03-14 21:09:39 -04:00
Deluan
e36a42f356 build: generate binaries for Linux armv6, armv7 and arm68 (v8) (fixes #92) 2020-03-14 21:09:39 -04:00
dependabot-preview[bot]
9d1960232c build(deps): [security] bump acorn from 5.7.3 to 5.7.4 in /ui
Bumps [acorn](https://github.com/acornjs/acorn) from 5.7.3 to 5.7.4. **This update includes a security fix.**
- [Release notes](https://github.com/acornjs/acorn/releases)
- [Commits](https://github.com/acornjs/acorn/compare/5.7.3...5.7.4)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-03-13 18:01:31 -04:00
Deluan
d3547544bf feat: new WebUI icon 2020-03-11 20:18:22 -04:00
Deluan
9cb42606ba fix: force full rescan to enable search by album artist 2020-03-10 17:23:25 -04:00
dependabot-preview[bot]
7772afce1c build(deps): bump @testing-library/react from 9.4.1 to 9.5.0 in /ui
Bumps [@testing-library/react](https://github.com/testing-library/react-testing-library) from 9.4.1 to 9.5.0.
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/master/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v9.4.1...v9.5.0)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-03-10 17:11:46 -04:00
Deluan
10e76257c6 fix: increase contrast in WebUI's dark theme 2020-03-08 16:12:12 -04:00
Deluan
9235ab6414 fix: index albumArtist as part of the album searchable fields 2020-03-07 13:10:20 -05:00
Deluan
59356f0029 refactor: removed indirect call introduced by intellij's refactor 2020-03-06 16:28:20 -05:00
Deluan
9ae14015a1 build: get Go and Node versions from go.mod and .nvmrc respectively 2020-03-06 11:50:39 -05:00
Deluan
0b131e91c1 chore: upgrade to NodeJS 13.10 2020-03-06 10:57:00 -05:00
Deluan
77b12eafde chore: upgrade react-jinke-music-player 2020-03-05 08:57:40 -05:00
Deluan
050778460d fix: missing id in queue items was preventing scrobble to work properly 2020-03-04 10:30:58 -05:00
Deluan
28bc9c1d4f fix: AlbumShow was adding previous played tracks when trying to shuffle the album 2020-03-02 14:51:52 -05:00
Deluan
5e7aaa667b fix: missing id in queue items was preventing scrobble to work properly 2020-03-02 14:20:57 -05:00
Deluan
1afc495920 chore: upgrade react, react-dom and react-redux 2020-03-02 13:06:27 -05:00
Deluan
cf7d877714 chore: upgrade @testing-library/user-event 2020-03-02 12:04:58 -05:00
Deluan
81831da67a chore: upgrade react-admin 2020-03-02 11:58:23 -05:00
Deluan
fcd2fcae67 chore: upgrade @testing-library, react-scripts 2020-03-02 11:52:06 -05:00
Deluan
1c33b0aea8 docs: update API compatibility chart 2020-03-02 09:48:46 -05:00
Deluan
fc06163b5a refactor: remove superfluous (and untested) code 2020-03-02 09:37:47 -05:00
Deluan
72f0a6fb66 chore: removed unused (video) mime types 2020-03-02 00:16:15 -05:00
Deluan
6f5a322927 fix: login must be case-insensitive 2020-03-01 15:45:41 -05:00
Deluan
a7f8e4ee2b fix: only set created_at when adding data to DB 2020-02-28 18:43:22 -05:00
Deluan
0850872b0f fix: ormer.Driver() is not available when creating orms with NewOrmWithDB() 2020-02-28 16:09:27 -05:00
Deluan
1d886156d5 feat: better SQLite3 configuration, to avoid DB contention 2020-02-28 15:06:31 -05:00
Deluan
faa2a978c0 refactor: use only one DB instance for the whole application 2020-02-28 15:06:31 -05:00
Deluan
38faffa907 feat: notice function to notify (in logs) about important changes in migrations 2020-02-28 14:00:41 -05:00
Deluan
65a792be3a fix: handle nil pointer dereference 2020-02-28 11:02:38 -05:00
Deluan
876354e58e feat: MaxTranscodingCacheSize is now specified in MB 2020-02-26 14:08:14 -05:00
Deluan
14b33bc34d fix: there are no docker images available for node 13.9 2020-02-26 12:00:00 -05:00
Deluan
9044aa8740 chore: upgrade NodeJS to 13.9.0 2020-02-26 09:52:25 -05:00
Deluan
07ac14f810 chore: upgrade Go to 1.14 2020-02-26 09:37:48 -05:00
Deluan
0370f0a3ea refactor: rename ffmpeg to transcoder 2020-02-25 10:32:34 -05:00
Deluan
33ede13eef fix: check if album is starred before adding the starred date in the response. also return "starred" in search responses 2020-02-24 22:06:12 -05:00
Deluan
e032bfcf6b refactor: make parameters consistent 2020-02-24 19:04:54 -05:00
Deluan
f4014c475d refactor: make fakeFFmpeg more configurable, change test name 2020-02-24 14:17:32 -05:00
Deluan
f394de664a refactor: new transcoding engine. third (fourth?) time is a charm! 2020-02-24 13:56:09 -05:00
Deluan
d2eea64528 fix: typo 2020-02-23 21:41:10 -05:00
Deluan
d7b5e6a36c fix: add public attribute to playlists. Even though it is optional,
DSub requires it
2020-02-23 00:10:05 -05:00
Deluan
b49b9e3ca0 chore: remove unused script 2020-02-22 20:29:57 -05:00
Deluan
1322bb3bf3 refactor: move cache constructor 2020-02-21 09:36:29 -05:00
Deluan
13a046a679 fix: change stream cache eviction check period to every 10 minutes 2020-02-20 20:12:52 -05:00
Deluan
e6d2056438 fix: typo 2020-02-20 19:39:32 -05:00
Deluan
a6b0c57ce0 feat: add a proper caching system to the transcoding functionality 2020-02-20 19:25:39 -05:00
Deluan
fc14e346b9 feat: store duration as float, to cater for milliseconds 2020-02-20 17:02:06 -05:00
Deluan
5525145906 fix: audio stream's bitrate has precedence over container's bitrate 2020-02-20 13:56:45 -05:00
Deluan
74d87790b8 refactor: better ffmpeg output metadata parsing 2020-02-20 10:41:16 -05:00
Deluan
8ce796756f fix: error message 2020-02-19 15:34:05 -05:00
Deluan
a412989f7e refactor: more stable transcoder, based on http.FileSystem 2020-02-19 14:53:35 -05:00
Deluan
ae02dc203e chore: remove unused code 2020-02-19 09:08:05 -05:00
Deluan
fc7595a464 fix: cover art detection regex 2020-02-18 11:19:22 -05:00
Deluan
4ceaea7732 fix: extract stream level metadata 2020-02-18 10:00:05 -05:00
Deluan
894536c8ec Revert "fix: extract stream level metadata"
This reverts commit 92f6e55821.
2020-02-15 23:18:37 -05:00
Deluan
92f6e55821 fix: extract stream level metadata 2020-02-15 20:47:06 -05:00
Deluan
c3bd181648 feat: use tini to help in avoiding dangling processes 2020-02-15 18:34:47 -05:00
Deluan
3b12c92ad5 feat: add cache to the getCoverArt endpoint, avoid it being reloaded every single time in the UI 2020-02-15 14:32:11 -05:00
Deluan
272d897ec9 chore: go mod tidy 2020-02-15 11:37:27 -05:00
Deluan
e6d717cbbc fix: prevent zombies in transcoding 2020-02-15 11:05:03 -05:00
Deluan
b7f1fc0374 refactor: remove unused import 2020-02-14 09:16:59 -05:00
Deluan
de525edde0 feat: add song count and duration to AlbumDetails 2020-02-14 09:14:50 -05:00
Deluan
7f94660183 feat: use different resource for listing songs in albums 2020-02-14 09:02:32 -05:00
Deluan
b2d022b823 fix: ignore environment dependant test 2020-02-13 20:19:51 -05:00
Deluan
ba08f00c20 feat: make rescan faster, only loading metadata from changed files 2020-02-13 20:18:17 -05:00
Deluan
d9993c5877 refactor: separate metadata extraction from audio files scanning 2020-02-13 10:03:52 -05:00
Deluan
edb839a41d fix: only update artists and albums if there were any changes in files 2020-02-12 23:05:10 -05:00
Deluan
9fa73e3b7b feat: implement AlbumShow using a Datagrid. WIP: still need to make it responsive 2020-02-12 20:35:35 -05:00
dependabot-preview[bot]
8ebb85b0af build(deps): bump github.com/astaxie/beego from 1.12.0 to 1.12.1
Bumps [github.com/astaxie/beego](https://github.com/astaxie/beego) from 1.12.0 to 1.12.1.
- [Release notes](https://github.com/astaxie/beego/releases)
- [Commits](https://github.com/astaxie/beego/compare/v1.12.0...v1.12.1)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-02-10 08:57:04 -05:00
Deluan
a37beac753 feat: add X-Content-Duration header to the stream response 2020-02-09 22:09:18 -05:00
Deluan
8a31e80b7a fix: find songs and albums when sending an artist name search query 2020-02-09 19:52:06 -05:00
Deluan
ce11a2f3be feat: fake getArtistInfo/getArtistInfo2, just to enable artist browsing in MusicStash 2020-02-09 19:42:37 -05:00
Deluan
5a95feeedc fix: allow searches with 2 chars. closes #65 2020-02-09 12:20:34 -05:00
230 changed files with 6854 additions and 2957 deletions

View File

@@ -3,6 +3,7 @@ ui/node_modules
ui/build
Jamstash-master
Dockerfile
docker-compose*.yml
data
*.db
testDB

View File

@@ -1,5 +1,5 @@
name: Build
on: [push]
on: [push, pull_request]
jobs:
go:
name: Test Server on ${{ matrix.os }}
@@ -11,10 +11,10 @@ jobs:
os: [macOS-latest, ubuntu-latest]
steps:
- name: Set up Go 1.13
- name: Set up Go 1.14
uses: actions/setup-go@v1
with:
go-version: 1.13
go-version: 1.14
id: go
- name: Check out code into the Go module directory

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
node-version: 13.12
- name: Build UI
run: |
cd ui
@@ -24,7 +23,7 @@ jobs:
- name: Fetch tags
run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
- name: Run GoReleaser
uses: docker://bepsays/ci-goreleaser:latest
uses: docker://bepsays/ci-goreleaser:1.14-1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -2,6 +2,8 @@
before:
hooks:
- apt-get update
- apt-get install -y gcc-arm-linux-gnueabi gcc-aarch64-linux-gnu
- go get -u github.com/go-bindata/go-bindata/...
- go-bindata -fs -prefix ui/build -tags embed -nocompress -pkg assets -o assets/embedded_gen.go ui/build/...
- git checkout .
@@ -21,7 +23,7 @@ builds:
ldflags:
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
- id: navidrome_linux
- id: navidrome_linux_amd64
env:
- CGO_ENABLED=1
goos:
@@ -34,6 +36,38 @@ builds:
- "-extldflags '-static'"
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
- id: navidrome_linux_arm
env:
- CGO_ENABLED=1
- CC=arm-linux-gnueabi-gcc
goos:
- linux
goarch:
- arm
goarm:
- 6
- 7
flags:
- -tags=embed
ldflags:
- "-extldflags '-static'"
- "-extld=$CC"
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
- id: navidrome_linux_arm64
env:
- CGO_ENABLED=1
- CC=aarch64-linux-gnu-gcc
goos:
- linux
goarch:
- arm64
flags:
- -tags=embed
ldflags:
- "-extldflags '-static'"
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
- id: navidrome_windows_i686
env:
- CGO_ENABLED=1
@@ -69,8 +103,15 @@ archives:
format_overrides:
- goos: windows
format: zip
replacements:
darwin: macOS
linux: Linux
windows: Windows
386: i386
amd64: x86_64
checksum:
name_template: 'checksums.txt'
name_template: '{{ .ProjectName }}_checksums.txt'
snapshot:
name_template: "{{ .Tag }}-next"

2
.nvmrc
View File

@@ -1 +1 @@
v13.7.0
v13.12.0

View File

@@ -10,7 +10,7 @@ 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: Playlists (WIP), MultiUser (WIP), Jukebox, Sharing, Podcasts, Bookmarks, Internet Radio.
* 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),
@@ -54,7 +54,7 @@ Navidrome is actively being tested with:
| `deletePlaylist` | |
| ||
| _MEDIA RETRIEVAL_ ||
| `stream` | No Transcoding/Downsampling support (for now)|
| `stream` | |
| `download` | |
| `getCoverArt` | Only gets embedded artwork |
| `getAvatar` | Always returns the same image |
@@ -62,7 +62,7 @@ Navidrome is actively being tested with:
| _MEDIA ANNOTATION_ ||
| `star` | |
| `unstar` | |
| `setRating` | Doesn't work with artists |
| `setRating` | |
| `scrobble` | No Last.FM support yet. It is used to update play count and last played |
| ||
| _USER MANAGEMENT_ ||

View File

@@ -1,6 +1,6 @@
#####################################################
### Build UI bundles
FROM node:13.7-alpine AS jsbuilder
FROM node:13.12-alpine AS jsbuilder
WORKDIR /src
COPY ui/package.json ui/package-lock.json ./
RUN npm ci
@@ -10,7 +10,7 @@ RUN npm run build
#####################################################
### Build executable
FROM golang:1.13-alpine AS gobuilder
FROM golang:1.14-alpine AS gobuilder
# Download build tools
RUN mkdir -p /src/ui/build
@@ -36,7 +36,7 @@ COPY --from=jsbuilder /src/build/* /src/ui/build/
COPY --from=jsbuilder /src/build/static/css/* /src/ui/build/static/css/
COPY --from=jsbuilder /src/build/static/js/* /src/ui/build/static/js/
RUN rm -rf /src/build/css /src/build/js
RUN GIT_TAG=$(git name-rev --name-only HEAD) && \
RUN GIT_TAG=$(git describe --tags `git rev-list --tags --max-count=1`) && \
GIT_TAG=${GIT_TAG#"tags/"} && \
GIT_SHA=$(git rev-parse --short HEAD) && \
echo "Building version: ${GIT_TAG} (${GIT_SHA})" && \
@@ -48,6 +48,11 @@ RUN GIT_TAG=$(git name-rev --name-only HEAD) && \
FROM alpine as release
MAINTAINER Deluan Quintao <navidrome@deluan.com>
# Download Tini
ENV TINI_VERSION v0.18.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-static /tini
RUN chmod +x /tini
COPY --from=gobuilder /src/navidrome /app/
COPY --from=gobuilder /tmp/ffmpeg*/ffmpeg /usr/bin/
@@ -58,10 +63,14 @@ VOLUME ["/data", "/music"]
ENV ND_MUSICFOLDER /music
ENV ND_DATAFOLDER /data
ENV ND_SCANINTERVAL 1m
ENV ND_TRANSCODINGCACHESIZE 100MB
ENV ND_SESSIONTIMEOUT 30m
ENV ND_LOGLEVEL info
ENV ND_PORT 4533
EXPOSE 4533
EXPOSE ${ND_PORT}
HEALTHCHECK CMD wget -O- http://localhost:${ND_PORT}/ping || exit 1
WORKDIR /app
ENTRYPOINT "/app/navidrome"
ENTRYPOINT ["/tini", "--"]
CMD ["/app/navidrome"]

View File

@@ -1,74 +1,90 @@
GO_VERSION=1.13
NODE_VERSION=v13.7.0
GO_VERSION=$(shell grep -e "^go " go.mod | cut -f 2 -d ' ')
NODE_VERSION=$(shell cat .nvmrc)
GIT_SHA=$(shell git rev-parse --short HEAD)
.PHONY: dev
## Default target just build the Go project.
default:
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=master"
.PHONY: default
dev: check_env
@goreman -f Procfile.dev -b 4533 start
.PHONY: dev
.PHONY: server
server: check_go_env
@reflex -d none -c reflex.conf
.PHONY: server
wire: check_go_env
wire ./...
.PHONY: wire
.PHONY: watch
watch: check_go_env
ginkgo watch -notify ./...
.PHONY: watch
.PHONY: test
test: check_go_env
go test ./... -v
# @(cd ./ui && npm test -- --watchAll=false)
.PHONY: test
.PHONY: testall
testall: check_go_env test
@(cd ./ui && npm test -- --watchAll=false)
.PHONY: testall
.PHONY: setup
setup: Jamstash-master
@which goconvey || (echo "Installing GoConvey" && GO111MODULE=off go get -u github.com/smartystreets/goconvey)
@which wire || (echo "Installing Wire" && GO111MODULE=off go get -u github.com/google/wire/cmd/wire)
@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
.PHONY: static
static:
cd static && go-bindata -fs -prefix "static" -nocompress -ignore="\\\*.go" -pkg static .
.PHONY: static
Jamstash-master:
wget -N https://github.com/tsquillario/Jamstash/archive/master.zip
unzip -o master.zip
rm master.zip
(cd Jamstash-master && npm ci && npx bower install && npx grunt build)
rm -rf Jamstash-master/node_modules Jamstash-master/bower_components
.PHONE: check_env
check_env: check_go_env check_node_env
.PHONE: check_env
check_hooks:
@lefthook add pre-commit
@lefthook add pre-push
.PHONE: check_hooks
.PHONY: check_go_env
check_go_env:
@(hash go) || (echo "\nERROR: GO environment not setup properly!\n"; exit 1)
@go version | grep -q $(GO_VERSION) || (echo "\nERROR: Please upgrade your GO version\n"; exit 1)
@go version | grep -q $(GO_VERSION) || (echo "\nERROR: Please upgrade your GO version\nThis project requires version $(GO_VERSION)"; exit 1)
.PHONY: check_go_env
.PHONY: check_node_env
check_node_env:
@(hash node) || (echo "\nERROR: Node environment not setup properly!\n"; exit 1)
@node --version | grep -q $(NODE_VERSION) || (echo "\nERROR: Please check your Node version. Should be $(NODE_VERSION)\n"; exit 1)
.PHONY: check_node_env
.PHONY: build
build: check_go_env
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=master"
.PHONY: build
.PHONY: buildall
buildall: check_env
@(cd ./ui && npm run build)
go-bindata -fs -prefix "ui/build" -tags embed -nocompress -pkg assets -o assets/embedded_gen.go ui/build/...
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=master" -tags=embed
.PHONY: buildall
.PHONY: release
release:
@if [[ ! "${V}" =~ ^[0-9]+\.[0-9]+\.[0-9]+.*$$ ]]; then echo "Usage: make release V=X.X.X"; exit 1; fi
go mod tidy
@@ -76,7 +92,8 @@ release:
make test
git tag v${V}
git push origin v${V}
.PHONY: release
.PHONY: dist
dist:
docker run -it -v $(PWD):/github/workspace -w /github/workspace bepsays/ci-goreleaser:1.13-4 goreleaser release --rm-dist --skip-publish --snapshot
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,15 +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
@@ -20,21 +24,36 @@ please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join
- Multi-user, each user has their own play counts, playlists, favourites, etc..
- Very low resource usage: Ex: with a library of 300GB (~29000 songs), it uses less than 50MB of RAM
- Multi-platform, runs on macOS, Linux and Windows. Docker images are also provided
- Ready to use Raspberry Pi binaries available
- Automatically monitors your library for changes, importing new files and reloading new metadata
- Modern and responsive Web interface based on Material UI, to manage users and browse your library
- Compatible with the huge selection of clients for [Subsonic](http://www.subsonic.org),
[Airsonic](https://airsonic.github.io/) and [Madsonic](https://www.madsonic.org/).
See the [complete list of available mobile and web apps](https://airsonic.github.io/docs/apps/)
- Transcoding/Downsampling on-the-fly (WIP. Experimental support is available)
- [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)
Navidrome should be compatible with all Subsonic clients. The following clients are tested and confirmed to work properly:
- Android: [DSub](https://play.google.com/store/apps/details?id=github.daneren2005.dsub),
[Ultrasonic](https://play.google.com/store/apps/details?id=org.moire.ultrasonic) and
[Music Stash](https://play.google.com/store/apps/details?id=com.ghenry22.mymusicstash)
- iOS: [play:Sub](http://michaelsapps.dk/playsubapp/)
- Web: [Jamstash](http://jamstash.com),
[Aurial](http://shrimpza.github.io/aurial/),
[Subfire](http://p.subfireplayer.net/) and
[Subplayer](https://github.com/peguerosdc/subplayer)
For more options, look at the [list of clients](https://airsonic.github.io/docs/apps/) maintained by
the Airsonic project. Please open an [issue](https://github.com/deluan/navidrome/issues) if you have any
trouble with the client of your choice.
## Road map
This project is being actively worked on. Expect a more polished experience and new features/releases
on a frequent basis. Some upcoming features planned:
- Complete WebUI, to browse and listen to your library
- Last.FM integration
- Pre-build binaries for Raspberry Pi
- Smart/dynamic playlists (similar to iTunes)
- Support for audiobooks (bookmarking)
- Jukebox mode
@@ -49,17 +68,19 @@ Various options are available:
### Pre-built executables
Just head to the [releases page](https://github.com/deluan/navidrome/releases) and download the latest version for you
platform. There are builds available for Linux, macOS and Windows (32 and 64 bits).
platform. There are builds available for Linux (amd64 and arm), macOS and Windows (32 and 64 bits).
For Raspberry Pi (tested with Raspbian Buster on Pi 4), use the Linux arm builds.
Remember to install [ffmpeg](https://ffmpeg.org/download.html) in your system, a requirement for Navidrome to work properly.
You may find the latest static build for your platform here: https://johnvansickle.com/ffmpeg/
Remember to install [ffmpeg](https://ffmpeg.org/download.html) in your system, a requirement for Navidrome to work
properly. You may find the latest static build for your platform here: https://johnvansickle.com/ffmpeg/
If you have any issues with these binaries, or need a binary for a different platform, please
[open an issue](https://github.com/deluan/navidrome/issues)
### Docker
[Docker images](https://hub.docker.com/r/deluan/navidrome) are available. They include everything needed to run Navidrome. Example of usage:
[Docker images](https://hub.docker.com/r/deluan/navidrome) are available. They include everything needed
to run Navidrome. Example of usage:
```yaml
# This is just an example. Customize it to your needs.
@@ -77,16 +98,19 @@ services:
ND_SCANINTERVAL: 1m
ND_LOGLEVEL: info
ND_PORT: 4533
ND_TRANSCODINGCACHESIZE: 100MB
ND_SESSIONTIMEOUT: 30m
ND_BASEURL: ""
volumes:
- "./data:/data"
- "/path/to/your/music/folder:/music"
- "/path/to/your/music/folder:/music:ro"
```
To get the cutting-edge, latest version from master, use the image `deluan/navidrome:develop`
### Build from source
You will need to install [Go 1.13](https://golang.org/dl/) and [Node 13.7.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)
@@ -114,6 +138,12 @@ user.
For more options, run `navidrome --help`
### Running as a service
Check the [contrib](https://github.com/deluan/navidrome/tree/master/contrib)
folder for startup files for your init system.
## Screenshots
<p align="center">
@@ -121,7 +151,7 @@ For more options, run `navidrome --help`
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-login.png">
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-player.png">
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-album-view.png">
<img width="900"src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-desktop-player.png">
<img width="900" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-desktop-player.png">
</p>
</p>

View File

@@ -1,98 +0,0 @@
#!/bin/bash
# Script to transfort .itc files into images (JPG or PNG)
#
# .itc files are located in ~/Music/iTunes/Album Artwork
#
# This script uses (/!\ needs ) ImageMagick's convert, hexdump, printf and dd.
#
# This script might be a little slow, You might want to look at Simon Kennedy's work at http://www.sffjunkie.co.uk/python-itc.html
#
# ~/{Library Path}/Album Artwork/Cache/D989408F65D05F99/04/13/04/D989408F65D05F99-EB5B7A9086F4B4D4.itc
#
# The filenames are an amalgam of the library ID (D989408F65D05F99) and the track's ID (EB5B7A9086F4B4D4).
# The directory structure comes from the library ID and the last three digits of the track's ID converted to decimal,
# ie 4D4 becomes 04, 13, 04.
#
AlbumArtwork="${HOME}/Music/iTunes 1/Album Artwork"
DestinationDir="Artwork"
IFS=$'\n'
if [ ! -d "$DestinationDir" ]; then
mkdir "$DestinationDir"
echo "new Images dir"
fi
for file in `find "$AlbumArtwork" -name '*.itc'`; do
start=0x11C
exit=0;
i=1;
echo $file
while [ 1 ]; do
typeOffset=$(($start+0x30))
imageType=$(hexdump -n 4 -s $typeOffset -e '"0x"4/1 "%02x" "\n"' $file)
#If there is no next byte, jump to the next itc file.
if [[ -z $imageType ]]; then
break
fi
imageOffsetOffset=$(($start+8))
itemSize=$(hexdump -n 4 -s $start -e '"0x"4/1 "%02x" "\n"' $file)
imageOffset=$(hexdump -n 4 -s $imageOffsetOffset -e '"0x"4/1 "%02x" "\n"' $file)
imageStart=$(($start+$imageOffset))
imageSize=$(($itemSize-imageOffset))
imageWidth=$(hexdump -n 4 -s $(($start+56)) -e '"0x"4/1 "%02x" "\n"' $file)
imageWidth=$(printf "%d" $imageWidth)
imageHeight=$(hexdump -n 4 -s $(($start+60)) -e '"0x"4/1 "%02x" "\n"' $file)
imageHeight=$(printf "%d" $imageHeight)
dir=$(dirname "$file")
xbase=${file##*/} #file.etc
xpref=${xbase%.*} #file prefix
#echo $file
#echo itemsize $itemSize
#echo start $start
#echo imageOffset $imageOffset
#echo imageStart $imageStart
#echo imageSize $imageSize
#echo imageWidth $imageWidth
#echo imageHeight $imageHeight
if [[ $imageType -eq 0x504E4766 ]] || [[ $imageType -eq 0x0000000E ]] ; then
targetFile="$DestinationDir/$xpref-$i.png"
if [ ! -f "$targetFile" ]; then
echo PNG
dd skip=$imageStart count=$imageSize if="$file" of="$targetFile" bs=1 &> /dev/null
fi
elif [[ $imageType -eq 0x41524762 ]] ; then
targetFile="$DestinationDir/$xpref-$i.png"
if [ ! -f "$targetFile" ]; then
echo ARGB
dd skip=$imageStart count=$imageSize if="$file" of="$TMPDIR/test$i" bs=1 &> /dev/null
#Using a matrix to convert ARGB to RGBA since imagemagick does only support rgba input
convert -size $imageWidth"x"$imageHeight -depth 8 -color-matrix '0 1 0 0 0 0 1 0 0 0 0 1 1 0 0 0' rgba:"$TMPDIR/test$i" "$targetFile"
fi
elif [[ $imageType -eq 0x0000000D ]] ; then
targetFile="$DestinationDir/$xpref-$i.jpg"
if [ ! -f "$targetFile" ]; then
echo JPG
dd skip=$imageStart count=$imageSize if="$file" of="$targetFile" bs=1 &> /dev/null
fi
else
echo $imageType
exit=1
break;
fi
start=$(($start+$itemSize))
i=$(($i+1))
done
done

View File

@@ -13,23 +13,25 @@ import (
)
type nd struct {
Port string `default:"4533"`
MusicFolder string `default:"./music"`
DataFolder string `default:"./"`
DbPath string
LogLevel string `default:"info"`
Port string `default:"4533"`
MusicFolder string `default:"./music"`
DataFolder string `default:"./"`
ScanInterval string `default:"1m"`
DbPath string ``
LogLevel string `default:"info"`
SessionTimeout string `default:"30m"`
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]([)"`
EnableDownsampling bool `default:"false"`
MaxBitRate int `default:"0"`
DownsampleCommand string `default:"ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -"`
ProbeCommand string `default:"ffmpeg -i %s -f ffmetadata"`
ScanInterval string `default:"1m"`
TranscodingCacheSize string `default:"100MB"` // in MB
ImageCacheSize string `default:"100MB"` // in MB
ProbeCommand string `default:"ffmpeg -i %s -f ffmetadata"`
// DevFlags. These are used to enable/disable debugging and incomplete features
DevDisableBanner bool `default:"false"`
DevLogSourceLine bool `default:"false"`
DevAutoCreateAdminPassword string `default:""`
}
@@ -82,7 +84,7 @@ func LoadFromFile(confFile string, skipFlags ...bool) {
os.Exit(2)
}
if Server.DbPath == "" {
Server.DbPath = filepath.Join(Server.DataFolder, "navidrome.db")
Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath)
}
if os.Getenv("PORT") != "" {
Server.Port = os.Getenv("PORT")

View File

@@ -3,17 +3,18 @@ package consts
import (
"fmt"
"strings"
"unicode"
"github.com/deluan/navidrome/static"
)
func getBanner() string {
data, _ := static.Asset("banner.txt")
return strings.TrimSuffix(string(data), "\n")
return strings.TrimRightFunc(string(data), unicode.IsSpace)
}
func Banner() string {
version := "Version: " + Version()
padding := strings.Repeat(" ", 52-len(version))
return fmt.Sprintf("%s%s%s\n", getBanner(), padding, version)
return fmt.Sprintf("%s\n%s%s\n", getBanner(), padding, version)
}

View File

@@ -1,19 +1,65 @@
package consts
import "time"
import (
"crypto/md5"
"fmt"
"strings"
"time"
)
const (
AppName = "navidrome"
LocalConfigFile = "./navidrome.toml"
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL"
InitialSetupFlagKey = "InitialSetup"
JWTSecretKey = "JWTSecret"
JWTIssuer = "ND"
JWTTokenExpiration = 30 * time.Minute
UIAuthorizationHeader = "X-ND-Authorization"
JWTSecretKey = "JWTSecret"
JWTIssuer = "ND"
DefaultSessionTimeout = 30 * time.Minute
UIAssetsLocalPath = "ui/build"
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 (
DefaultTranscodings = []map[string]interface{}{
{
"name": "mp3 audio",
"targetFormat": "mp3",
"defaultBitRate": 192,
"command": "ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -",
},
{
"name": "opus audio",
"targetFormat": "oga",
"defaultBitRate": 128,
"command": "ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -c:a libopus -f opus -",
},
}
)
var (
VariousArtists = "Various Artists"
VariousArtistsID = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(VariousArtists))))
UnknownArtist = "[Unknown Artist]"
UnknownArtistID = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(UnknownArtist))))
)

View File

@@ -8,7 +8,6 @@ func init() {
".ogg": "audio/ogg",
".oga": "audio/ogg",
".opus": "audio/ogg",
".ogx": "application/ogg",
".aac": "audio/mp4",
".m4a": "audio/mp4",
".m4b": "audio/mp4",
@@ -18,20 +17,8 @@ func init() {
".ape": "audio/x-monkeys-audio",
".mpc": "audio/x-musepack",
".shn": "audio/x-shn",
".flv": "video/x-flv",
".avi": "video/avi",
".mpg": "video/mpeg",
".mpeg": "video/mpeg",
".mp4": "video/mp4",
".m4v": "video/x-m4v",
".mkv": "video/x-matroska",
".mov": "video/quicktime",
".wmv": "video/x-ms-wmv",
".ogv": "video/ogg",
".divx": "video/divx",
".m2ts": "video/MP2T",
".ts": "video/MP2T",
".webm": "video/webm",
".aif": "audio/x-aiff",
".aiff": "audio/x-aiff",
".gif": "image/gif",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",

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
}

35
contrib/navidrome.service Normal file
View File

@@ -0,0 +1,35 @@
# This file ususaly goes in /etc/systemd/system
[Unit]
Description=Navidrome Daemon
After=network.target
[Service]
User=navidrome
Group=navidrome
Type=simple
ExecStart=/opt/navidrome/navidrome
WorkingDirectory=/opt/navidrome
TimeoutStopSec=20
KillMode=process
Restart=on-failure
# See https://www.freedesktop.org/software/systemd/man/systemd.exec.html
DevicePolicy=closed
NoNewPrivileges=yes
PrivateTmp=yes
PrivateUsers=yes
ProtectControlGroups=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
SystemCallFilter=~@clock @debug @module @mount @obsolete @privileged @reboot @setuid @swap
ReadWritePaths=/opt/navidrome/
PrivateDevices=yes
ProtectSystem=full
ProtectHome=true
[Install]
WantedBy=multi-user.target

View File

@@ -13,32 +13,36 @@ import (
)
var (
once sync.Once
Driver = "sqlite3"
Path string
)
func Init() {
var (
once sync.Once
db *sql.DB
)
func Db() *sql.DB {
once.Do(func() {
var err error
Path = conf.Server.DbPath
if Path == ":memory:" {
Path = "file::memory:?cache=shared"
conf.Server.DbPath = Path
}
log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver)
db, err = sql.Open(Driver, Path)
if err != nil {
panic(err)
}
})
return db
}
func EnsureLatestVersion() {
Init()
db, err := sql.Open(Driver, Path)
defer db.Close()
if err != nil {
log.Error("Failed to open DB", err)
os.Exit(1)
}
db := Db()
err = goose.SetDialect(Driver)
err := goose.SetDialect(Driver)
if err != nil {
log.Error("Invalid DB driver", "driver", Driver, err)
os.Exit(1)

View File

@@ -0,0 +1,129 @@
package migration
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(Up20200220143731, Down20200220143731)
}
func Up20200220143731(tx *sql.Tx) error {
notice(tx, "This migration will force the next scan to be a full rescan!")
_, err := tx.Exec(`
create table media_file_dg_tmp
(
id varchar(255) not null
primary key,
path varchar(255) default '' not null,
title varchar(255) default '' not null,
album varchar(255) default '' not null,
artist varchar(255) default '' not null,
artist_id varchar(255) default '' not null,
album_artist varchar(255) default '' not null,
album_id varchar(255) default '' not null,
has_cover_art bool default FALSE not null,
track_number integer default 0 not null,
disc_number integer default 0 not null,
year integer default 0 not null,
size integer default 0 not null,
suffix varchar(255) default '' not null,
duration real default 0 not null,
bit_rate integer default 0 not null,
genre varchar(255) default '' not null,
compilation bool default FALSE not null,
created_at datetime,
updated_at datetime
);
insert into media_file_dg_tmp(id, path, title, album, artist, artist_id, album_artist, album_id, has_cover_art, track_number, disc_number, year, size, suffix, duration, bit_rate, genre, compilation, created_at, updated_at) select id, path, title, album, artist, artist_id, album_artist, album_id, has_cover_art, track_number, disc_number, year, size, suffix, duration, bit_rate, genre, compilation, created_at, updated_at from media_file;
drop table media_file;
alter table media_file_dg_tmp rename to media_file;
create index media_file_album_id
on media_file (album_id);
create index media_file_genre
on media_file (genre);
create index media_file_path
on media_file (path);
create index media_file_title
on media_file (title);
create table album_dg_tmp
(
id varchar(255) not null
primary key,
name varchar(255) default '' not null,
artist_id varchar(255) default '' not null,
cover_art_path varchar(255) default '' not null,
cover_art_id varchar(255) default '' not null,
artist varchar(255) default '' not null,
album_artist varchar(255) default '' not null,
year integer default 0 not null,
compilation bool default FALSE not null,
song_count integer default 0 not null,
duration real default 0 not null,
genre varchar(255) default '' not null,
created_at datetime,
updated_at datetime
);
insert into album_dg_tmp(id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, year, compilation, song_count, duration, genre, created_at, updated_at) select id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, year, compilation, song_count, duration, genre, created_at, updated_at from album;
drop table album;
alter table album_dg_tmp rename to album;
create index album_artist
on album (artist);
create index album_artist_id
on album (artist_id);
create index album_genre
on album (genre);
create index album_name
on album (name);
create index album_year
on album (year);
create table playlist_dg_tmp
(
id varchar(255) not null
primary key,
name varchar(255) default '' not null,
comment varchar(255) default '' not null,
duration real default 0 not null,
owner varchar(255) default '' not null,
public bool default FALSE not null,
tracks text not null
);
insert into playlist_dg_tmp(id, name, comment, duration, owner, public, tracks) select id, name, comment, duration, owner, public, tracks from playlist;
drop table playlist;
alter table playlist_dg_tmp rename to playlist;
create index playlist_name
on playlist (name);
-- Force a full rescan
delete from property where id like 'LastScan%';
update media_file set updated_at = '0001-01-01';
`)
return err
}
func Down20200220143731(tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,21 @@
package migration
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(Up20200310171621, Down20200310171621)
}
func Up20200310171621(tx *sql.Tx) error {
notice(tx, "A full rescan will be performed to enable search by Album Artist!")
return forceFullRescan(tx)
}
func Down20200310171621(tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@@ -0,0 +1,53 @@
package migration
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(Up20200310181627, Down20200310181627)
}
func Up20200310181627(tx *sql.Tx) error {
_, err := tx.Exec(`
create table transcoding
(
id varchar(255) not null primary key,
name varchar(255) not null,
target_format varchar(255) not null,
command varchar(255) default '' not null,
default_bit_rate int default 192,
unique (name),
unique (target_format)
);
create table player
(
id varchar(255) not null primary key,
name varchar not null,
type varchar,
user_name varchar not null,
client varchar not null,
ip_address varchar,
last_seen timestamp,
max_bit_rate int default 0,
transcoding_id varchar,
unique (name),
foreign key (transcoding_id)
references transcoding(id)
on update restrict
on delete restrict
);
`)
return err
}
func Down20200310181627(tx *sql.Tx) error {
_, err := tx.Exec(`
drop table transcoding;
drop table player;
`)
return err
}

View File

@@ -0,0 +1,42 @@
package migration
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(Up20200319211049, Down20200319211049)
}
func Up20200319211049(tx *sql.Tx) error {
_, err := tx.Exec(`
alter table media_file
add full_text varchar(255) default '';
create index if not exists media_file_full_text
on media_file (full_text);
alter table album
add full_text varchar(255) default '';
create index if not exists album_full_text
on album (full_text);
alter table artist
add full_text varchar(255) default '';
create index if not exists artist_full_text
on artist (full_text);
drop table if exists search;
`)
if err != nil {
return err
}
notice(tx, "A full rescan will be performed!")
return forceFullRescan(tx)
}
func Down20200319211049(tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@@ -0,0 +1,33 @@
package migration
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(Up20200325185135, Down20200325185135)
}
func Up20200325185135(tx *sql.Tx) error {
_, err := tx.Exec(`
alter table album
add album_artist_id varchar(255) default '';
create index album_artist_album_id
on album (album_artist_id);
alter table media_file
add album_artist_id varchar(255) default '';
create index media_file_artist_album_id
on media_file (album_artist_id);
`)
if err != nil {
return err
}
notice(tx, "A full rescan will be performed!")
return forceFullRescan(tx)
}
func Down20200325185135(tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,19 @@
package migration
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(Up20200326090707, Down20200326090707)
}
func Up20200326090707(tx *sql.Tx) error {
notice(tx, "A full rescan will be performed!")
return forceFullRescan(tx)
}
func Down20200326090707(tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,80 @@
package migration
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(Up20200327193744, Down20200327193744)
}
func Up20200327193744(tx *sql.Tx) error {
_, err := tx.Exec(`
create table album_dg_tmp
(
id varchar(255) not null
primary key,
name varchar(255) default '' not null,
artist_id varchar(255) default '' not null,
cover_art_path varchar(255) default '' not null,
cover_art_id varchar(255) default '' not null,
artist varchar(255) default '' not null,
album_artist varchar(255) default '' not null,
min_year int default 0 not null,
max_year integer default 0 not null,
compilation bool default FALSE not null,
song_count integer default 0 not null,
duration real default 0 not null,
genre varchar(255) default '' not null,
created_at datetime,
updated_at datetime,
full_text varchar(255) default '',
album_artist_id varchar(255) default ''
);
insert into album_dg_tmp(id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, max_year, compilation, song_count, duration, genre, created_at, updated_at, full_text, album_artist_id) select id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, year, compilation, song_count, duration, genre, created_at, updated_at, full_text, album_artist_id from album;
drop table album;
alter table album_dg_tmp rename to album;
create index album_artist
on album (artist);
create index album_artist_album
on album (artist);
create index album_artist_album_id
on album (album_artist_id);
create index album_artist_id
on album (artist_id);
create index album_full_text
on album (full_text);
create index album_genre
on album (genre);
create index album_name
on album (name);
create index album_min_year
on album (min_year);
create index album_max_year
on album (max_year);
`)
if err != nil {
return err
}
notice(tx, "A full rescan will be performed!")
return forceFullRescan(tx)
}
func Down20200327193744(tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

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
}

55
db/migration/migration.go Normal file
View File

@@ -0,0 +1,55 @@
package migration
import (
"database/sql"
"fmt"
"sync"
"github.com/deluan/navidrome/consts"
)
// Use this in migrations that need to communicate something important (braking changes, forced reindexes, etc...)
func notice(tx *sql.Tx, msg string) {
if isDBInitialized(tx) {
fmt.Printf(`
*************************************************************************************
NOTICE: %s
*************************************************************************************
`, msg)
}
}
// Call this in migrations that requires a full rescan
func forceFullRescan(tx *sql.Tx) error {
_, err := tx.Exec(`
delete from property where id like 'LastScan%';
update media_file set updated_at = '0001-01-01';
`)
return err
}
var once sync.Once
func isDBInitialized(tx *sql.Tx) (initialized bool) {
once.Do(func() {
rows, err := tx.Query("select count(*) from property where id='" + consts.InitialSetupFlagKey + "'")
checkErr(err)
initialized = checkCount(rows) > 0
})
return initialized
}
func checkCount(rows *sql.Rows) (count int) {
for rows.Next() {
err := rows.Scan(&count)
checkErr(err)
}
return count
}
func checkErr(err error) {
if err != nil {
panic(err)
}
}

View File

@@ -13,6 +13,9 @@ services:
ND_SCANINTERVAL: 1m
ND_LOGLEVEL: info
ND_PORT: 4533
ND_TRANSCODINGCACHESIZE: 100MB
ND_SESSIONTIMEOUT: 30m
ND_BASEURL: ""
volumes:
- "./data:/data"
- "./music:/music"

View File

@@ -5,6 +5,7 @@ import (
"sync"
"time"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
@@ -13,9 +14,10 @@ import (
)
var (
once sync.Once
JwtSecret []byte
TokenAuth *jwtauth.JWTAuth
once sync.Once
JwtSecret []byte
TokenAuth *jwtauth.JWTAuth
sessionTimeOut time.Duration
)
func InitTokenAuth(ds model.DataStore) {
@@ -39,8 +41,21 @@ func CreateToken(u *model.User) (string, error) {
return TouchToken(token)
}
func getSessionTimeOut() time.Duration {
if sessionTimeOut == 0 {
if to, err := time.ParseDuration(conf.Server.SessionTimeout); err != nil {
sessionTimeOut = consts.DefaultSessionTimeout
} else {
sessionTimeOut = to
}
log.Info("Setting Session Timeout", "value", sessionTimeOut)
}
return sessionTimeOut
}
func TouchToken(token *jwt.Token) (string, error) {
expireIn := time.Now().Add(consts.JWTTokenExpiration).Unix()
timeout := getSessionTimeOut()
expireIn := time.Now().Add(timeout).Unix()
claims := token.Claims.(jwt.MapClaims)
claims["exp"] = expireIn

View File

@@ -37,7 +37,7 @@ func (b *browser) MediaFolders(ctx context.Context) (model.MediaFolders, error)
func (b *browser) Indexes(ctx context.Context, mediaFolderId string, ifModifiedSince time.Time) (model.ArtistIndexes, time.Time, error) {
// TODO Proper handling of mediaFolderId param
folder, err := b.ds.MediaFolder(ctx).Get(mediaFolderId)
folder, _ := b.ds.MediaFolder(ctx).Get(mediaFolderId)
l, err := b.ds.Property(ctx).DefaultGet(model.PropLastScan+"-"+folder.Path, "-1")
ms, _ := strconv.ParseInt(l, 10, 64)
@@ -155,20 +155,23 @@ func (b *browser) buildAlbumDir(al *model.Album, tracks model.MediaFiles) *Direc
dir := &DirectoryInfo{
Id: al.ID,
Name: al.Name,
Parent: al.ArtistID,
Artist: al.Artist,
ArtistId: al.ArtistID,
Parent: al.AlbumArtistID,
Artist: al.AlbumArtist,
ArtistId: al.AlbumArtistID,
SongCount: al.SongCount,
Duration: al.Duration,
Duration: int(al.Duration),
Created: al.CreatedAt,
Year: al.Year,
Year: al.MaxYear,
Genre: al.Genre,
CoverArt: al.CoverArtId,
PlayCount: int32(al.PlayCount),
Starred: al.StarredAt,
UserRating: al.Rating,
}
if al.Starred {
dir.Starred = al.StarredAt
}
dir.Entries = FromMediaFiles(tracks)
return dir
}

View File

@@ -14,7 +14,7 @@ var _ = Describe("Browser", func() {
var repo *mockGenreRepository
var b Browser
BeforeSuite(func() {
BeforeEach(func() {
repo = &mockGenreRepository{data: model.Genres{
{Name: "Rock", SongCount: 1000, AlbumCount: 100},
{Name: "", SongCount: 13, AlbumCount: 13},

View File

@@ -1,9 +1,11 @@
package engine
import (
"context"
"fmt"
"time"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/model"
)
@@ -51,7 +53,9 @@ func FromArtist(ar *model.Artist) Entry {
e.Title = ar.Name
e.AlbumCount = ar.AlbumCount
e.IsDir = true
e.Starred = ar.StarredAt
if ar.Starred {
e.Starred = ar.StarredAt
}
return e
}
@@ -60,18 +64,20 @@ func FromAlbum(al *model.Album) Entry {
e.Id = al.ID
e.Title = al.Name
e.IsDir = true
e.Parent = al.ArtistID
e.Parent = al.AlbumArtistID
e.Album = al.Name
e.Year = al.Year
e.Year = al.MaxYear
e.Artist = al.AlbumArtist
e.Genre = al.Genre
e.CoverArt = al.CoverArtId
e.Created = al.CreatedAt
e.AlbumId = al.ID
e.ArtistId = al.ArtistID
e.Duration = al.Duration
e.ArtistId = al.AlbumArtistID
e.Duration = int(al.Duration)
e.SongCount = al.SongCount
e.Starred = al.StarredAt
if al.Starred {
e.Starred = al.StarredAt
}
e.PlayCount = int32(al.PlayCount)
e.UserRating = al.Rating
return e
@@ -88,7 +94,7 @@ func FromMediaFile(mf *model.MediaFile) Entry {
e.Artist = mf.Artist
e.Genre = mf.Genre
e.Track = mf.TrackNumber
e.Duration = mf.Duration
e.Duration = int(mf.Duration)
e.Size = mf.Size
e.Suffix = mf.Suffix
e.BitRate = mf.BitRate
@@ -105,9 +111,11 @@ func FromMediaFile(mf *model.MediaFile) Entry {
e.Created = mf.CreatedAt
e.AlbumId = mf.AlbumID
e.ArtistId = mf.ArtistID
e.Type = "music" // TODO Hardcoded for now
e.Type = "music"
e.PlayCount = int32(mf.PlayCount)
e.Starred = mf.StarredAt
if mf.Starred {
e.Starred = mf.StarredAt
}
e.UserRating = mf.Rating
return e
}
@@ -115,7 +123,7 @@ func FromMediaFile(mf *model.MediaFile) Entry {
func realArtistName(mf *model.MediaFile) string {
switch {
case mf.Compilation:
return "Various Artists"
return consts.VariousArtists
case mf.AlbumArtist != "":
return mf.AlbumArtist
}
@@ -146,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,141 @@ 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
}
}
cacheKey := imageCacheKey(path, size, lastUpdate)
r, w, err := c.cache.Get(cacheKey)
if err != nil {
return model.ErrNotFound
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)
}
if size > 0 {
return resizeImage(reader, size, out)
}
_, err = io.Copy(out, reader)
_, err = io.Copy(out, r)
return err
}
func resizeImage(reader io.Reader, size int, out io.Writer) error {
img, _, err := image.Decode(reader)
if err != nil {
return err
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
}
m := resize.Resize(uint(size), 0, img, resize.NearestNeighbor)
return jpeg.Encode(out, m, &jpeg.Options{Quality: 75})
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 readFromTag(path string) (io.Reader, error) {
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 +152,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)
}

105
engine/cover_test.go Normal file
View File

@@ -0,0 +1,105 @@
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"}]`)
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!"))
})
})
})

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

28
engine/file_caches.go Normal file
View File

@@ -0,0 +1,28 @@
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) {
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,33 @@
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())
})
})
})

View File

@@ -2,219 +2,216 @@ package engine
import (
"context"
"fmt"
"io"
"io/ioutil"
"mime"
"os"
"os/exec"
"strconv"
"strings"
"time"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/engine/transcoder"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/utils"
"github.com/djherbis/fscache"
)
type MediaStreamer interface {
NewStream(ctx context.Context, id string, maxBitRate int, format string) (mediaStream, error)
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error)
}
func NewMediaStreamer(ds model.DataStore) MediaStreamer {
return &mediaStreamer{ds: ds}
}
type TranscodingCache fscache.Cache
type mediaStream interface {
io.ReadSeeker
ContentType() string
Name() string
ModTime() time.Time
Close() error
func NewMediaStreamer(ds model.DataStore, ffm transcoder.Transcoder, cache TranscodingCache) MediaStreamer {
return &mediaStreamer{ds: ds, ffm: ffm, cache: cache}
}
type mediaStreamer struct {
ds model.DataStore
ds model.DataStore
ffm transcoder.Transcoder
cache fscache.Cache
}
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, maxBitRate int, format string) (mediaStream, error) {
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error) {
mf, err := ms.ds.MediaFile(ctx).Get(id)
if err != nil {
return nil, err
}
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)
}()
if format == "raw" || !conf.Server.EnableDownsampling {
bitRate = mf.BitRate
format = mf.Suffix
} else {
if maxBitRate == 0 {
bitRate = mf.BitRate
} else {
bitRate = utils.MinInt(mf.BitRate, maxBitRate)
}
format = mf.Suffix
}
if conf.Server.MaxBitRate != 0 {
bitRate = utils.MinInt(bitRate, conf.Server.MaxBitRate)
}
format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate)
log.Trace(ctx, "Selected transcoding options",
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format,
)
s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
var stream mediaStream
if bitRate == mf.BitRate && mime.TypeByExtension("."+format) == mf.ContentType() {
if format == "raw" {
log.Debug(ctx, "Streaming raw file", "id", mf.ID, "path", mf.Path,
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
f, err := os.Open(mf.Path)
if err != nil {
return nil, err
}
stream = &rawMediaStream{ctx: ctx, mf: mf, file: f}
return stream, nil
s.Reader = f
s.Closer = f
s.Seeker = f
s.format = mf.Suffix
return s, nil
}
key := cacheKey(id, bitRate, format)
r, w, err := ms.cache.Get(key)
if err != nil {
log.Error(ctx, "Error creating stream caching buffer", "id", mf.ID, err)
return nil, err
}
// If this is a brand new transcoding request, not in the cache, start transcoding
if w != nil {
log.Trace(ctx, "Cache miss. Starting new transcoding session", "id", mf.ID)
t, err := ms.ds.Transcoding(ctx).FindByFormat(format)
if err != nil {
log.Error(ctx, "Error loading transcoding command", "format", format, err)
return nil, os.ErrInvalid
}
out, err := ms.ffm.Start(ctx, t.Command, mf.Path, bitRate, format)
if err != nil {
log.Error(ctx, "Error starting transcoder", "id", mf.ID, err)
return nil, os.ErrInvalid
}
go copyAndClose(ctx, w, out)
}
// If it is in the cache, check if the stream is done being written. If so, return a ReaderSeeker
if w == nil {
size := getFinalCachedSize(r)
if size > 0 {
log.Debug(ctx, "Streaming cached file", "id", mf.ID, "path", mf.Path,
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix, "size", size)
sr := io.NewSectionReader(r, 0, size)
s.Reader = sr
s.Closer = r
s.Seeker = sr
s.format = format
return s, nil
}
}
log.Debug(ctx, "Streaming transcoded file", "id", mf.ID, "path", mf.Path,
"requestBitrate", bitRate, "requestFormat", format,
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
f := &transcodedMediaStream{ctx: ctx, mf: mf, bitRate: bitRate, format: format}
return f, err
// All other cases, just return a ReadCloser, without Seek capabilities
s.Reader = r
s.Closer = r
s.format = format
return s, nil
}
type rawMediaStream struct {
file *os.File
ctx context.Context
mf *model.MediaFile
func copyAndClose(ctx context.Context, w io.WriteCloser, r io.ReadCloser) {
_, err := io.Copy(w, r)
if err != nil {
log.Error(ctx, "Error copying data to cache", err)
}
err = r.Close()
if err != nil {
log.Error(ctx, "Error closing transcode output", err)
}
err = w.Close()
if err != nil {
log.Error(ctx, "Error closing cache", err)
}
}
func (m *rawMediaStream) Read(p []byte) (n int, err error) {
return m.file.Read(p)
}
func (m *rawMediaStream) Seek(offset int64, whence int) (int64, error) {
return m.file.Seek(offset, whence)
}
func (m *rawMediaStream) ContentType() string {
return m.mf.ContentType()
}
func (m *rawMediaStream) Name() string {
return m.mf.Path
}
func (m *rawMediaStream) ModTime() time.Time {
return m.mf.UpdatedAt
}
func (m *rawMediaStream) Close() error {
log.Trace(m.ctx, "Closing file", "id", m.mf.ID, "path", m.mf.Path)
return m.file.Close()
}
type transcodedMediaStream struct {
type Stream struct {
ctx context.Context
mf *model.MediaFile
pipe io.ReadCloser
bitRate int
format string
skip int64
pos int64
io.Reader
io.Closer
io.Seeker
}
func (m *transcodedMediaStream) Read(p []byte) (n int, err error) {
// Open the pipe and optionally skip a initial chunk of the stream (to simulate a Seek)
if m.pipe == nil {
m.pipe, err = newTranscode(m.ctx, m.mf.Path, m.bitRate, m.format)
if err != nil {
return 0, err
}
if m.skip > 0 {
_, err := io.CopyN(ioutil.Discard, m.pipe, m.skip)
m.pos = m.skip
if err != nil {
return 0, err
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.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) {
format = "raw"
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
if reqFormat != "" {
cFormat = reqFormat
} else {
if hasDefault {
cFormat = trc.TargetFormat
cBitRate = trc.DefaultBitRate
if p, ok := ctx.Value("player").(model.Player); ok {
cBitRate = p.MaxBitRate
}
}
}
n, err = m.pipe.Read(p)
m.pos += int64(n)
if err == io.EOF {
m.Close()
if reqBitRate > 0 {
cBitRate = reqBitRate
}
if cBitRate == 0 && cFormat == "" {
return
}
t, err := ds.Transcoding(ctx).FindByFormat(cFormat)
if err == nil {
format = t.TargetFormat
if cBitRate != 0 {
bitRate = cBitRate
} else {
bitRate = t.DefaultBitRate
}
}
if format == mf.Suffix && bitRate >= mf.BitRate {
format = "raw"
bitRate = 0
}
return
}
// This is an attempt to make a pipe seekable. It is very wasteful, restarting the stream every time
// a Seek happens. This is ok-ish for audio, but would kill the server for video.
func (m *transcodedMediaStream) Seek(offset int64, whence int) (int64, error) {
size := int64((m.mf.Duration)*m.bitRate*1000) / 8
log.Trace(m.ctx, "Seeking transcoded stream", "path", m.mf.Path, "offset", offset, "whence", whence, "size", size)
func cacheKey(id string, bitRate int, format string) string {
return fmt.Sprintf("%s.%d.%s", id, bitRate, format)
}
switch whence {
case io.SeekEnd:
m.skip = size - offset
offset = size
case io.SeekStart:
m.skip = offset
case io.SeekCurrent:
io.CopyN(ioutil.Discard, m.pipe, offset)
m.pos += offset
offset = m.pos
}
// If need to Seek to a previous position, close the pipe (will be restarted on next Read)
var err error
if whence != io.SeekCurrent {
if m.pipe != nil {
err = m.Close()
func getFinalCachedSize(r fscache.ReadAtCloser) int64 {
cr, ok := r.(*fscache.CacheReader)
if ok {
size, final, err := cr.Size()
if final && err == nil {
return size
}
}
return offset, err
return -1
}
func (m *transcodedMediaStream) ContentType() string {
return mime.TypeByExtension(".mp3")
}
func (m *transcodedMediaStream) Name() string {
return m.mf.Path
}
func (m *transcodedMediaStream) ModTime() time.Time {
return m.mf.UpdatedAt
}
func (m *transcodedMediaStream) Close() error {
log.Trace(m.ctx, "Closing stream", "id", m.mf.ID, "path", m.mf.Path)
err := m.pipe.Close()
m.pipe = nil
m.pos = 0
return err
}
func newTranscode(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
cmdLine, args := createTranscodeCommand(path, maxBitRate, format)
log.Trace(ctx, "Executing ffmpeg command", "arg0", cmdLine, "args", args)
cmd := exec.Command(cmdLine, args...)
cmd.Stderr = os.Stderr
if f, err = cmd.StdoutPipe(); err != nil {
return f, err
}
return f, cmd.Start()
}
func createTranscodeCommand(path string, maxBitRate int, format string) (string, []string) {
cmd := conf.Server.DownsampleCommand
split := strings.Split(cmd, " ")
for i, s := range split {
s = strings.Replace(s, "%s", path, -1)
s = strings.Replace(s, "%b", strconv.Itoa(maxBitRate), -1)
split[i] = s
}
return split[0], split[1:]
func NewTranscodingCache() (TranscodingCache, error) {
return newFileCache("transcoding", conf.Server.TranscodingCacheSize, consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems)
}

View File

@@ -1,9 +1,10 @@
package engine
import (
"time"
"context"
"io"
"strings"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/persistence"
@@ -12,64 +13,185 @@ import (
)
var _ = Describe("MediaStreamer", func() {
var streamer MediaStreamer
var ds model.DataStore
ffmpeg := &fakeFFmpeg{Data: "fake data"}
ctx := log.NewContext(nil)
BeforeEach(func() {
conf.Server.EnableDownsampling = true
ds = &persistence.MockDataStore{}
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "bitRate": 128}]`, 1)
streamer = NewMediaStreamer(ds)
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}]`)
streamer = NewMediaStreamer(ds, ffmpeg, testCache)
})
Context("NewStream", func() {
It("returns a rawMediaStream if format is 'raw'", func() {
Expect(streamer.NewStream(ctx, "123", 0, "raw")).To(BeAssignableToTypeOf(&rawMediaStream{}))
It("returns a seekable stream if format is 'raw'", func() {
s, err := streamer.NewStream(ctx, "123", "raw", 0)
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a rawMediaStream if maxBitRate is 0", func() {
Expect(streamer.NewStream(ctx, "123", 0, "mp3")).To(BeAssignableToTypeOf(&rawMediaStream{}))
It("returns a seekable stream if maxBitRate is 0", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 0)
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a rawMediaStream if maxBitRate is higher than file bitRate", func() {
Expect(streamer.NewStream(ctx, "123", 256, "mp3")).To(BeAssignableToTypeOf(&rawMediaStream{}))
It("returns a seekable stream if maxBitRate is higher than file bitRate", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 320)
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a transcodedMediaStream if maxBitRate is lower than file bitRate", func() {
s, err := streamer.NewStream(ctx, "123", 64, "mp3")
It("returns a NON seekable stream if transcode is required", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 64)
Expect(err).To(BeNil())
Expect(s).To(BeAssignableToTypeOf(&transcodedMediaStream{}))
Expect(s.(*transcodedMediaStream).bitRate).To(Equal(64))
Expect(s.Seekable()).To(BeFalse())
Expect(s.Duration()).To(Equal(float32(257.0)))
})
It("returns a seekable stream if the file is complete in the cache", func() {
Eventually(func() bool { return ffmpeg.closed }).Should(BeTrue())
s, err := streamer.NewStream(ctx, "123", "mp3", 64)
Expect(err).To(BeNil())
Expect(s.Seekable()).To(BeTrue())
})
})
Context("rawMediaStream", func() {
var rawStream mediaStream
var modTime time.Time
BeforeEach(func() {
modTime = time.Now()
mf := &model.MediaFile{ID: "123", Path: "test.mp3", UpdatedAt: modTime, Suffix: "mp3"}
rawStream = &rawMediaStream{mf: mf, ctx: ctx}
Context("selectTranscodingOptions", func() {
mf := &model.MediaFile{}
Context("player is not configured", func() {
It("returns raw if raw is requested", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
Expect(format).To(Equal("raw"))
})
It("returns raw if a transcoder does not exists", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0)
Expect(format).To(Equal("raw"))
})
It("returns the requested format if a transcoder exists", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
Expect(format).To(Equal("mp3"))
Expect(bitRate).To(Equal(160)) // Default Bit Rate
})
It("returns raw if requested format is the same as the original and it is not necessary to downsample", func() {
mf.Suffix = "mp3"
mf.BitRate = 112
format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128)
Expect(format).To(Equal("raw"))
})
It("returns the requested format if requested BitRate is lower than original", func() {
mf.Suffix = "mp3"
mf.BitRate = 320
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
Expect(format).To(Equal("mp3"))
Expect(bitRate).To(Equal(192))
})
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))
})
})
It("returns the ContentType", func() {
Expect(rawStream.ContentType()).To(Equal("audio/mpeg"))
Context("player has format configured", func() {
BeforeEach(func() {
t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
ctx = context.WithValue(ctx, "transcoding", t)
})
It("returns raw if raw is requested", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
Expect(format).To(Equal("raw"))
})
It("returns configured format/bitrate as default", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(96))
})
It("returns requested format", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
Expect(format).To(Equal("mp3"))
Expect(bitRate).To(Equal(160)) // Default Bit Rate
})
It("returns requested bitrate", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(80))
})
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))
})
})
It("returns the ModTime", func() {
Expect(rawStream.ModTime()).To(Equal(modTime))
Context("player has maxBitRate configured", func() {
BeforeEach(func() {
t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
p := model.Player{ID: "player1", TranscodingId: t.ID, MaxBitRate: 80}
ctx = context.WithValue(ctx, "transcoding", t)
ctx = context.WithValue(ctx, "player", p)
})
It("returns raw if raw is requested", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
Expect(format).To(Equal("raw"))
})
It("returns configured format/bitrate as default", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(80))
})
It("returns requested format", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
Expect(format).To(Equal("mp3"))
Expect(bitRate).To(Equal(160)) // Default Bit Rate
})
It("returns requested bitrate", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(80))
})
})
})
Context("createTranscodeCommand", func() {
BeforeEach(func() {
conf.Server.DownsampleCommand = "ffmpeg -i %s -b:a %bk mp3 -"
})
It("creates a valid command line", func() {
cmd, args := createTranscodeCommand("/music library/file.mp3", 123, "")
Expect(cmd).To(Equal("ffmpeg"))
Expect(args).To(Equal([]string{"-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
})
})
})
type fakeFFmpeg struct {
Data string
r io.Reader
closed bool
}
func (ff *fakeFFmpeg) Start(ctx context.Context, cmd, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
ff.r = strings.NewReader(ff.Data)
return ff, nil
}
func (ff *fakeFFmpeg) Read(p []byte) (n int, err error) {
return ff.r.Read(p)
}
func (ff *fakeFFmpeg) Close() error {
ff.closed = true
return nil
}

View File

@@ -1,86 +0,0 @@
package engine
import (
"errors"
"time"
)
func CreateMockNowPlayingRepo() *MockNowPlaying {
return &MockNowPlaying{}
}
type MockNowPlaying struct {
NowPlayingRepository
data []NowPlayingInfo
t time.Time
err bool
}
func (m *MockNowPlaying) SetError(err bool) {
m.err = err
}
func (m *MockNowPlaying) Enqueue(info *NowPlayingInfo) error {
if m.err {
return errors.New("Error!")
}
m.data = append(m.data, NowPlayingInfo{})
copy(m.data[1:], m.data[0:])
m.data[0] = *info
if !m.t.IsZero() {
m.data[0].Start = m.t
m.t = time.Time{}
}
return nil
}
func (m *MockNowPlaying) Dequeue(playerId int) (*NowPlayingInfo, error) {
if len(m.data) == 0 {
return nil, nil
}
l := len(m.data)
info := m.data[l-1]
m.data = m.data[:l-1]
return &info, nil
}
func (m *MockNowPlaying) Count(playerId int) (int64, error) {
return int64(len(m.data)), nil
}
func (m *MockNowPlaying) GetAll() ([]*NowPlayingInfo, error) {
np, err := m.Head(1)
if np == nil || err != nil {
return nil, err
}
return []*NowPlayingInfo{np}, err
}
func (m *MockNowPlaying) Head(playerId int) (*NowPlayingInfo, error) {
if len(m.data) == 0 {
return nil, nil
}
info := m.data[0]
return &info, nil
}
func (m *MockNowPlaying) Tail(playerId int) (*NowPlayingInfo, error) {
if len(m.data) == 0 {
return nil, nil
}
info := m.data[len(m.data)-1]
return &info, nil
}
func (m *MockNowPlaying) ClearAll() {
m.data = make([]NowPlayingInfo, 0)
m.err = false
}
func (m *MockNowPlaying) OverrideNow(t time.Time) {
m.t = t
}

View File

@@ -1,46 +0,0 @@
package engine
import (
"errors"
"github.com/deluan/navidrome/model"
)
func CreateMockPropertyRepo() *MockProperty {
return &MockProperty{data: make(map[string]string)}
}
type MockProperty struct {
model.PropertyRepository
data map[string]string
err bool
}
func (m *MockProperty) SetError(err bool) {
m.err = err
}
func (m *MockProperty) Put(id string, value string) error {
if m.err {
return errors.New("Error!")
}
m.data[id] = value
return nil
}
func (m *MockProperty) Get(id string) (string, error) {
if m.err {
return "", errors.New("Error!")
}
return m.data[id], nil
}
func (m *MockProperty) DefaultGet(id string, defaultValue string) (string, error) {
v, err := m.Get(id)
if v == "" {
v = defaultValue
}
return v, err
}

View File

@@ -0,0 +1,22 @@
package engine
import "github.com/deluan/navidrome/model"
type mockTranscodingRepository struct {
model.TranscodingRepository
}
func (m *mockTranscodingRepository) Get(id string) (*model.Transcoding, error) {
return &model.Transcoding{ID: id, TargetFormat: "mp3", DefaultBitRate: 160}, nil
}
func (m *mockTranscodingRepository) FindByFormat(format string) (*model.Transcoding, error) {
switch format {
case "mp3":
return &model.Transcoding{ID: "mp31", TargetFormat: "mp3", DefaultBitRate: 160}, nil
case "oga":
return &model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 128}, nil
default:
return nil, model.ErrNotFound
}
}

67
engine/players.go Normal file
View File

@@ -0,0 +1,67 @@
package engine
import (
"context"
"fmt"
"time"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/google/uuid"
)
type Players interface {
Get(ctx context.Context, playerId string) (*model.Player, error)
Register(ctx context.Context, id, client, typ, ip string) (*model.Player, *model.Transcoding, error)
}
func NewPlayers(ds model.DataStore) Players {
return &players{ds}
}
type players struct {
ds model.DataStore
}
func (p *players) Register(ctx context.Context, id, client, typ, ip string) (*model.Player, *model.Transcoding, error) {
var plr *model.Player
var trc *model.Transcoding
var err error
userName := ctx.Value("username").(string)
if id != "" {
plr, err = p.ds.Player(ctx).Get(id)
if err == nil && plr.Client != client {
id = ""
}
}
if err != nil || id == "" {
plr, err = p.ds.Player(ctx).FindByName(client, userName)
if err == nil {
log.Debug("Found player by name", "id", plr.ID, "client", client, "userName", userName)
} else {
r, _ := uuid.NewRandom()
plr = &model.Player{
ID: r.String(),
Name: fmt.Sprintf("%s (%s)", client, userName),
UserName: userName,
Client: client,
}
log.Info("Registering new player", "id", plr.ID, "client", client, "userName", userName)
}
}
plr.LastSeen = time.Now()
plr.Type = typ
plr.IPAddress = ip
err = p.ds.Player(ctx).Put(plr)
if err != nil {
return nil, nil, err
}
if plr.TranscodingId != "" {
trc, err = p.ds.Transcoding(ctx).Get(plr.TranscodingId)
}
return plr, trc, err
}
func (p *players) Get(ctx context.Context, playerId string) (*model.Player, error) {
return p.ds.Player(ctx).Get(playerId)
}

138
engine/players_test.go Normal file
View File

@@ -0,0 +1,138 @@
package engine
import (
"context"
"time"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/persistence"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("Players", func() {
var players Players
var repo *mockPlayerRepository
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid", UserName: "johndoe"})
ctx = context.WithValue(ctx, "username", "johndoe")
var beforeRegister time.Time
BeforeEach(func() {
repo = &mockPlayerRepository{}
ds := &persistence.MockDataStore{MockedPlayer: repo, MockedTranscoding: &mockTranscodingRepository{}}
players = NewPlayers(ds)
beforeRegister = time.Now()
})
Describe("Register", func() {
It("creates a new player when no ID is specified", func() {
p, trc, err := players.Register(ctx, "", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.ID).ToNot(BeEmpty())
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
Expect(p.Client).To(Equal("client"))
Expect(p.UserName).To(Equal("johndoe"))
Expect(p.Type).To(Equal("chrome"))
Expect(repo.lastSaved).To(Equal(p))
Expect(trc).To(BeNil())
})
It("creates a new player if it cannot find any matching player", func() {
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.ID).ToNot(BeEmpty())
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
Expect(repo.lastSaved).To(Equal(p))
Expect(trc).To(BeNil())
})
It("creates a new player if client does not match the one in DB", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client1111", LastSeen: time.Time{}}
repo.add(plr)
p, trc, err := players.Register(ctx, "123", "client2222", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.ID).ToNot(BeEmpty())
Expect(p.ID).ToNot(Equal("123"))
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
Expect(p.Client).To(Equal("client2222"))
Expect(trc).To(BeNil())
})
It("finds players by ID", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}}
repo.add(plr)
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.ID).To(Equal("123"))
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
Expect(repo.lastSaved).To(Equal(p))
Expect(trc).To(BeNil())
})
It("finds player by client and user names when ID is not found", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserName: "johndoe", LastSeen: time.Time{}}
repo.add(plr)
p, _, err := players.Register(ctx, "999", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.ID).To(Equal("123"))
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
Expect(repo.lastSaved).To(Equal(p))
})
It("finds player by client and user names when not ID is provided", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserName: "johndoe", LastSeen: time.Time{}}
repo.add(plr)
p, _, err := players.Register(ctx, "", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.ID).To(Equal("123"))
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
Expect(repo.lastSaved).To(Equal(p))
})
It("finds player by ID and return its transcoding", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}, TranscodingId: "1"}
repo.add(plr)
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.ID).To(Equal("123"))
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
Expect(repo.lastSaved).To(Equal(p))
Expect(trc.ID).To(Equal("1"))
})
})
})
type mockPlayerRepository struct {
model.PlayerRepository
lastSaved *model.Player
data map[string]model.Player
}
func (m *mockPlayerRepository) add(p *model.Player) {
if m.data == nil {
m.data = make(map[string]model.Player)
}
m.data[p.ID] = *p
}
func (m *mockPlayerRepository) Get(id string) (*model.Player, error) {
if p, ok := m.data[id]; ok {
return &p, nil
}
return nil, model.ErrNotFound
}
func (m *mockPlayerRepository) FindByName(client, userName string) (*model.Player, error) {
for _, p := range m.data {
if p.Client == client && p.UserName == userName {
return &p, nil
}
}
return nil, model.ErrNotFound
}
func (m *mockPlayerRepository) Put(p *model.Player) error {
m.lastSaved = p
return nil
}

View File

@@ -51,7 +51,7 @@ func (p *playlists) Create(ctx context.Context, playlistId, name string, ids []s
}
func (p *playlists) getUser(ctx context.Context) string {
user, ok := ctx.Value("user").(*model.User)
user, ok := ctx.Value("user").(model.User)
if ok {
return user.UserName
}
@@ -73,15 +73,15 @@ func (p *playlists) Delete(ctx context.Context, playlistId string) error {
func (p *playlists) Update(ctx context.Context, playlistId string, name *string, idsToAdd []string, idxToRemove []int) error {
pls, err := p.ds.Playlist(ctx).Get(playlistId)
if err != nil {
return err
}
owner := p.getUser(ctx)
if owner != pls.Owner {
return model.ErrNotAuthorized
}
if err != nil {
return err
}
if name != nil {
pls.Name = *name
}
@@ -102,7 +102,11 @@ func (p *playlists) Update(ctx context.Context, playlistId string, name *string,
}
func (p *playlists) GetAll(ctx context.Context) (model.Playlists, error) {
return p.ds.Playlist(ctx).GetAll(model.QueryOptions{})
all, err := p.ds.Playlist(ctx).GetAll(model.QueryOptions{})
for i := range all {
all[i].Public = true
}
return all, err
}
type PlaylistInfo struct {
@@ -127,7 +131,7 @@ func (p *playlists) Get(ctx context.Context, id string) (*PlaylistInfo, error) {
Id: pl.ID,
Name: pl.Name,
SongCount: len(pl.Tracks),
Duration: pl.Duration,
Duration: int(pl.Duration),
Public: pl.Public,
Owner: pl.Owner,
Comment: pl.Comment,

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

@@ -0,0 +1,50 @@
package transcoder
import (
"context"
"io"
"os"
"os/exec"
"strconv"
"strings"
"github.com/deluan/navidrome/log"
)
type Transcoder interface {
Start(ctx context.Context, command, path string, maxBitRate int, format string) (f io.ReadCloser, err error)
}
func New() Transcoder {
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)
log.Trace(ctx, "Executing ffmpeg command", "cmd", arg0, "args", args)
cmd := exec.Command(arg0, args...)
cmd.Stderr = os.Stderr
if f, err = cmd.StdoutPipe(); err != nil {
return
}
if err = cmd.Start(); err != nil {
return
}
go cmd.Wait() // prevent zombies
return
}
// Path will always be an absolute path
func createTranscodeCommand(cmd, path string, maxBitRate int, format string) (string, []string) {
split := strings.Split(cmd, " ")
for i, s := range split {
s = strings.Replace(s, "%s", path, -1)
s = strings.Replace(s, "%b", strconv.Itoa(maxBitRate), -1)
split[i] = s
}
return split[0], split[1:]
}

View File

@@ -0,0 +1,25 @@
package transcoder
import (
"testing"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/tests"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
func TestTranscoder(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelCritical)
RegisterFailHandler(Fail)
RunSpecs(t, "Transcoder Suite")
}
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", "-"}))
})
})

View File

@@ -1,6 +1,9 @@
package engine
import "github.com/google/wire"
import (
"github.com/deluan/navidrome/engine/transcoder"
"github.com/google/wire"
)
var Set = wire.NewSet(
NewBrowser,
@@ -13,4 +16,8 @@ var Set = wire.NewSet(
NewNowPlayingRepository,
NewUsers,
NewMediaStreamer,
transcoder.New,
NewTranscodingCache,
NewImageCache,
NewPlayers,
)

20
go.mod
View File

@@ -1,19 +1,22 @@
module github.com/deluan/navidrome
go 1.13
go 1.14
require (
github.com/BurntSushi/toml v0.3.1 // indirect
github.com/Masterminds/squirrel v1.2.0
github.com/astaxie/beego v1.12.0
github.com/astaxie/beego v1.12.1
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4
github.com/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/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.3+incompatible
github.com/go-chi/cors v1.0.0
github.com/go-chi/chi v4.1.0+incompatible
github.com/go-chi/cors v1.0.1
github.com/go-chi/jwtauth v4.0.4+incompatible
github.com/go-sql-driver/mysql v1.5.0 // indirect
github.com/golang/protobuf v1.3.1 // indirect
@@ -24,19 +27,18 @@ 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.4.2
github.com/smartystreets/assertions v1.0.1 // indirect
github.com/smartystreets/goconvey v1.6.4
github.com/sirupsen/logrus v1.5.0
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
golang.org/x/text v0.3.2 // indirect
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
gopkg.in/djherbis/atime.v1 v1.0.0 // indirect
gopkg.in/djherbis/stream.v1 v1.2.0 // indirect
gopkg.in/yaml.v2 v2.2.8 // indirect
)

46
go.sum
View File

@@ -4,8 +4,8 @@ github.com/Knetic/govaluate v3.0.0+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8L
github.com/Masterminds/squirrel v1.2.0 h1:K1NhbTO21BWG47IVR0OnIZuE0LZcXAYqywrC3Ko53KI=
github.com/Masterminds/squirrel v1.2.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
github.com/OwnLocal/goes v1.0.0/go.mod h1:8rIFjBGTue3lCU0wplczcUgt9Gxgrkkrw7etMIcn8TM=
github.com/astaxie/beego v1.12.0 h1:MRhVoeeye5N+Flul5PoVfD9CslfdoH+xqC/xvSQ5u2Y=
github.com/astaxie/beego v1.12.0/go.mod h1:fysx+LZNZKnvh4GED/xND7jWtjCR6HzydR2Hh2Im57o=
github.com/astaxie/beego v1.12.1 h1:dfpuoxpzLVgclveAXe4PyNKqkzgm5zF4tgF2B3kkM2I=
github.com/astaxie/beego v1.12.1/go.mod h1:kPBWpSANNbSdIqOc8SUL9h+1oyBMZhROeYsXQDbidWQ=
github.com/beego/goyaml2 v0.0.0-20130207012346-5545475820dd/go.mod h1:1b+Y/CofkYwXMUU0OhQqGvsY2Bvgr4j6jfT699wyZKQ=
github.com/beego/x2j v0.0.0-20131220205130-a0352aadc542/go.mod h1:kSeGC/p1AbBiEp5kat81+DSQrZenVBZXklMLaELspWU=
github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60=
@@ -22,12 +22,18 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4 h1:VSoAcWJvj656TSyWbJ5KuGsi/J8dO5+iO9+5/7I8wao=
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0 h1:qHX6TTBIsrpg0JkYNkoePIpstV37lhRVLj23bHUDNwk=
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/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=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
@@ -37,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.3+incompatible h1:gakN3pDJnzZN5jqFV2TEdF66rTfKeITyR8qu6ekICEY=
github.com/go-chi/chi v4.0.3+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/cors v1.0.0 h1:e6x8k7uWbUwYs+aXDoiUzeQFT6l0cygBYyNhD7/1Tg0=
github.com/go-chi/cors v1.0.0/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
github.com/go-chi/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.0.1 h1:56TT/uWGoLWZpnMI/AwAmCneikXr5eLsiIq27wrKecw=
github.com/go-chi/cors v1.0.1/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
github.com/go-chi/jwtauth v4.0.4+incompatible h1:LGIxg6YfvSBzxU2BljXbrzVc1fMlgqSKBQgKOGAVtPY=
github.com/go-chi/jwtauth v4.0.4+incompatible/go.mod h1:Q5EIArY/QnD6BdS+IyDw7B2m6iNbnPxtfd6/BcmtWbs=
github.com/go-redis/redis v6.14.2+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
@@ -89,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=
@@ -115,10 +119,10 @@ github.com/siddontang/rdb v0.0.0-20150307021120-fc89ed2e418d h1:NVwnfyR3rENtlz62
github.com/siddontang/rdb v0.0.0-20150307021120-fc89ed2e418d/go.mod h1:AMEsy7v5z92TR1JKMkLLoaOQk++LVnOKL3ScbJ8GNGA=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
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=
@@ -130,19 +134,26 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
github.com/syndtr/goleveldb v0.0.0-20181127023241-353a9fca669c h1:3eGShk3EQf5gJCYW+WzA0TEJQd37HLOmlYF7N0YJwv0=
github.com/syndtr/goleveldb v0.0.0-20181127023241-353a9fca669c/go.mod h1:Z4AUp2Km+PwemOoO/VB5AOx9XSsIItzFjoJlOSiYmn0=
github.com/wendal/errors v0.0.0-20130201093226-f66c77a7882b/go.mod h1:Q12BUT7DqIlHRmgv3RskH+UCM/4eqVMgI0EMmlSpAXc=
golang.org/x/crypto v0.0.0-20181127143415-eb0de9b17e85/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/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-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2 h1:CCH4IOTTfewWjGOlSp+zGcjutRKlBEZQ6wTn8ozI/nI=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -157,14 +168,21 @@ 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=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/djherbis/atime.v1 v1.0.0 h1:eMRqB/JrLKocla2PBPKgQYg/p5UG4L6AUAs92aP7F60=
gopkg.in/djherbis/atime.v1 v1.0.0/go.mod h1:hQIUStKmJfvf7xdh/wtK84qe+DsTV5LnA9lzxxtPpJ8=
gopkg.in/djherbis/stream.v1 v1.2.0 h1:3tZuXO+RK8opjw8/BJr780h+eAPwOFfLHCKRKyYxk3s=
gopkg.in/djherbis/stream.v1 v1.2.0/go.mod h1:aEV8CBVRmSpLamVJfM903Npic1IKmb2qS30VAZ+sssg=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=

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

14
main.go
View File

@@ -1,21 +1,25 @@
package main
import (
"fmt"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/db"
)
func main() {
if !conf.Server.DevDisableBanner {
println(consts.Banner())
}
println(consts.Banner())
conf.Load()
db.EnsureLatestVersion()
subsonic, err := CreateSubsonicAPIRouter()
if err != nil {
panic(fmt.Sprintf("Could not create the Subsonic API router. Aborting! err=%v", err))
}
a := CreateServer(conf.Server.MusicFolder)
a.MountRouter("/rest", CreateSubsonicAPIRouter())
a.MountRouter("/app", CreateAppRouter("/app"))
a.MountRouter(consts.URLPathSubsonicAPI, subsonic)
a.MountRouter(consts.URLPathUI, CreateAppRouter())
a.Run(":" + conf.Server.Port)
}

View File

@@ -3,27 +3,30 @@ package model
import "time"
type Album struct {
ID string `json:"id" orm:"column(id)"`
Name string `json:"name"`
ArtistID string `json:"artistId" orm:"pk;column(artist_id)"`
CoverArtPath string `json:"coverArtPath"`
CoverArtId string `json:"coverArtId"`
Artist string `json:"artist"`
AlbumArtist string `json:"albumArtist"`
Year int `json:"year"`
Compilation bool `json:"compilation"`
SongCount int `json:"songCount"`
Duration int `json:"duration"`
Genre string `json:"genre"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id" orm:"column(id)"`
Name string `json:"name"`
CoverArtPath string `json:"coverArtPath"`
CoverArtId string `json:"coverArtId"`
ArtistID string `json:"artistId" orm:"pk;column(artist_id)"`
Artist string `json:"artist"`
AlbumArtistID string `json:"albumArtistId" orm:"pk;column(album_artist_id)"`
AlbumArtist string `json:"albumArtist"`
MaxYear int `json:"maxYear"`
MinYear int `json:"minYear"`
Compilation bool `json:"compilation"`
SongCount int `json:"songCount"`
Duration float32 `json:"duration"`
Genre string `json:"genre"`
FullText string `json:"fullText"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// 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
@@ -31,9 +34,8 @@ 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(artistId string) (Albums, error)
FindByArtist(albumArtistId string) (Albums, error)
GetAll(...QueryOptions) (Albums, error)
GetRandom(...QueryOptions) (Albums, error)
GetStarred(options ...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

@@ -6,13 +6,14 @@ type Artist struct {
ID string `json:"id" orm:"column(id)"`
Name string `json:"name"`
AlbumCount int `json:"albumCount" orm:"column(album_count)"`
FullText string `json:"fullText"`
// Annotations
PlayCount int `json:"-" orm:"-"`
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

@@ -28,6 +28,8 @@ type DataStore interface {
Playlist(ctx context.Context) PlaylistRepository
Property(ctx context.Context) PropertyRepository
User(ctx context.Context) UserRepository
Transcoding(ctx context.Context) TranscodingRepository
Player(ctx context.Context) PlayerRepository
Resource(ctx context.Context, model interface{}) ResourceRepository

View File

@@ -6,33 +6,35 @@ import (
)
type MediaFile struct {
ID string `json:"id" orm:"pk;column(id)"`
Path string `json:"path"`
Title string `json:"title"`
Album string `json:"album"`
Artist string `json:"artist"`
ArtistID string `json:"artistId" orm:"pk;column(artist_id)"`
AlbumArtist string `json:"albumArtist"`
AlbumID string `json:"albumId" orm:"pk;column(album_id)"`
HasCoverArt bool `json:"hasCoverArt"`
TrackNumber int `json:"trackNumber"`
DiscNumber int `json:"discNumber"`
Year int `json:"year"`
Size int `json:"size"`
Suffix string `json:"suffix"`
Duration int `json:"duration"`
BitRate int `json:"bitRate"`
Genre string `json:"genre"`
Compilation bool `json:"compilation"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id" orm:"pk;column(id)"`
Path string `json:"path"`
Title string `json:"title"`
Album string `json:"album"`
ArtistID string `json:"artistId" orm:"pk;column(artist_id)"`
Artist string `json:"artist"`
AlbumArtistID string `json:"albumArtistId"`
AlbumArtist string `json:"albumArtist"`
AlbumID string `json:"albumId" orm:"pk;column(album_id)"`
HasCoverArt bool `json:"hasCoverArt"`
TrackNumber int `json:"trackNumber"`
DiscNumber int `json:"discNumber"`
Year int `json:"year"`
Size int `json:"size"`
Suffix string `json:"suffix"`
Duration float32 `json:"duration"`
BitRate int `json:"bitRate"`
Genre string `json:"genre"`
FullText string `json:"fullText"`
Compilation bool `json:"compilation"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// 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 {

25
model/player.go Normal file
View File

@@ -0,0 +1,25 @@
package model
import (
"time"
)
type Player struct {
ID string `json:"id" orm:"column(id)"`
Name string `json:"name"`
Type string `json:"type"`
UserName string `json:"userName"`
Client string `json:"client"`
IPAddress string `json:"ipAddress"`
LastSeen time.Time `json:"lastSeen"`
TranscodingId string `json:"transcodingId"`
MaxBitRate int `json:"maxBitRate"`
}
type Players []Player
type PlayerRepository interface {
Get(id string) (*Player, error)
FindByName(client, userName string) (*Player, error)
Put(p *Player) error
}

View File

@@ -4,7 +4,7 @@ type Playlist struct {
ID string
Name string
Comment string
Duration int
Duration float32
Owner string
Public bool
Tracks MediaFiles

18
model/transcoding.go Normal file
View File

@@ -0,0 +1,18 @@
package model
type Transcoding struct {
ID string `json:"id" orm:"column(id)"`
Name string `json:"name"`
TargetFormat string `json:"targetFormat"`
Command string `json:"command"`
DefaultBitRate int `json:"defaultBitRate"`
}
type Transcodings []Transcoding
type TranscodingRepository interface {
Get(id string) (*Transcoding, error)
CountAll(...QueryOptions) (int64, error)
Put(*Transcoding) error
FindByFormat(format string) (*Transcoding, error)
}

View File

@@ -22,6 +22,7 @@ type UserRepository interface {
CountAll(...QueryOptions) (int64, error)
Get(id string) (*User, error)
Put(*User) error
// FindByUsername must be case-insensitive
FindByUsername(username string) (*User, error)
UpdateLastLoginAt(id string) error
UpdateLastAccessAt(id string) error

View File

@@ -6,6 +6,7 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/rest"
@@ -13,6 +14,7 @@ import (
type albumRepository struct {
sqlRepository
sqlRestful
}
func NewAlbumRepository(ctx context.Context, o orm.Ormer) model.AlbumRepository {
@@ -20,9 +22,41 @@ func NewAlbumRepository(ctx context.Context, o orm.Ormer) model.AlbumRepository
r.ctx = ctx
r.ormer = o
r.tableName = "album"
r.sortMappings = map[string]string{
"artist": "compilation asc, album_artist asc, name asc",
"random": "RANDOM()",
}
r.filterMappings = map[string]filterFunc{
"name": fullTextFilter,
"compilation": booleanFilter,
"artist_id": artistFilter,
"year": yearFilter,
}
return r
}
func yearFilter(field string, value interface{}) Sqlizer {
return Or{
And{
Gt{"min_year": 0},
LtOrEq{"min_year": value},
GtOrEq{"max_year": value},
},
Eq{"max_year": value},
}
}
func artistFilter(field string, value interface{}) Sqlizer {
return Exists("media_file", And{
ConcatExpr("album_id=album.id"),
Or{
Eq{"artist_id": value},
Eq{"album_artist_id": value},
},
})
}
func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) {
return r.count(Select(), options...)
}
@@ -31,16 +65,8 @@ func (r *albumRepository) Exists(id string) (bool, error) {
return r.exists(Select().Where(Eq{"id": id}))
}
func (r *albumRepository) Put(a *model.Album) error {
_, err := r.put(a.ID, a)
if err != nil {
return err
}
return r.index(a.ID, a.Name)
}
func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder {
return r.newSelectWithAnnotation("id", options...).Columns("*")
return r.newSelectWithAnnotation("album.id", options...).Columns("*")
}
func (r *albumRepository) Get(id string) (*model.Album, error) {
@@ -54,7 +80,7 @@ func (r *albumRepository) Get(id string) (*model.Album, error) {
}
func (r *albumRepository) FindByArtist(artistId string) (model.Albums, error) {
sq := r.selectAlbum().Where(Eq{"artist_id": artistId}).OrderBy("year")
sq := r.selectAlbum().Where(Eq{"album_artist_id": artistId}).OrderBy("max_year")
res := model.Albums{}
err := r.queryAll(sq, &res)
return res, err
@@ -70,12 +96,7 @@ func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, e
// TODO Keep order when paginating
func (r *albumRepository) GetRandom(options ...model.QueryOptions) (model.Albums, error) {
sq := r.selectAlbum(options...)
switch r.ormer.Driver().Type() {
case orm.DRMySQL:
sq = sq.OrderBy("RAND()")
default:
sq = sq.OrderBy("RANDOM()")
}
sq = sq.OrderBy("RANDOM()")
results := model.Albums{}
err := r.queryAll(sq, &results)
return results, err
@@ -86,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.compilation, f.genre,
max(f.year) as 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`).
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,
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")
@@ -106,10 +129,12 @@ func (r *albumRepository) Refresh(ids ...string) error {
al.CoverArtId = ""
}
if al.Compilation {
al.AlbumArtist = "Various Artists"
al.AlbumArtist = consts.VariousArtists
al.AlbumArtistID = consts.VariousArtistsID
}
if al.AlbumArtist == "" {
al.AlbumArtist = al.Artist
al.AlbumArtistID = al.ArtistID
}
al.UpdatedAt = time.Now()
if al.CurrentId != "" {
@@ -118,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

@@ -14,7 +14,7 @@ var _ = Describe("AlbumRepository", func() {
var repo model.AlbumRepository
BeforeEach(func() {
ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"})
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
repo = NewAlbumRepository(ctx, orm.NewOrm())
})

View File

@@ -16,6 +16,7 @@ import (
type artistRepository struct {
sqlRepository
sqlRestful
indexGroups utils.IndexGroups
}
@@ -25,11 +26,14 @@ func NewArtistRepository(ctx context.Context, o orm.Ormer) model.ArtistRepositor
r.ormer = o
r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups)
r.tableName = "artist"
r.filterMappings = map[string]filterFunc{
"name": fullTextFilter,
}
return r
}
func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder {
return r.newSelectWithAnnotation("id", options...).Columns("*")
return r.newSelectWithAnnotation("artist.id", options...).Columns("*")
}
func (r *artistRepository) CountAll(options ...model.QueryOptions) (int64, error) {
@@ -52,11 +56,9 @@ func (r *artistRepository) getIndexKey(a *model.Artist) string {
}
func (r *artistRepository) Put(a *model.Artist) error {
a.FullText = r.getFullText(a.Name)
_, err := r.put(a.ID, a)
if err != nil {
return err
}
return r.index(a.ID, a.Name)
return err
}
func (r *artistRepository) Get(id string) (*model.Artist, error) {
@@ -106,17 +108,14 @@ func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
func (r *artistRepository) Refresh(ids ...string) error {
type refreshArtist struct {
model.Artist
CurrentId string
AlbumArtist string
Compilation bool
CurrentId string
}
var artists []refreshArtist
sel := Select("f.artist_id as id", "f.artist as name", "f.album_artist", "f.compilation",
"count(*) as album_count", "a.id as current_id").
sel := Select("f.album_artist_id as id", "f.album_artist as name", "count(*) as album_count", "a.id as current_id").
From("album f").
LeftJoin("artist a on f.artist_id = a.id").
Where(Eq{"f.artist_id": ids}).
GroupBy("f.artist_id").OrderBy("f.id")
LeftJoin("artist a on f.album_artist_id = a.id").
Where(Eq{"f.album_artist_id": ids}).
GroupBy("f.album_artist_id").OrderBy("f.id")
err := r.queryAll(sel, &artists)
if err != nil {
return err
@@ -125,12 +124,6 @@ func (r *artistRepository) Refresh(ids ...string) error {
toInsert := 0
toUpdate := 0
for _, ar := range artists {
if ar.Compilation {
ar.AlbumArtist = "Various Artists"
}
if ar.AlbumArtist != "" {
ar.Name = ar.AlbumArtist
}
if ar.CurrentId != "" {
toUpdate++
} else {
@@ -158,7 +151,7 @@ func (r *artistRepository) GetStarred(options ...model.QueryOptions) (model.Arti
}
func (r *artistRepository) PurgeEmpty() error {
del := Delete(r.tableName).Where("id not in (select distinct(artist_id) from album)")
del := Delete(r.tableName).Where("id not in (select distinct(album_artist_id) from album)")
c, err := r.executeSQL(del)
if err == nil {
if c > 0 {

View File

@@ -14,7 +14,7 @@ var _ = Describe("ArtistRepository", func() {
var repo model.ArtistRepository
BeforeEach(func() {
ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"})
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
repo = NewArtistRepository(ctx, orm.NewOrm())
})
@@ -24,7 +24,7 @@ var _ = Describe("ArtistRepository", func() {
})
})
Describe("Exist", func() {
Describe("Exists", func() {
It("returns true for an artist that is in the DB", func() {
Expect(repo.Exists("3")).To(BeTrue())
})

View File

@@ -5,6 +5,10 @@ import (
"fmt"
"regexp"
"strings"
"github.com/Masterminds/squirrel"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/utils"
)
func toSqlArgs(rec interface{}) (map[string]interface{}, error) {
@@ -19,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
}
@@ -33,30 +39,17 @@ func toSnakeCase(str string) string {
return strings.ToLower(snake)
}
func ToStruct(m map[string]interface{}, rec interface{}, fieldNames []string) error {
var r = make(map[string]interface{}, len(m))
for _, f := range fieldNames {
v, ok := m[f]
if !ok {
return fmt.Errorf("invalid field '%s'", f)
}
r[toCamelCase(f)] = v
}
// Convert to JSON...
b, err := json.Marshal(r)
if err != nil {
return err
}
// ... then convert to struct
err = json.Unmarshal(b, &rec)
return err
func Exists(subTable string, cond squirrel.Sqlizer) exists {
return exists{subTable: subTable, cond: cond}
}
var matchUnderscore = regexp.MustCompile("_([A-Za-z])")
func toCamelCase(str string) string {
return matchUnderscore.ReplaceAllStringFunc(str, func(s string) string {
return strings.ToUpper(strings.Replace(s, "_", "", -1))
})
type exists struct {
subTable string
cond squirrel.Sqlizer
}
func (e exists) ToSql() (string, []interface{}, error) {
sql, args, err := e.cond.ToSql()
sql = fmt.Sprintf("exists (select 1 from %s where %s)", e.subTable, sql)
return sql, args, err
}

View File

@@ -0,0 +1,19 @@
package persistence
import (
"github.com/Masterminds/squirrel"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("Helpers", func() {
Describe("Exists", func() {
It("constructs the correct EXISTS query", func() {
e := Exists("album", squirrel.Eq{"id": 1})
sql, args, err := e.ToSql()
Expect(sql).To(Equal("exists (select 1 from album where id = ?)"))
Expect(args).To(Equal([]interface{}{1}))
Expect(err).To(BeNil())
})
})
})

View File

@@ -14,6 +14,7 @@ import (
type mediaFileRepository struct {
sqlRepository
sqlRestful
}
func NewMediaFileRepository(ctx context.Context, o orm.Ormer) *mediaFileRepository {
@@ -25,6 +26,9 @@ func NewMediaFileRepository(ctx context.Context, o orm.Ormer) *mediaFileReposito
"artist": "artist asc, album asc, disc_number asc, track_number asc",
"album": "album asc, disc_number asc, track_number asc",
}
r.filterMappings = map[string]filterFunc{
"title": fullTextFilter,
}
return r
}
@@ -37,11 +41,9 @@ func (r mediaFileRepository) Exists(id string) (bool, error) {
}
func (r mediaFileRepository) Put(m *model.MediaFile) error {
m.FullText = r.getFullText(m.Title, m.Album, m.Artist, m.AlbumArtist)
_, err := r.put(m.ID, m)
if err != nil {
return err
}
return r.index(m.ID, m.Title)
return err
}
func (r mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder {
@@ -100,12 +102,7 @@ func (r mediaFileRepository) GetStarred(options ...model.QueryOptions) (model.Me
// TODO Keep order when paginating
func (r mediaFileRepository) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) {
sq := r.selectMediaFile(options...)
switch r.ormer.Driver().Type() {
case orm.DRMySQL:
sq = sq.OrderBy("RAND()")
default:
sq = sq.OrderBy("RANDOM()")
}
sq = sq.OrderBy("RANDOM()")
results := model.MediaFiles{}
err := r.queryAll(sq, &results)
return results, err

View File

@@ -16,7 +16,7 @@ var _ = Describe("MediaRepository", func() {
var mr model.MediaFileRepository
BeforeEach(func() {
ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"})
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
mr = NewMediaFileRepository(ctx, orm.NewOrm())
})

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
}
@@ -68,8 +71,8 @@ func (m *MockAlbum) FindByArtist(artistId string) (model.Albums, error) {
var res = make(model.Albums, len(m.data))
i := 0
for _, a := range m.data {
if a.ArtistID == artistId {
res[i] = *a
if a.AlbumArtistID == artistId {
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

@@ -7,11 +7,13 @@ import (
)
type MockDataStore struct {
MockedGenre model.GenreRepository
MockedAlbum model.AlbumRepository
MockedArtist model.ArtistRepository
MockedMediaFile model.MediaFileRepository
MockedUser model.UserRepository
MockedGenre model.GenreRepository
MockedAlbum model.AlbumRepository
MockedArtist model.ArtistRepository
MockedMediaFile model.MediaFileRepository
MockedUser model.UserRepository
MockedPlayer model.PlayerRepository
MockedTranscoding model.TranscodingRepository
}
func (db *MockDataStore) Album(context.Context) model.AlbumRepository {
@@ -61,6 +63,20 @@ func (db *MockDataStore) User(context.Context) model.UserRepository {
return db.MockedUser
}
func (db *MockDataStore) Transcoding(context.Context) model.TranscodingRepository {
if db.MockedTranscoding != nil {
return db.MockedTranscoding
}
return struct{ model.TranscodingRepository }{}
}
func (db *MockDataStore) Player(context.Context) model.PlayerRepository {
if db.MockedPlayer != nil {
return db.MockedPlayer
}
return struct{ model.PlayerRepository }{}
}
func (db *MockDataStore) WithTx(block func(db model.DataStore) error) error {
return block(db)
}

View File

@@ -3,7 +3,6 @@ package persistence
import (
"context"
"reflect"
"sync"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/db"
@@ -11,74 +10,79 @@ import (
"github.com/deluan/navidrome/model"
)
var (
once sync.Once
)
type SQLStore struct {
orm orm.Ormer
}
func New() model.DataStore {
once.Do(func() {
err := orm.RegisterDataBase("default", db.Driver, db.Path)
if err != nil {
panic(err)
}
})
return &SQLStore{}
}
func (db *SQLStore) Album(ctx context.Context) model.AlbumRepository {
return NewAlbumRepository(ctx, db.getOrmer())
func (s *SQLStore) Album(ctx context.Context) model.AlbumRepository {
return NewAlbumRepository(ctx, s.getOrmer())
}
func (db *SQLStore) Artist(ctx context.Context) model.ArtistRepository {
return NewArtistRepository(ctx, db.getOrmer())
func (s *SQLStore) Artist(ctx context.Context) model.ArtistRepository {
return NewArtistRepository(ctx, s.getOrmer())
}
func (db *SQLStore) MediaFile(ctx context.Context) model.MediaFileRepository {
return NewMediaFileRepository(ctx, db.getOrmer())
func (s *SQLStore) MediaFile(ctx context.Context) model.MediaFileRepository {
return NewMediaFileRepository(ctx, s.getOrmer())
}
func (db *SQLStore) MediaFolder(ctx context.Context) model.MediaFolderRepository {
return NewMediaFolderRepository(ctx, db.getOrmer())
func (s *SQLStore) MediaFolder(ctx context.Context) model.MediaFolderRepository {
return NewMediaFolderRepository(ctx, s.getOrmer())
}
func (db *SQLStore) Genre(ctx context.Context) model.GenreRepository {
return NewGenreRepository(ctx, db.getOrmer())
func (s *SQLStore) Genre(ctx context.Context) model.GenreRepository {
return NewGenreRepository(ctx, s.getOrmer())
}
func (db *SQLStore) Playlist(ctx context.Context) model.PlaylistRepository {
return NewPlaylistRepository(ctx, db.getOrmer())
func (s *SQLStore) Playlist(ctx context.Context) model.PlaylistRepository {
return NewPlaylistRepository(ctx, s.getOrmer())
}
func (db *SQLStore) Property(ctx context.Context) model.PropertyRepository {
return NewPropertyRepository(ctx, db.getOrmer())
func (s *SQLStore) Property(ctx context.Context) model.PropertyRepository {
return NewPropertyRepository(ctx, s.getOrmer())
}
func (db *SQLStore) User(ctx context.Context) model.UserRepository {
return NewUserRepository(ctx, db.getOrmer())
func (s *SQLStore) User(ctx context.Context) model.UserRepository {
return NewUserRepository(ctx, s.getOrmer())
}
func (db *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
func (s *SQLStore) Transcoding(ctx context.Context) model.TranscodingRepository {
return NewTranscodingRepository(ctx, s.getOrmer())
}
func (s *SQLStore) Player(ctx context.Context) model.PlayerRepository {
return NewPlayerRepository(ctx, s.getOrmer())
}
func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
switch m.(type) {
case model.User:
return db.User(ctx).(model.ResourceRepository)
return s.User(ctx).(model.ResourceRepository)
case model.Transcoding:
return s.Transcoding(ctx).(model.ResourceRepository)
case model.Player:
return s.Player(ctx).(model.ResourceRepository)
case model.Artist:
return db.Artist(ctx).(model.ResourceRepository)
return s.Artist(ctx).(model.ResourceRepository)
case model.Album:
return db.Album(ctx).(model.ResourceRepository)
return s.Album(ctx).(model.ResourceRepository)
case model.MediaFile:
return db.MediaFile(ctx).(model.ResourceRepository)
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
}
func (db *SQLStore) WithTx(block func(tx model.DataStore) error) error {
o := orm.NewOrm()
err := o.Begin()
func (s *SQLStore) WithTx(block func(tx model.DataStore) error) error {
o, err := orm.NewOrmWithDB(db.Driver, "default", db.Db())
if err != nil {
return err
}
err = o.Begin()
if err != nil {
return err
}
@@ -101,41 +105,33 @@ func (db *SQLStore) WithTx(block func(tx model.DataStore) error) error {
return nil
}
func (db *SQLStore) GC(ctx context.Context) error {
err := db.Album(ctx).PurgeEmpty()
func (s *SQLStore) GC(ctx context.Context) error {
err := s.Album(ctx).PurgeEmpty()
if err != nil {
return err
}
err = db.Artist(ctx).PurgeEmpty()
err = s.Artist(ctx).PurgeEmpty()
if err != nil {
return err
}
err = db.MediaFile(ctx).(*mediaFileRepository).cleanSearchIndex()
err = s.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations()
if err != nil {
return err
}
err = db.Album(ctx).(*albumRepository).cleanSearchIndex()
err = s.Album(ctx).(*albumRepository).cleanAnnotations()
if err != nil {
return err
}
err = db.Artist(ctx).(*artistRepository).cleanSearchIndex()
if err != nil {
return err
}
err = db.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations()
if err != nil {
return err
}
err = db.Album(ctx).(*albumRepository).cleanAnnotations()
if err != nil {
return err
}
return db.Artist(ctx).(*artistRepository).cleanAnnotations()
return s.Artist(ctx).(*artistRepository).cleanAnnotations()
}
func (db *SQLStore) getOrmer() orm.Ormer {
if db.orm == nil {
return orm.NewOrm()
func (s *SQLStore) getOrmer() orm.Ormer {
if s.orm == nil {
o, err := orm.NewOrmWithDB(db.Driver, "default", db.Db())
if err != nil {
log.Error("Error obtaining new orm instance", err)
}
return o
}
return db.orm
return s.orm
}

View File

@@ -21,8 +21,8 @@ func TestPersistence(t *testing.T) {
//os.Remove("./test-123.db")
//conf.Server.Path = "./test-123.db"
conf.Server.DbPath = ":memory:"
db.Init()
conf.Server.DbPath = "file::memory:?cache=shared"
orm.RegisterDataBase("default", db.Driver, conf.Server.DbPath)
New()
db.EnsureLatestVersion()
log.SetLevel(log.LevelCritical)
@@ -31,8 +31,8 @@ func TestPersistence(t *testing.T) {
}
var (
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1}
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2}
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1, FullText: "kraftwerk"}
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2, FullText: "beatles the"}
testArtists = model.Artists{
artistKraftwerk,
artistBeatles,
@@ -40,9 +40,9 @@ var (
)
var (
albumSgtPeppers = model.Album{ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", ArtistID: "3", Genre: "Rock", CoverArtId: "1", CoverArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, Year: 1967}
albumAbbeyRoad = model.Album{ID: "2", Name: "Abbey Road", Artist: "The Beatles", ArtistID: "3", Genre: "Rock", CoverArtId: "2", CoverArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, Year: 1969}
albumRadioactivity = model.Album{ID: "3", Name: "Radioactivity", Artist: "Kraftwerk", ArtistID: "2", Genre: "Electronic", CoverArtId: "3", CoverArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2}
albumSgtPeppers = model.Album{ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", 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")}
songComeTogether = model.MediaFile{ID: "2", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "2", Album: "Abbey Road", Genre: "Rock", Path: P("/beatles/1/come together.mp3")}
songRadioactivity = model.MediaFile{ID: "3", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Album: "Radioactivity", Genre: "Electronic", Path: P("/kraft/radio/radio.mp3")}
songAntenna = model.MediaFile{ID: "4", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Genre: "Electronic", Path: P("/kraft/radio/antenna.mp3")}
songDayInALife = model.MediaFile{ID: "1", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "1", Album: "Sgt Peppers", Genre: "Rock", Path: P("/beatles/1/sgt/a day.mp3"), FullText: "a 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,
@@ -86,7 +86,7 @@ var _ = Describe("Initialize test DB", func() {
// TODO Load this data setup from file(s)
BeforeSuite(func() {
o := orm.NewOrm()
ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"})
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
mr := NewMediaFileRepository(ctx, o)
for _, s := range testSongs {
err := mr.Put(&s)
@@ -95,9 +95,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

@@ -0,0 +1,118 @@
package persistence
import (
"context"
. "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/model"
"github.com/deluan/rest"
)
type playerRepository struct {
sqlRepository
sqlRestful
}
func NewPlayerRepository(ctx context.Context, o orm.Ormer) model.PlayerRepository {
r := &playerRepository{}
r.ctx = ctx
r.ormer = o
r.tableName = "player"
return r
}
func (r *playerRepository) Put(p *model.Player) error {
_, err := r.put(p.ID, p)
return err
}
func (r *playerRepository) Get(id string) (*model.Player, error) {
sel := r.newSelect().Columns("*").Where(Eq{"id": id})
var res model.Player
err := r.queryOne(sel, &res)
return &res, err
}
func (r *playerRepository) FindByName(client, userName string) (*model.Player, error) {
sel := r.newSelect().Columns("*").Where(And{Eq{"client": client}, Eq{"user_name": userName}})
var res model.Player
err := r.queryOne(sel, &res)
return &res, err
}
func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBuilder {
s := r.newSelect(options...)
u := loggedUser(r.ctx)
if u.IsAdmin {
return s
}
return s.Where(Eq{"user_name": u.UserName})
}
func (r *playerRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.count(r.newRestSelect(), r.parseRestOptions(options...))
}
func (r *playerRepository) Read(id string) (interface{}, error) {
sel := r.newRestSelect().Columns("*").Where(Eq{"id": id})
var res model.Player
err := r.queryOne(sel, &res)
return &res, err
}
func (r *playerRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
sel := r.newRestSelect(r.parseRestOptions(options...)).Columns("*")
res := model.Players{}
err := r.queryAll(sel, &res)
return res, err
}
func (r *playerRepository) EntityName() string {
return "player"
}
func (r *playerRepository) NewInstance() interface{} {
return &model.Player{}
}
func (r *playerRepository) isPermitted(p *model.Player) bool {
u := loggedUser(r.ctx)
return u.IsAdmin || p.UserName == u.UserName
}
func (r *playerRepository) Save(entity interface{}) (string, error) {
t := entity.(*model.Player)
if !r.isPermitted(t) {
return "", rest.ErrPermissionDenied
}
id, err := r.put(t.ID, t)
if err == model.ErrNotFound {
return "", rest.ErrNotFound
}
return id, err
}
func (r *playerRepository) Update(entity interface{}, cols ...string) error {
t := entity.(*model.Player)
if !r.isPermitted(t) {
return rest.ErrPermissionDenied
}
_, err := r.put(t.ID, t)
if err == model.ErrNotFound {
return rest.ErrNotFound
}
return err
}
func (r *playerRepository) Delete(id string) error {
err := r.delete(And{Eq{"id": id}, Eq{"user_name": loggedUser(r.ctx).UserName}})
if err == model.ErrNotFound {
return rest.ErrNotFound
}
return err
}
var _ model.PlayerRepository = (*playerRepository)(nil)
var _ rest.Repository = (*playerRepository)(nil)
var _ rest.Persistable = (*playerRepository)(nil)

View File

@@ -13,7 +13,7 @@ type playlist struct {
ID string `orm:"column(id)"`
Name string
Comment string
Duration int
Duration float32
Owner string
Public bool
Tracks string
@@ -71,7 +71,7 @@ func (r *playlistRepository) GetWithTracks(id string) (*model.Playlist, error) {
continue
}
pls.Duration += mf.Duration
newTracks = append(newTracks, model.MediaFile(*mf))
newTracks = append(newTracks, *mf)
}
pls.Tracks = newTracks
return pls, err

View File

@@ -21,7 +21,7 @@ var _ = Describe("PlaylistRepository", func() {
})
})
Describe("Exist", func() {
Describe("Exists", func() {
It("returns true for an existing playlist", func() {
Expect(repo.Exists("11")).To(BeTrue())
})

View File

@@ -8,11 +8,6 @@ import (
"github.com/deluan/navidrome/model"
)
type property struct {
ID string `orm:"pk;column(id)"`
Value string
}
type propertyRepository struct {
sqlRepository
}

View File

@@ -10,18 +10,6 @@ import (
"github.com/google/uuid"
)
type annotation struct {
AnnID string `json:"annID" orm:"pk;column(ann_id)"`
UserID string `json:"userID" orm:"pk;column(user_id)"`
ItemID string `json:"itemID" orm:"pk;column(item_id)"`
ItemType string `json:"itemType"`
PlayCount int `json:"playCount"`
PlayDate time.Time `json:"playDate"`
Rating int `json:"rating"`
Starred bool `json:"starred"`
StarredAt time.Time `json:"starredAt"`
}
const annotationTable = "annotation"
func (r sqlRepository) newSelectWithAnnotation(idField string, options ...model.QueryOptions) SelectBuilder {

View File

@@ -11,7 +11,6 @@ import (
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/rest"
"github.com/google/uuid"
)
@@ -29,7 +28,7 @@ func userId(ctx context.Context) string {
if user == nil {
return invalidUserId
}
usr := user.(*model.User)
usr := user.(model.User)
return usr.ID
}
@@ -38,7 +37,8 @@ func loggedUser(ctx context.Context) *model.User {
if user == nil {
return &model.User{}
}
return user.(*model.User)
u := user.(model.User)
return &u
}
func (r sqlRepository) newSelect(options ...model.QueryOptions) SelectBuilder {
@@ -99,8 +99,11 @@ func (r sqlRepository) executeSQL(sq Sqlizer) (int64, error) {
return 0, err
}
start := time.Now()
var c int64
res, err := r.ormer.Raw(query, args...).Exec()
c, _ := res.RowsAffected()
if res != nil {
c, _ = res.RowsAffected()
}
r.logSQL(query, args, err, c, start)
if err != nil {
if err.Error() != "LastInsertId is not supported by this driver" {
@@ -157,6 +160,8 @@ func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOpt
func (r sqlRepository) put(id string, m interface{}) (newId string, err error) {
values, _ := toSqlArgs(m)
createdAt := values["created_at"]
delete(values, "created_at")
if id != "" {
update := Update(r.tableName).Where(Eq{"id": id}).SetMap(values)
count, err := r.executeSQL(update)
@@ -173,6 +178,9 @@ func (r sqlRepository) put(id string, m interface{}) (newId string, err error) {
id = rand.String()
values["id"] = id
}
if createdAt != nil {
values["created_at"] = createdAt
}
insert := Insert(r.tableName).SetMap(values)
_, err = r.executeSQL(insert)
return id, err
@@ -188,7 +196,7 @@ func (r sqlRepository) delete(cond Sqlizer) error {
}
func (r sqlRepository) logSQL(sql string, args []interface{}, err error, rowsAffected int64, start time.Time) {
lapsed := time.Since(start)
elapsed := time.Since(start)
var fmtArgs []string
for i := range args {
var f string
@@ -201,26 +209,8 @@ func (r sqlRepository) logSQL(sql string, args []interface{}, err error, rowsAff
fmtArgs = append(fmtArgs, f)
}
if err != nil {
log.Error(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, "lapsedTime", lapsed, err)
log.Error(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, "elapsedTime", elapsed, err)
} else {
log.Trace(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, "lapsedTime", lapsed)
log.Trace(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, "elapsedTime", elapsed)
}
}
func (r sqlRepository) parseRestOptions(options ...rest.QueryOptions) model.QueryOptions {
qo := model.QueryOptions{}
if len(options) > 0 {
qo.Sort = options[0].Sort
qo.Order = strings.ToLower(options[0].Order)
qo.Max = options[0].Max
qo.Offset = options[0].Offset
if len(options[0].Filters) > 0 {
filters := And{}
for f, v := range options[0].Filters {
filters = append(filters, Like{f: fmt.Sprintf("%s%%", v)})
}
qo.Filters = filters
}
}
return qo
}

View File

@@ -0,0 +1,73 @@
package persistence
import (
"fmt"
"strings"
. "github.com/Masterminds/squirrel"
"github.com/deluan/navidrome/model"
"github.com/deluan/rest"
"github.com/kennygrant/sanitize"
)
type filterFunc = func(field string, value interface{}) Sqlizer
type sqlRestful struct {
filterMappings map[string]filterFunc
}
func (r sqlRestful) parseRestFilters(options rest.QueryOptions) Sqlizer {
if len(options.Filters) == 0 {
return nil
}
filters := And{}
for f, v := range options.Filters {
if ff, ok := r.filterMappings[f]; ok {
filters = append(filters, ff(f, v))
} else if f == "id" {
filters = append(filters, eqFilter(f, v))
} else {
filters = append(filters, startsWithFilter(f, v))
}
}
return filters
}
func (r sqlRestful) parseRestOptions(options ...rest.QueryOptions) model.QueryOptions {
qo := model.QueryOptions{}
if len(options) > 0 {
qo.Sort = options[0].Sort
qo.Order = strings.ToLower(options[0].Order)
qo.Max = options[0].Max
qo.Offset = options[0].Offset
qo.Filters = r.parseRestFilters(options[0])
}
return qo
}
func eqFilter(field string, value interface{}) Sqlizer {
return Eq{field: value}
}
func startsWithFilter(field string, value interface{}) Sqlizer {
return Like{field: fmt.Sprintf("%s%%", value)}
}
func booleanFilter(field string, value interface{}) Sqlizer {
v := strings.ToLower(value.(string))
return Eq{field: strings.ToLower(v) == "true"}
}
func fullTextFilter(field string, value interface{}) Sqlizer {
q := value.(string)
q = strings.TrimSpace(sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*"))))
parts := strings.Split(q, " ")
filters := And{}
for _, part := range parts {
filters = append(filters, Or{
Like{"full_text": part + "%"},
Like{"full_text": "%" + part + "%"},
})
}
return filters
}

View File

@@ -0,0 +1,49 @@
package persistence
import (
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("sqlRestful", func() {
Describe("parseRestFilters", func() {
var r sqlRestful
var options rest.QueryOptions
BeforeEach(func() {
r = sqlRestful{}
})
It("returns nil if filters is empty", func() {
options.Filters = nil
Expect(r.parseRestFilters(options)).To(BeNil())
})
It("returns a '=' condition for 'id' filter", func() {
options.Filters = map[string]interface{}{"id": "123"}
Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Eq{"id": "123"}}))
})
It("returns a 'in' condition for multiples 'id' filters", func() {
options.Filters = map[string]interface{}{"id": []string{"123", "456"}}
Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Eq{"id": []string{"123", "456"}}}))
})
It("returns a 'like' condition for other filters", func() {
options.Filters = map[string]interface{}{"name": "joe"}
Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Like{"name": "joe%"}}))
})
It("uses the custom filter", func() {
r.filterMappings = map[string]filterFunc{
"test": func(field string, value interface{}) squirrel.Sqlizer {
return squirrel.Gt{field: value}
},
}
options.Filters = map[string]interface{}{"test": 100}
Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Gt{"test": 100}}))
})
})
})

View File

@@ -1,47 +1,40 @@
package persistence
import (
"sort"
"strings"
. "github.com/Masterminds/squirrel"
"github.com/deluan/navidrome/log"
"github.com/kennygrant/sanitize"
)
const searchTable = "search"
func (r sqlRepository) index(id string, text string) error {
sanitizedText := strings.TrimSpace(sanitize.Accents(strings.ToLower(text)))
values := map[string]interface{}{
"id": id,
"item_type": r.tableName,
"full_text": sanitizedText,
func (r sqlRepository) getFullText(text ...string) string {
sanitizedText := strings.Builder{}
for _, txt := range text {
sanitizedText.WriteString(strings.TrimSpace(sanitize.Accents(strings.ToLower(txt))) + " ")
}
update := Update(searchTable).Where(Eq{"id": id}).SetMap(values)
count, err := r.executeSQL(update)
if err != nil {
return err
words := make(map[string]struct{})
for _, w := range strings.Fields(sanitizedText.String()) {
words[w] = struct{}{}
}
if count > 0 {
return nil
var fullText []string
for w := range words {
fullText = append(fullText, w)
}
insert := Insert(searchTable).SetMap(values)
_, err = r.executeSQL(insert)
return err
sort.Strings(fullText)
return strings.Join(fullText, " ")
}
func (r sqlRepository) doSearch(q string, offset, size int, results interface{}, orderBys ...string) error {
q = strings.TrimSpace(sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*"))))
if len(q) <= 2 {
if len(q) < 2 {
return nil
}
sq := Select("*").From(r.tableName)
sq := r.newSelectWithAnnotation(r.tableName + ".id").Columns("*")
sq = sq.Limit(uint64(size)).Offset(uint64(offset))
if len(orderBys) > 0 {
sq = sq.OrderBy(orderBys...)
}
sq = sq.Join("search").Where("search.id = " + r.tableName + ".id")
parts := strings.Split(q, " ")
for _, part := range parts {
sq = sq.Where(Or{
@@ -52,15 +45,3 @@ func (r sqlRepository) doSearch(q string, offset, size int, results interface{},
err := r.queryAll(sq, results)
return err
}
func (r sqlRepository) cleanSearchIndex() error {
del := Delete(searchTable).Where(Eq{"item_type": r.tableName}).Where("id not in (select id from " + r.tableName + ")")
c, err := r.executeSQL(del)
if err != nil {
return err
}
if c > 0 {
log.Debug(r.ctx, "Clean-up search index", "table", r.tableName, "totalDeleted", c)
}
return nil
}

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

@@ -0,0 +1,99 @@
package persistence
import (
"context"
. "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/model"
"github.com/deluan/rest"
)
type transcodingRepository struct {
sqlRepository
sqlRestful
}
func NewTranscodingRepository(ctx context.Context, o orm.Ormer) model.TranscodingRepository {
r := &transcodingRepository{}
r.ctx = ctx
r.ormer = o
r.tableName = "transcoding"
return r
}
func (r *transcodingRepository) Get(id string) (*model.Transcoding, error) {
sel := r.newSelect().Columns("*").Where(Eq{"id": id})
var res model.Transcoding
err := r.queryOne(sel, &res)
return &res, err
}
func (r *transcodingRepository) CountAll(qo ...model.QueryOptions) (int64, error) {
return r.count(Select(), qo...)
}
func (r *transcodingRepository) FindByFormat(format string) (*model.Transcoding, error) {
sel := r.newSelect().Columns("*").Where(Eq{"target_format": format})
var res model.Transcoding
err := r.queryOne(sel, &res)
return &res, err
}
func (r *transcodingRepository) Put(t *model.Transcoding) error {
_, err := r.put(t.ID, t)
return err
}
func (r *transcodingRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.count(Select(), r.parseRestOptions(options...))
}
func (r *transcodingRepository) Read(id string) (interface{}, error) {
return r.Get(id)
}
func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
sel := r.newSelect(r.parseRestOptions(options...)).Columns("*")
res := model.Transcodings{}
err := r.queryAll(sel, &res)
return res, err
}
func (r *transcodingRepository) EntityName() string {
return "transcoding"
}
func (r *transcodingRepository) NewInstance() interface{} {
return &model.Transcoding{}
}
func (r *transcodingRepository) Save(entity interface{}) (string, error) {
t := entity.(*model.Transcoding)
id, err := r.put(t.ID, t)
if err == model.ErrNotFound {
return "", rest.ErrNotFound
}
return id, err
}
func (r *transcodingRepository) Update(entity interface{}, cols ...string) error {
t := entity.(*model.Transcoding)
_, err := r.put(t.ID, t)
if err == model.ErrNotFound {
return rest.ErrNotFound
}
return err
}
func (r *transcodingRepository) Delete(id string) error {
err := r.delete(Eq{"id": id})
if err == model.ErrNotFound {
return rest.ErrNotFound
}
return err
}
var _ model.TranscodingRepository = (*transcodingRepository)(nil)
var _ rest.Repository = (*transcodingRepository)(nil)
var _ rest.Persistable = (*transcodingRepository)(nil)

View File

@@ -14,6 +14,7 @@ import (
type userRepository struct {
sqlRepository
sqlRestful
}
func NewUserRepository(ctx context.Context, o orm.Ormer) model.UserRepository {
@@ -65,6 +66,7 @@ func (r *userRepository) Put(u *model.User) error {
}
func (r *userRepository) FindByUsername(username string) (*model.User, error) {
username = strings.ToLower(username)
sel := r.newSelect().Columns("*").Where(Eq{"user_name": username})
var usr model.User
err := r.queryOne(sel, &usr)

View File

@@ -0,0 +1,41 @@
package persistence
import (
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("UserRepository", func() {
var repo model.UserRepository
BeforeEach(func() {
repo = NewUserRepository(log.NewContext(nil), orm.NewOrm())
})
Describe("Put/Get/FindByUsername", func() {
usr := model.User{
ID: "123",
UserName: "AdMiN",
Name: "Admin",
Email: "admin@admin.com",
Password: "wordpass",
IsAdmin: true,
}
It("saves the user to the DB", func() {
Expect(repo.Put(&usr)).To(BeNil())
})
It("returns the newly created user", func() {
actual, err := repo.Get("123")
Expect(err).ToNot(HaveOccurred())
Expect(actual.Name).To(Equal("Admin"))
})
It("find the user by case-insensitive username", func() {
actual, err := repo.FindByUsername("aDmIn")
Expect(err).ToNot(HaveOccurred())
Expect(actual.Name).To(Equal("Admin"))
})
})
})

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

@@ -46,10 +46,10 @@ func (s *ChangeDetector) Scan(lastModifiedSince time.Time) (changed []string, de
func (s *ChangeDetector) loadDir(dirPath string) (children []string, lastUpdated time.Time, err error) {
dir, err := os.Open(dirPath)
defer dir.Close()
if err != nil {
return
}
defer dir.Close()
dirInfo, err := os.Stat(dirPath)
if err != nil {
return

View File

@@ -24,26 +24,26 @@ type Metadata struct {
tags map[string]string
}
func (m *Metadata) Title() string { return m.tags["title"] }
func (m *Metadata) Album() string { return m.tags["album"] }
func (m *Metadata) Artist() string { return m.tags["artist"] }
func (m *Metadata) AlbumArtist() string { return m.tags["album_artist"] }
func (m *Metadata) Composer() string { return m.tags["composer"] }
func (m *Metadata) Genre() string { return m.tags["genre"] }
func (m *Metadata) Year() int { return m.parseYear("year") }
func (m *Metadata) TrackNumber() (int, int) { return m.parseTuple("trackNum", "trackTotal") }
func (m *Metadata) DiscNumber() (int, int) { return m.parseTuple("discNum", "discTotal") }
func (m *Metadata) HasPicture() bool { return m.tags["hasPicture"] == "Video" }
func (m *Metadata) Comment() string { return m.tags["comment"] }
func (m *Metadata) Title() string { return m.getTag("title", "sort_name") }
func (m *Metadata) Album() string { return m.getTag("album", "sort_album") }
func (m *Metadata) Artist() string { return m.getTag("artist", "sort_artist") }
func (m *Metadata) AlbumArtist() string { return m.getTag("album_artist") }
func (m *Metadata) Composer() string { return m.getTag("composer", "tcm", "sort_composer") }
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", "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() int { return m.parseDuration("duration") }
func (m *Metadata) Duration() float32 { return m.parseDuration("duration") }
func (m *Metadata) BitRate() int { return m.parseInt("bitrate") }
func (m *Metadata) ModificationTime() time.Time { return m.fileInfo.ModTime() }
func (m *Metadata) FilePath() string { return m.filePath }
func (m *Metadata) Suffix() string { return m.suffix }
func (m *Metadata) Size() int { return int(m.fileInfo.Size()) }
func ExtractAllMetadata(dirPath string) (map[string]*Metadata, error) {
func LoadAllAudioFiles(dirPath string) (map[string]os.FileInfo, error) {
dir, err := os.Open(dirPath)
if err != nil {
return nil, err
@@ -52,7 +52,7 @@ func ExtractAllMetadata(dirPath string) (map[string]*Metadata, error) {
if err != nil {
return nil, err
}
var audioFiles []string
audioFiles := make(map[string]os.FileInfo)
for _, f := range files {
if f.IsDir() {
continue
@@ -62,16 +62,18 @@ func ExtractAllMetadata(dirPath string) (map[string]*Metadata, error) {
if !isAudioFile(extension) {
continue
}
audioFiles = append(audioFiles, filePath)
fi, err := os.Stat(filePath)
if err != nil {
log.Error("Could not stat file", "filePath", filePath, err)
} else {
audioFiles[filePath] = fi
}
}
if len(audioFiles) == 0 {
return map[string]*Metadata{}, nil
}
return probe(audioFiles)
return audioFiles, nil
}
func probe(inputs []string) (map[string]*Metadata, error) {
func ExtractAllMetadata(inputs []string) (map[string]*Metadata, error) {
cmdLine, args := createProbeCommand(inputs)
log.Trace("Executing command", "arg0", cmdLine, "args", args)
@@ -92,7 +94,22 @@ func probe(inputs []string) (map[string]*Metadata, error) {
return mds, nil
}
var inputRegex = regexp.MustCompile(`(?m)^Input #\d+,.*,\sfrom\s'(.*)'`)
var (
// Input #0, mp3, from 'groovin.mp3':
inputRegex = regexp.MustCompile(`(?m)^Input #\d+,.*,\sfrom\s'(.*)'`)
// TITLE : Back In Black
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+)`)
// Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s
bitRateRx = regexp.MustCompile(`^\s{4}Stream #\d+:\d+: (Audio):.*, (\d+) kb/s`)
// Stream #0:1: Video: mjpeg, yuvj444p(pc, bt470bg/unknown/unknown), 600x600 [SAR 1:1 DAR 1:1], 90k tbr, 90k tbn, 90k tbc`
coverRx = regexp.MustCompile(`^\s{4}Stream #\d+:\d+: (Video):.*`)
)
func parseOutput(output string) map[string]string {
split := map[string]string{}
@@ -139,47 +156,44 @@ func isAudioFile(extension string) bool {
return strings.HasPrefix(typ, "audio/")
}
var (
tagsRx = map[*regexp.Regexp]string{
regexp.MustCompile(`(?i)^\s{4}compilation\s+:(.*)`): "compilation",
regexp.MustCompile(`(?i)^\s{4}genre\s+:\s(.*)`): "genre",
regexp.MustCompile(`(?i)^\s{4}title\s+:\s(.*)`): "title",
regexp.MustCompile(`(?i)^\s{4}comment\s+:\s(.*)`): "comment",
regexp.MustCompile(`(?i)^\s{4}artist\s+:\s(.*)`): "artist",
regexp.MustCompile(`(?i)^\s{4}album_artist\s+:\s(.*)`): "album_artist",
regexp.MustCompile(`(?i)^\s{4}TCM\s+:\s(.*)`): "composer",
regexp.MustCompile(`(?i)^\s{4}album\s+:\s(.*)`): "album",
regexp.MustCompile(`(?i)^\s{4}track\s+:\s(.*)`): "trackNum",
regexp.MustCompile(`(?i)^\s{4}tracktotal\s+:\s(.*)`): "trackTotal",
regexp.MustCompile(`(?i)^\s{4}disc\s+:\s(.*)`): "discNum",
regexp.MustCompile(`(?i)^\s{4}disctotal\s+:\s(.*)`): "discTotal",
regexp.MustCompile(`(?i)^\s{4}TPA\s+:\s(.*)`): "discNum",
regexp.MustCompile(`(?i)^\s{4}date\s+:\s(.*)`): "year",
regexp.MustCompile(`^\s{4}Stream #\d+:\d+: (.+):\s`): "hasPicture",
}
durationRx = regexp.MustCompile(`^\s\sDuration: ([\d.:]+).*bitrate: (\d+)`)
)
func (m *Metadata) parseInfo(info string) {
reader := strings.NewReader(info)
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := scanner.Text()
for rx, tag := range tagsRx {
match := rx.FindStringSubmatch(line)
if len(match) > 0 {
m.tags[tag] = match[1]
break
}
match = durationRx.FindStringSubmatch(line)
if len(match) == 0 {
continue
if len(line) == 0 {
continue
}
match := tagsRx.FindStringSubmatch(line)
if len(match) > 0 {
tagName := strings.ToLower(match[1])
tagValue := strings.TrimSpace(match[2])
// Skip when the tag was previously found
if _, ok := m.tags[tagName]; !ok {
m.tags[tagName] = tagValue
}
continue
}
match = coverRx.FindStringSubmatch(line)
if len(match) > 0 {
m.tags["has_picture"] = "true"
continue
}
match = durationRx.FindStringSubmatch(line)
if len(match) > 0 {
m.tags["duration"] = match[1]
if len(match) > 1 {
m.tags["bitrate"] = match[2]
}
continue
}
match = bitRateRx.FindStringSubmatch(line)
if len(match) > 0 {
m.tags["bitrate"] = match[2]
}
}
}
@@ -192,15 +206,6 @@ func (m *Metadata) parseInt(tagName string) int {
return 0
}
var tagYearFormats = []string{
"2006",
"2006.01",
"2006.01.02",
"2006-01",
"2006-01-02",
time.RFC3339,
}
var dateRegex = regexp.MustCompile(`^([12]\d\d\d)`)
func (m *Metadata) parseYear(tagName string) int {
@@ -216,17 +221,28 @@ func (m *Metadata) parseYear(tagName string) int {
return 0
}
func (m *Metadata) parseTuple(numTag string, totalTag string) (int, int) {
if v, ok := m.tags[numTag]; ok {
tuple := strings.Split(v, "/")
t1, t2 := 0, 0
t1, _ = strconv.Atoi(tuple[0])
if len(tuple) > 1 {
t2, _ = strconv.Atoi(tuple[1])
} else {
t2, _ = strconv.Atoi(m.tags[totalTag])
func (m *Metadata) getTag(tags ...string) string {
for _, t := range tags {
if v, ok := m.tags[t]; ok {
return v
}
}
return ""
}
func (m *Metadata) parseTuple(tags ...string) (int, int) {
for _, tagName := range tags {
if v, ok := m.tags[tagName]; ok {
tuple := strings.Split(v, "/")
t1, t2 := 0, 0
t1, _ = strconv.Atoi(tuple[0])
if len(tuple) > 1 {
t2, _ = strconv.Atoi(tuple[1])
} else {
t2, _ = strconv.Atoi(m.tags[tagName+"total"])
}
return t1, t2
}
return t1, t2
}
return 0, 0
}
@@ -241,17 +257,18 @@ func (m *Metadata) parseBool(tagName string) bool {
var zeroTime = time.Date(0000, time.January, 1, 0, 0, 0, 0, time.UTC)
func (m *Metadata) parseDuration(tagName string) int {
func (m *Metadata) parseDuration(tagName string) float32 {
if v, ok := m.tags[tagName]; ok {
d, err := time.Parse("15:04:05", v)
if err != nil {
return 0
}
return int(d.Sub(zeroTime).Seconds())
return float32(d.Sub(zeroTime).Seconds())
}
return 0
}
// Inputs will always be absolute paths
func createProbeCommand(inputs []string) (string, []string) {
cmd := conf.Server.ProbeCommand

View File

@@ -9,9 +9,9 @@ var _ = Describe("Metadata", func() {
// TODO Need to mock `ffmpeg`
XContext("ExtractAllMetadata", func() {
It("correctly parses metadata from all files in folder", func() {
mds, err := ExtractAllMetadata("tests/fixtures")
mds, err := ExtractAllMetadata([]string{"tests/fixtures/test.mp3", "tests/fixtures/test.ogg"})
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(3))
Expect(mds).To(HaveLen(2))
m := mds["tests/fixtures/test.mp3"]
Expect(m.Title()).To(Equal("Song"))
@@ -45,43 +45,107 @@ var _ = Describe("Metadata", func() {
Expect(m.FilePath()).To(Equal("tests/fixtures/test.ogg"))
Expect(m.Size()).To(Equal(4408))
})
})
Context("LoadAllAudioFiles", func() {
It("return all audiofiles from the folder", func() {
files, err := LoadAllAudioFiles("tests/fixtures")
Expect(err).ToNot(HaveOccurred())
Expect(files).To(HaveLen(3))
Expect(files).To(HaveKey("tests/fixtures/test.ogg"))
Expect(files).To(HaveKey("tests/fixtures/test.mp3"))
Expect(files).To(HaveKey("tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
})
It("returns error if path does not exist", func() {
_, err := ExtractAllMetadata("./INVALID/PATH")
_, err := LoadAllAudioFiles("./INVALID/PATH")
Expect(err).To(HaveOccurred())
})
It("returns empty map if there are no audio files in path", func() {
Expect(ExtractAllMetadata(".")).To(BeEmpty())
Expect(LoadAllAudioFiles(".")).To(BeEmpty())
})
})
Context("extractMetadata", func() {
It("detects embedded cover art correctly", func() {
const output = `
Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3':
Metadata:
compilation : 1
Duration: 00:00:01.02, start: 0.000000, bitrate: 477 kb/s
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s
Stream #0:1: Video: mjpeg, yuvj444p(pc, bt470bg/unknown/unknown), 600x600 [SAR 1:1 DAR 1:1], 90k tbr, 90k tbn, 90k tbc`
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
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':
Duration: 00:00:01.02, start: 0.000000, bitrate: 477 kb/s
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s`
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
Expect(md.BitRate()).To(Equal(192))
})
It("parses correctly the compilation tag", func() {
const outputWithOverlappingTitleTag = `
const output = `
Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3':
Metadata:
compilation : 1
Duration: 00:05:02.63, start: 0.000000, bitrate: 140 kb/s`
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithOverlappingTitleTag)
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
Expect(md.Compilation()).To(BeTrue())
})
It("parses correct the title without overlapping with the stream tag", func() {
const outputWithOverlappingTitleTag = `
It("parses duration with milliseconds", func() {
const output = `
Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3':
Duration: 00:05:02.63, start: 0.000000, bitrate: 140 kb/s`
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
Expect(md.Duration()).To(BeNumerically("~", 302.63, 0.001))
})
It("parses stream level tags", func() {
const output = `
Input #0, ogg, from './01-02 Drive (Teku).opus':
Metadata:
ALBUM : Hot Wheels Acceleracers Soundtrack
Duration: 00:03:37.37, start: 0.007500, bitrate: 135 kb/s
Stream #0:0(eng): Audio: opus, 48000 Hz, stereo, fltp
Metadata:
TITLE : Drive (Teku)`
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
Expect(md.Title()).To(Equal("Drive (Teku)"))
})
It("does not overlap top level tags with the stream level tags", func() {
const output = `
Input #0, mp3, from 'groovin.mp3':
Metadata:
title : Groovin' (feat. Daniel Sneijers, Susanne Alt)
Duration: 00:03:34.28, start: 0.025056, bitrate: 323 kb/s
Metadata:
title : cover
At least one output file must be specified`
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithOverlappingTitleTag)
title : garbage`
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
Expect(md.Title()).To(Equal("Groovin' (feat. Daniel Sneijers, Susanne Alt)"))
})
It("ignores case in the tag name", func() {
const outputWithOverlappingTitleTag = `
const output = `
Input #0, flac, from '/Users/deluan/Downloads/06. Back In Black.flac':
Metadata:
ALBUM : Back In Black
@@ -93,7 +157,7 @@ Input #0, flac, from '/Users/deluan/Downloads/06. Back In Black.flac':
TRACKTOTAL : 10
track : 6
Duration: 00:04:16.00, start: 0.000000, bitrate: 995 kb/s`
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithOverlappingTitleTag)
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
Expect(md.Title()).To(Equal("Back In Black"))
Expect(md.Album()).To(Equal("Back In Black"))
Expect(md.Genre()).To(Equal("Hard Rock"))
@@ -154,13 +218,13 @@ Tracklist:
"May 12, 2016": 0,
}
for tag, expected := range examples {
md := &Metadata{tags: map[string]string{"year": tag}}
md := &Metadata{tags: map[string]string{"date": tag}}
Expect(md.Year()).To(Equal(expected))
}
})
It("returns 0 if year is invalid", func() {
md := &Metadata{tags: map[string]string{"year": "invalid"}}
md := &Metadata{tags: map[string]string{"date": "invalid"}}
Expect(md.Year()).To(Equal(0))
})
})

View File

@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
)
@@ -143,45 +144,64 @@ func (s *TagScanner) processChangedDir(ctx context.Context, dir string, updatedA
return err
}
for _, t := range ct {
currentTracks[t.ID] = t
updatedArtists[t.ArtistID] = true
updatedAlbums[t.AlbumID] = true
currentTracks[t.Path] = t
}
// Load tracks from the folder
newTracks, err := s.loadTracks(dir)
// Load tracks FileInfo from the folder
files, err := LoadAllAudioFiles(dir)
if err != nil {
return err
}
// If no tracks to process, return
if len(newTracks)+len(currentTracks) == 0 {
// If no files to process, return
if len(files)+len(currentTracks) == 0 {
return nil
}
// If track from folder is newer than the one in DB, update/insert in DB and delete from the current tracks
log.Trace("Processing changed folder", "dir", dir, "tracksInDB", len(currentTracks), "tracksInFolder", len(newTracks))
// If track from folder is newer than the one in DB, select for update/insert in DB and delete from the current tracks
log.Trace("Processing changed folder", "dir", dir, "tracksInDB", len(currentTracks), "tracksInFolder", len(files))
var filesToUpdate []string
for filePath, info := range files {
c, ok := currentTracks[filePath]
if !ok || (ok && info.ModTime().After(c.UpdatedAt)) {
filesToUpdate = append(filesToUpdate, filePath)
}
delete(currentTracks, filePath)
}
numUpdatedTracks := 0
numPurgedTracks := 0
for _, n := range newTracks {
c, ok := currentTracks[n.ID]
if !ok || (ok && n.UpdatedAt.After(c.UpdatedAt)) {
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.ArtistID] = true
updatedArtists[n.AlbumArtistID] = true
updatedAlbums[n.AlbumID] = true
numUpdatedTracks++
if err != nil {
return err
}
}
delete(currentTracks, n.ID)
}
// Remaining tracks from DB that are not in the folder are deleted
for id := range currentTracks {
numPurgedTracks++
if err := s.ds.MediaFile(ctx).Delete(id); err != nil {
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
}
}
}
@@ -198,19 +218,20 @@ func (s *TagScanner) processDeletedDir(ctx context.Context, dir string, updatedA
return err
}
for _, t := range ct {
updatedArtists[t.ArtistID] = true
updatedArtists[t.AlbumArtistID] = true
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)
}
func (s *TagScanner) loadTracks(dirPath string) (model.MediaFiles, error) {
mds, err := ExtractAllMetadata(dirPath)
func (s *TagScanner) loadTracks(filePaths []string) (model.MediaFiles, error) {
mds, err := ExtractAllMetadata(filePaths)
if err != nil {
return nil, err
}
var mfs model.MediaFiles
for _, md := range mds {
mf := s.toMediaFile(md)
@@ -226,13 +247,10 @@ func (s *TagScanner) toMediaFile(md *Metadata) model.MediaFile {
mf.Album = md.Album()
mf.AlbumID = s.albumID(md)
mf.Album = s.mapAlbumName(md)
if md.Artist() == "" {
mf.Artist = "[Unknown Artist]"
} else {
mf.Artist = md.Artist()
}
mf.ArtistID = s.artistID(md)
mf.AlbumArtist = md.AlbumArtist()
mf.Artist = s.mapArtistName(md)
mf.AlbumArtistID = s.albumArtistID(md)
mf.AlbumArtist = s.mapAlbumArtistName(md)
mf.Genre = md.Genre()
mf.Compilation = md.Compilation()
mf.Year = md.Year()
@@ -261,19 +279,26 @@ func (s *TagScanner) mapTrackTitle(md *Metadata) string {
return md.Title()
}
func (s *TagScanner) mapArtistName(md *Metadata) string {
func (s *TagScanner) mapAlbumArtistName(md *Metadata) string {
switch {
case md.Compilation():
return "Various Artists"
return consts.VariousArtists
case md.AlbumArtist() != "":
return md.AlbumArtist()
case md.Artist() != "":
return md.Artist()
default:
return "[Unknown Artist]"
return consts.UnknownArtist
}
}
func (s *TagScanner) mapArtistName(md *Metadata) string {
if md.Artist() != "" {
return md.Artist()
}
return consts.UnknownArtist
}
func (s *TagScanner) mapAlbumName(md *Metadata) string {
name := md.Album()
if name == "" {
@@ -287,10 +312,14 @@ func (s *TagScanner) trackID(md *Metadata) string {
}
func (s *TagScanner) albumID(md *Metadata) string {
albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", s.mapArtistName(md), s.mapAlbumName(md)))
albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", s.mapAlbumArtistName(md), s.mapAlbumName(md)))
return fmt.Sprintf("%x", md5.Sum([]byte(albumPath)))
}
func (s *TagScanner) artistID(md *Metadata) string {
return fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(s.mapArtistName(md)))))
}
func (s *TagScanner) albumArtistID(md *Metadata) string {
return fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(s.mapAlbumArtistName(md)))))
}

View File

@@ -15,42 +15,46 @@ 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{})
app.R(r, "/artist", model.Artist{})
app.R(r, "/transcoding", model.Transcoding{})
app.R(r, "/player", model.Player{})
// Keepalive endpoint to be used to keep the session valid (ex: while playing songs)
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"response":"ok"}`)) })
})
// 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(),
})
}
@@ -149,7 +148,7 @@ func validateLogin(userRepo model.UserRepository, userName, password string) (*m
func contextWithUser(ctx context.Context, ds model.DataStore, claims jwt.MapClaims) context.Context {
userName := claims["sub"].(string)
user, _ := ds.User(ctx).FindByUsername(userName)
return context.WithValue(ctx, "user", user)
return context.WithValue(ctx, "user", *user)
}
func getToken(ds model.DataStore, ctx context.Context) (*jwt.Token, error) {
@@ -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,35 +5,33 @@ 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(),
}
err = t.Execute(w, data)
if err != nil {
@@ -41,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
}

Some files were not shown because too many files have changed in this diff Show More