Compare commits

...

365 Commits

Author SHA1 Message Date
Deluan
2d1d992e17 Support Windows paths 2020-06-14 03:11:16 -04:00
Deluan
653b5ea9d3 Replace map[string]bool with map[string]struct{} 2020-06-14 03:11:16 -04:00
Deluan
e73b71aaf7 Remove tracks from DB that were deleted while Navidrome was not running. Fixes #151 2020-06-14 03:11:16 -04:00
Deluan
01919661e9 Skip unreadable directories. Fixes #328 2020-06-14 03:11:16 -04:00
Deluan
3190611ec8 Call ffmpeg in batches 2020-06-14 03:11:16 -04:00
Deluan
6a3dabbb06 Optimize queries by path 2020-06-14 03:11:16 -04:00
Deluan
238020c839 Handle folders with lots of albums and/or artists 2020-06-14 03:11:16 -04:00
Deluan
72b2e756f7 Revert "Show indicator on current playing song. Fixes #128"
This implementation causes performance issues
2020-06-13 16:41:11 -04:00
Deluan
86bc8d97a0 Support dark themes in "Playing" indicator 2020-06-13 14:38:25 -04:00
Deluan
003b73fe1a Remove invalid propType 2020-06-13 14:04:45 -04:00
Deluan
be2afb94ae Show indicator on current playing song. Fixes #128 2020-06-13 14:04:45 -04:00
Deluan
f8a18b59b0 Add link to album from player's song title. Fixes #324 2020-06-12 17:02:13 -04:00
Deluan Quintão
c216b14655 Add total downloads badge 2020-06-12 14:21:02 -04:00
Deluan
4702c5abbd Add track/artist being played to the page title. Closes #317 2020-06-11 22:40:35 -04:00
Deluan
c742ae0843 Remove unused feature toggles 2020-06-11 22:11:59 -04:00
Deluan
0033966c25 No need to delete the playlist tracks explicitly 2020-06-10 18:07:10 -04:00
Deluan
f072ffd377 Add confirmation when deleting user 2020-06-10 18:07:10 -04:00
Deluan
94d88395e7 Add referential integrity to player and playlist tables 2020-06-10 18:07:10 -04:00
Deluan
c9bcb333ae Add more options to Players list 2020-06-10 18:07:10 -04:00
Deluan Quintão
84ed3eb427 Update README.md 2020-06-10 11:34:51 -04:00
Deluan
8bd9787c51 Fix function naming 2020-06-09 20:45:53 -04:00
Deluan
1c466d6083 Fix formatting 2020-06-09 20:34:36 -04:00
Deluan
a64b15c174 Fix navigation issues caused by the use of useListParams 2020-06-09 20:29:12 -04:00
Deluan
7148741a4f Revert "Keep image aspect ratio when resizing"
This reverts commit 50f4bd86
2020-06-09 19:36:19 -04:00
Deluan
630c71119a Use fix for Opus cover art from https://github.com/dhowden/tag/pull/69 2020-06-09 17:08:05 -04:00
Deluan
50f4bd86a3 Keep image aspect ratio when resizing 2020-06-09 10:34:47 -04:00
Deluan
44c74f42e1 Add clickToPlay functionality to playlists 2020-06-09 08:54:11 -04:00
Deluan
29c7513879 Update updated_at field when modifying the playlist 2020-06-09 07:55:35 -04:00
Deluan
82d437f004 Better defaults to sort orders in List views 2020-06-09 07:46:28 -04:00
Deluan
b54d4c75ae Show year in album tile if album grid is filtered bu artist 2020-06-08 20:37:18 -04:00
Deluan
b636565c62 Disable public toggle if user is not the playlist's owner 2020-06-08 19:19:38 -04:00
Deluan
b4e06c416d Allow toggling a playlist public from the Playlist list view. Closes #344 2020-06-08 18:39:31 -04:00
dependabot-preview[bot]
5e2d463129 Bump @testing-library/user-event from 10.4.1 to 11.2.0 in /ui
Bumps [@testing-library/user-event](https://github.com/testing-library/user-event) from 10.4.1 to 11.2.0.
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/master/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v10.4.1...v11.2.0)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-06-05 13:42:40 -04:00
Deluan
12d5d9573e Bum @testing-library dependencies 2020-06-05 13:34:46 -04:00
dependabot-preview[bot]
42ee8b64cb [Security] Bump websocket-extensions from 0.1.3 to 0.1.4 in /ui
Bumps [websocket-extensions](https://github.com/faye/websocket-extensions-node) from 0.1.3 to 0.1.4. **This update includes a security fix.**
- [Release notes](https://github.com/faye/websocket-extensions-node/releases)
- [Changelog](https://github.com/faye/websocket-extensions-node/blob/master/CHANGELOG.md)
- [Commits](https://github.com/faye/websocket-extensions-node/compare/0.1.3...0.1.4)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-06-05 13:31:47 -04:00
Deluan
3908ad2681 Upgrade ReactAdmin to 3.6.0 2020-06-05 12:13:50 -04:00
Deluan
e9115dab4c Allow Writable to have multiple children 2020-06-05 11:55:30 -04:00
Deluan
79cf33281c Redirect to Playlists list after creating or editing 2020-06-05 11:55:30 -04:00
Deluan
2adb290c34 Do not show a "loading" datagrid for an empty playlist 2020-06-05 11:55:29 -04:00
Deluan
c6f23139bc Handle playlist's permissions on server 2020-06-05 11:55:29 -04:00
Deluan
4906b816af Only allows adding to a writable playlist 2020-06-05 10:26:53 -04:00
Deluan
39afe0c669 Check permissions for playlists 2020-06-05 10:22:31 -04:00
Deluan
f8a7ef1e19 Fix typo 2020-06-04 20:13:25 -04:00
Deluan
4776dba003 Make cursor=move for the whole playlist item row 2020-06-04 19:44:26 -04:00
Deluan
331fa1d952 Add ability to reorder playlist items 2020-06-04 19:05:41 -04:00
Deluan
b597a34cb4 Remove flickering when loading/refreshing Playlist show view 2020-06-04 16:54:30 -04:00
Deluan
51fb1d1349 Increase cover art max-age to maximum 2020-06-04 14:45:00 -04:00
Deluan
8fd86def18 Bump ginkgo version to 1.12.3 2020-06-03 09:43:34 -04:00
Deluan
5d285f92f5 Bump chi version to 4.1.2 2020-06-03 09:42:16 -04:00
Deluan
888151728f Increase album art placeholder's resolution 2020-06-03 09:40:37 -04:00
Deluan
b836dfe7f4 Do not reset the SongList query params 2020-05-31 14:27:02 -04:00
Deluan
ddcfc546fb Link is not on the album cover, leaving a gap between albums.
Other small improvements
2020-05-31 13:57:17 -04:00
Deluan
86a9f9e410 Show album info on hover 2020-05-30 19:42:08 -04:00
Deluan
14d7a69088 Fix context menu "display on hover" in playlists 2020-05-30 11:18:01 -04:00
Deluan
35e4eec293 Add album to playlist 2020-05-30 11:17:33 -04:00
Deluan
7547888f10 Change default session timeout to 24h 2020-05-30 10:34:16 -04:00
Deluan
fbedbb7893 Fix context menu on mobile, removed console warnings 2020-05-29 22:50:33 -04:00
Deluan
a7640c9df4 Optimized call to retrieve album songs 2020-05-29 17:34:54 -04:00
Deluan
8f8d992da4 Only add to playlist songs from selected discNumber (if present) 2020-05-29 16:42:13 -04:00
Deluan
3fe8b02cbd Make album context menu only visible on hover 2020-05-29 12:33:50 -04:00
Deluan
ba8c8725dd Refactor: move multiDisc detection logic to SongDatagrid 2020-05-29 12:20:17 -04:00
Deluan
915b701e44 Add context menu to individual discs in a set 2020-05-29 12:08:07 -04:00
Deluan
596100b58d Refactor: improve readability 2020-05-29 11:21:53 -04:00
Deluan
d8699b03bd Fix album sort fields 2020-05-28 20:48:58 -04:00
Deluan
7b36096153 Fix class of disc subtitle row 2020-05-28 09:25:53 -04:00
Deluan
62290bca77 Remove extra , 2020-05-28 08:16:31 -04:00
Deluan
498e196d48 Allow playing one disc of a set, by clicking on its number/name 2020-05-27 21:07:51 -04:00
Deluan Quintão
432fe10a5e Update tr.json (POEditor.com) 2020-05-27 09:48:04 -04:00
Deluan Quintão
7e625d68b5 Update de.json (POEditor.com) 2020-05-27 09:48:04 -04:00
Deluan
50f3a2c11d Upgrade Node to v14 2020-05-27 05:35:25 -04:00
Deluan
9028d301f0 Change log level for playlist log messages 2020-05-26 22:03:25 -04:00
Deluan
26dba27778 Always show song context menu on tablets 2020-05-26 22:02:15 -04:00
Deluan
7170485d08 Rename property 2020-05-26 17:59:04 -04:00
Deluan
2c68ba3934 only show playlist tracks' context menu on hover 2020-05-26 16:18:28 -04:00
Deluan
201a22e613 Change index in playlist to start from 1 2020-05-26 13:50:15 -04:00
Deluan Quintão
3ca295c863 Update it.json (POEditor.com) 2020-05-25 23:23:36 -04:00
Deluan Quintão
be85fe3773 Update de.json (POEditor.com) 2020-05-25 23:23:36 -04:00
Deluan Quintão
7c3d96cf6c Update fr.json (POEditor.com) 2020-05-25 23:23:36 -04:00
Deluan Quintão
50b44c1991 Update cs.json (POEditor.com) 2020-05-25 23:23:36 -04:00
Deluan
f9dae2dd2a Added individual AddToPlaylistDialogs to each list view 2020-05-25 22:51:31 -04:00
Deluan
00811f8000 Cancel the dialog when clicking the backdrop 2020-05-25 22:51:31 -04:00
Deluan
9c940cd44f Show AutomcompleteInput even if the list of playlists is not loaded yet 2020-05-25 22:51:31 -04:00
Deluan
1607dc8b88 Remove unused dependency 2020-05-25 22:51:31 -04:00
Deluan
a42a16696e Translate messages 2020-05-25 22:51:31 -04:00
Deluan
6db63e4dfc Use creatable autocomplete, to select or create a new playlist 2020-05-25 22:51:31 -04:00
Deluan
23bd5e1131 First version of dialog 2020-05-25 22:51:31 -04:00
Deluan
8973477fe5 npm audit fix 2020-05-25 21:43:50 -04:00
Deluan
fbd6c965b0 Always return public attribute in playlist response 2020-05-25 21:00:05 -04:00
Deluan
aaa4f1531e Ignore brackets in search 2020-05-25 11:05:30 -04:00
Deluan
72e92c7318 Fix nil pointer dereference 2020-05-25 10:54:07 -04:00
Deluan
72cb3850d1 Update React Admin to 3.5.3 2020-05-24 23:32:36 -04:00
Deluan
a6cc88177c Fix "starred" sorting 2020-05-24 12:49:32 -04:00
Deluan Quintão
d6ad833538 Update de.json (POEditor.com) 2020-05-24 12:23:39 -04:00
Deluan Quintão
eb1749ce71 Update fr.json (POEditor.com) 2020-05-24 12:23:39 -04:00
Deluan Quintão
acebe18c95 Update cs.json (POEditor.com) 2020-05-24 12:23:39 -04:00
Deluan
cac1a20ec8 Use a ☆ instead of the word "starred" 2020-05-24 12:14:45 -04:00
Deluan
ac8f92d7ac Fix ContextMenu column label 2020-05-24 12:14:44 -04:00
Deluan
207565bde0 Update pt translation 2020-05-24 11:33:05 -04:00
Deluan
3ae1586e10 Add "No playlists available" to context menu 2020-05-24 11:27:17 -04:00
Deluan
5c46f7822f Better disc subtitle 2020-05-23 15:33:29 -04:00
Deluan
c13766bbc3 More optimization for small screens 2020-05-23 14:11:39 -04:00
Deluan
290e8c4bf0 Make Starred into a "QuickFilter" 2020-05-23 13:25:30 -04:00
Deluan
442671578d Fix warning in JS console (wrong property type) 2020-05-23 12:49:39 -04:00
Deluan
1bca8fca97 Enable UI starred by default 2020-05-23 01:07:34 -04:00
Deluan
e811816021 Fix pagination in Songs when filtered by starred 2020-05-23 00:43:45 -04:00
Deluan
9331be67a3 Fix pagination in Songs 2020-05-23 00:17:35 -04:00
Deluan
55ad5c9fc9 Remove unused import, fix build 2020-05-22 23:33:40 -04:00
Deluan
ec0002e77a Add a sortable Starred column and a Starred filter to Song List 2020-05-22 23:10:58 -04:00
Deluan
3632608de0 Replace child.type.name, as it is not available in the production build 2020-05-22 22:23:00 -04:00
Deluan
0a3e6c66c1 Alwasy show context menu on mobile views 2020-05-22 21:47:48 -04:00
Deluan
52a46e61e0 Remove duplication 2020-05-22 21:31:45 -04:00
Deluan
de2759b3d5 Fix react key conflic 2020-05-22 20:48:49 -04:00
Deluan
978e7f2eaa Only show SongContextMenu on hover 2020-05-22 20:15:58 -04:00
delucks
ae847103a2 Correct response body for getSongsByGenre 2020-05-22 18:08:35 -04:00
Deluan
6f6b223453 Disable ToggleStar on playlist tracks 2020-05-22 15:45:03 -04:00
Deluan
8a68cecdb9 Add ToggleStar to SongContextMenu (WIP) 2020-05-22 15:23:42 -04:00
Deluan
e21262675e More log to media_streamer 2020-05-21 21:26:48 -04:00
Deluan
a3ba05b2cc Use latest ci-goreleaser: Go 1.14.3 and Goreleaser 1.35.0 2020-05-21 14:56:56 -04:00
Deluan
294712739a Bump ginkgo/gomega dependencies 2020-05-21 13:41:12 -04:00
Fup
ad725ac355 Add [Install] section to systemd unit file.
This section apparently is mandatory now.
2020-05-21 08:19:12 -04:00
Deluan
17df63b550 Fix child.size and directory.playCount compatibility with Subsonic API. Fixes #304 2020-05-19 23:51:23 -04:00
Deluan
c2d1e9df9f Remove orphan tracks from playlists after they are removed from library 2020-05-18 20:32:01 -04:00
Deluan
0e4f7036eb Make playlist songs look better in mobile 2020-05-18 18:00:55 -04:00
Deluan
a4183aea8c Unexport private functions 2020-05-18 15:06:33 -04:00
Deluan
9e845cb116 Skip scanning folders if they contain a .ndignore file. Closes #297 2020-05-18 14:37:01 -04:00
Deluan Quintão
f82fefe0ab Update de.json (POEditor.com) 2020-05-18 13:52:30 -04:00
Deluan
f28531b609 Add album name to song details 2020-05-18 13:32:12 -04:00
Deluan
14f3ffbee6 Allow sorting playlist tracks 2020-05-18 13:21:47 -04:00
Deluan
94e1b1f65d Add context menu to playlist songs 2020-05-18 13:05:54 -04:00
Deluan
274eb805f9 Upgrade golangci-lint 2020-05-18 12:43:03 -04:00
Deluan
84ea852339 Prettier 2020-05-18 12:19:01 -04:00
Deluan
cf019849f0 Add missing translation key 2020-05-18 12:15:46 -04:00
Deluan
76a5d1928e Fix some JS warnings 2020-05-18 12:12:04 -04:00
Deluan
3dced978c7 Add button to edit playlist details 2020-05-18 12:12:04 -04:00
Deluan
6071ae143e Bump ginkgo version 2020-05-18 10:25:33 -04:00
Deluan
05a07f31c9 Bump react-music-player version 2020-05-18 10:14:50 -04:00
Deluan
1afbbbf189 Add SongContextMenu to Album Songs 2020-05-17 20:57:38 -04:00
Deluan
308163c2e0 Add "AddToPlaylist" to AlbumContextMenu 2020-05-17 20:30:05 -04:00
Deluan Quintão
176bfe1506 Update fr.json (POEditor.com) 2020-05-17 15:51:52 -04:00
Deluan Quintão
4c3f3f3573 Update cs.json (POEditor.com) 2020-05-17 15:51:50 -04:00
Deluan
1aef21a4a9 Update translations for playlists 2020-05-17 10:23:46 -04:00
Deluan
d1a0ffaaee Check permissions in playlists 2020-05-16 23:14:28 -04:00
Deluan
41010515ee Enable Playlist Management in the UI by default 2020-05-16 19:16:48 -04:00
Deluan
a734a1aaa3 Add filter by name to Playlist list 2020-05-16 19:14:19 -04:00
Deluan
bf1dc33782 Add option to shuffle playlist 2020-05-16 19:11:52 -04:00
Deluan
c43798c5dd Filter out songs not in the playlist 2020-05-16 19:02:33 -04:00
Deluan
12cf2f1104 Remove tracks from playlist 2020-05-16 18:35:34 -04:00
Deluan
5c95eed517 Rename actions 2020-05-16 18:35:34 -04:00
Deluan
e81a9dd1b5 Add tracks to playlist 2020-05-16 18:35:34 -04:00
Deluan
fd49ae319f Add Playlist action 2020-05-16 18:35:34 -04:00
Deluan
f881e2a54b Add option to enable (experimental) playlists in UI 2020-05-16 18:35:34 -04:00
Deluan
0ca79eead4 Show Playlist tracks 2020-05-16 18:35:34 -04:00
Deluan
8a709c489a Add playlist views 2020-05-16 18:35:34 -04:00
Deluan
b1f5d35f73 Fix DurationField breaking when the record does not have the source field 2020-05-16 18:35:34 -04:00
Deluan
5682d0e721 Remove tracks from Playlist's GetAll 2020-05-16 18:35:34 -04:00
Deluan
ab690215ef Make Playlist's songCount sortable 2020-05-16 18:35:34 -04:00
Deluan
8f9601090c Add helper functions tests 2020-05-16 18:35:34 -04:00
Deluan
aebee651ac Add nested resource playlist/{id}/tracks 2020-05-16 18:35:34 -04:00
Deluan
a56e588c8e Create relation table for playlist tracks 2020-05-16 18:35:34 -04:00
Deluan
27de18f8c9 Fix typo 2020-05-16 18:35:34 -04:00
Deluan
5afcd0ad22 Make field names camelCase 2020-05-16 18:35:34 -04:00
Deluan
fec589dce5 Add playlist list 2020-05-16 18:35:34 -04:00
Deluan
4e613be960 Add playlists REST endpoint 2020-05-16 18:35:34 -04:00
Deluan
8e2480a82d Fix duration (in web player) when playing transcoded files. Thanks @lijinke666
See: https://github.com/lijinke666/react-music-player/issues/90
2020-05-16 13:24:25 -04:00
Deluan
50eda78ca1 Revert "Save perPage selection in localstorage"
This reverts commit 9490374faa.
2020-05-15 11:04:48 -04:00
Deluan
b3af0f880b Use a custom List component 2020-05-15 11:03:59 -04:00
Deluan
9490374faa Save perPage selection in localstorage 2020-05-14 23:05:14 -04:00
Deluan
a340b62fdf Link to artist from album list 2020-05-14 20:42:21 -04:00
Deluan
0d1af8c635 Fix potential null reference exception 2020-05-14 19:01:07 -04:00
Deluan
377c9e6be6 Revert "Upgrade golangci-lint"
This reverts commit b8ae5ccb02.
2020-05-13 23:26:54 -04:00
Deluan
b8ae5ccb02 Upgrade golangci-lint 2020-05-13 16:50:13 -04:00
Deluan
f8362a4acb Fix staticcheck's SA1029 2020-05-13 16:49:55 -04:00
Deluan
5ce3135f00 Fix gosec's G601 2020-05-13 15:32:42 -04:00
Deluan
162971f7b3 Remove console.log 2020-05-12 14:48:22 -04:00
Deluan
49dd13002c Update Portuguese translation 2020-05-12 14:20:24 -04:00
Deluan
1e5c879fc6 Extract disc subtitle strings for translation 2020-05-12 14:13:34 -04:00
Deluan
e369cbf493 Disable album songs sorting 2020-05-12 13:47:59 -04:00
Deluan
a88270a22b Add multidisc labels, even if there are no disc subtitles 2020-05-12 13:14:23 -04:00
Deluan
4355f4fe2d Show disc subtitles (if available) 2020-05-12 12:57:53 -04:00
Deluan
0d9361734f Import and display disc subtitles 2020-05-12 12:57:53 -04:00
Deluan
7f75994906 go mod tidy 2020-05-11 10:54:35 -04:00
dependabot-preview[bot]
e9d594ebcf Bump github.com/Masterminds/squirrel from 1.3.0 to 1.4.0
Bumps [github.com/Masterminds/squirrel](https://github.com/Masterminds/squirrel) from 1.3.0 to 1.4.0.
- [Release notes](https://github.com/Masterminds/squirrel/releases)
- [Commits](https://github.com/Masterminds/squirrel/compare/v1.3.0...v1.4.0)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-05-11 09:46:22 -04:00
Deluan
0d1e2a92f6 Make MediaFolder ID int32 2020-05-09 22:29:02 -04:00
Deluan
1ed6d130b1 Fix getMusicFolders and getIndexes API compliance. Fix #286 2020-05-09 21:02:38 -04:00
Deluan
09267d2ffd Don't skip to the next song when there is an streaming error 2020-05-09 15:56:12 -04:00
Deluan
3a6639f820 Fix golangci-lint installation 2020-05-09 15:02:23 -04:00
Deluan Quintão
8b79b288eb Update de.json (POEditor.com) 2020-05-09 12:53:51 -04:00
Deluan Quintão
a0cde80c52 Update pt.json (POEditor.com) 2020-05-09 12:53:51 -04:00
Deluan Quintão
458636d2b8 Update fr.json (POEditor.com) 2020-05-09 12:53:51 -04:00
Deluan Quintão
8b30af561e Update cs.json (POEditor.com) 2020-05-09 12:53:51 -04:00
Deluan Quintão
1fb2b9bf1d Update tr.json (POEditor.com) (+1 squashed commit)
Squashed commits:
[9480cf8] Update tr.json (POEditor.com)
2020-05-09 12:53:51 -04:00
Deluan
5c9fdb064d Use official golangci-lint GH action 2020-05-08 17:13:57 -04:00
Deluan
70047fe20e Add songCount column to Artist table 2020-05-08 10:05:48 -04:00
Deluan
1c41582d79 Run pre-push hooks in parallel 2020-05-07 12:00:10 -04:00
Deluan
9a854f6cc4 Add golangci-lint to git pre-push hook 2020-05-07 11:57:07 -04:00
Deluan
06ab88415a Refactor album actions, simplify usage 2020-05-07 11:24:28 -04:00
Deluan
16f2b056ef Fix deprecated version input 2020-05-07 09:50:01 -04:00
Deluan
a761e6f2d0 go mod tidy 2020-05-07 09:31:10 -04:00
dependabot-preview[bot]
da7489cecd Bump github.com/onsi/gomega from 1.9.0 to 1.10.0
Bumps [github.com/onsi/gomega](https://github.com/onsi/gomega) from 1.9.0 to 1.10.0.
- [Release notes](https://github.com/onsi/gomega/releases)
- [Changelog](https://github.com/onsi/gomega/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/gomega/compare/v1.9.0...v1.10.0)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-05-07 09:12:17 -04:00
Deluan
0472988645 Fix next track not working after adding to queue 2020-05-07 00:28:32 -04:00
Deluan
7e0881f0ec Play the remainder of the album when clicking on a album's song 2020-05-06 16:02:31 -04:00
Deluan
f8fb4c8f54 Make the borders of the AlbumSongs round 2020-05-06 08:44:25 -04:00
Deluan
ddcacbb6e5 Fix covers overflow in some resolutions 2020-05-06 08:44:10 -04:00
Deluan Quintão
9d7512e9ab Update README 2020-05-06 08:08:43 -04:00
Deluan Quintão
2e31b4d046 Update translations (#264)
* Update it.json (POEditor.com)

* Update de.json (POEditor.com)

* Update pt.json (POEditor.com)

* Add Czech translation

* Update fr.json (POEditor.com)

* Update nl.json (POEditor.com)

* Update cs.json (POEditor.com)

* Update English translation

* Update it.json (POEditor.com)
2020-05-05 18:32:22 -04:00
Deluan
c585ca7131 Add random as a valid sort option for song resource 2020-05-05 16:17:09 -04:00
Deluan
29e2ab1b4a Set default view to Album list 2020-05-05 15:44:05 -04:00
Deluan
8880294ee7 Change default album view mode to Grid 2020-05-05 15:34:31 -04:00
Deluan
a8d3466b0e Unselect album songs after clicking on bulk "Play Later" button 2020-05-05 15:08:30 -04:00
Deluan
0ee000a8a0 Resolve TODO (workaround is necessary) 2020-05-05 12:35:50 -04:00
Deluan
0833d87f94 Add "Play Later" action to AlbumContextMenu 2020-05-05 12:20:41 -04:00
Deluan
23836d7c3c Change addTrack action to addTracks, supporting multiple tracks to be added to the queue in one call 2020-05-05 12:07:50 -04:00
Deluan
5495451448 Use only one call to the server when adding songs to the queue
Also show a message when there's an error communication with the server
2020-05-05 11:19:41 -04:00
Deluan
bb01c8973f Fix lint error 2020-05-04 20:46:16 -04:00
Deluan
2f4d4c6e38 Add missing translation terms 2020-05-04 20:27:09 -04:00
Deluan
8d99c3ab92 Add validation tests to translations files 2020-05-04 19:54:10 -04:00
Deluan Quintão
8f66e87099 Install reflex in setup-dev target 2020-05-04 17:07:01 -04:00
Deluan
3e778e6007 Bump github.com/sirupsen/logrus from 1.5.0 to 1.6.0 2020-05-04 13:28:16 -04:00
Deluan
b2d6dd0254 Add pr-# tag to Docker image 2020-05-04 12:19:20 -04:00
Deluan
589c4cf225 Fix screenshot proportion 2020-05-03 19:25:39 -04:00
Deluan
4b70cc52d6 Reduce log level of config file being used 2020-05-03 14:09:31 -04:00
Deluan
cc1205c79d Simplify README.md 2020-05-03 00:04:33 -04:00
Deluan
cccd0235cf Add option to specify ConfigFile path 2020-05-02 23:17:38 -04:00
Deluan
17e51756ef Removed dependencies for ra language files 2020-05-02 22:30:55 -04:00
Deluan
13ce21843f go mod tidy 2020-05-02 18:00:18 -04:00
Deluan
151f43b95f Refactor i18n functions a bit 2020-05-02 17:44:24 -04:00
Deluan
055c77b38c Remove "default" from Dark theme name 2020-05-02 14:50:46 -04:00
Deluan
8dc2d7a5e0 Make context menu icon smaller 2020-05-02 14:50:15 -04:00
Deluan
a71d5b3954 Add remaining languages 2020-05-02 14:19:01 -04:00
Deluan
854a923fea Don't sort ReadAll translations, as it will be sorted in the UI 2020-05-02 14:19:01 -04:00
Deluan
496b467c1d Cater for differences when loading embedded Assets and in dev mode 2020-05-02 14:19:01 -04:00
Deluan
056d5e7111 Remove empty keys to allow English fallback 2020-05-02 14:19:01 -04:00
Deluan Quintão
e43c172d96 Update de.json (POEditor.com) 2020-05-02 14:19:01 -04:00
Deluan Quintão
0b56c3f026 Update pt.json (POEditor.com) 2020-05-02 14:19:01 -04:00
Deluan Quintão
5445d20ecd Update en.json (POEditor.com) 2020-05-02 14:19:01 -04:00
Deluan
2f7443e4bd Use English as fallback language 2020-05-02 14:19:01 -04:00
Deluan
41cf99541d Move translations to server 2020-05-02 14:19:01 -04:00
Deluan
1a9663d432 Move static to resources. Embed them at build time 2020-05-02 14:19:01 -04:00
Deluan
b7dcdedf41 More error handling 2020-05-02 14:19:01 -04:00
Deluan
bf8f9d2be8 Fix context menu icon color on Light theme 2020-05-01 12:08:32 -04:00
Deluan
6d20ca27f6 Add mobile album list view 2020-05-01 11:50:07 -04:00
Deluan
3bb573b45f Add AlbumContextMenu to AlbumListView 2020-05-01 11:27:09 -04:00
Deluan
9b2d91c0f2 Fix songs pagination param in AlbumContextMenu 2020-05-01 11:05:36 -04:00
Deluan
b002a69bf8 Fix language sorting 2020-05-01 10:48:28 -04:00
Deluan
e341df1e26 Rename Chinese translation file to zh 2020-05-01 10:43:49 -04:00
Deluan
35e8c1c407 Add all translation keys to English 2020-05-01 10:41:47 -04:00
Deluan
d1a88ed8d6 Remove duplicated translation key 2020-05-01 10:28:31 -04:00
Deluan
10a7dfeb15 Add SongContextMenu 2020-05-01 10:22:24 -04:00
Deluan
dbde5330bd Mark helper function as unexported 2020-05-01 09:17:21 -04:00
Deluan
9b817edd1a go mod tidy 2020-05-01 09:08:35 -04:00
dependabot-preview[bot]
261d73410a Bump github.com/Masterminds/squirrel from 1.2.0 to 1.3.0
Bumps [github.com/Masterminds/squirrel](https://github.com/Masterminds/squirrel) from 1.2.0 to 1.3.0.
- [Release notes](https://github.com/Masterminds/squirrel/releases)
- [Commits](https://github.com/Masterminds/squirrel/compare/v1.2.0...v1.3.0)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-05-01 09:01:49 -04:00
Deluan
555c78f536 Reduce flickering of album covers 2020-05-01 09:00:00 -04:00
Deluan
0270a9c924 Remove dangling create-react-app README 2020-04-30 15:19:55 -04:00
Deluan
a45e278cda Bum react-music-player version to 4.12.0 2020-04-30 14:18:05 -04:00
stncrn
bdbee7f541 Add setup step: download node dependencies 2020-04-30 09:54:15 -04:00
Deluan
b453ee6598 Fix color of album context menu when in Light mode.
Fix is to make it always white
2020-04-29 22:46:34 -04:00
Deluan
716de24f1e Localize translation config notice 2020-04-29 21:59:05 -04:00
Deluan
c816ca4525 Add config option to enable/disable Transcoding configuration 2020-04-29 21:59:05 -04:00
Srihari Chandana
eb7d2dcaa1 fixed compile errors 2020-04-29 21:51:44 -04:00
Srihari Chandana
e6d4cfba96 cleaned up logic 2020-04-29 21:51:44 -04:00
Srihari Chandana
2a5d2d70ba replaced GridButton with GridMenu 2020-04-29 21:51:44 -04:00
Srihari Chandana
e539ddceb9 fixed code to remove warnings 2020-04-29 21:51:44 -04:00
Srihari Chandana
00666da9c1 added grid play button 2020-04-29 21:51:44 -04:00
Deluan
7ad9c385b5 Fix typo 2020-04-29 17:38:03 -04:00
Sumner Evans
e65fb189ce Added back configs that I totally missed because I was tired 2020-04-29 17:18:44 -04:00
Sumner Evans
1afe409a79 Update the sample navidrome.service for use in Arch Linux 2020-04-29 17:18:44 -04:00
jvoisin
dbf9c8be7d An other batch of linters 2020-04-29 14:09:45 -04:00
jvoisin
26188e6d8a Enable a couple of linters 2020-04-29 09:03:07 -04:00
Brian Pierson
d6c70554b3 Fixing 50 shades of blue 2020-04-29 08:15:28 -04:00
Deluan
5990a4285f Replace goreman with node-foreman 2020-04-28 23:24:57 -04:00
Deluan
08e9ac63b1 Add cron workflow to remove old pipeline artifacts 2020-04-28 14:13:34 -04:00
Deluan
71a1f65be2 Bump @testing-library dependencies 2020-04-28 12:06:05 -04:00
Deluan
5862157a2c Move test file to fixtures folder 2020-04-28 11:59:47 -04:00
Deluan
d4f17f2b73 Fix username English translation (fix #231) 2020-04-27 23:23:03 -04:00
Deluan
ea1d534c29 Fix NavBar title translations 2020-04-27 23:22:17 -04:00
Deluan
069de0f9ea Add a try catch to display the record when DurationField fails 2020-04-27 22:46:40 -04:00
Deluan
e871c7daee Add links to documentation on how to contribute with themes and translations 2020-04-27 20:43:58 -04:00
dependabot-preview[bot]
320fe11a66 Bump prettier from 2.0.4 to 2.0.5 in /ui
Bumps [prettier](https://github.com/prettier/prettier) from 2.0.4 to 2.0.5.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/2.0.4...2.0.5)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-04-27 17:41:49 -04:00
Deluan
5fdc09a5b9 Fix pipeline (disable docker job when running on a PR from a forked repo) 2020-04-27 14:59:12 -04:00
Deluan
46f1b33812 Fix logging when first arg is a context.Context without a logger 2020-04-26 19:33:57 -04:00
Deluan
b44218fdcc Move the shuffleAlbum logic into an action 2020-04-26 19:15:52 -04:00
Deluan
4441ae1f0b Break up setup target, to avoid installing tools not required for building only 2020-04-26 16:10:40 -04:00
Deluan
1c3ee89ab4 Disable docker steps if secrets are not available 2020-04-26 15:52:21 -04:00
Deluan
ebc7964157 Fix formatting 2020-04-26 15:07:36 -04:00
Deluan
ad6c86d78a Check formatting in pipeline 2020-04-26 15:07:36 -04:00
Deluan
f3097496c6 Add golangci-lint to Go build step 2020-04-26 15:07:36 -04:00
Deluan
ddeefad501 Fix goimport and gosec warnings 2020-04-26 15:07:36 -04:00
Deluan
5cd453afeb Fix all errcheck warnings 2020-04-26 15:07:36 -04:00
Deluan
03c3c192ed Fixing static checks about passing nil context 2020-04-26 15:07:36 -04:00
Deluan
95790b9eff Remove unused code 2020-04-26 15:07:36 -04:00
ElleshaHackett
6bf7c751a1 Add Dutch language 2020-04-26 15:07:14 -04:00
Kevin Morssink
1019bb8258 Add Dutch language 2020-04-26 15:07:14 -04:00
Deluan
531155d016 Check if persistedState exists beforetrying to use it (fix #214) 2020-04-25 13:37:02 -04:00
Deluan
47311d16cf Trigger pipeline on new tags 2020-04-25 12:35:36 -04:00
Deluan
ef3466787d Fix the pipeline 2020-04-25 12:12:48 -04:00
Deluan
b7fd116bd8 Only triggers the pipeline on pushes to master and PRs 2020-04-25 12:06:05 -04:00
Deluan
34ad740e07 Enable French translation 2020-04-25 11:59:37 -04:00
Deluan
79454d7a92 Fix artist link contrast in light theme 2020-04-25 11:57:52 -04:00
Deluan
87cc397bc3 Add current playing track id to the Redux store 2020-04-25 11:57:52 -04:00
jvoisin
37602a2049 Bump the french traduction 2020-04-25 11:57:22 -04:00
Deluan
56ea380bb3 Add link to artist's albums on the album cover 2020-04-25 09:47:56 -04:00
Deluan
177ace1cee Turn off autoplay when reloading the play queue from the Redux store 2020-04-25 09:30:43 -04:00
Deluan
61e3fe21ff Add 'SNAPSHOT' to version when building locally, as this is not an "official" build 2020-04-25 09:27:22 -04:00
Deluan
8dcca76ec9 Fix various small sort issues 2020-04-24 17:37:28 -04:00
Deluan
1dd3a794f8 Reduce level of "invalid year" log message 2020-04-24 16:00:14 -04:00
Deluan
6c5dd245fe Parse TSO2 (seems that ffmpeg does not process this tag in some situations) 2020-04-24 15:02:20 -04:00
Deluan
3b3ad65612 Use order fields to sort by artist and album 2020-04-24 15:02:20 -04:00
Deluan
e6f798811d Generate Artist Index using the OrderArtistName 2020-04-24 15:02:20 -04:00
Deluan
371e8ab6ca Generate Order Fields based on sanitized version of original fields 2020-04-24 15:02:20 -04:00
Deluan
69c19e946c Add sort tags and use them in search 2020-04-24 15:02:20 -04:00
Deluan
d7edbf93f0 Make test more reliable
In some systems, it was detecting the `go.mod` file as an audio file, probably because of the system's mime-type configuration
2020-04-24 11:05:17 -04:00
Deluan
fb4d920fba Small change to trigger the pipeline 2020-04-23 22:29:33 -04:00
Deluan
5a072fbd10 Follow symlinks to directories when scanning 2020-04-23 20:31:44 -04:00
Deluan
79c9d8f4f4 Parameterize docker image name 2020-04-23 19:31:24 -04:00
Deluan
871bf5a70a Rename pipeline 2020-04-23 19:31:24 -04:00
Deluan
e4af235ce9 Move chmod to copy image, make the final image smaller 2020-04-23 19:31:24 -04:00
Deluan
00384a60f3 Unify GH actions 2020-04-23 19:31:24 -04:00
Deluan
f7b3ff4b34 Build and release docker images 2020-04-23 19:31:24 -04:00
Deluan
eaa48306fc Make Dockerfile platform independent
Thanks @0xERROR: https://github.com/deluan/navidrome/issues/92#issuecomment-614630429
2020-04-23 19:31:24 -04:00
Deluan
f5572b8447 Fix git tag detection 2020-04-23 19:31:24 -04:00
Deluan
a756751cc6 Build binary artifacts 2020-04-23 19:31:24 -04:00
Deluan
b8a3af090d Add cache to build workflow 2020-04-23 19:31:24 -04:00
Deluan
d534cb96a9 Replace math.Max with utils.MaxInt 2020-04-21 08:41:04 -04:00
Dimitri Herzog
f1e1d3bc07 request throttling only for media group api 2020-04-21 08:39:14 -04:00
Deluan
694be54428 Replace math.Max with utils.MaxInt 2020-04-20 12:17:01 -04:00
Deluan
76531fb1cd Remove old pre-commit script (in favour of lefthook) 2020-04-20 11:57:38 -04:00
Dimitri Herzog
716f4c5cf7 configuration for request throttling 2020-04-20 11:51:00 -04:00
jvoisin
ba2d4b6859 Add a .git-blame-ignore-revs file 2020-04-20 10:41:41 -04:00
Deluan
2ec5e47328 Set version correctly when building locally 2020-04-20 09:47:44 -04:00
Deluan
b3f70538a9 Upgrade Prettier to 2.0.4. Reformatted all JS files 2020-04-20 09:09:29 -04:00
Deluan
de115ff466 Bump Testing Library and moved it to devDependencies 2020-04-20 09:02:08 -04:00
Deluan
129f02b36b Bump ReactAdmin to 3.4.2 2020-04-20 08:50:21 -04:00
Deluan
1a8d219197 Remove generated comments from migrations 2020-04-19 23:29:08 -04:00
Deluan
80c8d85cb9 Fine tune search functionality 2020-04-19 23:29:07 -04:00
Deluan
db02f5f07f go mod tidy 2020-04-19 14:51:16 -04:00
Deluan
579294b0f1 Make Players and Transcodings view mobile-friendly 2020-04-19 13:54:51 -04:00
Deluan
f83d0d471d Fix getRandomSongs filters 2020-04-19 13:37:25 -04:00
Deluan Quintão
3b7d7bdb04 Disable French translation 2020-04-18 14:24:27 -04:00
jvoisin
05958f5195 Add French localization 2020-04-18 14:24:27 -04:00
Deluan
6cf4b81de9 Fix year range when querying by year 2020-04-18 14:05:44 -04:00
Deluan
689449df9e Force reindex to fix album by year searches 2020-04-18 11:08:54 -04:00
Deluan
dae938de6f Don't try to install Jamstash as part of initial setup 2020-04-17 22:11:58 -04:00
Deluan
f6617ff77d Add Chinese Simplified translation 2020-04-17 21:54:41 -04:00
Deluan
defdc2ea6b Bump Subsonic API to 1.10.2 2020-04-17 21:44:34 -04:00
Deluan
1fd6571a87 Refactored getSongsByGenre 2020-04-17 21:44:34 -04:00
Deluan
4c0250f9f8 Add fromYear/toYear params to getRandomSongs 2020-04-17 21:44:34 -04:00
Deluan
0e1735e7a9 Add getSongsByGenre endpoint 2020-04-17 21:44:34 -04:00
Deluan
a698e434fd Refactor list_generator to use new filters 2020-04-17 21:44:34 -04:00
Deluan
95f658336c Implement byYear and byGenre AlbumLists 2020-04-17 21:44:34 -04:00
Deluan
69dc4d97b3 Always fill album's min_year if max_year is filled 2020-04-17 21:44:34 -04:00
jvoisin
4aeb63c16e Add a couple of patterns to .gitignore 2020-04-17 10:06:35 -04:00
dependabot-preview[bot]
e5efadf99e Bump github.com/go-chi/chi from 4.1.0+incompatible to 4.1.1+incompatible
Bumps [github.com/go-chi/chi](https://github.com/go-chi/chi) from 4.1.0+incompatible to 4.1.1+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.1.0...v4.1.1)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-04-17 08:09:06 -04:00
AlphaJack
d117d5794d Add Italian localization 2020-04-17 08:05:30 -04:00
Deluan
d09a2182e0 Lax Node version (only matches major version 13) 2020-04-17 00:21:42 -04:00
Deluan
b8b09820b1 Use deluan/ci-goreleaser 2020-04-16 17:44:12 -04:00
Deluan
2cfd7babb3 Add more Portuguese translations 2020-04-16 13:02:39 -04:00
Deluan
161a9b340c Add more Portuguese translations 2020-04-16 12:53:46 -04:00
Deluan
605253446a Fix AlbumLink label in Songs view 2020-04-16 10:26:24 -04:00
Deluan
f8d9b1508e Add prettier npm script 2020-04-15 22:11:23 -04:00
Deluan
3c4de3c8b5 Move language merge logic to i18n/index
This simplifies implementations one new languages
2020-04-15 22:11:23 -04:00
Deluan
a6c9bf1b15 Persist language selection to localStorage 2020-04-15 22:11:23 -04:00
Deluan
bf6ec67528 Add Language Selector to Personal settings 2020-04-15 22:11:23 -04:00
Deluan
289ba68824 Add Portuguese translation (incomplete) 2020-04-15 22:11:23 -04:00
Deluan
2dfe01963a Build binary for Linux MUSL (ex: Alpine). Fix #142 2020-04-15 08:49:30 -04:00
Deluan
5ed1d5c19f Upgrade github.com/djherbis/fscache to v0.10.1, tentatively fix #177 2020-04-15 08:45:10 -04:00
244 changed files with 8605 additions and 2674 deletions

View File

@@ -1,6 +1,5 @@
.DS_Store
ui/node_modules
ui/build
Jamstash-master
Dockerfile
docker-compose*.yml
@@ -11,4 +10,4 @@ navidrome
navidrome.db
navidrome.toml
assets/*gen.go
dist

2
.git-blame-ignore-revs Normal file
View File

@@ -0,0 +1,2 @@
# Upgrade Prettier to 2.0.4. Reformatted all JS files
b3f70538a9138bc279a451f4f358605097210d41

View File

@@ -1,53 +0,0 @@
name: Build
on: [push, pull_request]
jobs:
go:
name: Test Server on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
# TODO Fix tests in Windows
# os: [macOS-latest, ubuntu-latest, windows-latest]
os: [macOS-latest, ubuntu-latest]
steps:
- name: Set up Go 1.14
uses: actions/setup-go@v1
with:
go-version: 1.14
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v1
- name: Download dependencies
run: go mod download
- name: Test
run: go test -cover ./... -v
js:
name: Test UI
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/setup-node@v1
with:
node-version: 13
- name: npm install dependencies
run: |
cd ui
npm ci
# TODO: Enable when there are tests to run
# - name: npm test
# run: |
# cd ui
# CI=test npm test
- name: npm build
run: |
cd ui
npm run build

22
.github/workflows/docker-tags.sh vendored Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
GIT_TAG="${GITHUB_REF##refs/tags/}"
GIT_BRANCH="${GITHUB_REF##refs/heads/}"
GIT_SHA=$(git rev-parse --short HEAD)
PR_NUM=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
DOCKER_IMAGE_TAG="--tag ${DOCKER_IMAGE}:sha-${GIT_SHA}"
if [[ $PR_NUM != "null" ]]; then
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:pr-${PR_NUM}"
fi
if [[ $GITHUB_REF != "$GIT_TAG" ]]; then
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:${GIT_TAG#v} --tag ${DOCKER_IMAGE}:latest"
elif [[ $GITHUB_REF == "refs/heads/master" ]]; then
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:develop"
elif [[ $GIT_BRANCH = feature/* ]]; then
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:$(echo $GIT_BRANCH | tr / -)"
fi
echo ${DOCKER_IMAGE_TAG}

40
.github/workflows/pipeline.dockerfile vendored Normal file
View File

@@ -0,0 +1,40 @@
#####################################################
### Copy platform specific binary
FROM bash as copy-binary
ARG TARGETPLATFORM
RUN echo "Target Platform = ${TARGETPLATFORM}"
COPY dist .
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then cp navidrome_linux_musl_amd64_linux_amd64/navidrome /navidrome; fi
RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then cp navidrome_linux_arm64_linux_arm64/navidrome /navidrome; fi
RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then cp navidrome_linux_arm_linux_arm_6/navidrome /navidrome; fi
RUN if [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then cp navidrome_linux_arm_linux_arm_7/navidrome /navidrome; fi
RUN chmod +x /navidrome
#####################################################
### Build Final Image
FROM alpine as release
LABEL maintainer="deluan@navidrome.org"
# Install ffmpeg and output build config
RUN apk add --no-cache ffmpeg
RUN ffmpeg -buildconf
COPY --from=copy-binary /navidrome /app/
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 ${ND_PORT}
HEALTHCHECK CMD wget -O- http://localhost:${ND_PORT}/ping || exit 1
WORKDIR /app
ENTRYPOINT ["/app/navidrome"]

161
.github/workflows/pipeline.yml vendored Normal file
View File

@@ -0,0 +1,161 @@
name: Pipeline
on:
push:
branches:
- master
tags:
- "v*"
pull_request:
branches:
- master
jobs:
golangci-lint:
name: Lint Server
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: golangci-lint
uses: golangci/golangci-lint-action@v1
with:
version: v1.27
github-token: ${{ secrets.GITHUB_TOKEN }}
go:
name: Test Server on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
# TODO Fix tests in Windows
# os: [macOS-latest, ubuntu-latest, windows-latest]
os: [macOS-latest, ubuntu-latest]
steps:
- name: Set up Go 1.14
uses: actions/setup-go@v1
with:
go-version: 1.14
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- uses: actions/cache@v1
id: cache-go
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Download dependencies
if: steps.cache-go.outputs.cache-hit != 'true'
run: go mod download
- name: Test
run: go test -cover ./... -v
js:
name: Build JS bundle
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: 14
- uses: actions/cache@v1
id: cache-npm
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('ui/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: npm install dependencies
run: |
cd ui
npm ci
- name: npm check-formatting
run: |
cd ui
npm run check-formatting
- name: npm build
run: |
cd ui
npm run build
- uses: actions/upload-artifact@v1
with:
name: js-bundle
path: ui/build
binaries:
name: Binaries
needs: [js, go, golangci-lint]
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Unshallow
run: git fetch --prune --unshallow
- uses: actions/download-artifact@v1
with:
name: js-bundle
path: ui/build
- name: Run GoReleaser - SNAPSHOT
if: startsWith(github.ref, 'refs/tags/') != true
uses: docker://deluan/ci-goreleaser:1.14.3-0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: goreleaser release --rm-dist --skip-publish --snapshot
- name: Run GoReleaser - RELEASE
if: startsWith(github.ref, 'refs/tags/')
uses: docker://deluan/ci-goreleaser:1.14.3-0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: goreleaser release --rm-dist
- uses: actions/upload-artifact@v1
with:
name: binaries
path: dist
docker:
name: Docker images
needs: [binaries]
runs-on: ubuntu-latest
env:
DOCKER_IMAGE: ${{secrets.DOCKER_IMAGE}}
steps:
- name: Set up Docker Buildx
id: buildx
uses: crazy-max/ghaction-docker-buildx@v1
if: env.DOCKER_IMAGE != ''
with:
buildx-version: latest
qemu-version: latest
- uses: actions/checkout@v1
if: env.DOCKER_IMAGE != ''
- uses: actions/download-artifact@v1
if: env.DOCKER_IMAGE != ''
with:
name: binaries
path: dist
- name: Build the Docker image and push
if: env.DOCKER_IMAGE != ''
env:
DOCKER_IMAGE: ${{secrets.DOCKER_IMAGE}}
DOCKER_PLATFORM: linux/amd64,linux/arm/v7,linux/arm64
run: |
echo ${{secrets.DOCKER_PASSWORD}} | docker login -u ${{secrets.DOCKER_USERNAME}} --password-stdin
docker buildx build --platform ${DOCKER_PLATFORM} `.github/workflows/docker-tags.sh` -f .github/workflows/pipeline.dockerfile --push .

View File

@@ -1,31 +0,0 @@
name: Release
on:
push:
tags:
- '*'
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v1
with:
fetch-depth: 0
- uses: actions/setup-node@v1
with:
node-version: 13.12
- name: Build UI
run: |
cd ui
npm ci
npm run build
- name: Fetch tags
run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
- name: Run GoReleaser
uses: docker://bepsays/ci-goreleaser:1.14-1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: goreleaser release --rm-dist

View File

@@ -0,0 +1,18 @@
name: Remove old artifacts
on:
schedule:
# Every day at 1am
- cron: '0 1 * * *'
jobs:
remove-old-artifacts:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Remove old artifacts
uses: c-hive/gha-remove-artifacts@v1
with:
age: '7 days'
skip-tags: false

5
.gitignore vendored
View File

@@ -9,7 +9,6 @@ vendor/*/
wiki
TODO.md
var
Artwork
navidrome.toml
master.zip
Jamstash-master
@@ -20,3 +19,7 @@ navidrome.db
dist
music
docker-compose.override.yml
navidrome.db-shm
navidrome.db-wal
tags

29
.golangci.yml Normal file
View File

@@ -0,0 +1,29 @@
linters:
enable:
- bodyclose
- deadcode
- dogsled
- errcheck
- gocyclo
- goimports
- goprintffuncname
- gosec
- gosimple
- govet
- ineffassign
- interfacer
- misspell
- rowserrcheck
- staticcheck
- structcheck
- typecheck
- unconvert
- unused
- varcheck
- whitespace
issues:
exclude-rules:
- linters:
- gosec
text: "(G501|G401):"

View File

@@ -1,12 +1,10 @@
# GoReleaser config
project_name: navidrome
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 resources -tags embed -ignore="\\\*.go" -pkg resources -o resources/embedded_gen.go resources/...
- go-bindata -fs -prefix ui/build -tags embed -nocompress -pkg assets -o assets/embedded_gen.go ui/build/...
- git checkout .
builds:
- id: navidrome_darwin
@@ -21,7 +19,7 @@ builds:
flags:
- -tags=embed
ldflags:
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
- id: navidrome_linux_amd64
env:
@@ -34,7 +32,21 @@ builds:
- -tags=embed
ldflags:
- "-extldflags '-static'"
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
- id: navidrome_linux_musl_amd64
env:
- CGO_ENABLED=1
- CC=musl-gcc
goos:
- linux
goarch:
- amd64
flags:
- -tags=embed
ldflags:
- "-extldflags '-static'"
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
- id: navidrome_linux_arm
env:
@@ -51,8 +63,7 @@ builds:
- -tags=embed
ldflags:
- "-extldflags '-static'"
- "-extld=$CC"
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
- id: navidrome_linux_arm64
env:
@@ -66,7 +77,7 @@ builds:
- -tags=embed
ldflags:
- "-extldflags '-static'"
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
- id: navidrome_windows_i686
env:
@@ -81,7 +92,7 @@ builds:
- -tags=embed
ldflags:
- "-extldflags '-static'"
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
- id: navidrome_windows_x64
env:
@@ -96,10 +107,24 @@ builds:
- -tags=embed
ldflags:
- "-extldflags '-static'"
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
archives:
-
- id: musl
builds:
- navidrome_linux_musl_amd64
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_musl_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
replacements:
linux: Linux
amd64: x86_64
- id: default
builds:
- navidrome_darwin
- navidrome_linux_amd64
- navidrome_linux_arm
- navidrome_linux_arm64
- navidrome_windows_i686
- navidrome_windows_x64
format_overrides:
- goos: windows
format: zip
@@ -111,16 +136,16 @@ archives:
amd64: x86_64
checksum:
name_template: '{{ .ProjectName }}_checksums.txt'
name_template: "{{ .ProjectName }}_checksums.txt"
snapshot:
name_template: "{{ .Tag }}-next"
name_template: "{{ .Tag }}-SNAPSHOT"
release:
draft: true
changelog:
sort: asc
# sort: asc
filters:
exclude:
- '^docs:'
- "^docs:"

2
.nvmrc
View File

@@ -1 +1 @@
v13.12.0
v14

View File

@@ -1,6 +1,6 @@
#####################################################
### Build UI bundles
FROM node:13.12-alpine AS jsbuilder
FROM node:14-alpine AS jsbuilder
WORKDIR /src
COPY ui/package.json ui/package-lock.json ./
RUN npm ci
@@ -17,11 +17,6 @@ RUN mkdir -p /src/ui/build
RUN apk add -U --no-cache build-base git
RUN go get -u github.com/go-bindata/go-bindata/...
# Download and unpack static ffmpeg
ARG FFMPEG_URL=https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
RUN wget -O /tmp/ffmpeg.tar.xz ${FFMPEG_URL}
RUN cd /tmp && tar xJf ffmpeg.tar.xz && rm ffmpeg.tar.xz
# Download project dependencies
WORKDIR /src
COPY go.mod go.sum ./
@@ -40,23 +35,19 @@ 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})" && \
go-bindata -fs -prefix resources -tags embed -ignore="\\\*.go" -pkg resources -o resources/embedded_gen.go resources/...
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=${GIT_TAG}" -tags=embed
#####################################################
### Build Final Image
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
LABEL maintainer="deluan@navidrome.org"
COPY --from=gobuilder /src/navidrome /app/
COPY --from=gobuilder /tmp/ffmpeg*/ffmpeg /usr/bin/
# Check if ffmpeg runs properly
# Install ffmpeg and output build config
RUN apk add --no-cache ffmpeg
RUN ffmpeg -buildconf
VOLUME ["/data", "/music"]
@@ -72,5 +63,4 @@ EXPOSE ${ND_PORT}
HEALTHCHECK CMD wget -O- http://localhost:${ND_PORT}/ping || exit 1
WORKDIR /app
ENTRYPOINT ["/tini", "--"]
CMD ["/app/navidrome"]
ENTRYPOINT ["/app/navidrome"]

View File

@@ -2,6 +2,7 @@ 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)
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)
## Default target just build the Go project.
default:
@@ -9,7 +10,7 @@ default:
.PHONY: default
dev: check_env
@goreman -f Procfile.dev -b 4533 start
npx foreman -j Procfile.dev -p 4533 start
.PHONY: dev
server: check_go_env
@@ -26,29 +27,36 @@ watch: check_go_env
test: check_go_env
go test ./... -v
# @(cd ./ui && npm test -- --watchAll=false)
.PHONY: test
testall: check_go_env test
@(cd ./ui && npm test -- --watchAll=false)
.PHONY: testall
setup: Jamstash-master
@which wire || (echo "Installing Wire" && GO111MODULE=off go get -u github.com/google/wire/cmd/wire)
update-snapshots: check_go_env
UPDATE_SNAPSHOTS=true ginkgo ./server/subsonic/...
.PHONY: update-snapshots
create-migration:
@if [ -z "${name}" ]; then echo "Usage: make create-migration name=name_of_migration_file"; exit 1; fi
goose -dir db/migration create ${name}
.PHONY: create-migration
setup:
@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
static:
cd static && go-bindata -fs -prefix "static" -nocompress -ignore="\\\*.go" -pkg static .
.PHONY: static
setup-dev: setup
@which wire || (echo "Installing Wire" && GO111MODULE=off go get -u github.com/google/wire/cmd/wire)
@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 reflex || (echo "Installing Reflex" && GO111MODULE=off go get -u github.com/cespare/reflex)
@which golangci-lint || (echo "Installing GolangCI-Lint" && cd .. && GO111MODULE=on go get github.com/golangci/golangci-lint/cmd/golangci-lint@v1.27.0)
@which lefthook || (echo "Installing Lefthook" && GO111MODULE=off go get -u github.com/Arkweid/lefthook)
@lefthook install
.PHONY: setup
Jamstash-master:
wget -N https://github.com/tsquillario/Jamstash/archive/master.zip
@@ -58,12 +66,12 @@ Jamstash-master:
rm -rf Jamstash-master/node_modules Jamstash-master/bower_components
check_env: check_go_env check_node_env
.PHONE: check_env
.PHONY: check_env
check_hooks:
@lefthook add pre-commit
@lefthook add pre-push
.PHONE: check_hooks
.PHONY: check_hooks
check_go_env:
@(hash go) || (echo "\nERROR: GO environment not setup properly!\n"; exit 1)
@@ -76,13 +84,14 @@ check_node_env:
.PHONY: check_node_env
build: check_go_env
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=master"
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT"
.PHONY: build
buildall: check_env
@(cd ./ui && npm run build)
go-bindata -fs -prefix "resources" -tags embed -ignore="\\\*.go" -pkg resources -o resources/embedded_gen.go resources/...
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
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT" -tags=embed
.PHONY: buildall
release:
@@ -95,5 +104,5 @@ release:
.PHONY: release
snapshot:
docker run -it -v $(PWD):/workspace -w /workspace bepsays/ci-goreleaser:1.14-1 goreleaser release --rm-dist --skip-publish --snapshot
docker run -it -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:1.14.3-0 goreleaser release --rm-dist --skip-publish --snapshot
.PHONY: snapshot

172
README.md
View File

@@ -2,6 +2,7 @@
[![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)
[![Downloads](https://img.shields.io/github/downloads/deluan/navidrome/total)](https://github.com/deluan/navidrome/releases/latest)
[![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/)
@@ -12,151 +13,48 @@ music collection from any browser or mobile device. It's like your personal Spot
__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 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
([ui/backend dev](https://www.navidrome.org/docs/developers/),
[translations](https://www.navidrome.org/docs/developers/translations/),
[themes](https://www.navidrome.org/docs/developers/creating-themes)), please join the chat in our
[Discord server](https://discord.gg/xh7j7yF).
## Features
- Handles very large music collections
- Streams virtually any audio format available
- Reads and uses all your beautifully curated metadata (id3 tags)
- 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
- [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
- Smart/dynamic playlists (similar to iTunes)
- Support for audiobooks (bookmarking)
- Jukebox mode
- Sharing links to albums/songs/playlists
- Podcasts
## Installation
Various options are available:
See instructions in the [project's website](https://www.navidrome.org/docs/installation/)
### Pre-built executables
## Features
- Handles very **large music collections**
- Streams virtually **any audio format** available
- Reads and uses all your beautifully curated **metadata**
- Great support for **Box Sets** (multi-disc albums)
- **Multi-user**, each user has their own play counts, playlists, favourites, etc...
- Very **low resource usage**
- **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided
- Ready to use **Raspberry Pi** binaries and Docker images available
- Automatically **monitors your library** for changes, importing new files and reloading new metadata
- **Themeable**, modern and responsive **Web interface** based on [Material UI](https://material-ui.com)
- **Compatible** with all Subsonic/Madsonic/Airsonic [clients](https://www.navidrome.org/docs/overview/#apps)
- **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported**
- Translated to **various languages**
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 (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/
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:
```yaml
# This is just an example. Customize it to your needs.
version: "3"
services:
navidrome:
image: deluan/navidrome:latest
ports:
- "4533:4533"
environment:
# All options with their default values:
ND_MUSICFOLDER: /music
ND_DATAFOLDER: /data
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: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.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)
After the prerequisites above are installed, clone this repository and build the application with:
```shell script
$ git clone https://github.com/deluan/navidrome
$ cd navidrome
$ make setup # Install tools required for Navidrome's development
$ make buildall # Build UI and server, generates a single executable
```
This will generate the `navidrome` executable binary in the project's root folder.
### Running for the first time
Start the server with:
```shell script
./navidrome
```
The server should start listening for requests on the default port __4533__
After starting Navidrome for the first time, go to http://localhost:4533. It will ask you to create your first admin
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.
## Documentation
All documentation can be found in the project's website: https://www.navidrome.org/docs.
Here are some useful direct links:
- [Overview](https://www.navidrome.org/docs/overview/)
- [Installation](https://www.navidrome.org/docs/installation/)
- [Docker](https://www.navidrome.org/docs/installation/docker/)
- [Binaries](https://www.navidrome.org/docs/installation/pre-built-binaries/)
- [Build from source](https://www.navidrome.org/docs/installation/build-from-source/)
- [Development](https://www.navidrome.org/docs/developers/)
- [Subsonic API Compatibility](https://www.navidrome.org/docs/developers/subsonic-api/)
## Screenshots
<p align="center">
<p float="left">
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-login.png">
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-player.png">
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-album-view.png">
<img width="900" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-desktop-player.png">
<p align="left">
<img height="550" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-login.png">
<img height="550" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-player.png">
<img height="550" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-album-view.png">
<img width="550" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-desktop-player.png">
</p>
</p>
## Subsonic API Version Compatibility
Check the up to date [compatibility table](https://www.navidrome.org/docs/developers/subsonic-api)
for the latest Subsonic features available.

View File

@@ -6,7 +6,6 @@ import (
"net/http"
"sync"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/log"
)
@@ -14,7 +13,7 @@ var once sync.Once
func AssetFile() http.FileSystem {
once.Do(func() {
log.Warn("Using external assets from " + consts.UIAssetsLocalPath)
log.Warn("Using external assets from 'ui/build' folder")
})
return http.Dir(consts.UIAssetsLocalPath)
return http.Dir("ui/build")
}

View File

@@ -1,14 +0,0 @@
#!/usr/bin/env bash
gofmtcmd=`which goimports || echo "gofmt"`
gofiles=$(git diff --name-only --diff-filter=ACM | grep '.go$')
[ -z "$gofiles" ] && exit 0
unformatted=`$gofmtcmd -l $gofiles`
[ -z "$unformatted" ] && exit 0
for f in $unformatted; do
$gofmtcmd -w -l "$f"
gofmt -s -w -l "$f"
done

View File

@@ -1,28 +0,0 @@
#!/bin/sh
# Copyright 2012 The Go Authors. All rights reserved.
# Use of this source code is governed by a BSD-style
# license that can be found in the LICENSE file.
# git gofmt pre-commit hook
#
# To use, store as .git/hooks/pre-commit inside your repository and make sure
# it has execute permissions.
#
# This script does not handle file names that contain spaces.
gofmtcmd=`which goimports || echo "gofmt"`
gofiles=$(git diff --cached --name-only --diff-filter=ACM | grep '.go$')
[ -z "$gofiles" ] && exit 0
unformatted=$($gofmtcmd -l $gofiles)
[ -z "$unformatted" ] && exit 0
# Some files are not gofmt'd. Print message and fail.
echo >&2 "Go files must be formatted with $gofmcmd. Please run:"
for fn in $unformatted; do
echo >&2 " $gofmtcmd -w $PWD/$fn"
done
exit 1

View File

@@ -13,13 +13,14 @@ import (
)
type nd struct {
ConfigFile string `default:"./navidrome.toml"`
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"`
SessionTimeout string `default:"24h"`
BaseURL string `default:""`
UILoginBackgroundURL string `default:"https://source.unsplash.com/random/1600x900?music"`
@@ -27,9 +28,10 @@ type nd struct {
IgnoredArticles string `default:"The El La Los Las Le Les Os As O A"`
IndexGroups string `default:"A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)"`
TranscodingCacheSize string `default:"100MB"` // in MB
ImageCacheSize string `default:"100MB"` // in MB
ProbeCommand string `default:"ffmpeg %s -f ffmetadata"`
EnableTranscodingConfig bool `default:"false"`
TranscodingCacheSize string `default:"100MB"` // in MB
ImageCacheSize string `default:"100MB"` // in MB
ProbeCommand string `default:"ffmpeg %s -f ffmetadata"`
// DevFlags. These are used to enable/disable debugging and incomplete features
DevLogSourceLine bool `default:"false"`
@@ -38,6 +40,28 @@ type nd struct {
var Server = &nd{}
// TODO refactor configuration and use something different. Maybe https://github.com/spf13/cobra
// This function loads the whole config just to get the ConfigFile. This is very cumbersome, but doesn't
// seem there's a simpler way to do this with multiconfig. Time to replace this library?
func configFile() string {
conf := &nd{}
loader := multiconfig.MultiLoader(
&multiconfig.TagLoader{},
&multiconfig.EnvironmentLoader{},
&multiconfig.FlagLoader{},
)
d := &multiconfig.DefaultLoader{}
d.Loader = loader
d.Validator = multiconfig.MultiValidator(&multiconfig.RequiredValidator{})
if err := d.Load(conf); err != nil {
return consts.LocalConfigFile
}
if _, err := os.Stat(conf.ConfigFile); err != nil {
return consts.LocalConfigFile
}
return conf.ConfigFile
}
func newWithPath(path string, skipFlags ...bool) *multiconfig.DefaultLoader {
var loaders []multiconfig.Loader
@@ -91,9 +115,9 @@ func LoadFromFile(confFile string, skipFlags ...bool) {
}
log.SetLevelString(Server.LogLevel)
log.SetLogSourceLine(Server.DevLogSourceLine)
log.Trace("Loaded configuration", "file", confFile, "config", fmt.Sprintf("%#v", Server))
log.Debug("Loaded configuration", "file", confFile, "config", fmt.Sprintf("%#v", Server))
}
func Load() {
LoadFromFile(consts.LocalConfigFile)
LoadFromFile(configFile())
}

View File

@@ -5,11 +5,11 @@ import (
"strings"
"unicode"
"github.com/deluan/navidrome/static"
"github.com/deluan/navidrome/resources"
)
func getBanner() string {
data, _ := static.Asset("banner.txt")
data, _ := resources.Asset("banner.txt")
return strings.TrimRightFunc(string(data), unicode.IsSpace)
}

View File

@@ -11,7 +11,7 @@ const (
AppName = "navidrome"
LocalConfigFile = "./navidrome.toml"
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL"
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on"
InitialSetupFlagKey = "InitialSetup"
UIAuthorizationHeader = "X-ND-Authorization"
@@ -19,13 +19,19 @@ const (
JWTIssuer = "ND"
DefaultSessionTimeout = 30 * time.Minute
UIAssetsLocalPath = "ui/build"
DevInitialUserName = "admin"
DevInitialName = "Dev Admin"
URLPathUI = "/app"
URLPathSubsonicAPI = "/rest"
RequestThrottleBacklogLimit = 100
RequestThrottleBacklogTimeout = time.Minute
I18nFolder = "i18n"
SkipScanFile = ".ndignore"
PlaceholderAlbumArt = "navidrome-600x600.png"
)
// Cache options
@@ -61,5 +67,4 @@ 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

@@ -1,19 +1,25 @@
# This file ususaly goes in /etc/systemd/system
[Unit]
Description=Navidrome Daemon
After=network.target
Description=Navidrome Music Server and Streamer compatible with Subsonic/Airsonic
After=remote-fs.target network.target
AssertPathExists=/var/lib/navidrome
[Install]
WantedBy=multi-user.target
[Service]
User=navidrome
Group=navidrome
Type=simple
ExecStart=/opt/navidrome/navidrome
WorkingDirectory=/opt/navidrome
ExecStart=/usr/bin/navidrome
WorkingDirectory=/var/lib/navidrome
TimeoutStopSec=20
KillMode=process
Restart=on-failure
EnvironmentFile=-/etc/sysconfig/navidrome
# See https://www.freedesktop.org/software/systemd/man/systemd.exec.html
DevicePolicy=closed
NoNewPrivileges=yes
@@ -26,10 +32,17 @@ 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
ReadWritePaths=/var/lib/navidrome
[Install]
WantedBy=multi-user.target
# You can uncomment the following line if you're not using the jukebox This
# will prevent navidrome from accessing any real (physical) devices
#PrivateDevices=yes
# You can change the following line to `strict` instead of `full` if you don't
# want navidrome to be able to write anything on your filesystem outside of
# /var/lib/navidrome.
ProtectSystem=full
# You can comment the following line if you don't have any media in /home/*.
# This will prevent navidrome from ever reading/writing anything there.
ProtectHome=true

View File

@@ -27,7 +27,7 @@ func Db() *sql.DB {
var err error
Path = conf.Server.DbPath
if Path == ":memory:" {
Path = "file::memory:?cache=shared"
Path = "file::memory:?cache=shared&_foreign_keys=on"
conf.Server.DbPath = Path
}
log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver)

View File

@@ -51,6 +51,5 @@ create index annotation_starred
}
func Down20200208222418(tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@@ -16,6 +16,5 @@ func Up20200310171621(tx *sql.Tx) error {
}
func Down20200310171621(tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@@ -37,6 +37,5 @@ drop table if exists search;
}
func Down20200319211049(tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@@ -2,6 +2,7 @@ package migration
import (
"database/sql"
"github.com/pressly/goose"
)

View File

@@ -2,6 +2,7 @@ package migration
import (
"database/sql"
"github.com/pressly/goose"
)

View File

@@ -2,6 +2,7 @@ package migration
import (
"database/sql"
"github.com/pressly/goose"
)
@@ -75,6 +76,5 @@ create index album_max_year
}
func Down20200327193744(tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@@ -25,6 +25,5 @@ create index if not exists media_file_track_number
}
func Down20200404214704(tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@@ -2,6 +2,7 @@ package migration
import (
"database/sql"
"github.com/pressly/goose"
)

View File

@@ -2,6 +2,7 @@ package migration
import (
"database/sql"
"github.com/pressly/goose"
)

View File

@@ -0,0 +1,20 @@
package migration
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(Up20200418110522, Down20200418110522)
}
func Up20200418110522(tx *sql.Tx) error {
notice(tx, "A full rescan will be performed to fix search Albums by year")
return forceFullRescan(tx)
}
func Down20200418110522(tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,20 @@
package migration
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(Up20200419222708, Down20200419222708)
}
func Up20200419222708(tx *sql.Tx) error {
notice(tx, "A full rescan will be performed to change the search behaviour")
return forceFullRescan(tx)
}
func Down20200419222708(tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,65 @@
package migration
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(Up20200423204116, Down20200423204116)
}
func Up20200423204116(tx *sql.Tx) error {
_, err := tx.Exec(`
alter table artist
add order_artist_name varchar(255) collate nocase;
alter table artist
add sort_artist_name varchar(255) collate nocase;
create index if not exists artist_order_artist_name
on artist (order_artist_name);
alter table album
add order_album_name varchar(255) collate nocase;
alter table album
add order_album_artist_name varchar(255) collate nocase;
alter table album
add sort_album_name varchar(255) collate nocase;
alter table album
add sort_artist_name varchar(255) collate nocase;
alter table album
add sort_album_artist_name varchar(255) collate nocase;
create index if not exists album_order_album_name
on album (order_album_name);
create index if not exists album_order_album_artist_name
on album (order_album_artist_name);
alter table media_file
add order_album_name varchar(255) collate nocase;
alter table media_file
add order_album_artist_name varchar(255) collate nocase;
alter table media_file
add order_artist_name varchar(255) collate nocase;
alter table media_file
add sort_album_name varchar(255) collate nocase;
alter table media_file
add sort_artist_name varchar(255) collate nocase;
alter table media_file
add sort_album_artist_name varchar(255) collate nocase;
alter table media_file
add sort_title varchar(255) collate nocase;
create index if not exists media_file_order_album_name
on media_file (order_album_name);
create index if not exists media_file_order_artist_name
on media_file (order_artist_name);
`)
if err != nil {
return err
}
notice(tx, "A full rescan will be performed to change the search behaviour")
return forceFullRescan(tx)
}
func Down20200423204116(tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,27 @@
package migration
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(Up20200508093059, Down20200508093059)
}
func Up20200508093059(tx *sql.Tx) error {
_, err := tx.Exec(`
alter table artist
add song_count integer default 0 not null;
`)
if err != nil {
return err
}
notice(tx, "A full rescan will be performed to calculate artists' song counts")
return forceFullRescan(tx)
}
func Down20200508093059(tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,27 @@
package migration
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(Up20200512104202, Down20200512104202)
}
func Up20200512104202(tx *sql.Tx) error {
_, err := tx.Exec(`
alter table media_file
add disc_subtitle varchar(255);
`)
if err != nil {
return err
}
notice(tx, "A full rescan will be performed to import disc subtitles")
return forceFullRescan(tx)
}
func Down20200512104202(tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,100 @@
package migration
import (
"database/sql"
"strings"
"github.com/deluan/navidrome/log"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(Up20200516140647, Down20200516140647)
}
func Up20200516140647(tx *sql.Tx) error {
_, err := tx.Exec(`
create table if not exists playlist_tracks
(
id integer default 0 not null,
playlist_id varchar(255) not null,
media_file_id varchar(255) not null
);
create unique index if not exists playlist_tracks_pos
on playlist_tracks (playlist_id, id);
`)
if err != nil {
return err
}
rows, err := tx.Query("select id, tracks from playlist")
if err != nil {
return err
}
defer rows.Close()
var id, tracks string
for rows.Next() {
err := rows.Scan(&id, &tracks)
if err != nil {
return err
}
err = Up20200516140647UpdatePlaylistTracks(tx, id, tracks)
if err != nil {
return err
}
}
err = rows.Err()
if err != nil {
return err
}
_, err = tx.Exec(`
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,
song_count integer default 0 not null,
owner varchar(255) default '' not null,
public bool default FALSE not null,
created_at datetime,
updated_at datetime
);
insert into playlist_dg_tmp(id, name, comment, duration, owner, public, created_at, updated_at)
select id, name, comment, duration, owner, public, created_at, updated_at from playlist;
drop table playlist;
alter table playlist_dg_tmp rename to playlist;
create index playlist_name
on playlist (name);
update playlist set song_count = (select count(*) from playlist_tracks where playlist_id = playlist.id)
where id <> ''
`)
return err
}
func Up20200516140647UpdatePlaylistTracks(tx *sql.Tx, id string, tracks string) error {
trackList := strings.Split(tracks, ",")
stmt, err := tx.Prepare("insert into playlist_tracks (playlist_id, media_file_id, id) values (?, ?, ?)")
if err != nil {
return err
}
for i, trackId := range trackList {
_, err := stmt.Exec(id, trackId, i+1)
if err != nil {
log.Error("Error adding track to playlist", "playlistId", id, "trackId", trackId, err)
}
}
return nil
}
func Down20200516140647(tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,123 @@
package migration
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(Up20200608153717, Down20200608153717)
}
func Up20200608153717(tx *sql.Tx) error {
// First delete dangling players
_, err := tx.Exec(`
delete from player where user_name not in (select user_name from user)`)
if err != nil {
return err
}
// Add foreign key to player table
err = updatePlayer_20200608153717(tx)
if err != nil {
return err
}
// Add foreign key to playlist table
err = updatePlaylist_20200608153717(tx)
if err != nil {
return err
}
// Add foreign keys to playlist_tracks table
return updatePlaylistTracks_20200608153717(tx)
}
func updatePlayer_20200608153717(tx *sql.Tx) error {
_, err := tx.Exec(`
create table player_dg_tmp
(
id varchar(255) not null
primary key,
name varchar not null
unique,
type varchar,
user_name varchar not null
references user (user_name)
on update cascade on delete cascade,
client varchar not null,
ip_address varchar,
last_seen timestamp,
max_bit_rate int default 0,
transcoding_id varchar null
);
insert into player_dg_tmp(id, name, type, user_name, client, ip_address, last_seen, max_bit_rate, transcoding_id) select id, name, type, user_name, client, ip_address, last_seen, max_bit_rate, transcoding_id from player;
drop table player;
alter table player_dg_tmp rename to player;
`)
return err
}
func updatePlaylist_20200608153717(tx *sql.Tx) error {
_, err := tx.Exec(`
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,
song_count integer default 0 not null,
owner varchar(255) default '' not null
constraint playlist_user_user_name_fk
references user (user_name)
on update cascade on delete cascade,
public bool default FALSE not null,
created_at datetime,
updated_at datetime
);
insert into playlist_dg_tmp(id, name, comment, duration, song_count, owner, public, created_at, updated_at) select id, name, comment, duration, song_count, owner, public, created_at, updated_at from playlist;
drop table playlist;
alter table playlist_dg_tmp rename to playlist;
create index playlist_name
on playlist (name);
`)
return err
}
func updatePlaylistTracks_20200608153717(tx *sql.Tx) error {
_, err := tx.Exec(`
create table playlist_tracks_dg_tmp
(
id integer default 0 not null,
playlist_id varchar(255) not null
constraint playlist_tracks_playlist_id_fk
references playlist
on update cascade on delete cascade,
media_file_id varchar(255) not null
);
insert into playlist_tracks_dg_tmp(id, playlist_id, media_file_id) select id, playlist_id, media_file_id from playlist_tracks;
drop table playlist_tracks;
alter table playlist_tracks_dg_tmp rename to playlist_tracks;
create unique index playlist_tracks_pos
on playlist_tracks (playlist_id, id);
`)
return err
}
func Down20200608153717(tx *sql.Tx) error {
return nil
}

View File

@@ -1,6 +1,7 @@
package auth
import (
"context"
"fmt"
"sync"
"time"
@@ -22,7 +23,7 @@ var (
func InitTokenAuth(ds model.DataStore) {
once.Do(func() {
secret, err := ds.Property(nil).DefaultGet(consts.JWTSecretKey, "not so secret")
secret, err := ds.Property(context.TODO()).DefaultGet(consts.JWTSecretKey, "not so secret")
if err != nil {
log.Error("No JWT secret found in DB. Setting a temp one, but please report this error", err)
}

View File

@@ -61,7 +61,7 @@ type DirectoryInfo struct {
Entries Entries
Parent string
Starred time.Time
PlayCount int32
PlayCount int64
UserRating int
AlbumCount int
CoverArt string
@@ -80,10 +80,6 @@ func (b *browser) Artist(ctx context.Context, id string) (*DirectoryInfo, error)
return nil, err
}
log.Debug(ctx, "Found Artist", "id", id, "name", a.Name)
var albumIds []string
for _, al := range albums {
albumIds = append(albumIds, al.ID)
}
return b.buildArtistDir(a, albums), nil
}
@@ -93,11 +89,6 @@ func (b *browser) Album(ctx context.Context, id string) (*DirectoryInfo, error)
return nil, err
}
log.Debug(ctx, "Found Album", "id", id, "name", al.Name)
var mfIds []string
for _, mf := range tracks {
mfIds = append(mfIds, mf.ID)
}
return b.buildAlbumDir(al, tracks), nil
}
@@ -144,9 +135,10 @@ func (b *browser) buildArtistDir(a *model.Artist, albums model.Albums) *Director
}
dir.Entries = make(Entries, len(albums))
for i, al := range albums {
for i := range albums {
al := albums[i]
dir.Entries[i] = FromAlbum(&al)
dir.PlayCount += int32(al.PlayCount)
dir.PlayCount += al.PlayCount
}
return dir
}
@@ -164,7 +156,7 @@ func (b *browser) buildAlbumDir(al *model.Album, tracks model.MediaFiles) *Direc
Year: al.MaxYear,
Genre: al.Genre,
CoverArt: al.CoverArtId,
PlayCount: int32(al.PlayCount),
PlayCount: al.PlayCount,
UserRating: al.Rating,
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/model/request"
)
type Entry struct {
@@ -22,7 +23,7 @@ type Entry struct {
Starred time.Time
Track int
Duration int
Size int
Size int64
Suffix string
BitRate int
ContentType string
@@ -133,7 +134,8 @@ func realArtistName(mf *model.MediaFile) string {
func FromAlbums(albums model.Albums) Entries {
entries := make(Entries, len(albums))
for i, al := range albums {
for i := range albums {
al := albums[i]
entries[i] = FromAlbum(&al)
}
return entries
@@ -141,7 +143,8 @@ func FromAlbums(albums model.Albums) Entries {
func FromMediaFiles(mfs model.MediaFiles) Entries {
entries := make(Entries, len(mfs))
for i, mf := range mfs {
for i := range mfs {
mf := mfs[i]
entries[i] = FromMediaFile(&mf)
}
return entries
@@ -149,17 +152,17 @@ func FromMediaFiles(mfs model.MediaFiles) Entries {
func FromArtists(ars model.Artists) Entries {
entries := make(Entries, len(ars))
for i, ar := range ars {
for i := range ars {
ar := ars[i]
entries[i] = FromArtist(&ar)
}
return entries
}
func userName(ctx context.Context) string {
user := ctx.Value("user")
if user == nil {
if user, ok := request.UserFrom(ctx); !ok {
return "UNKNOWN"
} else {
return user.UserName
}
usr := user.(model.User)
return usr.UserName
}

View File

@@ -18,7 +18,7 @@ import (
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/static"
"github.com/deluan/navidrome/resources"
"github.com/dhowden/tag"
"github.com/disintegration/imaging"
"github.com/djherbis/fscache"
@@ -74,7 +74,9 @@ func (c *cover) Get(ctx context.Context, id string, size int, out io.Writer) err
log.Error(ctx, "Error loading cover art", "path", path, "size", size, err)
return
}
io.Copy(w, reader)
if _, err := io.Copy(w, reader); err != nil {
log.Error(ctx, "Error saving covert art to cache", "path", path, "size", size, err)
}
}()
} else {
log.Trace(ctx, "Loading image from cache", "path", path, "size", size, "lastUpdate", lastUpdate)
@@ -120,7 +122,7 @@ func (c *cover) getCover(ctx context.Context, path string, size int) (reader io.
defer func() {
if err != nil {
log.Warn(ctx, "Error extracting image", "path", path, "size", size, err)
reader, err = static.AssetFile().Open("navidrome-310x310.png")
reader, err = resources.AssetFile().Open(consts.PlaceholderAlbumArt)
}
}()
var data []byte

View File

@@ -2,6 +2,7 @@ package engine
import (
"bytes"
"context"
"image"
"github.com/deluan/navidrome/log"
@@ -14,7 +15,7 @@ import (
var _ = Describe("Cover", func() {
var cover Cover
var ds model.DataStore
ctx := log.NewContext(nil)
ctx := log.NewContext(context.TODO())
BeforeEach(func() {
ds = &persistence.MockDataStore{MockedTranscoding: &mockTranscodingRepository{}}

View File

@@ -9,88 +9,108 @@ import (
)
type ListGenerator interface {
GetNewest(ctx context.Context, offset int, size int) (Entries, error)
GetRecent(ctx context.Context, offset int, size int) (Entries, error)
GetFrequent(ctx context.Context, offset int, size int) (Entries, error)
GetHighest(ctx context.Context, offset int, size int) (Entries, error)
GetRandom(ctx context.Context, offset int, size int) (Entries, error)
GetByName(ctx context.Context, offset int, size int) (Entries, error)
GetByArtist(ctx context.Context, offset int, size int) (Entries, error)
GetStarred(ctx context.Context, offset int, size int) (Entries, error)
GetAllStarred(ctx context.Context) (artists Entries, albums Entries, mediaFiles Entries, err error)
GetNowPlaying(ctx context.Context) (Entries, error)
GetRandomSongs(ctx context.Context, size int, genre string) (Entries, error)
GetSongs(ctx context.Context, offset, size int, filter ListFilter) (Entries, error)
GetAlbums(ctx context.Context, offset, size int, filter ListFilter) (Entries, error)
}
func NewListGenerator(ds model.DataStore, npRepo NowPlayingRepository) ListGenerator {
return &listGenerator{ds, npRepo}
}
type ListFilter model.QueryOptions
func ByNewest() ListFilter {
return ListFilter{Sort: "createdAt", Order: "desc"}
}
func ByRecent() ListFilter {
return ListFilter{Sort: "playDate", Order: "desc", Filters: squirrel.Gt{"play_date": time.Time{}}}
}
func ByFrequent() ListFilter {
return ListFilter{Sort: "playCount", Order: "desc", Filters: squirrel.Gt{"play_count": 0}}
}
func ByRandom() ListFilter {
return ListFilter{Sort: "random()"}
}
func ByName() ListFilter {
return ListFilter{Sort: "name"}
}
func ByArtist() ListFilter {
return ListFilter{Sort: "artist"}
}
func ByStarred() ListFilter {
return ListFilter{Sort: "starred_at", Order: "desc", Filters: squirrel.Eq{"starred": true}}
}
func ByRating() ListFilter {
return ListFilter{Sort: "Rating", Order: "desc", Filters: squirrel.Gt{"rating": 0}}
}
func ByGenre(genre string) ListFilter {
return ListFilter{
Sort: "genre asc, name asc",
Filters: squirrel.Eq{"genre": genre},
}
}
func ByYear(fromYear, toYear int) ListFilter {
return ListFilter{
Sort: "max_year, name",
Filters: squirrel.Or{
squirrel.And{
squirrel.GtOrEq{"min_year": fromYear},
squirrel.LtOrEq{"min_year": toYear},
},
squirrel.And{
squirrel.GtOrEq{"max_year": fromYear},
squirrel.LtOrEq{"max_year": toYear},
},
},
}
}
func SongsByGenre(genre string) ListFilter {
return ListFilter{
Sort: "genre asc, title asc",
Filters: squirrel.Eq{"genre": genre},
}
}
func SongsByRandom(genre string, fromYear, toYear int) ListFilter {
options := ListFilter{
Sort: "random()",
}
ff := squirrel.And{}
if genre != "" {
ff = append(ff, squirrel.Eq{"genre": genre})
}
if fromYear != 0 {
ff = append(ff, squirrel.GtOrEq{"year": fromYear})
}
if toYear != 0 {
ff = append(ff, squirrel.LtOrEq{"year": toYear})
}
options.Filters = ff
return options
}
type listGenerator struct {
ds model.DataStore
npRepo NowPlayingRepository
}
func (g *listGenerator) query(ctx context.Context, qo model.QueryOptions) (Entries, error) {
albums, err := g.ds.Album(ctx).GetAll(qo)
if err != nil {
return nil, err
}
albumIds := make([]string, len(albums))
for i, al := range albums {
albumIds[i] = al.ID
}
return FromAlbums(albums), err
}
func (g *listGenerator) GetNewest(ctx context.Context, offset int, size int) (Entries, error) {
qo := model.QueryOptions{Sort: "CreatedAt", Order: "desc", Offset: offset, Max: size}
return g.query(ctx, qo)
}
func (g *listGenerator) GetRecent(ctx context.Context, offset int, size int) (Entries, error) {
qo := model.QueryOptions{Sort: "PlayDate", Order: "desc", Offset: offset, Max: size,
Filters: squirrel.Gt{"play_date": time.Time{}}}
return g.query(ctx, qo)
}
func (g *listGenerator) GetFrequent(ctx context.Context, offset int, size int) (Entries, error) {
qo := model.QueryOptions{Sort: "PlayCount", Order: "desc", Offset: offset, Max: size,
Filters: squirrel.Gt{"play_count": 0}}
return g.query(ctx, qo)
}
func (g *listGenerator) GetHighest(ctx context.Context, offset int, size int) (Entries, error) {
qo := model.QueryOptions{Sort: "Rating", Order: "desc", Offset: offset, Max: size,
Filters: squirrel.Gt{"rating": 0}}
return g.query(ctx, qo)
}
func (g *listGenerator) GetByName(ctx context.Context, offset int, size int) (Entries, error) {
qo := model.QueryOptions{Sort: "Name", Offset: offset, Max: size}
return g.query(ctx, qo)
}
func (g *listGenerator) GetByArtist(ctx context.Context, offset int, size int) (Entries, error) {
qo := model.QueryOptions{Sort: "Artist", Offset: offset, Max: size}
return g.query(ctx, qo)
}
func (g *listGenerator) GetRandom(ctx context.Context, offset int, size int) (Entries, error) {
albums, err := g.ds.Album(ctx).GetRandom(model.QueryOptions{Max: size, Offset: offset})
if err != nil {
return nil, err
}
return FromAlbums(albums), nil
}
func (g *listGenerator) GetRandomSongs(ctx context.Context, size int, genre string) (Entries, error) {
options := model.QueryOptions{Max: size}
if genre != "" {
options.Filters = squirrel.Eq{"genre": genre}
}
mediaFiles, err := g.ds.MediaFile(ctx).GetRandom(options)
func (g *listGenerator) GetSongs(ctx context.Context, offset, size int, filter ListFilter) (Entries, error) {
qo := model.QueryOptions(filter)
qo.Offset = offset
qo.Max = size
mediaFiles, err := g.ds.MediaFile(ctx).GetAll(qo)
if err != nil {
return nil, err
}
@@ -98,6 +118,18 @@ func (g *listGenerator) GetRandomSongs(ctx context.Context, size int, genre stri
return FromMediaFiles(mediaFiles), nil
}
func (g *listGenerator) GetAlbums(ctx context.Context, offset, size int, filter ListFilter) (Entries, error) {
qo := model.QueryOptions(filter)
qo.Offset = offset
qo.Max = size
albums, err := g.ds.Album(ctx).GetAll(qo)
if err != nil {
return nil, err
}
return FromAlbums(albums), nil
}
func (g *listGenerator) GetStarred(ctx context.Context, offset int, size int) (Entries, error) {
qo := model.QueryOptions{Offset: offset, Max: size, Sort: "starred_at", Order: "desc"}
albums, err := g.ds.Album(ctx).GetStarred(qo)
@@ -126,16 +158,6 @@ func (g *listGenerator) GetAllStarred(ctx context.Context) (artists Entries, alb
return nil, nil, nil, err
}
var mfIds []string
for _, mf := range mfs {
mfIds = append(mfIds, mf.ID)
}
var artistIds []string
for _, ar := range ars {
artistIds = append(artistIds, ar.ID)
}
artists = FromArtists(ars)
albums = FromAlbums(als)
mediaFiles = FromMediaFiles(mfs)
@@ -156,10 +178,9 @@ func (g *listGenerator) GetNowPlaying(ctx context.Context) (Entries, error) {
}
entries[i] = FromMediaFile(mf)
entries[i].UserName = np.Username
entries[i].MinutesAgo = int(time.Now().Sub(np.Start).Minutes())
entries[i].MinutesAgo = int(time.Since(np.Start).Minutes())
entries[i].PlayerId = np.PlayerId
entries[i].PlayerName = np.PlayerName
}
return entries, nil
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/deluan/navidrome/engine/transcoder"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/model/request"
"github.com/djherbis/fscache"
)
@@ -40,9 +41,11 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat str
var format string
var bitRate int
var cached bool
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)
log.Info("Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached,
"bitRate", bitRate, "user", userName(ctx), "transcoding", format != "raw",
"originalFormat", mf.Suffix, "originalBitRate", mf.BitRate)
}()
format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate)
@@ -75,8 +78,10 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat str
return nil, err
}
cached = w == nil
// If this is a brand new transcoding request, not in the cache, start transcoding
if w != nil {
if !cached {
log.Trace(ctx, "Cache miss. Starting new transcoding session", "id", mf.ID)
t, err := ms.ds.Transcoding(ctx).FindByFormat(format)
if err != nil {
@@ -92,7 +97,7 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat str
}
// If it is in the cache, check if the stream is done being written. If so, return a ReaderSeeker
if w == nil {
if cached {
size := getFinalCachedSize(r)
if size > 0 {
log.Debug(ctx, "Streaming cached file", "id", mf.ID, "path", mf.Path,
@@ -161,7 +166,7 @@ func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model
bitRate = mf.BitRate
return
}
trc, hasDefault := ctx.Value("transcoding").(model.Transcoding)
trc, hasDefault := request.TranscodingFrom(ctx)
var cFormat string
var cBitRate int
if reqFormat != "" {
@@ -170,7 +175,7 @@ func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model
if hasDefault {
cFormat = trc.TargetFormat
cBitRate = trc.DefaultBitRate
if p, ok := ctx.Value("player").(model.Player); ok {
if p, ok := request.PlayerFrom(ctx); ok {
cBitRate = p.MaxBitRate
}
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/model/request"
"github.com/deluan/navidrome/persistence"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
@@ -16,7 +17,7 @@ var _ = Describe("MediaStreamer", func() {
var streamer MediaStreamer
var ds model.DataStore
ffmpeg := &fakeFFmpeg{Data: "fake data"}
ctx := log.NewContext(nil)
ctx := log.NewContext(context.TODO())
BeforeEach(func() {
ds = &persistence.MockDataStore{MockedTranscoding: &mockTranscodingRepository{}}
@@ -101,7 +102,7 @@ var _ = Describe("MediaStreamer", func() {
Context("player has format configured", func() {
BeforeEach(func() {
t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
ctx = context.WithValue(ctx, "transcoding", t)
ctx = request.WithTranscoding(ctx, t)
})
It("returns raw if raw is requested", func() {
mf.Suffix = "flac"
@@ -142,8 +143,8 @@ var _ = Describe("MediaStreamer", 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)
ctx = request.WithTranscoding(ctx, t)
ctx = request.WithPlayer(ctx, p)
})
It("returns raw if raw is requested", func() {
mf.Suffix = "flac"

View File

@@ -110,7 +110,7 @@ func checkExpired(l *list.List, f func() *list.Element) *list.Element {
return nil
}
start := e.Value.(*NowPlayingInfo).Start
if time.Now().Sub(start) < NowPlayingExpire {
if time.Since(start) < NowPlayingExpire {
return e
}
l.Remove(e)

View File

@@ -7,6 +7,7 @@ import (
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/model/request"
"github.com/google/uuid"
)
@@ -27,7 +28,7 @@ func (p *players) Register(ctx context.Context, id, client, typ, ip string) (*mo
var plr *model.Player
var trc *model.Transcoding
var err error
userName := ctx.Value("username").(string)
userName, _ := request.UsernameFrom(ctx)
if id != "" {
plr, err = p.ds.Player(ctx).Get(id)
if err == nil && plr.Client != client {

View File

@@ -6,6 +6,7 @@ import (
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/model/request"
"github.com/deluan/navidrome/persistence"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
@@ -14,8 +15,9 @@ import (
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")
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "johndoe"})
ctx = request.WithUsername(ctx, "johndoe")
var beforeRegister time.Time
BeforeEach(func() {

View File

@@ -5,6 +5,7 @@ import (
"time"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/model/request"
"github.com/deluan/navidrome/utils"
)
@@ -25,34 +26,37 @@ type playlists struct {
}
func (p *playlists) Create(ctx context.Context, playlistId, name string, ids []string) error {
owner := p.getUser(ctx)
var pls *model.Playlist
var err error
// If playlistID is present, override tracks
if playlistId != "" {
pls, err = p.ds.Playlist(ctx).Get(playlistId)
if err != nil {
return err
}
if owner != pls.Owner {
return model.ErrNotAuthorized
}
pls.Tracks = nil
} else {
pls = &model.Playlist{
Name: name,
Owner: owner,
}
}
for _, id := range ids {
pls.Tracks = append(pls.Tracks, model.MediaFile{ID: id})
}
return p.ds.WithTx(func(tx model.DataStore) error {
owner := p.getUser(ctx)
var pls *model.Playlist
var err error
return p.ds.Playlist(ctx).Put(pls)
// If playlistID is present, override tracks
if playlistId != "" {
pls, err = tx.Playlist(ctx).Get(playlistId)
if err != nil {
return err
}
if owner != pls.Owner {
return model.ErrNotAuthorized
}
pls.Tracks = nil
} else {
pls = &model.Playlist{
Name: name,
Owner: owner,
}
}
for _, id := range ids {
pls.Tracks = append(pls.Tracks, model.MediaFile{ID: id})
}
return tx.Playlist(ctx).Put(pls)
})
}
func (p *playlists) getUser(ctx context.Context) string {
user, ok := ctx.Value("user").(model.User)
user, ok := request.UserFrom(ctx)
if ok {
return user.UserName
}
@@ -60,54 +64,54 @@ func (p *playlists) getUser(ctx context.Context) string {
}
func (p *playlists) Delete(ctx context.Context, playlistId string) error {
pls, err := p.ds.Playlist(ctx).Get(playlistId)
if err != nil {
return err
}
return p.ds.WithTx(func(tx model.DataStore) error {
pls, err := tx.Playlist(ctx).Get(playlistId)
if err != nil {
return err
}
owner := p.getUser(ctx)
if owner != pls.Owner {
return model.ErrNotAuthorized
}
return p.ds.Playlist(nil).Delete(playlistId)
owner := p.getUser(ctx)
if owner != pls.Owner {
return model.ErrNotAuthorized
}
return tx.Playlist(ctx).Delete(playlistId)
})
}
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 name != nil {
pls.Name = *name
}
newTracks := model.MediaFiles{}
for i, t := range pls.Tracks {
if utils.IntInSlice(i, idxToRemove) {
continue
return p.ds.WithTx(func(tx model.DataStore) error {
pls, err := tx.Playlist(ctx).Get(playlistId)
if err != nil {
return err
}
newTracks = append(newTracks, t)
}
for _, id := range idsToAdd {
newTracks = append(newTracks, model.MediaFile{ID: id})
}
pls.Tracks = newTracks
owner := p.getUser(ctx)
if owner != pls.Owner {
return model.ErrNotAuthorized
}
return p.ds.Playlist(ctx).Put(pls)
if name != nil {
pls.Name = *name
}
newTracks := model.MediaFiles{}
for i, t := range pls.Tracks {
if utils.IntInSlice(i, idxToRemove) {
continue
}
newTracks = append(newTracks, t)
}
for _, id := range idsToAdd {
newTracks = append(newTracks, model.MediaFile{ID: id})
}
pls.Tracks = newTracks
return tx.Playlist(ctx).Put(pls)
})
}
func (p *playlists) GetAll(ctx context.Context) (model.Playlists, error) {
all, err := p.ds.Playlist(ctx).GetAll(model.QueryOptions{})
for i := range all {
all[i].Public = true
}
return all, err
return p.ds.Playlist(ctx).GetAll()
}
type PlaylistInfo struct {
@@ -133,7 +137,7 @@ func (p *playlists) Get(ctx context.Context, id string) (*PlaylistInfo, error) {
plsInfo := &PlaylistInfo{
Id: pl.ID,
Name: pl.Name,
SongCount: len(pl.Tracks),
SongCount: pl.SongCount,
Duration: int(pl.Duration),
Public: pl.Public,
Owner: pl.Owner,

View File

@@ -2,7 +2,6 @@ package engine
import (
"context"
"errors"
"fmt"
"time"
@@ -44,7 +43,11 @@ func (s *scrobbler) Register(ctx context.Context, playerId int, trackId string,
return err
})
log.Info("Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", userName(ctx))
if err != nil {
log.Error("Error while scrobbling", "trackId", trackId, err)
} else {
log.Info("Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", userName(ctx))
}
return mf, err
}
@@ -57,7 +60,7 @@ func (s *scrobbler) NowPlaying(ctx context.Context, playerId int, playerName, tr
}
if mf == nil {
return nil, errors.New(fmt.Sprintf(`ID "%s" not found`, trackId))
return nil, fmt.Errorf(`ID "%s" not found`, trackId)
}
log.Info("Now Playing", "title", mf.Title, "artist", mf.Artist, "user", userName(ctx))

View File

@@ -30,7 +30,7 @@ func (ff *ffmpeg) Start(ctx context.Context, command, path string, maxBitRate in
args := createTranscodeCommand(command, path, maxBitRate)
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
cmd := exec.Command(args[0], args[1:]...)
cmd := exec.Command(args[0], args[1:]...) // #nosec
cmd.Stderr = os.Stderr
if f, err = cmd.StdoutPipe(); err != nil {
return
@@ -38,7 +38,9 @@ func (ff *ffmpeg) Start(ctx context.Context, command, path string, maxBitRate in
if err = cmd.Start(); err != nil {
return
}
go cmd.Wait() // prevent zombies
go func() { _ = cmd.Wait() }() // prevent zombies
return
}

24
go.mod
View File

@@ -4,22 +4,21 @@ go 1.14
require (
github.com/BurntSushi/toml v0.3.1 // indirect
github.com/Masterminds/squirrel v1.2.0
github.com/Masterminds/squirrel v1.4.0
github.com/astaxie/beego v1.12.1
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/dhowden/tag v0.0.0-20200412032933-5d76b8eaae27
github.com/disintegration/imaging v1.6.2
github.com/djherbis/fscache v0.10.0
github.com/djherbis/fscache v0.10.1
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.1.0+incompatible
github.com/go-chi/chi v4.1.2+incompatible
github.com/go-chi/cors v1.1.1
github.com/go-chi/jwtauth v4.0.4+incompatible
github.com/go-sql-driver/mysql v1.5.0 // indirect
github.com/golang/protobuf v1.3.1 // indirect
github.com/google/uuid v1.1.1
github.com/google/wire v0.4.0
github.com/kennygrant/sanitize v0.0.0-20170120101633-6a0bfdde8629
@@ -27,18 +26,17 @@ 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/onsi/ginkgo v1.12.0
github.com/onsi/gomega v1.9.0
github.com/onsi/ginkgo v1.12.3
github.com/onsi/gomega v1.10.1
github.com/pkg/errors v0.9.1 // indirect
github.com/pressly/goose v2.6.0+incompatible
github.com/sirupsen/logrus v1.5.0
github.com/sirupsen/logrus v1.6.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
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2 // indirect
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980 // 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
gopkg.in/djherbis/stream.v1 v1.3.1 // indirect
)
replace github.com/dhowden/tag => github.com/wader/tag v0.0.0-20200426234345-d072771f6a51

81
go.sum
View File

@@ -1,8 +1,8 @@
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Knetic/govaluate v3.0.0+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
github.com/Masterminds/squirrel v1.2.0 h1:K1NhbTO21BWG47IVR0OnIZuE0LZcXAYqywrC3Ko53KI=
github.com/Masterminds/squirrel v1.2.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
github.com/Masterminds/squirrel v1.4.0 h1:he5i/EXixZxrBUWcxzDYMiju9WZ3ld/l7QBNuo/eN3w=
github.com/Masterminds/squirrel v1.4.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
github.com/OwnLocal/goes v1.0.0/go.mod h1:8rIFjBGTue3lCU0wplczcUgt9Gxgrkkrw7etMIcn8TM=
github.com/astaxie/beego v1.12.1 h1:dfpuoxpzLVgclveAXe4PyNKqkzgm5zF4tgF2B3kkM2I=
github.com/astaxie/beego v1.12.1/go.mod h1:kPBWpSANNbSdIqOc8SUL9h+1oyBMZhROeYsXQDbidWQ=
@@ -26,12 +26,10 @@ github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0 h1:qHX6TTBIsrpg0JkYNko
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dhowden/tag v0.0.0-20200412032933-5d76b8eaae27 h1:Z6xaGRBbqfLR797upHuzQ6w4zg33BLKfAKtVCcmMDgg=
github.com/dhowden/tag v0.0.0-20200412032933-5d76b8eaae27/go.mod h1:SniNVYuaD1jmdEEvi+7ywb1QFR7agjeTdGKyFb0p7Rw=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/djherbis/fscache v0.10.0 h1:+O3s3LwKL1Jfz7txRHpgKOz8/krh9w+NzfzxtFdbsQg=
github.com/djherbis/fscache v0.10.0/go.mod h1:yyPYtkNnnPXsW+81lAcQS6yab3G2CRfnPLotBvtbf0c=
github.com/djherbis/fscache v0.10.1 h1:hDv+RGyvD+UDKyRYuLoVNbuRTnf2SrA2K3VyR1br9lk=
github.com/djherbis/fscache v0.10.1/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=
@@ -43,8 +41,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.1.0+incompatible h1:ETj3cggsVIY2Xao5ExCu6YhEh5MD6JTfcBzS37R260w=
github.com/go-chi/chi v4.1.0+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec=
github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/cors v1.1.1 h1:eHuqxsIw89iXcWnWUN8R72JMibABJTN/4IOYI5WERvw=
github.com/go-chi/cors v1.1.1/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
github.com/go-chi/jwtauth v4.0.4+incompatible h1:LGIxg6YfvSBzxU2BljXbrzVc1fMlgqSKBQgKOGAVtPY=
@@ -57,12 +57,21 @@ github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LB
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db h1:woRePGFeVFfLKN/pOkfl+p/TAqKOfFu+7KPlMVpok/w=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -80,6 +89,8 @@ github.com/koding/multiconfig v0.0.0-20170327155832-26b6dfd3a84a h1:KZAp4Cn6Wybs
github.com/koding/multiconfig v0.0.0-20170327155832-26b6dfd3a84a/go.mod h1:Y2SaZf2Rzd0pXkLVhLlCiAXFCLSXAIbTKDivVgff/AM=
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@@ -95,12 +106,16 @@ 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/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
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=
github.com/onsi/ginkgo v1.12.1 h1:mFwc4LvZ0xpSvDZ3E+k8Yte0hLOMxXUlP+yXtJqkYfQ=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.12.3 h1:+RYp9QczoWz9zfUyLP/5SLXQVhfr6gZOoKGfQqHuLZQ=
github.com/onsi/ginkgo v1.12.3/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.9.0 h1:R1uwffexN6Pr340GtYRIdZmAiN4J+iw6WG4wog1DUXg=
github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
@@ -119,8 +134,8 @@ 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/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
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/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
@@ -133,6 +148,8 @@ github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJy
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
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/wader/tag v0.0.0-20200426234345-d072771f6a51 h1:WAxntH7YQD6fIboAvewi7eU+2PQ7Y1K9OOXh67CM4bY=
github.com/wader/tag v0.0.0-20200426234345-d072771f6a51/go.mod h1:f3YqVk9PEeVf7T4JQ2+TdRqqjTg2fkaROZv0EMQOuKo=
github.com/wendal/errors v0.0.0-20130201093226-f66c77a7882b/go.mod h1:Q12BUT7DqIlHRmgv3RskH+UCM/4eqVMgI0EMmlSpAXc=
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=
@@ -145,8 +162,10 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJV
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/net v0.0.0-20200520004742-59133d7f0dd7 h1:AeiKBIuRw3UomYXSbLy0Mc2dDLfdtbT/IVn4keq83P0=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2 h1:eDrdRpKgkcCqKZQwyZRyeFZgfqt37SL7Kv3tok06cKE=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
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=
@@ -156,9 +175,14 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
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-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 h1:LfCXLvNmTYH9kEmVgqbnsWfruoXZIrh4YBgqVHtDvw0=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299 h1:DYfZAGf2WMFjMxbgTjaC+2HC7NkNAQs+6Q8b9WEB/F4=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980 h1:OjiUf46hAmXblsZdnoSXsEUSKU8r1UEzcL5RVZ4gO9Y=
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
@@ -170,19 +194,24 @@ golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b h1:NVD8gBK33xpdqCaZVVtd6OF
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=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
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/djherbis/stream.v1 v1.3.1 h1:uGfmsOY1qqMjQQphhRBSGLyA9qumJ56exkRu9ASTjCw=
gopkg.in/djherbis/stream.v1 v1.3.1/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=
@@ -192,5 +221,5 @@ gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View File

@@ -1,8 +1,12 @@
pre-push:
parallel: true
commands:
unit-tests:
tags: tests
run: go test ./...
lint:
tags: tests
run: golangci-lint run
pre-commit:
parallel: false

View File

@@ -24,6 +24,10 @@ const (
LevelTrace = Level(logrus.TraceLevel)
)
type contextKey string
const loggerCtxKey = contextKey("logger")
var (
currentLevel Level
defaultLogger = logrus.New()
@@ -66,7 +70,7 @@ func NewContext(ctx context.Context, keyValuePairs ...interface{}) context.Conte
}
logger := addFields(createNewLogger(), keyValuePairs)
ctx = context.WithValue(ctx, "logger", logger)
ctx = context.WithValue(ctx, loggerCtxKey, logger)
return ctx
}
@@ -176,10 +180,11 @@ func extractLogger(ctx interface{}) (*logrus.Entry, error) {
case *logrus.Entry:
return ctx, nil
case context.Context:
logger := ctx.Value("logger")
logger := ctx.Value(loggerCtxKey)
if logger != nil {
return logger.(*logrus.Entry), nil
}
return extractLogger(NewContext(ctx))
case *http.Request:
return extractLogger(ctx.Context())
}

View File

@@ -41,8 +41,8 @@ var _ = Describe("Logger", func() {
Expect(hook.LastEntry().Data).To(BeEmpty())
})
XIt("Empty context", func() {
Error(context.Background(), "Simple Message")
It("Empty context", func() {
Error(context.TODO(), "Simple Message")
Expect(hook.LastEntry().Message).To(Equal("Simple Message"))
Expect(hook.LastEntry().Data).To(BeEmpty())
})
@@ -70,7 +70,7 @@ var _ = Describe("Logger", func() {
})
It("can get data from the request's context", func() {
ctx := NewContext(nil, "foo", "bar")
ctx := NewContext(context.TODO(), "foo", "bar")
req := httptest.NewRequest("get", "/", nil).WithContext(ctx)
Error(req, "Simple Message", "key1", "value1")
@@ -136,7 +136,7 @@ var _ = Describe("Logger", 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)
ctx = context.WithValue(ctx, loggerCtxKey, logger)
Expect(extractLogger(ctx)).To(Equal(logger))
})
@@ -144,7 +144,7 @@ var _ = Describe("Logger", 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)
ctx = context.WithValue(ctx, loggerCtxKey, logger)
req := httptest.NewRequest("get", "/", nil).WithContext(ctx)
Expect(extractLogger(req)).To(Equal(logger))

View File

@@ -3,30 +3,30 @@ package model
import "time"
type Album struct {
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
// Annotations
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:"-"`
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"`
SortAlbumName string `json:"sortAlbumName"`
SortArtistName string `json:"sortArtistName"`
SortAlbumArtistName string `json:"sortAlbumArtistName"`
OrderAlbumName string `json:"orderAlbumName"`
OrderAlbumArtistName string `json:"orderAlbumArtistName"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type Albums []Album
@@ -41,6 +41,9 @@ type AlbumRepository interface {
GetStarred(options ...QueryOptions) (Albums, error)
Search(q string, offset int, size int) (Albums, error)
Refresh(ids ...string) error
PurgeEmpty() error
AnnotatedRepository
}
func (a Album) GetAnnotations() Annotations {
return a.Annotations
}

View File

@@ -2,6 +2,18 @@ package model
import "time"
type Annotations struct {
PlayCount int64 `json:"playCount"`
PlayDate time.Time `json:"playDate"`
Rating int `json:"rating"`
Starred bool `json:"starred"`
StarredAt time.Time `json:"starredAt"`
}
type AnnotatedModel interface {
GetAnnotations() Annotations
}
type AnnotatedRepository interface {
IncPlayCount(itemID string, ts time.Time) error
SetStar(starred bool, itemIDs ...string) error

View File

@@ -1,19 +1,15 @@
package model
import "time"
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
// Annotations
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:"-"`
ID string `json:"id" orm:"column(id)"`
Name string `json:"name"`
AlbumCount int `json:"albumCount"`
SongCount int `json:"songCount"`
FullText string `json:"fullText"`
SortArtistName string `json:"sortArtistName"`
OrderArtistName string `json:"orderArtistName"`
}
type Artists []Artist
@@ -33,6 +29,9 @@ type ArtistRepository interface {
Search(q string, offset int, size int) (Artists, error)
Refresh(ids ...string) error
GetIndex() (ArtistIndexes, error)
PurgeEmpty() error
AnnotatedRepository
}
func (a Artist) GetAnnotations() Annotations {
return a.Annotations
}

View File

@@ -6,35 +6,38 @@ import (
)
type MediaFile struct {
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
// Annotations
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:"-"`
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" orm:"pk;column(album_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"`
DiscSubtitle string `json:"discSubtitle"`
Year int `json:"year"`
Size int64 `json:"size"`
Suffix string `json:"suffix"`
Duration float32 `json:"duration"`
BitRate int `json:"bitRate"`
Genre string `json:"genre"`
FullText string `json:"fullText"`
SortTitle string `json:"sortTitle"`
SortAlbumName string `json:"sortAlbumName"`
SortArtistName string `json:"sortArtistName"`
SortAlbumArtistName string `json:"sortAlbumArtistName"`
OrderAlbumName string `json:"orderAlbumName"`
OrderArtistName string `json:"orderArtistName"`
OrderAlbumArtistName string `json:"orderAlbumArtistName"`
Compilation bool `json:"compilation"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (mf *MediaFile) ContentType() string {
@@ -48,8 +51,10 @@ type MediaFileRepository interface {
Exists(id string) (bool, error)
Put(m *MediaFile) error
Get(id string) (*MediaFile, error)
GetAll(options ...QueryOptions) (MediaFiles, error)
FindByAlbum(albumId string) (MediaFiles, error)
FindByPath(path string) (MediaFiles, error)
FindPathsRecursively(basePath string) ([]string, error)
GetStarred(options ...QueryOptions) (MediaFiles, error)
GetRandom(options ...QueryOptions) (MediaFiles, error)
Search(q string, offset int, size int) (MediaFiles, error)
@@ -58,3 +63,7 @@ type MediaFileRepository interface {
AnnotatedRepository
}
func (mf MediaFile) GetAnnotations() Annotations {
return mf.Annotations
}

View File

@@ -1,7 +1,7 @@
package model
type MediaFolder struct {
ID string
ID int32
Name string
Path string
}

View File

@@ -1,26 +1,47 @@
package model
import "time"
import (
"time"
)
type Playlist struct {
ID string
Name string
Comment string
Duration float32
Owner string
Public bool
Tracks MediaFiles
CreatedAt time.Time
UpdatedAt time.Time
ID string `json:"id" orm:"column(id)"`
Name string `json:"name"`
Comment string `json:"comment"`
Duration float32 `json:"duration"`
SongCount int `json:"songCount"`
Owner string `json:"owner"`
Public bool `json:"public"`
Tracks MediaFiles `json:"tracks,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type Playlists []Playlist
type PlaylistRepository interface {
CountAll() (int64, error)
CountAll(options ...QueryOptions) (int64, error)
Exists(id string) (bool, error)
Put(pls *Playlist) error
Get(id string) (*Playlist, error)
GetAll(options ...QueryOptions) (Playlists, error)
Delete(id string) error
Tracks(playlistId string) PlaylistTrackRepository
}
type Playlists []Playlist
type PlaylistTrack struct {
ID string `json:"id" orm:"column(id)"`
MediaFileID string `json:"mediaFileId" orm:"column(media_file_id)"`
PlaylistID string `json:"playlistId" orm:"column(playlist_id)"`
MediaFile
}
type PlaylistTracks []PlaylistTrack
type PlaylistTrackRepository interface {
ResourceRepository
Add(mediaFileIds []string) error
Update(mediaFileIds []string) error
Delete(id string) error
Reorder(pos int, newPos int) error
}

72
model/request/request.go Normal file
View File

@@ -0,0 +1,72 @@
package request
import (
"context"
"github.com/deluan/navidrome/model"
)
type contextKey string
const (
User = contextKey("user")
Username = contextKey("username")
Client = contextKey("client")
Version = contextKey("version")
Player = contextKey("player")
Transcoding = contextKey("transcoding")
)
func WithUser(ctx context.Context, u model.User) context.Context {
return context.WithValue(ctx, User, u)
}
func WithUsername(ctx context.Context, username string) context.Context {
return context.WithValue(ctx, Username, username)
}
func WithClient(ctx context.Context, client string) context.Context {
return context.WithValue(ctx, Client, client)
}
func WithVersion(ctx context.Context, version string) context.Context {
return context.WithValue(ctx, Version, version)
}
func WithPlayer(ctx context.Context, player model.Player) context.Context {
return context.WithValue(ctx, Player, player)
}
func WithTranscoding(ctx context.Context, t model.Transcoding) context.Context {
return context.WithValue(ctx, Transcoding, t)
}
func UserFrom(ctx context.Context) (model.User, bool) {
v, ok := ctx.Value(User).(model.User)
return v, ok
}
func UsernameFrom(ctx context.Context) (string, bool) {
v, ok := ctx.Value(Username).(string)
return v, ok
}
func ClientFrom(ctx context.Context) (string, bool) {
v, ok := ctx.Value(Client).(string)
return v, ok
}
func VersionFrom(ctx context.Context) (string, bool) {
v, ok := ctx.Value(Version).(string)
return v, ok
}
func PlayerFrom(ctx context.Context) (model.Player, bool) {
v, ok := ctx.Value(Player).(model.Player)
return v, ok
}
func TranscodingFrom(ctx context.Context) (model.Transcoding, bool) {
v, ok := ctx.Value(Transcoding).(model.Transcoding)
return v, ok
}

View File

@@ -2,6 +2,9 @@ package persistence
import (
"context"
"sort"
"strconv"
"strings"
"time"
. "github.com/Masterminds/squirrel"
@@ -23,8 +26,10 @@ func NewAlbumRepository(ctx context.Context, o orm.Ormer) model.AlbumRepository
r.ormer = o
r.tableName = "album"
r.sortMappings = map[string]string{
"artist": "compilation asc, album_artist asc, name asc",
"random": "RANDOM()",
"name": "order_album_name",
"artist": "compilation asc, order_album_artist_name asc, order_album_name asc",
"random": "RANDOM()",
"max_year": "max_year asc, name, order_album_name asc",
}
r.filterMappings = map[string]filterFunc{
"name": fullTextFilter,
@@ -48,7 +53,7 @@ func yearFilter(field string, value interface{}) Sqlizer {
}
func artistFilter(field string, value interface{}) Sqlizer {
return Exists("media_file", And{
return exists("media_file", And{
ConcatExpr("album_id=album.id"),
Or{
Eq{"artist_id": value},
@@ -71,12 +76,14 @@ func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuild
func (r *albumRepository) Get(id string) (*model.Album, error) {
sq := r.selectAlbum().Where(Eq{"id": id})
var res model.Album
err := r.queryOne(sq, &res)
if err != nil {
var res model.Albums
if err := r.queryAll(sq, &res); err != nil {
return nil, err
}
return &res, nil
if len(res) == 0 {
return nil, model.ErrNotFound
}
return &res[0], nil
}
func (r *albumRepository) FindByArtist(artistId string) (model.Albums, error) {
@@ -105,15 +112,20 @@ func (r *albumRepository) GetRandom(options ...model.QueryOptions) (model.Albums
func (r *albumRepository) Refresh(ids ...string) error {
type refreshAlbum struct {
model.Album
CurrentId string
HasCoverArt bool
SongArtists string
CurrentId string
HasCoverArt bool
SongArtists string
Years string
DiscSubtitles string
}
var albums []refreshAlbum
sel := Select(`album_id as id, album as name, f.artist, f.album_artist, f.artist_id, f.album_artist_id,
f.compilation, f.genre, max(f.year) as max_year, min(f.year) as min_year, sum(f.duration) as duration,
count(*) as song_count, a.id as current_id, f.id as cover_art_id, f.path as cover_art_path,
group_concat(f.artist, ' ') as song_artists, f.has_cover_art`).
f.sort_album_name, f.sort_artist_name, f.sort_album_artist_name,
f.order_album_name, f.order_album_artist_name,
f.compilation, f.genre, max(f.year) as max_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,
group_concat(f.disc_subtitle, ' ') as disc_subtitles,
group_concat(f.artist, ' ') as song_artists, group_concat(f.year, ' ') as years`).
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")
@@ -136,6 +148,7 @@ func (r *albumRepository) Refresh(ids ...string) error {
al.AlbumArtist = al.Artist
al.AlbumArtistID = al.ArtistID
}
al.MinYear = getMinYear(al.Years)
al.UpdatedAt = time.Now()
if al.CurrentId != "" {
toUpdate++
@@ -143,7 +156,8 @@ func (r *albumRepository) Refresh(ids ...string) error {
toInsert++
al.CreatedAt = time.Now()
}
al.FullText = r.getFullText(al.Name, al.Artist, al.AlbumArtist, al.SongArtists)
al.FullText = getFullText(al.Name, al.Artist, al.AlbumArtist, al.SongArtists,
al.SortAlbumName, al.SortArtistName, al.SortAlbumArtistName, al.DiscSubtitles)
_, err := r.put(al.ID, al.Album)
if err != nil {
return err
@@ -158,7 +172,19 @@ func (r *albumRepository) Refresh(ids ...string) error {
return err
}
func (r *albumRepository) PurgeEmpty() error {
func getMinYear(years string) int {
ys := strings.Fields(years)
sort.Strings(ys)
for _, y := range ys {
if y != "0" {
r, _ := strconv.Atoi(y)
return r
}
}
return 0
}
func (r *albumRepository) purgeEmpty() error {
del := Delete(r.tableName).Where("id not in (select distinct(album_id) from media_file)")
c, err := r.executeSQL(del)
if err == nil {

View File

@@ -6,6 +6,7 @@ import (
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/model/request"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
@@ -14,13 +15,13 @@ var _ = Describe("AlbumRepository", func() {
var repo model.AlbumRepository
BeforeEach(func() {
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe"})
repo = NewAlbumRepository(ctx, orm.NewOrm())
})
Describe("Get", func() {
It("returns an existent album", func() {
Expect(repo.Get("3")).To(Equal(&albumRadioactivity))
Expect(repo.Get("103")).To(Equal(&albumRadioactivity))
})
It("returns ErrNotFound when the album does not exist", func() {
_, err := repo.Get("666")
@@ -73,4 +74,16 @@ var _ = Describe("AlbumRepository", func() {
})
})
Describe("getMinYear", func() {
It("returns 0 when there's no valid year", func() {
Expect(getMinYear("a b c")).To(Equal(0))
Expect(getMinYear("")).To(Equal(0))
})
It("returns 0 when all values are 0", func() {
Expect(getMinYear("0 0 0 ")).To(Equal(0))
})
It("returns the smallest value from the list", func() {
Expect(getMinYear("2000 0 1800")).To(Equal(1800))
})
})
})

View File

@@ -26,6 +26,9 @@ func NewArtistRepository(ctx context.Context, o orm.Ormer) model.ArtistRepositor
r.ormer = o
r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups)
r.tableName = "artist"
r.sortMappings = map[string]string{
"name": "order_artist_name",
}
r.filterMappings = map[string]filterFunc{
"name": fullTextFilter,
}
@@ -44,6 +47,31 @@ func (r *artistRepository) Exists(id string) (bool, error) {
return r.exists(Select().Where(Eq{"id": id}))
}
func (r *artistRepository) Put(a *model.Artist) error {
a.FullText = getFullText(a.Name, a.SortArtistName)
_, err := r.put(a.ID, a)
return err
}
func (r *artistRepository) Get(id string) (*model.Artist, error) {
sel := r.selectArtist().Where(Eq{"id": id})
var res model.Artists
if err := r.queryAll(sel, &res); err != nil {
return nil, err
}
if len(res) == 0 {
return nil, model.ErrNotFound
}
return &res[0], nil
}
func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, error) {
sel := r.selectArtist(options...)
res := model.Artists{}
err := r.queryAll(sel, &res)
return res, err
}
func (r *artistRepository) getIndexKey(a *model.Artist) string {
name := strings.ToLower(utils.NoArticle(a.Name))
for k, v := range r.indexGroups {
@@ -55,38 +83,18 @@ func (r *artistRepository) getIndexKey(a *model.Artist) string {
return "#"
}
func (r *artistRepository) Put(a *model.Artist) error {
a.FullText = r.getFullText(a.Name)
_, err := r.put(a.ID, a)
return err
}
func (r *artistRepository) Get(id string) (*model.Artist, error) {
sel := r.selectArtist().Where(Eq{"id": id})
var res model.Artist
err := r.queryOne(sel, &res)
return &res, err
}
func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, error) {
sel := r.selectArtist(options...)
res := model.Artists{}
err := r.queryAll(sel, &res)
return res, err
}
// TODO Cache the index (recalculate when there are changes to the DB)
func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
sq := r.selectArtist().OrderBy("name")
sq := r.selectArtist().OrderBy("order_artist_name")
var all model.Artists
// TODO Paginate
err := r.queryAll(sq, &all)
if err != nil {
return nil, err
}
fullIdx := make(map[string]*model.ArtistIndex)
for _, a := range all {
for i := range all {
a := all[i]
ax := r.getIndexKey(&a)
idx, ok := fullIdx[ax]
if !ok {
@@ -111,7 +119,9 @@ func (r *artistRepository) Refresh(ids ...string) error {
CurrentId string
}
var artists []refreshArtist
sel := Select("f.album_artist_id as id", "f.album_artist as name", "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",
"f.sort_album_artist_name as sort_artist_name", "f.order_album_artist_name as order_artist_name",
"sum(f.song_count) as song_count").
From("album f").
LeftJoin("artist a on f.album_artist_id = a.id").
Where(Eq{"f.album_artist_id": ids}).
@@ -150,7 +160,7 @@ func (r *artistRepository) GetStarred(options ...model.QueryOptions) (model.Arti
return starred, err
}
func (r *artistRepository) PurgeEmpty() error {
func (r *artistRepository) purgeEmpty() error {
del := Delete(r.tableName).Where("id not in (select distinct(album_artist_id) from album)")
c, err := r.executeSQL(del)
if err == nil {

View File

@@ -6,6 +6,7 @@ import (
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/model/request"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
@@ -14,7 +15,8 @@ var _ = Describe("ArtistRepository", func() {
var repo model.ArtistRepository
BeforeEach(func() {
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid"})
repo = NewArtistRepository(ctx, orm.NewOrm())
})

View File

@@ -1,6 +1,8 @@
package persistence_test
import (
"context"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
@@ -13,7 +15,7 @@ var _ = Describe("GenreRepository", func() {
var repo model.GenreRepository
BeforeEach(func() {
repo = persistence.NewGenreRepository(log.NewContext(nil), orm.NewOrm())
repo = persistence.NewGenreRepository(log.NewContext(context.TODO()), orm.NewOrm())
})
It("returns all records", func() {

View File

@@ -23,7 +23,7 @@ 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 {
if !utils.StringInSlice(f, model.AnnotationFields) {
if !utils.StringInSlice(f, model.AnnotationFields) && v != nil {
r[toSnakeCase(f)] = v
}
}
@@ -39,16 +39,16 @@ func toSnakeCase(str string) string {
return strings.ToLower(snake)
}
func Exists(subTable string, cond squirrel.Sqlizer) exists {
return exists{subTable: subTable, cond: cond}
func exists(subTable string, cond squirrel.Sqlizer) existsCond {
return existsCond{subTable: subTable, cond: cond}
}
type exists struct {
type existsCond struct {
subTable string
cond squirrel.Sqlizer
}
func (e exists) ToSql() (string, []interface{}, error) {
func (e existsCond) 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

@@ -1,15 +1,60 @@
package persistence
import (
"time"
"github.com/Masterminds/squirrel"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("Helpers", func() {
Describe("toSnakeCase", func() {
It("converts camelCase", func() {
Expect(toSnakeCase("camelCase")).To(Equal("camel_case"))
})
It("converts PascalCase", func() {
Expect(toSnakeCase("PascalCase")).To(Equal("pascal_case"))
})
It("converts ALLCAPS", func() {
Expect(toSnakeCase("ALLCAPS")).To(Equal("allcaps"))
})
It("does not converts snake_case", func() {
Expect(toSnakeCase("snake_case")).To(Equal("snake_case"))
})
})
Describe("toSqlArgs", func() {
type Model struct {
ID string `json:"id"`
AlbumId string `json:"albumId"`
PlayCount int `json:"playCount"`
CreatedAt *time.Time
}
It("returns a map with snake_case keys", func() {
now := time.Now()
m := &Model{ID: "123", AlbumId: "456", CreatedAt: &now, PlayCount: 2}
args, err := toSqlArgs(m)
Expect(err).To(BeNil())
Expect(args).To(HaveKeyWithValue("id", "123"))
Expect(args).To(HaveKeyWithValue("album_id", "456"))
Expect(args).To(HaveKey("created_at"))
Expect(args).To(HaveLen(3))
})
It("remove null fields", func() {
m := &Model{ID: "123", AlbumId: "456"}
args, err := toSqlArgs(m)
Expect(err).To(BeNil())
Expect(args).To(HaveKey("id"))
Expect(args).To(HaveKey("album_id"))
Expect(args).To(HaveLen(2))
})
})
Describe("Exists", func() {
It("constructs the correct EXISTS query", func() {
e := Exists("album", squirrel.Eq{"id": 1})
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}))

View File

@@ -2,8 +2,9 @@ package persistence
import (
"context"
"fmt"
"os"
"strings"
"path/filepath"
. "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
@@ -23,17 +24,19 @@ func NewMediaFileRepository(ctx context.Context, o orm.Ormer) *mediaFileReposito
r.ormer = o
r.tableName = "media_file"
r.sortMappings = map[string]string{
"artist": "artist asc, album asc, disc_number asc, track_number asc",
"album": "album asc, disc_number asc, track_number asc",
"artist": "order_artist_name asc, album asc, disc_number asc, track_number asc",
"album": "order_album_name asc, disc_number asc, track_number asc",
"random": "RANDOM()",
}
r.filterMappings = map[string]filterFunc{
"title": fullTextFilter,
"title": fullTextFilter,
"starred": booleanFilter,
}
return r
}
func (r mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, error) {
return r.count(Select(), options...)
return r.count(r.newSelectWithAnnotation("media_file.id"), options...)
}
func (r mediaFileRepository) Exists(id string) (bool, error) {
@@ -41,7 +44,8 @@ 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)
m.FullText = getFullText(m.Title, m.Album, m.Artist, m.AlbumArtist,
m.SortTitle, m.SortAlbumName, m.SortArtistName, m.SortAlbumArtistName, m.DiscSubtitle)
_, err := r.put(m.ID, m)
return err
}
@@ -52,9 +56,14 @@ func (r mediaFileRepository) selectMediaFile(options ...model.QueryOptions) Sele
func (r mediaFileRepository) Get(id string) (*model.MediaFile, error) {
sel := r.selectMediaFile().Where(Eq{"id": id})
var res model.MediaFile
err := r.queryOne(sel, &res)
return &res, err
var res model.MediaFiles
if err := r.queryAll(sel, &res); err != nil {
return nil, err
}
if len(res) == 0 {
return nil, model.ErrNotFound
}
return &res[0], nil
}
func (r mediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
@@ -71,25 +80,27 @@ func (r mediaFileRepository) FindByAlbum(albumId string) (model.MediaFiles, erro
return res, err
}
// FindByPath only return mediafiles that are direct children of requested path
func (r mediaFileRepository) FindByPath(path string) (model.MediaFiles, error) {
sel := r.selectMediaFile().Where(Like{"path": path + "%"})
// Query by path based on https://stackoverflow.com/a/13911906/653632
sel0 := r.selectMediaFile().Columns(fmt.Sprintf("substr(path, %d) AS item", len(path)+2)).
Where(Like{"path": filepath.Join(path, "%")})
sel := r.newSelect().Columns("*", "item NOT GLOB '*"+string(os.PathSeparator)+"*' AS isLast").
Where(Eq{"isLast": 1}).FromSelect(sel0, "sel0")
res := model.MediaFiles{}
err := r.queryAll(sel, &res)
if err != nil {
return nil, err
}
return res, err
}
// Only return mediafiles that are direct child of requested path
filtered := model.MediaFiles{}
path = strings.ToLower(path) + string(os.PathSeparator)
for _, mf := range res {
filename := strings.TrimPrefix(strings.ToLower(mf.Path), path)
if len(strings.Split(filename, string(os.PathSeparator))) > 1 {
continue
}
filtered = append(filtered, mf)
}
return filtered, nil
// FindPathsRecursively returns a list of all subfolders of basePath, recursively
func (r mediaFileRepository) FindPathsRecursively(basePath string) ([]string, error) {
// Query based on https://stackoverflow.com/a/38330814/653632
sel := r.newSelect().Columns(fmt.Sprintf("distinct rtrim(path, replace(path, '%s', ''))", string(os.PathSeparator))).
Where(Like{"path": filepath.Join(basePath, "%")})
var res []string
err := r.queryAll(sel, &res)
return res, err
}
func (r mediaFileRepository) GetStarred(options ...model.QueryOptions) (model.MediaFiles, error) {
@@ -112,21 +123,14 @@ func (r mediaFileRepository) Delete(id string) error {
return r.delete(Eq{"id": id})
}
// DeleteByPath delete from the DB all mediafiles that are direct children of path
func (r mediaFileRepository) DeleteByPath(path string) error {
filtered, err := r.FindByPath(path)
if err != nil {
return err
}
if len(filtered) == 0 {
return nil
}
ids := make([]string, len(filtered))
for i, mf := range filtered {
ids[i] = mf.ID
}
log.Debug(r.ctx, "Deleting mediafiles by path", "path", path, "totalDeleted", len(ids))
del := Delete(r.tableName).Where(Eq{"id": ids})
_, err = r.executeSQL(del)
path = filepath.Clean(path)
del := Delete(r.tableName).
Where(And{Like{"path": filepath.Join(path, "%")},
Eq{fmt.Sprintf("substr(path, %d) glob '*%s*'", len(path)+2, string(os.PathSeparator)): 0}})
log.Debug(r.ctx, "Deleting mediafiles by path", "path", path)
_, err := r.executeSQL(del)
return err
}
@@ -153,8 +157,20 @@ func (r mediaFileRepository) EntityName() string {
}
func (r mediaFileRepository) NewInstance() interface{} {
return model.MediaFile{}
return &model.MediaFile{}
}
func (r mediaFileRepository) Save(entity interface{}) (string, error) {
mf := entity.(*model.MediaFile)
err := r.Put(mf)
return mf.ID, err
}
func (r mediaFileRepository) Update(entity interface{}, cols ...string) error {
mf := entity.(*model.MediaFile)
return r.Put(mf)
}
var _ model.MediaFileRepository = (*mediaFileRepository)(nil)
var _ model.ResourceRepository = (*mediaFileRepository)(nil)
var _ rest.Persistable = (*mediaFileRepository)(nil)

View File

@@ -7,6 +7,7 @@ import (
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/model/request"
"github.com/google/uuid"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
@@ -16,12 +17,13 @@ var _ = Describe("MediaRepository", func() {
var mr model.MediaFileRepository
BeforeEach(func() {
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid"})
mr = NewMediaFileRepository(ctx, orm.NewOrm())
})
It("gets mediafile from the DB", func() {
Expect(mr.Get("4")).To(Equal(&songAntenna))
Expect(mr.Get("1004")).To(Equal(&songAntenna))
})
It("returns ErrNotFound", func() {
@@ -39,7 +41,7 @@ var _ = Describe("MediaRepository", func() {
})
It("find mediafiles by album", func() {
Expect(mr.FindByAlbum("3")).To(Equal(model.MediaFiles{
Expect(mr.FindByAlbum("103")).To(Equal(model.MediaFiles{
songRadioactivity,
songAntenna,
}))
@@ -99,7 +101,7 @@ var _ = Describe("MediaRepository", func() {
Expect(err).To(BeNil())
Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix()))
Expect(mf.PlayCount).To(Equal(1))
Expect(mf.PlayCount).To(Equal(int64(1)))
})
It("increments play count on newly starred items", func() {
@@ -113,7 +115,7 @@ var _ = Describe("MediaRepository", func() {
Expect(err).To(BeNil())
Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix()))
Expect(mf.PlayCount).To(Equal(1))
Expect(mf.PlayCount).To(Equal(int64(1)))
})
})
})

View File

@@ -29,7 +29,7 @@ func (*mediaFolderRepository) GetAll() (model.MediaFolders, error) {
}
func hardCoded() model.MediaFolder {
mediaFolder := model.MediaFolder{ID: "0", Path: conf.Server.MusicFolder}
mediaFolder := model.MediaFolder{ID: 0, Path: conf.Server.MusicFolder}
mediaFolder.Name = "Music Library"
return mediaFolder
}

View File

@@ -72,6 +72,8 @@ func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRe
return s.Album(ctx).(model.ResourceRepository)
case model.MediaFile:
return s.MediaFile(ctx).(model.ResourceRepository)
case model.Playlist:
return s.Playlist(ctx).(model.ResourceRepository)
}
log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name())
return nil
@@ -106,11 +108,11 @@ func (s *SQLStore) WithTx(block func(tx model.DataStore) error) error {
}
func (s *SQLStore) GC(ctx context.Context) error {
err := s.Album(ctx).PurgeEmpty()
err := s.Album(ctx).(*albumRepository).purgeEmpty()
if err != nil {
return err
}
err = s.Artist(ctx).PurgeEmpty()
err = s.Artist(ctx).(*artistRepository).purgeEmpty()
if err != nil {
return err
}
@@ -122,7 +124,11 @@ func (s *SQLStore) GC(ctx context.Context) error {
if err != nil {
return err
}
return s.Artist(ctx).(*artistRepository).cleanAnnotations()
err = s.Artist(ctx).(*artistRepository).cleanAnnotations()
if err != nil {
return err
}
return s.Playlist(ctx).(*playlistRepository).removeOrphans()
}
func (s *SQLStore) getOrmer() orm.Ormer {

View File

@@ -10,6 +10,7 @@ import (
"github.com/deluan/navidrome/db"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/model/request"
"github.com/deluan/navidrome/tests"
_ "github.com/mattn/go-sqlite3"
. "github.com/onsi/ginkgo"
@@ -20,10 +21,9 @@ func TestPersistence(t *testing.T) {
tests.Init(t, true)
//os.Remove("./test-123.db")
//conf.Server.Path = "./test-123.db"
//conf.Server.DbPath = "./test-123.db"
conf.Server.DbPath = "file::memory:?cache=shared"
orm.RegisterDataBase("default", db.Driver, conf.Server.DbPath)
New()
_ = orm.RegisterDataBase("default", db.Driver, conf.Server.DbPath)
db.EnsureLatestVersion()
log.SetLevel(log.LevelCritical)
RegisterFailHandler(Fail)
@@ -31,8 +31,8 @@ func TestPersistence(t *testing.T) {
}
var (
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1, FullText: "kraftwerk"}
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2, FullText: "beatles the"}
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", 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"}
albumSgtPeppers = model.Album{ID: "101", Name: "Sgt Peppers", Artist: "The Beatles", OrderAlbumName: "sgt peppers", 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: "102", Name: "Abbey Road", Artist: "The Beatles", OrderAlbumName: "abbey road", 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: "103", Name: "Radioactivity", Artist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", Genre: "Electronic", CoverArtId: "3", CoverArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2, FullText: " kraftwerk radioactivity"}
testAlbums = model.Albums{
albumSgtPeppers,
albumAbbeyRoad,
@@ -51,10 +51,10 @@ var (
)
var (
songDayInALife = model.MediaFile{ID: "1", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "1", Album: "Sgt Peppers", Genre: "Rock", Path: P("/beatles/1/sgt/a day.mp3"), FullText: "a 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"}
songDayInALife = model.MediaFile{ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", 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: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Genre: "Rock", Path: P("/beatles/1/come together.mp3"), FullText: " abbey beatles come road the together"}
songRadioactivity = model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Genre: "Electronic", Path: P("/kraft/radio/radio.mp3"), FullText: " kraftwerk radioactivity"}
songAntenna = model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Genre: "Electronic", Path: P("/kraft/radio/antenna.mp3"), FullText: " antenna kraftwerk"}
testSongs = model.MediaFiles{
songDayInALife,
songComeTogether,
@@ -65,15 +65,15 @@ var (
var (
plsBest = model.Playlist{
ID: "10",
Name: "Best",
Comment: "No Comments",
Owner: "userid",
Public: true,
Tracks: model.MediaFiles{{ID: "1"}, {ID: "3"}},
Name: "Best",
Comment: "No Comments",
Owner: "userid",
Public: true,
SongCount: 2,
Tracks: model.MediaFiles{{ID: "1001"}, {ID: "1003"}},
}
plsCool = model.Playlist{ID: "11", Name: "Cool", Tracks: model.MediaFiles{{ID: "4"}}}
testPlaylists = model.Playlists{plsBest, plsCool}
plsCool = model.Playlist{Name: "Cool", Owner: "userid", Tracks: model.MediaFiles{{ID: "1004"}}}
testPlaylists = []*model.Playlist{&plsBest, &plsCool}
)
func P(path string) string {
@@ -85,9 +85,11 @@ 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 := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid"})
mr := NewMediaFileRepository(ctx, o)
for _, s := range testSongs {
for i := range testSongs {
s := testSongs[i]
err := mr.Put(&s)
if err != nil {
panic(err)
@@ -95,7 +97,8 @@ var _ = Describe("Initialize test DB", func() {
}
alr := NewAlbumRepository(ctx, o).(*albumRepository)
for _, a := range testAlbums {
for i := range testAlbums {
a := testAlbums[i]
_, err := alr.put(a.ID, &a)
if err != nil {
panic(err)
@@ -103,7 +106,8 @@ var _ = Describe("Initialize test DB", func() {
}
arr := NewArtistRepository(ctx, o)
for _, a := range testArtists {
for i := range testArtists {
a := testArtists[i]
err := arr.Put(&a)
if err != nil {
panic(err)
@@ -111,8 +115,8 @@ var _ = Describe("Initialize test DB", func() {
}
pr := NewPlaylistRepository(ctx, o)
for _, pls := range testPlaylists {
err := pr.Put(&pls)
for i := range testPlaylists {
err := pr.Put(testPlaylists[i])
if err != nil {
panic(err)
}

View File

@@ -19,6 +19,9 @@ func NewPlayerRepository(ctx context.Context, o orm.Ormer) model.PlayerRepositor
r.ctx = ctx
r.ormer = o
r.tableName = "player"
r.filterMappings = map[string]filterFunc{
"name": containsFilter,
}
return r
}
@@ -43,11 +46,19 @@ func (r *playerRepository) FindByName(client, userName string) (*model.Player, e
func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBuilder {
s := r.newSelect(options...)
return s.Where(r.addRestriction())
}
func (r *playerRepository) addRestriction(sql ...Sqlizer) Sqlizer {
s := And{}
if len(sql) > 0 {
s = append(s, sql[0])
}
u := loggedUser(r.ctx)
if u.IsAdmin {
return s
}
return s.Where(Eq{"user_name": u.UserName})
return append(s, Eq{"user_name": u.UserName})
}
func (r *playerRepository) Count(options ...rest.QueryOptions) (int64, error) {
@@ -106,7 +117,8 @@ func (r *playerRepository) Update(entity interface{}, cols ...string) error {
}
func (r *playerRepository) Delete(id string) error {
err := r.delete(And{Eq{"id": id}, Eq{"user_name": loggedUser(r.ctx).UserName}})
filter := r.addRestriction(And{Eq{"id": id}})
err := r.delete(filter)
if err == model.ErrNotFound {
return rest.ErrNotFound
}

View File

@@ -2,29 +2,18 @@ package persistence
import (
"context"
"strings"
"time"
. "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/rest"
)
type playlist struct {
ID string `orm:"column(id)"`
Name string
Comment string
Duration float32
Owner string
Public bool
Tracks string
CreatedAt time.Time
UpdatedAt time.Time
}
type playlistRepository struct {
sqlRepository
sqlRestful
}
func NewPlaylistRepository(ctx context.Context, o orm.Ormer) model.PlaylistRepository {
@@ -35,136 +24,193 @@ func NewPlaylistRepository(ctx context.Context, o orm.Ormer) model.PlaylistRepos
return r
}
func (r *playlistRepository) CountAll() (int64, error) {
return r.count(Select())
func (r *playlistRepository) userFilter() Sqlizer {
user := loggedUser(r.ctx)
if user.IsAdmin {
return And{}
}
return Or{
Eq{"public": true},
Eq{"owner": user.UserName},
}
}
func (r *playlistRepository) CountAll(options ...model.QueryOptions) (int64, error) {
sql := Select().Where(r.userFilter())
return r.count(sql, options...)
}
func (r *playlistRepository) Exists(id string) (bool, error) {
return r.exists(Select().Where(Eq{"id": id}))
return r.exists(Select().Where(And{Eq{"id": id}, r.userFilter()}))
}
func (r *playlistRepository) Delete(id string) error {
return r.delete(Eq{"id": id})
usr := loggedUser(r.ctx)
if !usr.IsAdmin {
pls, err := r.Get(id)
if err != nil {
return err
}
if pls.Owner != usr.UserName {
return rest.ErrPermissionDenied
}
}
return r.delete(And{Eq{"id": id}, r.userFilter()})
}
func (r *playlistRepository) Put(p *model.Playlist) error {
if p.ID == "" {
p.CreatedAt = time.Now()
} else {
ok, err := r.Exists(p.ID)
if err != nil {
return err
}
if !ok {
return model.ErrNotAuthorized
}
}
p.UpdatedAt = time.Now()
pls := r.fromModel(p)
_, err := r.put(pls.ID, pls)
return err
// Save tracks for later and set it to nil, to avoid trying to save it to the DB
tracks := p.Tracks
p.Tracks = nil
id, err := r.put(p.ID, p)
if err != nil {
return err
}
p.ID = id
// Only update tracks if they are specified
if tracks != nil {
err = r.updateTracks(id, tracks)
if err != nil {
return err
}
}
return r.loadTracks(p)
}
func (r *playlistRepository) Get(id string) (*model.Playlist, error) {
sel := r.newSelect().Columns("*").Where(Eq{"id": id})
var res playlist
err := r.queryOne(sel, &res)
pls := r.toModel(&res)
sel := r.newSelect().Columns("*").Where(And{Eq{"id": id}, r.userFilter()})
var pls model.Playlist
err := r.queryOne(sel, &pls)
if err != nil {
return nil, err
}
err = r.loadTracks(&pls)
return &pls, err
}
func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playlists, error) {
sel := r.newSelect(options...).Columns("*")
var res []playlist
sel := r.newSelect(options...).Columns("*").Where(r.userFilter())
res := model.Playlists{}
err := r.queryAll(sel, &res)
return r.toModels(res), err
return res, err
}
func (r *playlistRepository) toModels(all []playlist) model.Playlists {
result := make(model.Playlists, len(all))
for i, p := range all {
result[i] = r.toModel(&p)
func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error {
ids := make([]string, len(tracks))
for i := range tracks {
ids[i] = tracks[i].ID
}
return result
return r.Tracks(id).Update(ids)
}
func (r *playlistRepository) toModel(p *playlist) model.Playlist {
pls := model.Playlist{
ID: p.ID,
Name: p.Name,
Comment: p.Comment,
Duration: p.Duration,
Owner: p.Owner,
Public: p.Public,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
func (r *playlistRepository) loadTracks(pls *model.Playlist) error {
tracksQuery := Select().From("playlist_tracks").
LeftJoin("annotation on ("+
"annotation.item_id = media_file_id"+
" AND annotation.item_type = 'media_file'"+
" AND annotation.user_id = '"+userId(r.ctx)+"')").
Columns("starred", "starred_at", "play_count", "play_date", "rating", "f.*").
Join("media_file f on f.id = media_file_id").
Where(Eq{"playlist_id": pls.ID}).OrderBy("playlist_tracks.id")
err := r.queryAll(tracksQuery, &pls.Tracks)
if err != nil {
log.Error("Error loading playlist tracks", "playlist", pls.Name, "id", pls.ID)
}
if strings.TrimSpace(p.Tracks) != "" {
tracks := strings.Split(p.Tracks, ",")
for _, t := range tracks {
pls.Tracks = append(pls.Tracks, model.MediaFile{ID: t})
}
}
pls.Tracks = r.loadTracks(&pls)
return pls
return err
}
func (r *playlistRepository) fromModel(p *model.Playlist) playlist {
pls := playlist{
ID: p.ID,
Name: p.Name,
Comment: p.Comment,
Owner: p.Owner,
Public: p.Public,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
}
p.Tracks = r.loadTracks(p)
var newTracks []string
for _, t := range p.Tracks {
newTracks = append(newTracks, t.ID)
pls.Duration += t.Duration
}
pls.Tracks = strings.Join(newTracks, ",")
return pls
func (r *playlistRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.CountAll(r.parseRestOptions(options...))
}
// TODO: Introduce a relation table for Playlist <-> MediaFiles, and rewrite this method in pure SQL
func (r *playlistRepository) loadTracks(p *model.Playlist) model.MediaFiles {
if len(p.Tracks) == 0 {
return nil
func (r *playlistRepository) Read(id string) (interface{}, error) {
return r.Get(id)
}
func (r *playlistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
return r.GetAll(r.parseRestOptions(options...))
}
func (r *playlistRepository) EntityName() string {
return "playlist"
}
func (r *playlistRepository) NewInstance() interface{} {
return &model.Playlist{}
}
func (r *playlistRepository) Save(entity interface{}) (string, error) {
pls := entity.(*model.Playlist)
pls.Owner = loggedUser(r.ctx).UserName
err := r.Put(pls)
if err != nil {
return "", err
}
return pls.ID, err
}
func (r *playlistRepository) Update(entity interface{}, cols ...string) error {
pls := entity.(*model.Playlist)
usr := loggedUser(r.ctx)
if !usr.IsAdmin && pls.Owner != usr.UserName {
return rest.ErrPermissionDenied
}
err := r.Put(pls)
if err == model.ErrNotFound {
return rest.ErrNotFound
}
return err
}
func (r *playlistRepository) removeOrphans() error {
sel := Select("playlist_tracks.playlist_id as id", "p.name").From("playlist_tracks").
Join("playlist p on playlist_tracks.playlist_id = p.id").
LeftJoin("media_file mf on playlist_tracks.media_file_id = mf.id").
Where(Eq{"mf.id": nil}).
GroupBy("playlist_tracks.playlist_id")
var pls []struct{ Id, Name string }
err := r.queryAll(sel, &pls)
if err != nil {
return err
}
// Collect all ids
ids := make([]string, len(p.Tracks))
for i, t := range p.Tracks {
ids[i] = t.ID
}
// Break the list in chunks, up to 50 items, to avoid hitting SQLITE_MAX_FUNCTION_ARG limit
const chunkSize = 50
var chunks [][]string
for i := 0; i < len(ids); i += chunkSize {
end := i + chunkSize
if end > len(ids) {
end = len(ids)
for _, pl := range pls {
log.Debug(r.ctx, "Cleaning-up orphan tracks from playlist", "id", pl.Id, "name", pl.Name)
del := Delete("playlist_tracks").Where(And{
ConcatExpr("media_file_id not in (select id from media_file)"),
Eq{"playlist_id": pl.Id},
})
n, err := r.executeSQL(del)
if n == 0 || err != nil {
return err
}
log.Debug(r.ctx, "Deleted tracks, now reordering", "id", pl.Id, "name", pl.Name, "deleted", n)
chunks = append(chunks, ids[i:end])
}
// Query each chunk of media_file ids and store results in a map
mfRepo := NewMediaFileRepository(r.ctx, r.ormer)
trackMap := map[string]model.MediaFile{}
for i := range chunks {
idsFilter := Eq{"id": chunks[i]}
tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: idsFilter})
if err != nil {
log.Error(r.ctx, "Could not load playlist's tracks", "playlistName", p.Name, "playlistId", p.ID, err)
}
for _, t := range tracks {
trackMap[t.ID] = t
// To reorganize the playlist, just add an empty list of new tracks
trks := r.Tracks(pl.Id)
if err := trks.Add(nil); err != nil {
return err
}
}
// Create a new list of tracks with the same order as the original
newTracks := make(model.MediaFiles, len(p.Tracks))
for i, t := range p.Tracks {
newTracks[i] = trackMap[t.ID]
}
return newTracks
return nil
}
var _ model.PlaylistRepository = (*playlistRepository)(nil)
var _ rest.Repository = (*playlistRepository)(nil)
var _ rest.Persistable = (*playlistRepository)(nil)

View File

@@ -1,9 +1,12 @@
package persistence
import (
"context"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/model/request"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
@@ -12,7 +15,9 @@ var _ = Describe("PlaylistRepository", func() {
var repo model.PlaylistRepository
BeforeEach(func() {
repo = NewPlaylistRepository(log.NewContext(nil), orm.NewOrm())
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
repo = NewPlaylistRepository(ctx, orm.NewOrm())
})
Describe("Count", func() {
@@ -23,7 +28,7 @@ var _ = Describe("PlaylistRepository", func() {
Describe("Exists", func() {
It("returns true for an existing playlist", func() {
Expect(repo.Exists("11")).To(BeTrue())
Expect(repo.Exists(plsCool.ID)).To(BeTrue())
})
It("returns false for a non-existing playlist", func() {
Expect(repo.Exists("666")).To(BeFalse())
@@ -32,7 +37,7 @@ var _ = Describe("PlaylistRepository", func() {
Describe("Get", func() {
It("returns an existing playlist", func() {
p, err := repo.Get("10")
p, err := repo.Get(plsBest.ID)
Expect(err).To(BeNil())
// Compare all but Tracks and timestamps
p2 := *p
@@ -50,7 +55,7 @@ var _ = Describe("PlaylistRepository", func() {
Expect(err).To(MatchError(model.ErrNotFound))
})
It("returns all tracks", func() {
pls, err := repo.Get("10")
pls, err := repo.Get(plsBest.ID)
Expect(err).To(BeNil())
Expect(pls.Name).To(Equal(plsBest.Name))
Expect(pls.Tracks).To(Equal(model.MediaFiles{
@@ -60,32 +65,31 @@ var _ = Describe("PlaylistRepository", func() {
})
})
Describe("Put/Exists/Delete", func() {
var newPls model.Playlist
BeforeEach(func() {
newPls = model.Playlist{ID: "22", Name: "Great!", Tracks: model.MediaFiles{{ID: "4"}, {ID: "3"}}}
})
It("saves the playlist to the DB", func() {
Expect(repo.Put(&newPls)).To(BeNil())
})
It("adds repeated songs to a playlist and keeps the order", func() {
newPls.Tracks = append(newPls.Tracks, model.MediaFile{ID: "4"})
Expect(repo.Put(&newPls)).To(BeNil())
saved, _ := repo.Get("22")
Expect(saved.Tracks).To(HaveLen(3))
Expect(saved.Tracks[0].ID).To(Equal("4"))
Expect(saved.Tracks[1].ID).To(Equal("3"))
Expect(saved.Tracks[2].ID).To(Equal("4"))
})
It("returns the newly created playlist", func() {
Expect(repo.Exists("22")).To(BeTrue())
})
It("returns deletes the playlist", func() {
Expect(repo.Delete("22")).To(BeNil())
})
It("returns error if tries to retrieve the deleted playlist", func() {
Expect(repo.Exists("22")).To(BeFalse())
})
It("Put/Exists/Delete", func() {
By("saves the playlist to the DB")
newPls := model.Playlist{Name: "Great!", Owner: "userid",
Tracks: model.MediaFiles{{ID: "1004"}, {ID: "1003"}}}
By("saves the playlist to the DB")
Expect(repo.Put(&newPls)).To(BeNil())
By("adds repeated songs to a playlist and keeps the order")
newPls.Tracks = append(newPls.Tracks, model.MediaFile{ID: "1004"})
Expect(repo.Put(&newPls)).To(BeNil())
saved, _ := repo.Get(newPls.ID)
Expect(saved.Tracks).To(HaveLen(3))
Expect(saved.Tracks[0].ID).To(Equal("1004"))
Expect(saved.Tracks[1].ID).To(Equal("1003"))
Expect(saved.Tracks[2].ID).To(Equal("1004"))
By("returns the newly created playlist")
Expect(repo.Exists(newPls.ID)).To(BeTrue())
By("returns deletes the playlist")
Expect(repo.Delete(newPls.ID)).To(BeNil())
By("returns error if tries to retrieve the deleted playlist")
Expect(repo.Exists(newPls.ID)).To(BeFalse())
})
Describe("GetAll", func() {

View File

@@ -0,0 +1,195 @@
package persistence
import (
"time"
. "github.com/Masterminds/squirrel"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/utils"
"github.com/deluan/rest"
)
type playlistTrackRepository struct {
sqlRepository
sqlRestful
playlistId string
playlistRepo model.PlaylistRepository
}
func (r *playlistRepository) Tracks(playlistId string) model.PlaylistTrackRepository {
p := &playlistTrackRepository{}
p.playlistRepo = NewPlaylistRepository(r.ctx, r.ormer)
p.playlistId = playlistId
p.ctx = r.ctx
p.ormer = r.ormer
p.tableName = "playlist_tracks"
p.sortMappings = map[string]string{
"id": "playlist_tracks.id",
}
return p
}
func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.count(Select().Where(Eq{"playlist_id": r.playlistId}), r.parseRestOptions(options...))
}
func (r *playlistTrackRepository) Read(id string) (interface{}, error) {
sel := r.newSelect().
LeftJoin("annotation on ("+
"annotation.item_id = media_file_id"+
" AND annotation.item_type = 'media_file'"+
" AND annotation.user_id = '"+userId(r.ctx)+"')").
Columns("starred", "starred_at", "play_count", "play_date", "rating", "f.*", "playlist_tracks.*").
Join("media_file f on f.id = media_file_id").
Where(And{Eq{"playlist_id": r.playlistId}, Eq{"id": id}})
var trk model.PlaylistTrack
err := r.queryOne(sel, &trk)
return &trk, err
}
func (r *playlistTrackRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
sel := r.newSelect(r.parseRestOptions(options...)).
LeftJoin("annotation on ("+
"annotation.item_id = media_file_id"+
" AND annotation.item_type = 'media_file'"+
" AND annotation.user_id = '"+userId(r.ctx)+"')").
Columns("starred", "starred_at", "play_count", "play_date", "rating", "f.*", "playlist_tracks.*").
Join("media_file f on f.id = media_file_id").
Where(Eq{"playlist_id": r.playlistId})
res := model.PlaylistTracks{}
err := r.queryAll(sel, &res)
return res, err
}
func (r *playlistTrackRepository) EntityName() string {
return "playlist_tracks"
}
func (r *playlistTrackRepository) NewInstance() interface{} {
return &model.PlaylistTrack{}
}
func (r *playlistTrackRepository) Add(mediaFileIds []string) error {
if !r.isWritable() {
return rest.ErrPermissionDenied
}
if len(mediaFileIds) > 0 {
log.Debug(r.ctx, "Adding songs to playlist", "playlistId", r.playlistId, "mediaFileIds", mediaFileIds)
}
ids, err := r.getTracks()
if err != nil {
return err
}
// Append new tracks
ids = append(ids, mediaFileIds...)
// Update tracks and playlist
return r.Update(ids)
}
func (r *playlistTrackRepository) getTracks() ([]string, error) {
// Get all current tracks
all := r.newSelect().Columns("media_file_id").Where(Eq{"playlist_id": r.playlistId}).OrderBy("id")
var tracks model.PlaylistTracks
err := r.queryAll(all, &tracks)
if err != nil {
log.Error("Error querying current tracks from playlist", "playlistId", r.playlistId, err)
return nil, err
}
ids := make([]string, len(tracks))
for i := range tracks {
ids[i] = tracks[i].MediaFileID
}
return ids, nil
}
func (r *playlistTrackRepository) Update(mediaFileIds []string) error {
if !r.isWritable() {
return rest.ErrPermissionDenied
}
// Remove old tracks
del := Delete(r.tableName).Where(Eq{"playlist_id": r.playlistId})
_, err := r.executeSQL(del)
if err != nil {
return err
}
// Break the track list in chunks to avoid hitting SQLITE_MAX_FUNCTION_ARG limit
chunks := utils.BreakUpStringSlice(mediaFileIds, 50)
// Add new tracks, chunk by chunk
pos := 1
for i := range chunks {
ins := Insert(r.tableName).Columns("playlist_id", "media_file_id", "id")
for _, t := range chunks[i] {
ins = ins.Values(r.playlistId, t, pos)
pos++
}
_, err = r.executeSQL(ins)
if err != nil {
return err
}
}
return r.updateStats()
}
func (r *playlistTrackRepository) updateStats() error {
// Get total playlist duration and count
statsSql := Select("sum(duration) as duration", "count(*) as count").From("media_file").
Join("playlist_tracks f on f.media_file_id = media_file.id").
Where(Eq{"playlist_id": r.playlistId})
var res struct{ Duration, Count float32 }
err := r.queryOne(statsSql, &res)
if err != nil {
return err
}
// Update playlist's total duration and count
upd := Update("playlist").
Set("duration", res.Duration).
Set("song_count", res.Count).
Set("updated_at", time.Now()).
Where(Eq{"id": r.playlistId})
_, err = r.executeSQL(upd)
return err
}
func (r *playlistTrackRepository) Delete(id string) error {
if !r.isWritable() {
return rest.ErrPermissionDenied
}
err := r.delete(And{Eq{"playlist_id": r.playlistId}, Eq{"id": id}})
if err != nil {
return err
}
return r.updateStats()
}
func (r *playlistTrackRepository) Reorder(pos int, newPos int) error {
if !r.isWritable() {
return rest.ErrPermissionDenied
}
ids, err := r.getTracks()
if err != nil {
return err
}
newOrder := utils.MoveString(ids, pos-1, newPos-1)
return r.Update(newOrder)
}
func (r *playlistTrackRepository) isWritable() bool {
usr := loggedUser(r.ctx)
if usr.IsAdmin {
return true
}
pls, err := r.playlistRepo.Get(r.playlistId)
return err == nil && pls.Owner == usr.UserName
}
var _ model.PlaylistTrackRepository = (*playlistTrackRepository)(nil)

View File

@@ -1,8 +1,10 @@
package persistence
import (
"context"
"github.com/astaxie/beego/orm"
. "github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
@@ -12,7 +14,7 @@ var _ = Describe("Property Repository", func() {
var pr model.PropertyRepository
BeforeEach(func() {
pr = NewPropertyRepository(NewContext(nil), orm.NewOrm())
pr = NewPropertyRepository(log.NewContext(context.TODO()), orm.NewOrm())
})
It("saves and restore a new property", func() {

View File

@@ -96,3 +96,12 @@ func (r sqlRepository) cleanAnnotations() error {
}
return nil
}
func (r sqlRepository) updateAnnotations(id string, m interface{}) error {
ans := m.(model.AnnotatedModel).GetAnnotations()
err := r.SetStar(ans.Starred, id)
if err != nil {
return err
}
return r.SetRating(ans.Rating, id)
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/model/request"
"github.com/google/uuid"
)
@@ -24,21 +25,19 @@ type sqlRepository struct {
const invalidUserId = "-1"
func userId(ctx context.Context) string {
user := ctx.Value("user")
if user == nil {
if user, ok := request.UserFrom(ctx); !ok {
return invalidUserId
} else {
return user.ID
}
usr := user.(model.User)
return usr.ID
}
func loggedUser(ctx context.Context) *model.User {
user := ctx.Value("user")
if user == nil {
if user, ok := request.UserFrom(ctx); !ok {
return &model.User{}
} else {
return &user
}
u := user.(model.User)
return &u
}
func (r sqlRepository) newSelect(options ...model.QueryOptions) SelectBuilder {
@@ -113,6 +112,8 @@ func (r sqlRepository) executeSQL(sq Sqlizer) (int64, error) {
return res.RowsAffected()
}
// Note: Due to a bug in the QueryRow, this method does not map any embedded structs (ex: annotations)
// In this case, use the queryAll method and get the first item of the returned list
func (r sqlRepository) queryOne(sq Sqlizer, response interface{}) error {
query, args, err := sq.ToSql()
if err != nil {
@@ -160,7 +161,7 @@ func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOpt
func (r sqlRepository) put(id string, m interface{}) (newId string, err error) {
values, _ := toSqlArgs(m)
// Remove created_at from args and save it for later, if needed fo insert
// Remove created_at from args and save it for later, if needed for insert
createdAt := values["created_at"]
delete(values, "created_at")
if id != "" {
@@ -170,16 +171,19 @@ func (r sqlRepository) put(id string, m interface{}) (newId string, err error) {
return "", err
}
if count > 0 {
return id, nil
if _, ok := m.(model.AnnotatedModel); ok {
err = r.updateAnnotations(id, m)
}
return id, err
}
}
// if does not have an id OR could not update (new record with predefined id)
// If does not have an id OR could not update (new record with predefined id)
if id == "" {
rand, _ := uuid.NewRandom()
id = rand.String()
values["id"] = id
}
// It is a insert, if there was a created_at, add it back to args
// It is a insert. if there was a created_at, add it back to args
if createdAt != nil {
values["created_at"] = createdAt
}

View File

@@ -7,7 +7,6 @@ import (
. "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
@@ -53,21 +52,21 @@ func startsWithFilter(field string, value interface{}) Sqlizer {
return Like{field: fmt.Sprintf("%s%%", value)}
}
func containsFilter(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, "*"))))
q := sanitizeStrings(value.(string))
parts := strings.Split(q, " ")
filters := And{}
for _, part := range parts {
filters = append(filters, Or{
Like{"full_text": part + "%"},
Like{"full_text": "%" + part + "%"},
})
filters = append(filters, Like{"full_text": "% " + part + "%"})
}
return filters
}

View File

@@ -1,6 +1,7 @@
package persistence
import (
"regexp"
"sort"
"strings"
@@ -8,7 +9,14 @@ import (
"github.com/kennygrant/sanitize"
)
func (r sqlRepository) getFullText(text ...string) string {
var quotesRegex = regexp.MustCompile("[“”‘’'\"\\[\\(\\{\\]\\)\\}]")
func getFullText(text ...string) string {
fullText := sanitizeStrings(text...)
return " " + fullText
}
func sanitizeStrings(text ...string) string {
sanitizedText := strings.Builder{}
for _, txt := range text {
sanitizedText.WriteString(strings.TrimSpace(sanitize.Accents(strings.ToLower(txt))) + " ")
@@ -19,14 +27,18 @@ func (r sqlRepository) getFullText(text ...string) string {
}
var fullText []string
for w := range words {
fullText = append(fullText, w)
w = quotesRegex.ReplaceAllString(w, "")
if w != "" {
fullText = append(fullText, w)
}
}
sort.Strings(fullText)
return strings.Join(fullText, " ")
}
func (r sqlRepository) doSearch(q string, offset, size int, results interface{}, orderBys ...string) error {
q = strings.TrimSpace(sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*"))))
q = strings.TrimSuffix(q, "*")
q = sanitizeStrings(q)
if len(q) < 2 {
return nil
}
@@ -37,10 +49,7 @@ func (r sqlRepository) doSearch(q string, offset, size int, results interface{},
}
parts := strings.Split(q, " ")
for _, part := range parts {
sq = sq.Where(Or{
Like{"full_text": part + "%"},
Like{"full_text": "%" + part + "%"},
})
sq = sq.Where(Like{"full_text": "% " + part + "%"})
}
err := r.queryAll(sq, results)
return err

View File

@@ -6,23 +6,29 @@ import (
)
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"))
Expect(getFullText("Some Text")).To(Equal(" some text"))
})
It("removes accents", func() {
Expect(sqlRepository.getFullText("Quintão")).To(Equal("quintao"))
Expect(getFullText("Quintão")).To(Equal(" quintao"))
})
It("remove extra spaces", func() {
Expect(sqlRepository.getFullText(" some text ")).To(Equal("some text"))
Expect(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"))
Expect(getFullText("legião urbana urbana legiÃo")).To(Equal(" legiao urbana"))
})
It("remove symbols", func() {
Expect(getFullText("Toms Diner ' “40” A")).To(Equal(" 40 a diner toms"))
})
It("remove opening brackets", func() {
Expect(getFullText("[Five Years]")).To(Equal(" five years"))
})
})
})

View File

@@ -1,6 +1,8 @@
package persistence
import (
"context"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
@@ -12,7 +14,7 @@ var _ = Describe("UserRepository", func() {
var repo model.UserRepository
BeforeEach(func() {
repo = NewUserRepository(log.NewContext(nil), orm.NewOrm())
repo = NewUserRepository(log.NewContext(context.TODO()), orm.NewOrm())
})
Describe("Put/Get/FindByUsername", func() {

View File

@@ -1 +1 @@
-s -r "(\.go$$|navidrome.toml)" -R "(Jamstash-master|^ui|^data)" -- go run .
-s -r "(\.go$$|navidrome.toml|resources)" -R "(Jamstash-master|^ui|^data|^db/migration)" -- go run .

View File

28
resources/external.go Normal file
View File

@@ -0,0 +1,28 @@
// +build !embed
package resources
import (
"io/ioutil"
"net/http"
"sync"
"github.com/deluan/navidrome/log"
)
var once sync.Once
func Asset(filePath string) ([]byte, error) {
f, err := AssetFile().Open(filePath)
if err != nil {
return nil, err
}
return ioutil.ReadAll(f)
}
func AssetFile() http.FileSystem {
once.Do(func() {
log.Warn("Using external resources from 'resources' folder")
})
return http.Dir("resources")
}

283
resources/i18n/cs.json Normal file
View File

@@ -0,0 +1,283 @@
{
"languageName": "Čeština",
"resources": {
"song": {
"name": "Skladba |||| Skladby",
"fields": {
"albumArtist": "Interpret alba",
"duration": "Délka",
"trackNumber": "#",
"playCount": "Přehrání",
"title": "Název",
"artist": "Interpret",
"album": "Album",
"path": "Cesta k souboru",
"genre": "Žánr",
"compilation": "Kompilace",
"year": "Rok",
"size": "Velikost souboru",
"updatedAt": "Nahráno",
"bitRate": "Přenosová rychlost",
"discSubtitle": "Podtitul disku",
"starred": "Hvězdičkované"
},
"actions": {
"addToQueue": "Přehrát později",
"playNow": "Přehrát nyní",
"addToPlaylist": "Přidat do seznamu skladeb"
}
},
"album": {
"name": "Album |||| Alba",
"fields": {
"albumArtist": "Interpret alba",
"artist": "Interpret",
"duration": "Délka",
"songCount": "Skladby",
"playCount": "Přehrání",
"name": "Název",
"genre": "Žánr",
"compilation": "Kompilace",
"year": "Rok",
"updatedAt": "Aktualizováno"
},
"actions": {
"playAll": "Přehrát",
"playNext": "Přehrát další",
"addToQueue": "Přehrát později",
"shuffle": "Zamíchat"
}
},
"artist": {
"name": "Interpret |||| Interpreti",
"fields": {
"name": "Název",
"albumCount": "Počet alb",
"songCount": "Počet skladeb"
}
},
"user": {
"name": "Uživatel |||| Uživatelé",
"fields": {
"userName": "Uživatelské jméno",
"isAdmin": "Správcem",
"lastLoginAt": "Naposledy přihlášen",
"updatedAt": "Upraven",
"name": "Jméno",
"password": "Heslo",
"createdAt": "Vytvořen"
}
},
"player": {
"name": "Přehrávač |||| Přehrávače",
"fields": {
"name": "Název",
"transcodingId": "ID překódování",
"maxBitRate": "Max. přenosová rychlost",
"client": "Klient",
"userName": "Uživatelské jméno",
"lastSeen": "Naposledy spatřen"
}
},
"transcoding": {
"name": "Překódování |||| Překódování",
"fields": {
"name": "Název",
"targetFormat": "Cílený formát",
"defaultBitRate": "Výchozí přenosová rychlost",
"command": "Příkaz"
}
},
"playlist": {
"name": "Seznam skladeb |||| Seznamy skladeb",
"fields": {
"name": "Název",
"duration": "Délka",
"owner": "Vlastník",
"public": "Veřejný",
"updatedAt": "Nahrán",
"createdAt": "Vytvořen",
"songCount": "Skladby",
"comment": "Komentář"
},
"actions": {
"selectPlaylist": "Přidat skladby do seznamu:",
"addNewPlaylist": "Vytvořit \"%{name}\""
}
}
},
"ra": {
"auth": {
"welcome1": "Děkujeme, že jste si nainstalovali Navidrome!",
"welcome2": "Nejdříve vytvořte účet správce",
"confirmPassword": "Potvrďte heslo",
"buttonCreateAdmin": "Vytvořit správce",
"auth_check_error": "Pro pokračování se prosím přihlašte",
"user_menu": "Profil",
"username": "Uživatelské jméno",
"password": "Heslo",
"sign_in": "Přihlásit se",
"sign_in_error": "Ověření selhalo, zkuste to znovu",
"logout": "Odhlásit se"
},
"validation": {
"invalidChars": "Prosím, používejte pouze písmena a čísla",
"passwordDoesNotMatch": "Hesla se neschodují",
"required": "Povinné pole",
"minLength": "Musí obsahovat nejméně %{min} znaků",
"maxLength": "Může obsahovat maximálně %{max} znaků",
"minValue": "Musí být alespoň %{min}",
"maxValue": "Může být maximálně %{max}",
"number": "Musí být číslo",
"email": "Musí být platná emailová adresa",
"oneOf": "Musí splňovat jedno z: %{options}",
"regex": "Musí být ve specifickém formátu (regexp): %{pattern}"
},
"action": {
"add_filter": "Přidat filtr",
"add": "Přidat",
"back": "Jít zpět",
"bulk_actions": "%{smart_count} vybráno",
"cancel": "Zrušit",
"clear_input_value": "Smazat hodnotu",
"clone": "Klonovat",
"confirm": "Potvrdit",
"create": "Vytvořit",
"delete": "Smazat",
"edit": "Upravit",
"export": "Exportovat",
"list": "Seznam",
"refresh": "Obnovit",
"remove_filter": "Odstranit filtr",
"remove": "Odstranit",
"save": "Uložit",
"search": "Vyhledat",
"show": "Ukázat",
"sort": "Seřadit",
"undo": "Vrátit",
"expand": "Zvětšit",
"close": "Zavřít",
"open_menu": "Otevřít nabídku",
"close_menu": "Zavřít nabídku"
},
"boolean": {
"true": "Ano",
"false": "Ne"
},
"page": {
"create": "Vytvořit %{name}",
"dashboard": "Dashboard",
"edit": "%{name} #%{id}",
"error": "Něco se pokazilo",
"list": "%{name}",
"loading": "Načítání",
"not_found": "Nenalezeno",
"show": "%{name} #%{id}",
"empty": "Zatím žádné %{name}",
"invite": "Chcete jeden přidat?"
},
"input": {
"file": {
"upload_several": "Přetáhněte soubory pro nahrání nebo klikněte pro výběr",
"upload_single": "Přetáhněte soubor pro nahrání nebo klikněte pro jeho výběr"
},
"image": {
"upload_several": "Přetáhněte obrázky pro nahrání nebo klikněte pro výběr",
"upload_single": "Přetáhněte obrázek pro nahrání nebo klikněte pro jeho výběr"
},
"references": {
"all_missing": "Nelze nalézt referencovaná data",
"many_missing": "Minimálně jedna z referencí se nezdá být nadále dostupná",
"single_missing": "Reference se nezdá být nadále dostupná."
},
"password": {
"toggle_visible": "Skrýt heslo",
"toggle_hidden": "Ukázat heslo"
}
},
"message": {
"about": "O",
"are_you_sure": "Jste si jistý?",
"bulk_delete_content": "Jste si jistý, že chcete smazat %{name}? |||| Jste si jistý, že chcete smazat těchto %{smart_count} položek?",
"bulk_delete_title": "Smazat %{name} |||| Smazat %{smart_count} %{name} položek",
"delete_content": "Jste si jistý, že chcete smazat tuto položku?",
"delete_title": "Smazat %{name} #%{id}",
"details": "Detaily",
"error": "Objevila se chyba klienta a váš požadavek nemohl být splněn.",
"invalid_form": "Formulář není platný. Prosím zkontrolujte chyby.",
"loading": "Stránka se načítá, prosím vyčkejte",
"no": "Ne",
"not_found": "Napsali jste špatnou adresu URL, nebo jste následovali špatný odkaz.",
"yes": "Ano",
"unsaved_changes": "Některé vaše změny nebyly uloženy. Jste si jisti, že je chcete ignorovat?"
},
"navigation": {
"no_results": "Žádné výsledky nebyly nalezeny",
"no_more_results": "Stránka číslo %{page} je mimo rozsah. Zkuste předchozí.",
"page_out_of_boundaries": "Stránka číslo %{page} je mimo rozsah",
"page_out_from_end": "Nelze jít za poslední stranu",
"page_out_from_begin": "Nelze jít před první stranu",
"page_range_info": "%{offsetBegin}-%{offsetEnd} z %{total}",
"page_rows_per_page": "Řádků na stránce:",
"next": "Další",
"prev": "Předchozí"
},
"notification": {
"updated": "Prvek aktualizován |||| %{smart_count} prvků aktualizováno",
"created": "Prvek vytvořen",
"deleted": "Prvek smazán |||| %{smart_count} prvků smazáno",
"bad_item": "Nesprávný prvek",
"item_doesnt_exist": "Prvek neexistuje",
"http_error": "Chyba komunikace serveru",
"data_provider_error": "Chyba dataProvideru. Detaily najdete v konzoli.",
"i18n_error": "Nelze načíst překlady pro vybraný jazyk",
"canceled": "Akce zrušena",
"logged_out": "Vaše relace skončila, prosím připojte se znovu."
}
},
"message": {
"note": "POZNÁMKA",
"transcodingDisabled": "Měnění nastavení překódování je ve webovém prostředí vypnuto kvůli bezpečnosti. Pokud by jste chtěli změnit (upravit nebo přidat) možnosti překódování, restartujte server s možností %{config}.",
"transcodingEnabled": "Navidrome právě běží s možností %{config}, umožňující spouštění systémových příkazů z nastavení překódování pomocí webového rozhraní. Doporučujeme ji vypnout kvůli bezpečnosti a použít ji pouze pokud upravujete nastavení překódování.",
"songsAddedToPlaylist": "1 skladba přidána na seznam skladeb ||| %{smart_count} skladeb přidáno na seznam skladeb",
"noPlaylistsAvailable": "Žádné nejsou dostupné"
},
"menu": {
"library": "Knihovna",
"settings": "Nastavení",
"version": "Verze %{version}",
"theme": "Motiv",
"personal": {
"name": "Osobní",
"options": {
"theme": "Motiv",
"language": "Jazyk"
}
}
},
"player": {
"playListsText": "Fronta",
"openText": "Otevřít",
"closeText": "Zavřít",
"notContentText": "Žádné skladby",
"clickToPlayText": "Klikněte pro přehrání",
"clickToPauseText": "Klikněte pro pozastavní",
"nextTrackText": "Další skladba",
"previousTrackText": "Předchozí skladba",
"reloadText": "Znovu načíst",
"volumeText": "Hlasitost",
"toggleLyricText": "Přepnout text",
"toggleMiniModeText": "Zmenšit",
"destroyText": "Zničit",
"downloadText": "Stáhnout",
"removeAudioListsText": "Vymazat seznam",
"clickToDeleteText": "Klikněte pro odstratění %{name}",
"emptyLyricText": "Bez textu",
"playModeText": {
"order": "Popořadě",
"orderLoop": "Opakovat",
"singleLoop": "Opakovat jednou",
"shufflePlay": "Zamíchat"
}
}
}

283
resources/i18n/de.json Normal file
View File

@@ -0,0 +1,283 @@
{
"languageName": "Deutsch",
"resources": {
"song": {
"name": "Song |||| Songs",
"fields": {
"albumArtist": "Albuminterpret",
"duration": "Dauer",
"trackNumber": "Titel #",
"playCount": "Wiedergaben",
"title": "Titel",
"artist": "Künstler",
"album": "Album",
"path": "Dateipfad",
"genre": "Genre",
"compilation": "Kompilation",
"year": "Jahr",
"size": "Dateigröße",
"updatedAt": "Hochgeladen um",
"bitRate": "Bitrate",
"discSubtitle": "CD Untertitel",
"starred": "Favorit"
},
"actions": {
"addToQueue": "Später abspielen",
"playNow": "Jetzt abspielen",
"addToPlaylist": "Zur Playlist hinzufügen"
}
},
"album": {
"name": "Album |||| Alben",
"fields": {
"albumArtist": "Albuminterpret",
"artist": "Interpret",
"duration": "Dauer",
"songCount": "Songanzahl",
"playCount": "Wiedergaben",
"name": "Name",
"genre": "Genre",
"compilation": "Kompilation",
"year": "Jahr",
"updatedAt": "Aktualisiert um"
},
"actions": {
"playAll": "Abspielen",
"playNext": "Als nächstes abspielen",
"addToQueue": "Später abspielen",
"shuffle": "Zufallswiedergabe"
}
},
"artist": {
"name": "Interpret |||| Interpreten",
"fields": {
"name": "Name",
"albumCount": "Albumanzahl",
"songCount": "Songanzahl"
}
},
"user": {
"name": "Nutzer |||| Nutzer",
"fields": {
"userName": "Nutzername",
"isAdmin": "Ist Admin",
"lastLoginAt": "Letzer Login um",
"updatedAt": "Aktualisiert um",
"name": "Name",
"password": "Passwort",
"createdAt": "Erstellt um"
}
},
"player": {
"name": "Player |||| Players",
"fields": {
"name": "Name",
"transcodingId": "Transkodierungs-ID",
"maxBitRate": "Max. Bitrate",
"client": "Client",
"userName": "Nutzername",
"lastSeen": "Zuletzt gesehen um"
}
},
"transcoding": {
"name": "Transcodierung |||| Transcodierungen",
"fields": {
"name": "Name",
"targetFormat": "Zielformat",
"defaultBitRate": "Standardbitrate",
"command": "Befehl"
}
},
"playlist": {
"name": "Playlist |||| Playlists",
"fields": {
"name": "Name",
"duration": "Dauer",
"owner": "Inhaber",
"public": "Öffentlich",
"updatedAt": "Aktualisiert um",
"createdAt": "Erstellt um",
"songCount": "Songanzahl",
"comment": "Kommentar"
},
"actions": {
"selectPlaylist": "Songs zur Playlist hinzufügen",
"addNewPlaylist": "\"%{name}\" erstellen"
}
}
},
"ra": {
"auth": {
"welcome1": "Vielen Dank für die Installation von Navidrome!",
"welcome2": "Als erstes erstelle einen Admin-Benutzer",
"confirmPassword": "Passwort bestätigen",
"buttonCreateAdmin": "Admin erstellen",
"auth_check_error": "Bitte einloggen um fortzufahren",
"user_menu": "Profil",
"username": "Nutzername",
"password": "Passwort",
"sign_in": "Anmelden",
"sign_in_error": "Fehler bei der Anmeldung",
"logout": "Abmelden"
},
"validation": {
"invalidChars": "Bitte nur Buchstaben und Zahlen verwenden",
"passwordDoesNotMatch": "Passwort stimmt nicht überein",
"required": "Benötigt",
"minLength": "Muss mindestens %{min} Zeichen lang sein",
"maxLength": "Darf maximal %{max} Zeichen lang sein",
"minValue": "Muss mindestens %{min} sein",
"maxValue": "Muss %{max} oder weniger sein",
"number": "Muss eine Nummer sein",
"email": "Muss eine gültige E-Mail sein",
"oneOf": "Es muss einer sein von: %{options}",
"regex": "Es muss folgendem regulären Ausdruck entsprechen: %{pattern}"
},
"action": {
"add_filter": "Filter hinzufügen",
"add": "Neu",
"back": "Zurück",
"bulk_actions": "Ein Element ausgewählt |||| %{smart_count} Elemente ausgewählt",
"cancel": "Abbrechen",
"clear_input_value": "Eingabe löschen",
"clone": "Klonen",
"confirm": "Bestätigen",
"create": "Erstellen",
"delete": "Löschen",
"edit": "Bearbeiten",
"export": "Exportieren",
"list": "Liste",
"refresh": "Aktualisieren",
"remove_filter": "Filter entfernen",
"remove": "Entfernen",
"save": "Speichern",
"search": "Suchen",
"show": "Anzeigen",
"sort": "Sortieren",
"undo": "Zurücksetzen",
"expand": "Expandieren",
"close": "Schließen",
"open_menu": "Menü öffnen",
"close_menu": "Menü schließen"
},
"boolean": {
"true": "Ja",
"false": "Nein"
},
"page": {
"create": "%{name} erstellen",
"dashboard": "Dashboard",
"edit": "%{name} #%{id}",
"error": "Etwas ist schief gelaufen",
"list": "%{name}",
"loading": "Laden",
"not_found": "Nicht gefunden",
"show": "%{name} #%{id}",
"empty": "Noch kein %{name}.\n",
"invite": "Möchtest du eine hinzufügen?"
},
"input": {
"file": {
"upload_several": "Zum Hochladen Dateien hineinziehen oder hier klicken, um Dateien auszuwählen.",
"upload_single": "Zum Hochladen Datei hineinziehen oder hier klicken, um eine Datei auszuwählen."
},
"image": {
"upload_several": "Zum Hochladen Bilder hineinziehen oder hier klicken, um Bilder auszuwählen.",
"upload_single": "Zum Hochladen Bild hineinziehen oder hier klicken, um ein Bild auszuwählen."
},
"references": {
"all_missing": "Die zugehörigen Referenzen konnten nicht gefunden werden.",
"many_missing": "Mindestens eine der zugehörigen Referenzen scheint nicht mehr verfügbar zu sein.",
"single_missing": "Eine zugehörige Referenz scheint nicht mehr verfügbar zu sein."
},
"password": {
"toggle_visible": "Passwort verbergen",
"toggle_hidden": "Passwort anzeigen"
}
},
"message": {
"about": "Über",
"are_you_sure": "Bist du sicher?",
"bulk_delete_content": "Möchtest du \"%{name}\" wirklich löschen? |||| Möchtest du diese %{smart_count} Elemente wirklich löschen?",
"bulk_delete_title": "Lösche %{name} |||| Lösche %{smart_count} %{name} Elemente",
"delete_content": "Möchtest du diesen Inhalt wirklich löschen?",
"delete_title": "Lösche %{name} #%{id}",
"details": "Details",
"error": "Ein Fehler ist aufgetreten und deine Anfrage konnte nicht abgeschlossen werden.",
"invalid_form": "Das Formular ist ungültig. Bitte überprüfe deine Eingaben.",
"loading": "Die Seite wird geladen.",
"no": "Nein",
"not_found": "Die Seite konnte nicht gefunden werden.",
"yes": "Ja",
"unsaved_changes": "Einige deiner Änderungen wurden nicht gespeichert. Bist du sicher, dass du sie ignorieren möchtest?"
},
"navigation": {
"no_results": "Keine Resultate gefunden",
"no_more_results": "Die Seite %{page} enthält keine Inhalte.",
"page_out_of_boundaries": "Die Seite %{page} liegt ausserhalb des gültigen Bereichs",
"page_out_from_end": "Letzte Seite",
"page_out_from_begin": "Erste Seite",
"page_range_info": "%{offsetBegin}-%{offsetEnd} von %{total}",
"page_rows_per_page": "Zeilen pro Seite:",
"next": "Weiter",
"prev": "Zurück"
},
"notification": {
"updated": "Element wurde aktualisiert |||| %{smart_count} Elemente wurden aktualisiert",
"created": "Element wurde erstellt",
"deleted": "Element wurde gelöscht |||| %{smart_count} Elemente wurden gelöscht",
"bad_item": "Fehlerhaftes Element",
"item_doesnt_exist": "Das Element existiert nicht",
"http_error": "Fehler beim Kommunizieren mit dem Server",
"data_provider_error": "dataProvider Fehler. Prüfe die Konsole für Details.",
"i18n_error": "Die Übersetzungen für die angegebene Sprache können nicht geladen werden",
"canceled": "Aktion abgebrochen",
"logged_out": "Deine Session wurde beendet. Bitte erneut verbinden."
}
},
"message": {
"note": "HINWEIS",
"transcodingDisabled": "Die Änderung der Transcodierungskonfiguration über die Web-UI ist aus Sicherheitsgründen deaktiviert. Wenn du die Transcodierungsoptionen ändern (bearbeiten oder hinzufügen) möchtest, starte den Server mit der Konfigurationsoption %{config} neu.",
"transcodingEnabled": "Navidrome läuft derzeit mit %{config}, wodurch es möglich ist, Systembefehle aus den Transkodierungseinstellungen über die Webschnittstelle auszuführen. Wir empfehlen, es aus Sicherheitsgründen zu deaktivieren und nur bei der Konfiguration von Transkodierungsoptionen zu aktivieren.",
"songsAddedToPlaylist": "Einen Song zur Playlist hinzugefügt |||| %{smart_count} Songs zur Playlist hinzugefügt",
"noPlaylistsAvailable": "Keine Playlist verfügbar"
},
"menu": {
"library": "Bibliothek",
"settings": "Einstellungen",
"version": "Version %{version}",
"theme": "Design",
"personal": {
"name": "Persönlich",
"options": {
"theme": "Design",
"language": "Sprache"
}
}
},
"player": {
"playListsText": "Wiedergabeliste abspielen",
"openText": "Öffnen",
"closeText": "Schließen",
"notContentText": "Keine Musik",
"clickToPlayText": "Anklicken zum Abzuspielen",
"clickToPauseText": "Anklicken zum Pausieren",
"nextTrackText": "Nächster Titel",
"previousTrackText": "Vorheriger Titel",
"reloadText": "Neu laden",
"volumeText": "Lautstärke",
"toggleLyricText": "Liedtext umschalten",
"toggleMiniModeText": "Minimieren",
"destroyText": "Zerstören",
"downloadText": "Herunterladen",
"removeAudioListsText": "Audiolisten löschen",
"clickToDeleteText": "Klicken um %{Name} zu Löschen",
"emptyLyricText": "Kein Liedtext",
"playModeText": {
"order": "Der Reihe nach",
"orderLoop": "Wiederholen",
"singleLoop": "Eins wiederholen",
"shufflePlay": "Zufallswiedergabe"
}
}
}

283
resources/i18n/fr.json Normal file
View File

@@ -0,0 +1,283 @@
{
"languageName": "Français",
"resources": {
"song": {
"name": "Piste |||| Pistes",
"fields": {
"albumArtist": "Artiste",
"duration": "Durée",
"trackNumber": "#",
"playCount": "Nombre d'écoutes",
"title": "Titre",
"artist": "Artiste",
"album": "Album",
"path": "Chemin",
"genre": "Genre",
"compilation": "Compilation",
"year": "Année",
"size": "Taille",
"updatedAt": "Mise à jour",
"bitRate": "Bitrate",
"discSubtitle": "Sous-titre du disque",
"starred": "Favoris"
},
"actions": {
"addToQueue": "Ajouter à la file",
"playNow": "Lire",
"addToPlaylist": "Ajouter à la playlist"
}
},
"album": {
"name": "Album |||| Albums",
"fields": {
"albumArtist": "Artiste",
"artist": "Artiste",
"duration": "Durée",
"songCount": "Numéro de piste",
"playCount": "Nombre d'écoutes",
"name": "Nom",
"genre": "Genre",
"compilation": "Compilation",
"year": "Année",
"updatedAt": "Mise à jour le"
},
"actions": {
"playAll": "Lire",
"playNext": "Lire ensuite",
"addToQueue": "Ajouter à la file",
"shuffle": "Mélanger"
}
},
"artist": {
"name": "Artiste |||| Artistes",
"fields": {
"name": "Nom",
"albumCount": "Nombre d'albums",
"songCount": "Nombre de pistes"
}
},
"user": {
"name": "Utilisateur |||| Utilisateurs",
"fields": {
"userName": "Nom d'utilisateur",
"isAdmin": "Administrateur",
"lastLoginAt": "Dernière connexion",
"updatedAt": "Dernière mise à jour",
"name": "Nom",
"password": "Mot de passe",
"createdAt": "Crée le"
}
},
"player": {
"name": "Lecteur |||| Lecteurs",
"fields": {
"name": "Nom",
"transcodingId": "Transcodage",
"maxBitRate": "Bitrate maximum",
"client": "Client",
"userName": "Nom d'utilisateur",
"lastSeen": "Vu pour la dernière fois"
}
},
"transcoding": {
"name": "Conversion |||| Conversions",
"fields": {
"name": "Nom",
"targetFormat": "Format",
"defaultBitRate": "Bitrate par défaut",
"command": "Commande"
}
},
"playlist": {
"name": "Playlist |||| Playlists",
"fields": {
"name": "Nom",
"duration": "Durée",
"owner": "Propriétaire",
"public": "Public",
"updatedAt": "Mise à jour le",
"createdAt": "Crée le",
"songCount": "Titres",
"comment": "Commentaire"
},
"actions": {
"selectPlaylist": "Ajouter les pistes à la playlist",
"addNewPlaylist": "Créer \"%{name}\""
}
}
},
"ra": {
"auth": {
"welcome1": "Merci d'avoir installé Navidrome !",
"welcome2": "Pour commencer, créez un compte administrateur",
"confirmPassword": "Confirmer votre mot de passe",
"buttonCreateAdmin": "Créer un compte administrateur",
"auth_check_error": "Merci de vous connecter pour continuer",
"user_menu": "Profil",
"username": "Identifiant",
"password": "Mot de passe",
"sign_in": "Connexion",
"sign_in_error": "Échec de l'authentification, merci de réessayer",
"logout": "Déconnexion"
},
"validation": {
"invalidChars": "Merci d'utiliser uniquement des chiffres et des lettres",
"passwordDoesNotMatch": "Les mots de passes ne correspondent pas",
"required": "Ce champ est requis",
"minLength": "Minimum %{min} caractères",
"maxLength": "Maximum %{max} caractères",
"minValue": "Minimum %{min}",
"maxValue": "Maximum %{max}",
"number": "Doit être un nombre",
"email": "Doit être un email",
"oneOf": "Doit être au choix: %{options}",
"regex": "Doit respecter un format spécifique (regexp): %{pattern}"
},
"action": {
"add_filter": "Ajouter un filtre",
"add": "Ajouter",
"back": "Retour",
"bulk_actions": "%{smart_count} selectionné |||| %{smart_count} selectionnés",
"cancel": "Annuler",
"clear_input_value": "Vider le champ",
"clone": "Dupliquer",
"confirm": "Confirmer",
"create": "Créer",
"delete": "Supprimer",
"edit": "Éditer",
"export": "Exporter",
"list": "Liste",
"refresh": "Actualiser",
"remove_filter": "Supprimer ce filtre",
"remove": "Supprimer",
"save": "Enregistrer",
"search": "Rechercher",
"show": "Afficher",
"sort": "Trier",
"undo": "Annuler",
"expand": "Étendre",
"close": "Fermer",
"open_menu": "Ouvrir le menu",
"close_menu": "Fermer le menu"
},
"boolean": {
"true": "Oui",
"false": "Non"
},
"page": {
"create": "Créer %{name}",
"dashboard": "Tableau de bord",
"edit": "%{name} #%{id}",
"error": "Un problème est survenu",
"list": "%{name}",
"loading": "Chargement",
"not_found": "Page manquante",
"show": "%{name} #%{id}",
"empty": "Pas encore de %{name}.",
"invite": "Voulez-vous en créer un ?"
},
"input": {
"file": {
"upload_several": "Déposez les fichiers à uploader, ou cliquez pour en sélectionner.",
"upload_single": "Déposez le fichier à uploader, ou cliquez pour le sélectionner."
},
"image": {
"upload_several": "Déposez les images à uploader, ou cliquez pour en sélectionner.",
"upload_single": "Déposez l'image à uploader, ou cliquez pour la sélectionner."
},
"references": {
"all_missing": "Impossible de trouver des données de références.",
"many_missing": "Au moins une des références associées semble ne plus être disponible.",
"single_missing": "La référence associée ne semble plus disponible."
},
"password": {
"toggle_visible": "Cacher le mot de passe",
"toggle_hidden": "Montrer le mot de passe"
}
},
"message": {
"about": "Au sujet de",
"are_you_sure": "Êtes-vous sûr ?",
"bulk_delete_content": "Êtes-vous sûr(e) de vouloir supprimer cet élément ? |||| Êtes-vous sûr(e) de vouloir supprimer ces %{smart_count} éléments ?",
"bulk_delete_title": "Supprimer %{name} |||| Supprimer %{smart_count} %{name}",
"delete_content": "Êtes-vous sûr(e) de vouloir supprimer cet élément ?",
"delete_title": "Supprimer %{name} #%{id}",
"details": "Détails",
"error": "En raison d'une erreur côté navigateur, votre requête n'a pas pu aboutir.",
"invalid_form": "Le formulaire n'est pas valide.",
"loading": "La page est en cours de chargement, merci de bien vouloir patienter.",
"no": "Non",
"not_found": "L'URL saisie est incorrecte, ou vous avez suivi un mauvais lien.",
"yes": "Oui",
"unsaved_changes": "Certains changements n'ont pas été enregistrés. Êtes-vous sûr(e) de vouloir quitter cette page ?"
},
"navigation": {
"no_results": "Aucun résultat",
"no_more_results": "La page numéro %{page} est en dehors des limites. Essayez la page précédente.",
"page_out_of_boundaries": "La page %{page} est en dehors des limites",
"page_out_from_end": "Fin de la pagination",
"page_out_from_begin": "La page doit être supérieure à 1",
"page_range_info": "%{offsetBegin}-%{offsetEnd} sur %{total}",
"page_rows_per_page": "Lignes par page :",
"next": "Suivant",
"prev": "Précédent"
},
"notification": {
"updated": "Élément mis à jour |||| %{smart_count} élements mis à jour",
"created": "Élément créé",
"deleted": "Élément supprimé |||| %{smart_count} élements supprimés",
"bad_item": "Élément inconnu",
"item_doesnt_exist": "L'élément n'existe pas",
"http_error": "Erreur de communication avec le serveur",
"data_provider_error": "Erreur dans le dataProvider. Plus de détails dans la console.",
"i18n_error": "Erreur de chargement des traductions pour la langue sélectionnée",
"canceled": "Action annulée",
"logged_out": "Votre session a pris fin, veuillez vous reconnecter."
}
},
"message": {
"note": "NOTE",
"transcodingDisabled": "Le changement de paramètres depuis l'interface web est désactivé pour des raisons de sécurité. Pour changer (éditer ou supprimer) les options de transcodage, relancer le serveur avec l'option %{config} activée.",
"transcodingEnabled": "Navidrome fonctionne actuellement avec %{config}, rendant possible lexécution de commandes arbitraires depuis l'interface web. Il est recommandé de n'activer cette fonctionnalité uniquement lors de la configuration du Transcodage.",
"songsAddedToPlaylist": "Une piste ajoutée à la playlist |||| %{smart_count} pistes ajoutées à la playlist",
"noPlaylistsAvailable": "Aucune playlist"
},
"menu": {
"library": "Bibliothèque",
"settings": "Paramètres",
"version": "Version%{version}",
"theme": "Thème",
"personal": {
"name": "Paramètres personel",
"options": {
"theme": "Thème",
"language": "Langue"
}
}
},
"player": {
"playListsText": "File de lecture",
"openText": "Ouvrir",
"closeText": "Fermer",
"notContentText": "Absence de musique",
"clickToPlayText": "Cliquer pour lire",
"clickToPauseText": "Cliquer pour mettre en pause",
"nextTrackText": "Morceau suivant",
"previousTrackText": "Morceau précédent",
"reloadText": "Recharger",
"volumeText": "Volume",
"toggleLyricText": "Afficher/masquer les paroles",
"toggleMiniModeText": "Minimiser",
"destroyText": "Détruire",
"downloadText": "Télécharger",
"removeAudioListsText": "Vider la liste de lecture",
"clickToDeleteText": "Cliquer pour supprimer %{name}",
"emptyLyricText": "Absence de paroles",
"playModeText": {
"order": "Ordonner",
"orderLoop": "Tout répéter",
"singleLoop": "Repéter",
"shufflePlay": "Aleatoire"
}
}
}

283
resources/i18n/it.json Normal file
View File

@@ -0,0 +1,283 @@
{
"languageName": "Italiano",
"resources": {
"song": {
"name": "Traccia |||| Tracce",
"fields": {
"albumArtist": "Artista Album",
"duration": "Durata",
"trackNumber": "#",
"playCount": "Riproduzioni",
"title": "Titolo",
"artist": "Artista",
"album": "Album",
"path": "Percorso",
"genre": "Genere",
"compilation": "Compilation",
"year": "Anno",
"size": "Dimensioni",
"updatedAt": "Ultimo aggiornamento",
"bitRate": "Bitrate",
"discSubtitle": "Sottotitoli disco",
"starred": "Preferita"
},
"actions": {
"addToQueue": "Aggiungi alla coda",
"playNow": "Riproduci adesso",
"addToPlaylist": "Aggiungi alla playlist"
}
},
"album": {
"name": "Album |||| Album",
"fields": {
"albumArtist": "Artista Album",
"artist": "Artista",
"duration": "Durata",
"songCount": "Tracce",
"playCount": "Riproduzioni",
"name": "Nome",
"genre": "Genere",
"compilation": "Compilation",
"year": "Anno",
"updatedAt": "Ultimo aggiornamento"
},
"actions": {
"playAll": "Riproduci",
"playNext": "Riproduci come successivo",
"addToQueue": "Aggiungi alla coda",
"shuffle": "Riprodici casualmente"
}
},
"artist": {
"name": "Artista |||| Artisti",
"fields": {
"name": "Nome",
"albumCount": "Album",
"songCount": "Numero tracce"
}
},
"user": {
"name": "Utente |||| Utenti",
"fields": {
"userName": "Nome utente",
"isAdmin": "Amministratore",
"lastLoginAt": "Ultimo accesso",
"updatedAt": "Ultimo aggiornamento",
"name": "Nome",
"password": "Password",
"createdAt": "Creato a"
}
},
"player": {
"name": "Client |||| Client",
"fields": {
"name": "Nome",
"transcodingId": "Transcodifica",
"maxBitRate": "Bitrate massimo",
"client": "Applicazione",
"userName": "Nome utente",
"lastSeen": "Ultimo acesso"
}
},
"transcoding": {
"name": "Transcodifica |||| Transcodifiche",
"fields": {
"name": "Nome",
"targetFormat": "Formato",
"defaultBitRate": "Bitrate predefinito",
"command": "Comando"
}
},
"playlist": {
"name": "Playlist |||| Playlist",
"fields": {
"name": "Nome",
"duration": "Durata",
"owner": "Creatore",
"public": "Pubblica",
"updatedAt": "Ultimo aggiornamento",
"createdAt": "Data creazione",
"songCount": "Tracce",
"comment": "Commento"
},
"actions": {
"selectPlaylist": "Aggiungi tracce alla playlist:",
"addNewPlaylist": "Aggiungi \"%{name}\""
}
}
},
"ra": {
"auth": {
"welcome1": "Grazie per aver installato Navidrome!",
"welcome2": "Per iniziare, crea un amministratore",
"confirmPassword": "Conferma la password",
"buttonCreateAdmin": "Crea amministratore",
"auth_check_error": "Per favore accedi per continuare",
"user_menu": "Profile",
"username": "Nome utente",
"password": "Password",
"sign_in": "Accedi",
"sign_in_error": "Autenticazione fallita, per favore riprova",
"logout": "Disconnetti"
},
"validation": {
"invalidChars": "Per favore usa solo lettere e numeri",
"passwordDoesNotMatch": "Le password non coincidono",
"required": "Campo obbligatorio",
"minLength": "Deve essere lungo almeno %{min} caratteri",
"maxLength": "Deve essere lungo al massimo %{max} caratteri",
"minValue": "Deve essere almeno %{min}",
"maxValue": "Deve essere al massimo %{max}",
"number": "Deve essere un numero",
"email": "Deve essere un indirizzo email valido",
"oneOf": "Deve essere uno di: %{options}",
"regex": "Deve rispettare il formato (espressione regolare): %{pattern}"
},
"action": {
"add_filter": "Aggiungi un filtro",
"add": "Aggiungi",
"back": "Indietro",
"bulk_actions": "Un elemento selezionato ||| %{smart_count} elementi selezionati",
"cancel": "Annulla",
"clear_input_value": "Cancella",
"clone": "Duplica",
"confirm": "Conferma",
"create": "Crea",
"delete": "Rimuovi",
"edit": "Modifica",
"export": "Esporta",
"list": "Elenco",
"refresh": "Aggiorna",
"remove_filter": "Rimuovi questo filtro",
"remove": "Remove",
"save": "Salva",
"search": "Cerca",
"show": "Mostra",
"sort": "Ordina",
"undo": "Annulla",
"expand": "Espandi",
"close": "Chiudi",
"open_menu": "Apri menù",
"close_menu": "Chiudi menù"
},
"boolean": {
"true": "Si",
"false": "No"
},
"page": {
"create": "Aggiungi %{name}",
"dashboard": "Pannello di controllo",
"edit": "%{name} #%{id}",
"error": "Qualcosa è andato storto",
"list": "%{name}",
"loading": "Caricamento in corso",
"not_found": "Non trovato",
"show": "%{name} #%{id}",
"empty": "Nessun %{name} per adesso.",
"invite": "Vuoi invitare un amico?"
},
"input": {
"file": {
"upload_several": "Trascina i file da caricare, oppure clicca per selezionarli.",
"upload_single": "Trascina il file da caricare, oppure clicca per selezionarlo."
},
"image": {
"upload_several": "Trascina le immagini da caricare, oppure clicca per selezionarle.",
"upload_single": "Trascina l'immagine da caricare, oppure clicca per selezionarla."
},
"references": {
"all_missing": "Impossibile trovare i riferimenti associati.",
"many_missing": "Almeno uno dei riferimenti associati sembra non essere più disponibile.",
"single_missing": "Il riferimento associato sembra non essere più disponibile."
},
"password": {
"toggle_visible": "Nascondi password",
"toggle_hidden": "Mostra password"
}
},
"message": {
"about": "Informazioni",
"are_you_sure": "Sei sicuro ?",
"bulk_delete_content": "Sei sicuro di voler rimuovere questo %{name}? |||| Sei sicuro di voler rimuovere questi %{smart_count} elementi?",
"bulk_delete_title": "Rimuovi %{name} |||| Rimuovi %{smart_count} %{name}",
"delete_content": "Sei sicuro di voler eliminare questo elemento?",
"delete_title": "Rimuovi %{name} #%{id}",
"details": "Dettagli",
"error": "Un errore dal lato client ha impedito il completamento della tua richiesta.",
"invalid_form": "Il modulo non è valido. Per favore controlla la presenza di errori.",
"loading": "La pagina si sta caricando, solo un momento per favore",
"no": "No",
"not_found": "Hai inserito un URL inesistente, oppure hai cliccato un link errato.",
"yes": "Si",
"unsaved_changes": "Alcune modifiche non sono state salvate. Vuoi ripristinarle?"
},
"navigation": {
"no_results": "Nessun risultato trovato",
"no_more_results": "La pagina numero %{page} è fuori dall'intervallo. Prova la pagina precedente.",
"page_out_of_boundaries": "Il numero di pagina %{page} è fuori dallintervallo",
"page_out_from_end": "Non è possibile andare oltre lultima pagina",
"page_out_from_begin": "Non è possibile andare prima della prima pagina",
"page_range_info": "%{offsetBegin}-%{offsetEnd} di %{total}",
"page_rows_per_page": "Righe per pagina:",
"next": "Successivo",
"prev": "Precedente"
},
"notification": {
"updated": "Elemento aggiornato |||| %{smart_count} elementi aggiornati",
"created": "Elemento creato",
"deleted": "Elemento rimosso |||| %{smart_count} elementi rimossi",
"bad_item": "Elemento errato",
"item_doesnt_exist": "Elemento inesistente",
"http_error": "Errore di comunicazione con il server",
"data_provider_error": "Errore del dataProvider. Controlla la console per i dettagli.",
"i18n_error": "Impossibile caricare la traduzione per la lingua selezionata",
"canceled": "Azione annullata",
"logged_out": "La sessione è scaduta, per favore accedi di nuovo."
}
},
"message": {
"note": "Note",
"transcodingDisabled": "La possibilità di modificare le opzioni di transcodifica attraverso linterfaccia web è disabilitata per ragioni di sicurezza. Se desideri cambiare (modificare o aggiungere) opzioni di transcodifica, riavvia il server con lopzione %{config}.",
"transcodingEnabled": "Navidrome è al momento attivo con %{config}, rendendo possibile eseguire comandi remoti attraverso linterfaccia web. Si raccomanda di disabilitare questa opzione per ragioni di sicurezza e di abilitarla solo per configurare le opzioni di transcodifica.",
"songsAddedToPlaylist": "Aggiunta una traccia alla playlist |||| Aggiunte %{smart_count} tracce alla playlist",
"noPlaylistsAvailable": "Nessuna playlist"
},
"menu": {
"library": "Libreria",
"settings": "Impostazioni",
"version": "Versione %{version}",
"theme": "Tema",
"personal": {
"name": "Personale",
"options": {
"theme": "Tema",
"language": "Lingua"
}
}
},
"player": {
"playListsText": "Coda",
"openText": "Apri",
"closeText": "Chiudi",
"notContentText": "Nessuna traccia",
"clickToPlayText": "Clicca per riprodurre",
"clickToPauseText": "Clicca per mettere in pausa",
"nextTrackText": "Traccia successiva",
"previousTrackText": "Traccia precedente",
"reloadText": "Ricarica",
"volumeText": "Volume",
"toggleLyricText": "Mostra testo",
"toggleMiniModeText": "Minimizza",
"destroyText": "Distruggi",
"downloadText": "Scarica",
"removeAudioListsText": "Cancella coda",
"clickToDeleteText": "Clicca per rimuovere %{name}",
"emptyLyricText": "Nessun testo",
"playModeText": {
"order": "In ordine",
"orderLoop": "Ripeti",
"singleLoop": "Ripeti una volta",
"shufflePlay": "Casuale"
}
}
}

260
resources/i18n/nl.json Normal file
View File

@@ -0,0 +1,260 @@
{
"languageName": "Nederlands",
"resources": {
"song": {
"name": "Nummer |||| Nummers",
"fields": {
"albumArtist": "Album Artiest",
"duration": "Tijd",
"trackNumber": "Nummer #",
"playCount": "Aantal keren afgespeeld",
"title": "Titel",
"artist": "Artiest",
"album": "Album",
"path": "Bestandspad",
"genre": "Genre",
"compilation": "Compilatie",
"year": "Jaar",
"size": "Bestandsgrootte",
"updatedAt": "Laatst bijgewerkt op",
"bitRate": "Bitrate"
},
"actions": {
"addToQueue": "Toevoegen aan afspeellijst",
"playNow": "Nu Afspelen"
}
},
"album": {
"name": "Album |||| Albums",
"fields": {
"albumArtist": "Album Artiest",
"artist": "Artiest",
"duration": "Tijd",
"songCount": "Nummerss",
"playCount": "Aantal keren afgespeeld",
"name": "Naam",
"genre": "Genre",
"compilation": "Compilatie",
"year": "Jaar",
"updatedAt": "Bijgewerkt op"
},
"actions": {
"playAll": "Afspelen",
"playNext": "Hierna afspelen",
"addToQueue": "Toevoegen aan afspeellijst",
"shuffle": "Shuffle"
}
},
"artist": {
"name": "Artiest |||| Artiesten",
"fields": {
"name": "Naam",
"albumCount": "Aantal albums"
}
},
"user": {
"name": "Gebruiker |||| Gebruikers",
"fields": {
"userName": "Gebruikersnaam",
"isAdmin": "Is beheerder",
"lastLoginAt": "Laatst ingelogd op",
"updatedAt": "Laatst gewijzigd op",
"name": "Naam",
"password": "Wachtwoord",
"createdAt": "Aangemaakt op"
}
},
"player": {
"name": "Speler |||| Spelers",
"fields": {
"name": "Naam",
"transcodingId": "Transcoderingsidentifier",
"maxBitRate": "Maximale bitrate",
"client": "Client",
"userName": "Gebruikersnaam",
"lastSeen": "Laatst gezien op"
}
},
"transcoding": {
"name": "Transcodering |||| Transcoderingen",
"fields": {
"name": "Naam",
"targetFormat": "Doel formaat",
"defaultBitRate": "Standaard bitrate",
"command": "Commando"
}
}
},
"ra": {
"auth": {
"welcome1": "Bedankt voor het installeren van Navidrome!",
"welcome2": "Maak om te beginnen een beheerdersaccount",
"confirmPassword": "Bevestig wachtwoord",
"buttonCreateAdmin": "Beheerder maken",
"auth_check_error": "Log in om door te gaan",
"user_menu": "Profiel",
"username": "Gebruikersnaam",
"password": "Wachtwoord",
"sign_in": "Inloggen",
"sign_in_error": "Authenticatie mislukt, probeer opnieuw a.u.b.",
"logout": "Uitloggen"
},
"validation": {
"invalidChars": "Gebruik alleen letters en cijfers",
"passwordDoesNotMatch": "Wachtwoord komt niet overeen",
"required": "Verplicht",
"minLength": "Moet minimaal %{min} karakters bevatten",
"maxLength": "Mag hooguit %{max} karakters bevatten",
"minValue": "Moet groter of gelijk zijn aan %{min}",
"maxValue": "Moet kleiner of gelijk zijn aan %{max}",
"number": "Moet een getal zijn",
"email": "Moet een geldig e-mailadres zijn",
"oneOf": "Moet een zijn van: %{options}",
"regex": "Moet overeenkomen met een specifiek format (regexp): %{pattern}"
},
"action": {
"add_filter": "Voeg filter toe",
"add": "Voeg toe",
"back": "Ga terug",
"bulk_actions": "1 geselecteerd |||| %{smart_count} geselecteerd",
"cancel": "Annuleer",
"clear_input_value": "Veld wissen",
"clone": "Kloon",
"confirm": "Bevestig",
"create": "Toevoegen",
"delete": "Verwijderen",
"edit": "Bewerk",
"export": "Exporteer",
"list": "Lijst",
"refresh": "Ververs",
"remove_filter": "Verwijder dit filter",
"remove": "Verwijder",
"save": "Opslaan",
"search": "Zoek",
"show": "Toon",
"sort": "Sorteer",
"undo": "Ongedaan maken",
"expand": "Uitklappen",
"close": "Sluiten",
"open_menu": "Open menu",
"close_menu": "Sluit menu"
},
"boolean": {
"true": "Ja",
"false": "Nee"
},
"page": {
"create": "%{name} toevoegen",
"dashboard": "Dashboard",
"edit": "%{name} #%{id}",
"error": "Er is iets misgegaan",
"list": "%{name}",
"loading": "Aan het laden",
"not_found": "Niet gevonden",
"show": "%{name} #%{id}",
"empty": "Nog geen %{name}.",
"invite": "Wilt u er een toevoegen?"
},
"input": {
"file": {
"upload_several": "Drag en drop bestanden om te uploaden, of klik om bestanden te selecteren.",
"upload_single": "Drag en drop een bestand om te uploaden, of klik om een bestand te selecteren."
},
"image": {
"upload_several": "Drag en drop afbeeldingen om te uploaden, of klik om bestanden te selecteren.",
"upload_single": "Drag en drop een afbeelding om te uploaden, of klik om een bestand te selecteren."
},
"references": {
"all_missing": "De gerefereerde elementen konden niet gevonden worden.",
"many_missing": "Een of meer van de gerefereerde elementen is niet meer beschikbaar.",
"single_missing": "Een van de gerefereerde elementen is niet meer beschikbaar"
},
"password": {
"toggle_visible": "Verberg wachtwoord",
"toggle_hidden": "Toon wachtwoord"
}
},
"message": {
"about": "Over",
"are_you_sure": "Weet u het zeker?",
"bulk_delete_content": "Weet u zeker dat u dit %{name} item wilt verwijderen? |||| Weet u zeker dat u deze %{smart_count} items wilt verwijderen?",
"bulk_delete_title": "Verwijder %{name} |||| Verwijder %{smart_count} %{name}",
"delete_content": "Weet u zeker dat u dit item wilt verwijderen?",
"delete_title": "%{name} #%{id} verwijderen",
"details": "Details",
"error": "Er is een clientfout opgetreden en uw aanvraag kon niet worden voltooid.",
"invalid_form": "Het formulier is ongeldig. Controleer a.u.b. de foutmeldingen",
"loading": "De pagina is aan het laden, een moment a.u.b.",
"no": "Nee",
"not_found": "U heeft een verkeerde URL ingevoerd of een defecte link aangeklikt.",
"yes": "Ja",
"unsaved_changes": "Sommige van uw wijzigingen zijn niet opgeslagen. Weet ue zeker dat u ze wilt negeren?"
},
"navigation": {
"no_results": "Geen resultaten gevonden",
"no_more_results": "Pagina %{page} ligt buiten het bereik. Probeer de vorige pagina.",
"page_out_of_boundaries": "Paginanummer %{page} buiten bereik",
"page_out_from_end": "Laatste pagina",
"page_out_from_begin": "Eerste pagina",
"page_range_info": "%{offsetBegin}-%{offsetEnd} van %{total}",
"page_rows_per_page": "Rijen per pagina:",
"next": "Volgende",
"prev": "Vorige"
},
"notification": {
"updated": "Element bijgewerkt |||| %{smart_count} elementen bijgewerkt",
"created": "Element toegevoegd",
"deleted": "Element verwijderd |||| %{smart_count} elementen verwijderd",
"bad_item": "Incorrect element",
"item_doesnt_exist": "Element bestaat niet",
"http_error": "Server communicatie fout",
"data_provider_error": "dataProvider fout. Open console voor meer details.",
"i18n_error": "Kan de vertalingen voor de opgegeven taal niet laden",
"canceled": "Actie geannuleerd",
"logged_out": "Uw sessie is beëindigd, maak opnieuw verbinding."
}
},
"message": {
"note": "Notitie",
"transcodingDisabled": "Het wijzigen van de transcoderingsconfiguratie via de web interface is om veiligheidsredenen uitgeschakeld. Als u transcoderingsopties wilt wijzigen (bewerken of toevoegen), start u de server opnieuw op met de %{config} configuratie-optie.",
"transcodingEnabled": "Navidrome werkt momenteel met %{config}, waardoor het mogelijk is om systeemopdrachten uit te voeren vanuit de transcoderingsinstellingen via de web interface. We raden aan om het om veiligheidsredenen uit te schakelen en alleen in te schakelen bij het configureren van transcoderingsopties."
},
"menu": {
"library": "Bibliotheek",
"settings": "Instellingen",
"version": "Versie %{version}",
"theme": "Thema",
"personal": {
"name": "Persoonlijk",
"options": {
"theme": "Thema",
"language": "Taal"
}
}
},
"player": {
"playListsText": "Afspeellijst afspelen",
"openText": "Openen",
"closeText": "Sluiten",
"notContentText": "Geen muziek",
"clickToPlayText": "Klik om af te spelen",
"clickToPauseText": "Klik om te pauzeren",
"nextTrackText": "Volgende",
"previousTrackText": "Vorige",
"reloadText": "Herladen",
"volumeText": "Volume",
"toggleLyricText": "Songtekst aan/uit",
"toggleMiniModeText": "Minimaliseren",
"destroyText": "Vernietigen",
"downloadText": "Downloaden",
"removeAudioListsText": "Audiolijsten verwijderen",
"clickToDeleteText": "Klik om %{name} te verwijderen",
"emptyLyricText": "Geen songtekst",
"playModeText": {
"order": "In volgorde",
"orderLoop": "Herhalen",
"singleLoop": "Herhaal Eenmalig",
"shufflePlay": "Shuffle"
}
}
}

285
resources/i18n/pt.json Normal file
View File

@@ -0,0 +1,285 @@
{
"languageName": "Português",
"resources": {
"song": {
"name": "Música |||| Músicas",
"fields": {
"albumArtist": "Artista",
"duration": "Duração",
"trackNumber": "#",
"playCount": "Execuções",
"title": "Título",
"artist": "Artista",
"album": "Álbum",
"path": "Arquivo",
"genre": "Gênero",
"compilation": "Coletânea",
"year": "Ano",
"size": "Tamanho",
"updatedAt": "Últ. Atualização",
"bitRate": "Bitrate",
"discSubtitle": "Sub-título do disco"
},
"actions": {
"addToQueue": "Adicionar à fila",
"playNow": "Tocar agora",
"addToPlaylist": "Adicionar à playlist"
}
},
"album": {
"name": "Álbum |||| Álbuns",
"fields": {
"albumArtist": "Artista",
"artist": "Artista",
"duration": "Duração",
"songCount": "Músicas",
"playCount": "Execuções",
"name": "Nome",
"genre": "Gênero",
"compilation": "Coletânea",
"year": "Ano",
"updatedAt": "Últ. Atualização"
},
"actions": {
"playAll": "Tocar",
"playNext": "Tocar em seguida",
"addToQueue": "Adicionar à fila",
"shuffle": "Aleatório"
}
},
"artist": {
"name": "Artista |||| Artistas",
"fields": {
"name": "Nome",
"albumCount": "Total de Álbuns",
"songCount": "Total de Músicas"
}
},
"user": {
"name": "Usuário |||| Usuários",
"fields": {
"userName": "Usuário",
"isAdmin": "Admin?",
"lastLoginAt": "Últ. Login",
"updatedAt": "Últ. Atualização",
"name": "Nome",
"password": "Senha",
"createdAt": "Data de Criação"
}
},
"player": {
"name": "Tocador |||| Tocadores",
"fields": {
"name": "Nome",
"transcodingId": "Conversão",
"maxBitRate": "Bitrate máx",
"client": "Cliente",
"userName": "Usuário",
"lastSeen": "Últ. acesso"
}
},
"transcoding": {
"name": "Conversão |||| Conversões",
"fields": {
"name": "Nome",
"targetFormat": "Formato",
"defaultBitRate": "Bitrate padrão",
"command": "Comando"
}
},
"playlist": {
"name": "Playlist |||| Playlists",
"fields": {
"name": "Nome",
"comment": "Comentário",
"duration": "Duração",
"owner": "Dono",
"public": "Pública",
"updatedAt": "Últ. Atualização",
"createdAt": "Data de Criação ",
"songCount": "Músicas"
},
"actions": {
"selectPlaylist": "Selecione a playlist:",
"addNewPlaylist": "Criar \"%{name}\""
}
}
},
"ra": {
"auth": {
"welcome1": "Obrigado por instalar Navidrome!",
"welcome2": "Para iniciar, crie um usuário admin",
"confirmPassword": "Confirme a senha",
"buttonCreateAdmin": "Criar Admin",
"auth_check_error": "Por favor, faça login para continuar",
"user_menu": "Perfil",
"username": "Usuário",
"password": "Senha",
"sign_in": "Entrar",
"sign_in_error": "Erro na autenticação, tente novamente.",
"logout": "Sair"
},
"validation": {
"invalidChars": "Somente use letras e numeros",
"passwordDoesNotMatch": "Senha não confere",
"required": "Obrigatório",
"minLength": "Deve ser ter no mínimo %{min} caracteres",
"maxLength": "Deve ter no máximo %{max} caracteres",
"minValue": "Deve ser %{min} ou maior",
"maxValue": "Deve ser %{max} ou menor",
"number": "Deve ser um número",
"email": "Deve ser um email válido",
"oneOf": "Deve ser uma das seguintes opções: %{options}",
"regex": "Deve ter o formato específico (regexp): %{pattern}"
},
"action": {
"add_filter": "Adicionar Filtro",
"add": "Adicionar",
"back": "Voltar",
"bulk_actions": "1 item selecionado |||| %{smart_count} itens selecionados",
"cancel": "Cancelar",
"clear_input_value": "Limpar campo",
"clone": "Duplicar",
"confirm": "Confirmar",
"create": "Novo",
"delete": "Deletar",
"edit": "Editar",
"export": "Exportar",
"list": "Listar",
"refresh": "Atualizar",
"remove_filter": "Cancelar filtro",
"remove": "Excluir",
"save": "Salvar",
"search": "Buscar",
"show": "Exibir",
"sort": "Ordenar",
"undo": "Desfazer",
"unselect": "Deselecionar",
"expand": "Expandir",
"close": "Fechar",
"open_menu": "Abrir menu",
"close_menu": "Fechar menu"
},
"boolean": {
"true": "Sim",
"false": "Não"
},
"page": {
"create": "Criar %{name}",
"dashboard": "Painel de Controle",
"edit": "%{name} #%{id}",
"error": "Um erro ocorreu",
"list": "Listar %{name}",
"loading": "Carregando",
"not_found": "Não encontrado",
"show": "%{name} #%{id}",
"empty": "Ainda não há nenhum registro em %{name}",
"invite": "Gostaria de criar um novo?"
},
"input": {
"file": {
"upload_several": "Arraste alguns arquivos para fazer o upload, ou clique para selecioná-los.",
"upload_single": "Arraste o arquivo para fazer o upload, ou clique para selecioná-lo."
},
"image": {
"upload_several": "Arraste algumas imagens para fazer o upload ou clique para selecioná-las",
"upload_single": "Arraste um arquivo para upload ou clique em selecionar arquivo."
},
"references": {
"all_missing": "Não foi possível encontrar os dados das referencias.",
"many_missing": "Pelo menos uma das referências passadas não está mais disponível.",
"single_missing": "A referência passada aparenta não estar mais disponível."
},
"password": {
"toggle_visible": "Esconder senha",
"toggle_hidden": "Mostrar senha"
}
},
"message": {
"about": "Sobre",
"are_you_sure": "Tem certeza?",
"bulk_delete_content": "Você tem certeza que deseja excluir %{name}? |||| Você tem certeza que deseja excluir estes %{smart_count} itens?",
"bulk_delete_title": "Excluir %{name} |||| Excluir %{smart_count} %{name} itens",
"delete_content": "Você tem certeza que deseja excluir?",
"delete_title": "Excluir %{name} #%{id}",
"details": "Detalhes",
"error": "Um erro ocorreu e a sua requisição não pôde ser completada.",
"invalid_form": "Este formulário não está valido. Certifique-se de corrigir os erros",
"loading": "A página está carregando. Um momento, por favor",
"no": "Não",
"not_found": "Foi digitada uma URL inválida, ou o link pode estar quebrado.",
"yes": "Sim",
"unsaved_changes": "Algumas das suas mudanças não foram salvas, deseja realmente ignorá-las?"
},
"navigation": {
"no_results": "Nenhum resultado encontrado",
"no_more_results": "A página numero %{page} está fora dos limites. Tente a página anterior.",
"page_out_of_boundaries": "Página %{page} fora do limite",
"page_out_from_end": "Não é possível ir após a última página",
"page_out_from_begin": "Não é possível ir antes da primeira página",
"page_range_info": "%{offsetBegin}-%{offsetEnd} de %{total}",
"page_rows_per_page": "Resultados por página:",
"next": "Próximo",
"prev": "Anterior"
},
"notification": {
"updated": "Item atualizado com sucesso |||| %{smart_count} itens foram atualizados com sucesso",
"created": "Item criado com sucesso",
"deleted": "Item removido com sucesso! |||| %{smart_count} itens foram removidos com sucesso",
"bad_item": "Item incorreto",
"item_doesnt_exist": "Esse item não existe mais",
"http_error": "Erro na comunicação com servidor",
"data_provider_error": "Erro interno do servidor. Entre em contato",
"i18n_error": "Não foi possível carregar as traduções para o idioma especificado",
"canceled": "Ação cancelada",
"logged_out": "Sua sessão foi encerrada. Por favor, reconecte"
}
},
"message": {
"note": "ATENÇÃO",
"transcodingDisabled": "Por questão de segurança, esta tela de configuração está desabilitada. Se você quiser alterar estas configurações, reinicie o servidor com a opção %{config}",
"transcodingEnabled": "Navidrome está sendo executado com a opção %{config}. Isto permite que potencialmente se execute comandos do sistema pela interface Web. É recomendado que vc mantenha esta opção desabilitada, e só a habilite quando precisar configurar opções de Conversão",
"songsAddedToPlaylist": "Música adicionada à playlist |||| %{smart_count} músicas adicionadas à playlist",
"noPlaylistsAvailable": "Nenhuma playlist",
"delete_user_title": "Excluir usuário '%{name}'",
"delete_user_content": "Você tem certeza que deseja excluir o usuário e todos os seus dados (incluindo suas playlists e preferências)?"
},
"menu": {
"library": "Biblioteca",
"settings": "Configurações",
"version": "Versão %{version}",
"theme": "Tema",
"personal": {
"name": "Pessoal",
"options": {
"theme": "Tema",
"language": "Língua"
}
}
},
"player": {
"playListsText": "Fila de Execução",
"openText": "Abrir",
"closeText": "Fechar",
"notContentText": "Nenhum música",
"clickToPlayText": "Clique para tocar",
"clickToPauseText": "Clique para pausar",
"nextTrackText": "Próxima faixa",
"previousTrackText": "Faixa anterior",
"reloadText": "Recarregar",
"volumeText": "Volume",
"toggleLyricText": "Letra",
"toggleMiniModeText": "Minimizar",
"destroyText": "Destruir",
"downloadText": "Baixar",
"removeAudioListsText": "Limpar fila de execução",
"clickToDeleteText": "Clique para remover %{name}",
"emptyLyricText": "Letra não disponível",
"playModeText": {
"order": "Em ordem",
"orderLoop": "Repetir tudo",
"singleLoop": "Repetir",
"shufflePlay": "Aleatório"
}
}
}

283
resources/i18n/tr.json Normal file
View File

@@ -0,0 +1,283 @@
{
"languageName": "Türkçe",
"resources": {
"song": {
"name": "Şarkı |||| Şarkılar",
"fields": {
"albumArtist": "Albüm sanatçısı",
"duration": "Süre",
"trackNumber": "Parça #",
"playCount": "Oynatma",
"title": "Isim",
"artist": "Sanatçı",
"album": "Albüm",
"path": "Dosya yolu",
"genre": "Tür",
"compilation": "Derleme",
"year": "Yıl",
"size": "Dosya boyutu",
"updatedAt": "Yüklendiği zaman",
"bitRate": "Bir sayısı",
"discSubtitle": "Disk Altyazısı",
"starred": "Yıldızlı"
},
"actions": {
"addToQueue": "Sonra çal",
"playNow": "Şimdi cal",
"addToPlaylist": "Çalma listesine ekle"
}
},
"album": {
"name": "Albüm |||| Albümler",
"fields": {
"albumArtist": "Albüm sanatçısı",
"artist": "Sanatçı",
"duration": "Süre",
"songCount": "Şarkılar",
"playCount": "Oynatma",
"name": "Ad",
"genre": "Tür",
"compilation": "Derleme",
"year": "Yıl",
"updatedAt": "Güncellendi "
},
"actions": {
"playAll": "Çaldır",
"playNext": "Sonrakini çal",
"addToQueue": "Sonra çal",
"shuffle": "Karıştır"
}
},
"artist": {
"name": "Sanatçı |||| Sanatçılar",
"fields": {
"name": "Ad",
"albumCount": "Albüm Sayısı",
"songCount": "Şarkı sayısı"
}
},
"user": {
"name": "Kullanıcı |||| Kullanıcılar",
"fields": {
"userName": "Kullanıcı adı",
"isAdmin": "Yönetici mi",
"lastLoginAt": "Son Giriş Tarihi",
"updatedAt": "Güncelleme Tarihi",
"name": "Ad",
"password": "Şifre",
"createdAt": "Oluşturuldu"
}
},
"player": {
"name": "Çalar |||| Çalarlar",
"fields": {
"name": "Ad",
"transcodingId": "Kod dönüştürme kimliği",
"maxBitRate": "Maks. bit orani",
"client": "Cihaz",
"userName": "Kullanıcı adı",
"lastSeen": "Son Görülme"
}
},
"transcoding": {
"name": "Transcoding |||| Transcodings",
"fields": {
"name": "Ad",
"targetFormat": "Hedef Formatı",
"defaultBitRate": "Varsayılan bit orani",
"command": "komut"
}
},
"playlist": {
"name": "Çalma listesi |||| Çalma listeler",
"fields": {
"name": "Isim",
"duration": "Süre",
"owner": "Sahibi",
"public": "Görülebilir",
"updatedAt": "Güncelleme tarihi:",
"createdAt": "Oluşturma tarihi:",
"songCount": "Şarkılar",
"comment": "Yorum"
},
"actions": {
"selectPlaylist": "Bir çalma listesi seç:",
"addNewPlaylist": "Oluştur \"%{name}\""
}
}
},
"ra": {
"auth": {
"welcome1": "Navidrome'yi yüklediğiniz için teşekkürler!",
"welcome2": "Başlamak için bir yönetici kullanıcı oluştur",
"confirmPassword": "Şifreyi Onayla",
"buttonCreateAdmin": "Yönetici oluştur",
"auth_check_error": "Devam etmek için lütfen giriş yap",
"user_menu": "Profil",
"username": "Kullanıcı adı",
"password": "Parola",
"sign_in": "Giriş yap",
"sign_in_error": "Giriş başarısız. Lütfen tekrar deneyin",
"logout": ıkış"
},
"validation": {
"invalidChars": "Lütfen sadece harf ve rakam kullan",
"passwordDoesNotMatch": "Şifre eşleşmiyor",
"required": "Zorunlu alan",
"minLength": "En az %{min} karakter",
"maxLength": "En fazla %{max} karakter",
"minValue": "En az %{min} olmalı",
"maxValue": "En fazla %{max} olmali",
"number": "Sayısal bir değer olmalı",
"email": "E-posta geçerli değil",
"oneOf": "Bunlardan biri olmalı: %{options}",
"regex": "Belirli bir formatla eşleşmelidir (regexp): %{pattern}"
},
"action": {
"add_filter": "Filtre ekle",
"add": "Ekle",
"back": "Geri Dön",
"bulk_actions": "1 seçildi |||| %{smart_count} seçildi",
"cancel": "İptal",
"clear_input_value": "Temizle",
"clone": "Klonla",
"confirm": "Onayla",
"create": "Oluştur",
"delete": "Sil",
"edit": "Düzenle",
"export": "Dışa aktar",
"list": "Listele",
"refresh": "Yenile",
"remove_filter": "Filtreyi kaldır",
"remove": "Kaldır",
"save": "Kaydet",
"search": "Ara",
"show": "Göster",
"sort": "Sırala",
"undo": "Geri al",
"expand": "Genişlettir",
"close": "Kapat",
"open_menu": "Menüyü aç",
"close_menu": "Menüyü kapat"
},
"boolean": {
"true": "Evet",
"false": "Hayır"
},
"page": {
"create": "%{name} oluştur",
"dashboard": "Ana Sayfa",
"edit": "%{name} #%{id}",
"error": "Bazı şeyler yolunda değil",
"list": "%{name} listesi",
"loading": "Yükleniyor",
"not_found": "Sayfa bulunamadı",
"show": "%{name} #%{id}",
"empty": "Henüz %{name} yok.",
"invite": "Bir tane eklemek ister misin?"
},
"input": {
"file": {
"upload_several": "Yüklemek istediğiniz dosyaları buraya sürükleyin ya da seçmek için tıklayın.",
"upload_single": "Yüklemek istediğiniz dosyayı buraya sürükleyin ya da seçmek için tıklayın.."
},
"image": {
"upload_several": "Yüklemek istediğiniz resimleri buraya sürükleyin ya da seçmek için tıklayın.",
"upload_single": "Yüklemek istediğiniz resmi buraya sürükleyin ya da seçmek için tıklayın."
},
"references": {
"all_missing": "Referans verileri bulunamadı.",
"many_missing": "İlişkilendirilmiş referanslardan en az biri artık mevcut değil.",
"single_missing": "İlişkilendirilmiş referans artık mevcut değil."
},
"password": {
"toggle_visible": "Şifreyi gizle",
"toggle_hidden": "Şifreyi göster"
}
},
"message": {
"about": "Hakkında",
"are_you_sure": "Emin misiniz?",
"bulk_delete_content": "%{name} silmek istediğinizden emin misiniz? |||| %{smart_count} öğeyi silmek istediğinizden emin misiniz?",
"bulk_delete_title": "%{name} sil |||| %{smart_count} %{name} öğesi sil",
"delete_content": "Bu öğeyi silmek istediğinizden emin misiniz?",
"delete_title": "%{name} #%{id} Sil",
"details": "Detaylar",
"error": "Bir istemci hatası oluştu ve isteğiniz tamamlanamadı.",
"invalid_form": "Form geçerli değil. Lütfen hataları kontrol edin",
"loading": "Sayfa yükleniyor, lütfen bekleyiniz",
"no": "Hayır",
"not_found": "Hatalı bir URL girdiniz ya da yanlış bir linke tıkladınız",
"yes": "Evet",
"unsaved_changes": "Yaptığın değişikliklerin bazıları kaydedilmedi. Onları yoksaymak istediğinizden emin misin?"
},
"navigation": {
"no_results": "Kayıt bulunamadı",
"no_more_results": "%{page} sayfası mevcut değil. Önceki sayfayı deneyin.",
"page_out_of_boundaries": "%{page} sayfası mevcut değil",
"page_out_from_end": "Son sayfadan ileri gidemezsin",
"page_out_from_begin": "1. sayfadan geri gidemezsin",
"page_range_info": "%{offsetBegin}-%{offsetEnd} of %{total}",
"page_rows_per_page": "Sayfa başına kayıtlar",
"next": "Sonraki",
"prev": "Önceki"
},
"notification": {
"updated": "Öğe güncellendi |||| %{smart_count} öğe güncellendi",
"created": "Öğe oluşturuldu",
"deleted": "Öğe silindi |||| %{smart_count} öğe silindi",
"bad_item": "Hatalı öğe",
"item_doesnt_exist": "Öğe bulunamadı",
"http_error": "Sunucu iletişim hatası",
"data_provider_error": "dataProvider hatası. Detay için konsolu gözden geçir.",
"i18n_error": "Belirtilen dil için çeviriler yüklenemedi",
"canceled": "Eylem iptal edildi",
"logged_out": "Oturumunuz sona erdi, Lütfen yeniden bağlanın."
}
},
"message": {
"note": "NOT",
"transcodingDisabled": "Transcoding ayarlari web arayüzü üzerinden değiştirilmesi güvenlik nedeniyle devre dışı bırakılmıştır. Kod dönüştürme seçeneklerini değiştirmek (düzenlemek veya eklemek) istiyorsan, %{config} seçeneğiyle sunucuyu yeniden başlatın.",
"transcodingEnabled": "Navidrome şu anda %{config} ile çalışıyor, web arayüzünü kullanarak kod dönüştürme ayarlarından sistem komutlarını çalıştırmayı mümkün kılıyor. Güvenlik nedeniyle devre dışı bırakmanızı ve yalnızca Kod Dönüştürme seçeneklerini yapılandırırken etkinleştirmenizi öneririz.",
"songsAddedToPlaylist": "Çalma listesine 1 şarkı eklendi |||| Çalma listesine %{smart_count} şarkı eklendi",
"noPlaylistsAvailable": "Mevcut değil"
},
"menu": {
"library": "Müzik kütüphanesi",
"settings": "Ayarlar",
"version": "Sürüm %{version}",
"theme": "Tema",
"personal": {
"name": "Kişisel",
"options": {
"theme": "Tema",
"language": "Dil"
}
}
},
"player": {
"playListsText": "Oynatma Sırası",
"openText": "Aç",
"closeText": "Kapat",
"notContentText": "Müzik yok",
"clickToPlayText": "Oynatmak için tıkla",
"clickToPauseText": "Duraklatmak için tıkla",
"nextTrackText": "Sonraki parça",
"previousTrackText": "Önceki parça",
"reloadText": "Tekrar yükle",
"volumeText": "Ses",
"toggleLyricText": "Şarkı sözü aç/kapat",
"toggleMiniModeText": "Küçült",
"destroyText": "Yık",
"downloadText": "İndir",
"removeAudioListsText": "Ses listelerini sil",
"clickToDeleteText": "%{name} silmek için tıkla",
"emptyLyricText": "Şarkı sözü yok",
"playModeText": {
"order": "Sırayla",
"orderLoop": "Tekrar et",
"singleLoop": "Birini tekrarla",
"shufflePlay": "Karıştır"
}
}
}

256
resources/i18n/zn.json Normal file
View File

@@ -0,0 +1,256 @@
{
"languageName": "简体中文",
"resources": {
"song": {
"name": "歌曲 |||| 曲库",
"fields": {
"albumArtist": "专辑歌手",
"duration": "时长",
"trackNumber": "音轨 #",
"playCount": "播放次数",
"title": "",
"artist": "",
"album": "",
"path": "",
"genre": "",
"compilation": "",
"year": "",
"size": "",
"updatedAt": ""
},
"actions": {
"addToQueue": "稍后播放",
"playNow": ""
}
},
"album": {
"name": "专辑 |||| 专辑",
"fields": {
"albumArtist": "专辑歌手",
"artist": "歌手",
"duration": "时长",
"songCount": "曲目数",
"playCount": "播放次数",
"name": "",
"genre": "",
"compilation": "",
"year": ""
},
"actions": {
"playAll": "播放",
"playNext": "播放下一首",
"addToQueue": "稍后播放",
"shuffle": "刷新"
}
},
"artist": {
"name": "歌手 |||| 歌手",
"fields": {
"name": "",
"albumCount": ""
}
},
"user": {
"name": "用户 |||| 用户",
"fields": {
"userName": "用户名",
"isAdmin": "",
"lastLoginAt": "",
"updatedAt": "",
"name": ""
}
},
"player": {
"name": "用户 |||| 用户",
"fields": {
"name": "",
"transcodingId": "",
"maxBitRate": "",
"client": "",
"userName": "",
"lastSeen": ""
}
},
"transcoding": {
"name": "转码 |||| 转码",
"fields": {
"name": "",
"targetFormat": "",
"defaultBitRate": "",
"command": ""
}
}
},
"ra": {
"auth": {
"welcome1": "感谢您安装Navidrome!",
"welcome2": "为了开始使用,请创建一个管理员账户",
"confirmPassword": "确认密码",
"buttonCreateAdmin": "创建管理员",
"auth_check_error": "",
"user_menu": "设置",
"username": "用户名",
"password": "密码",
"sign_in": "登录",
"sign_in_error": "验证失败, 请重试",
"logout": "退出"
},
"validation": {
"invalidChars": "请只使用字母和数字",
"passwordDoesNotMatch": "密码不匹配",
"required": "必填",
"minLength": "必须不少于 %{min} 个字符",
"maxLength": "必须不多于 %{max} 个字符",
"minValue": "必须不小于 %{min}",
"maxValue": "必须不大于 %{max}",
"number": "必须为数字",
"email": "必须是有效的邮箱",
"oneOf": "必须为: %{options}其中一项",
"regex": "必须符合指定的格式 (regexp): %{pattern}"
},
"action": {
"add_filter": "增加检索",
"add": "增加",
"back": "回退",
"bulk_actions": "选中%{smart_count}项",
"cancel": "取消",
"clear_input_value": "",
"clone": "",
"confirm": "",
"create": "新建",
"delete": "删除",
"edit": "编辑",
"export": "导出",
"list": "列表",
"refresh": "刷新",
"remove_filter": "移除检索",
"remove": "删除",
"save": "保存",
"search": "检索",
"show": "显示",
"sort": "排序",
"undo": "撤销",
"expand": "",
"close": "",
"open_menu": "",
"close_menu": ""
},
"boolean": {
"true": "是",
"false": "否"
},
"page": {
"create": "新建 %{name}",
"dashboard": "概览",
"edit": "%{name} #%{id}",
"error": "出现错误",
"list": "%{name} 列表",
"loading": "加载中",
"not_found": "未发现",
"show": "%{name} #%{id}",
"empty": "",
"invite": ""
},
"input": {
"file": {
"upload_several": "将文件集合拖拽到这里, 或点击这里选择文件集合.",
"upload_single": "将文件拖拽到这里, 或点击这里选择文件."
},
"image": {
"upload_several": "将图片文件集合拖拽到这里, 或点击这里选择图片文件集合.",
"upload_single": "将图片文件拖拽到这里, 或点击这里选择图片文件."
},
"references": {
"all_missing": "未找到参考数据.",
"many_missing": "至少有一条参考数据不再可用.",
"single_missing": "关联的参考数据不再可用."
},
"password": {
"toggle_visible": "",
"toggle_hidden": ""
}
},
"message": {
"about": "关于",
"are_you_sure": "您确定操作?",
"bulk_delete_content": "您确定要删除 %{name}? |||| 您确定要删除 %{smart_count} 项?",
"bulk_delete_title": "删除 %{name} |||| 删除 %{smart_count}项 %{name} ",
"delete_content": "您确定要删除该条目?",
"delete_title": "删除 %{name} #%{id}",
"details": "",
"error": "",
"invalid_form": "表单输入无效. 请检查错误提示",
"loading": "正在加载页面, 请稍候",
"no": "否",
"not_found": "您输入了错误的URL或者错误的链接.",
"yes": "是",
"unsaved_changes": ""
},
"navigation": {
"no_results": "结果为空",
"no_more_results": "页码 %{page} 超出边界. 试试上一页.",
"page_out_of_boundaries": "页码 %{page} 超出边界",
"page_out_from_end": "已到最末页",
"page_out_from_begin": "已到最前页",
"page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}",
"page_rows_per_page": "每页行数:",
"next": "向后",
"prev": "向前"
},
"notification": {
"updated": "条目已更新 |||| %{smart_count} 项条目已更新",
"created": "条目已新建",
"deleted": "条目已删除 |||| %{smart_count} 项条目已删除",
"bad_item": "不正确的条目",
"item_doesnt_exist": "条目不存在",
"http_error": "与服务通信出错",
"data_provider_error": "dataProvider错误. 请检查console的详细信息.",
"i18n_error": "",
"canceled": "取消动作",
"logged_out": ""
}
},
"message": {
"note": "",
"transcodingDisabled": "",
"transcodingEnabled": ""
},
"menu": {
"library": "曲库",
"settings": "设置",
"version": "版本 %{version}",
"theme": "主题",
"personal": {
"name": "个性化",
"options": {
"theme": "主题",
"language": "语言"
}
}
},
"player": {
"playListsText": "播放队列",
"openText": "打开",
"closeText": "关闭",
"notContentText": "无音乐",
"clickToPlayText": "点击播放",
"clickToPauseText": "点击暂停",
"nextTrackText": "下一首",
"previousTrackText": "上一首",
"reloadText": "Reload",
"volumeText": "音量",
"toggleLyricText": "切换歌词",
"toggleMiniModeText": "最小化",
"destroyText": "损坏",
"downloadText": "下载",
"removeAudioListsText": "清空播放列表",
"clickToDeleteText": "点击删除 %{name}",
"emptyLyricText": "无歌词",
"playModeText": {
"order": "顺序播放",
"orderLoop": "列表循环",
"singleLoop": "单曲循环",
"shufflePlay": "随机播放"
}
}
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

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