mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-06 05:48:09 -05:00
Compare commits
388 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f373f5f83e | ||
|
|
92b7ef40af | ||
|
|
39cb3455db | ||
|
|
4ac4806bf8 | ||
|
|
a282f62395 | ||
|
|
3aac03d253 | ||
|
|
cd171c40cb | ||
|
|
78c40ab6b4 | ||
|
|
21f7c1906d | ||
|
|
23fe8cdee6 | ||
|
|
af55b93ac8 | ||
|
|
665b1f6898 | ||
|
|
35f748e0fb | ||
|
|
fc7a027d59 | ||
|
|
38c1999fcd | ||
|
|
180f1354fc | ||
|
|
abd51b2156 | ||
|
|
47976e13b1 | ||
|
|
bbd4503ac8 | ||
|
|
b40df6380e | ||
|
|
2d036b5966 | ||
|
|
f859772723 | ||
|
|
1be79fa945 | ||
|
|
1825b29737 | ||
|
|
13f08d3eae | ||
|
|
52d8aaa865 | ||
|
|
8dfc259857 | ||
|
|
deef8e162d | ||
|
|
b18e3289fb | ||
|
|
4d60f72b7e | ||
|
|
e0fa85be28 | ||
|
|
5b167031d2 | ||
|
|
cf8756b14b | ||
|
|
03867bd8b2 | ||
|
|
943f35f7a5 | ||
|
|
ca283f45ea | ||
|
|
bf93b5614c | ||
|
|
377d8f6b87 | ||
|
|
1eb62ee671 | ||
|
|
39c94d3cd9 | ||
|
|
3fa4ef0166 | ||
|
|
9116529b6d | ||
|
|
bd8b573743 | ||
|
|
a65318a00a | ||
|
|
a817701ee8 | ||
|
|
4a4a8aff34 | ||
|
|
80b8b69cee | ||
|
|
ab0e091736 | ||
|
|
e6d1e67297 | ||
|
|
a99924ea20 | ||
|
|
27adb84177 | ||
|
|
7a3bd935c2 | ||
|
|
cff5c1ee53 | ||
|
|
fd32a28788 | ||
|
|
4f25e9ebf4 | ||
|
|
514117a477 | ||
|
|
07e8f41849 | ||
|
|
133626dcd0 | ||
|
|
56a6fb91ab | ||
|
|
6e518d90d5 | ||
|
|
96b94106e6 | ||
|
|
a1c670b40d | ||
|
|
2230a9052f | ||
|
|
9b1be35c14 | ||
|
|
afe5a5b32a | ||
|
|
9edd7e9025 | ||
|
|
2be9a7dbec | ||
|
|
7305e3aa17 | ||
|
|
aa133e6b00 | ||
|
|
1f72399f44 | ||
|
|
3aef62f201 | ||
|
|
e5535f6aff | ||
|
|
76fc5b1425 | ||
|
|
a38f205c0b | ||
|
|
944107cb3d | ||
|
|
94fd0a10b5 | ||
|
|
669f293f1f | ||
|
|
532833ac7c | ||
|
|
59f1d7e88a | ||
|
|
caeff2862a | ||
|
|
841c1129ff | ||
|
|
ba30f7f8be | ||
|
|
6026638c03 | ||
|
|
cbab2e4eec | ||
|
|
a3ecc41e47 | ||
|
|
4d18212f5d | ||
|
|
5dea258058 | ||
|
|
0802ab73d7 | ||
|
|
865b9cd545 | ||
|
|
e70ec53983 | ||
|
|
2d0031f709 | ||
|
|
78ecda5239 | ||
|
|
a1879ff871 | ||
|
|
34eda3c8fc | ||
|
|
506899b083 | ||
|
|
3a4e2523dd | ||
|
|
674b56a53d | ||
|
|
58a0c44600 | ||
|
|
df4328819d | ||
|
|
b6aa6eb7b2 | ||
|
|
1187ee7cc1 | ||
|
|
0beec552b1 | ||
|
|
6a6d4c3f87 | ||
|
|
1216c9bdb8 | ||
|
|
2a888395fa | ||
|
|
56772f5c62 | ||
|
|
07b5469b4c | ||
|
|
58324b411f | ||
|
|
c0e5b445cf | ||
|
|
6820e120cb | ||
|
|
28aefb4858 | ||
|
|
e50a720818 | ||
|
|
900337081b | ||
|
|
34af6fc671 | ||
|
|
a25044bdf6 | ||
|
|
30e98843ed | ||
|
|
8fe335ed97 | ||
|
|
8549451ee7 | ||
|
|
596a4897a3 | ||
|
|
95eea0e9f8 | ||
|
|
61c286a77e | ||
|
|
15d11a9519 | ||
|
|
35625020e2 | ||
|
|
76e522710a | ||
|
|
aae9d89e8c | ||
|
|
0eae6d2a61 | ||
|
|
f6982fd8ae | ||
|
|
b364170d4f | ||
|
|
0aceda9b89 | ||
|
|
9df405a8ce | ||
|
|
366054e8cc | ||
|
|
8fa5544af7 | ||
|
|
073e40dc87 | ||
|
|
a45c08f217 | ||
|
|
6c8535c54a | ||
|
|
e2e79d6471 | ||
|
|
b5567090ed | ||
|
|
b836871161 | ||
|
|
45e708f591 | ||
|
|
608129963f | ||
|
|
f3d8222ddb | ||
|
|
c83808a445 | ||
|
|
c23e5c291c | ||
|
|
bd1c3d9229 | ||
|
|
48c0e1ca4b | ||
|
|
16397e08fc | ||
|
|
15a06fcd27 | ||
|
|
a2e0acd6a2 | ||
|
|
5f38e70a2b | ||
|
|
c19c599521 | ||
|
|
dd398224e7 | ||
|
|
5ac76ae7e0 | ||
|
|
c14147e6c5 | ||
|
|
59ce940cd6 | ||
|
|
cfecd7c6a2 | ||
|
|
d81a4472a0 | ||
|
|
147d26fb75 | ||
|
|
848318932d | ||
|
|
49153dc1c1 | ||
|
|
ca5da5b0ea | ||
|
|
c2e03c8162 | ||
|
|
f2ebbd26fa | ||
|
|
bbc4f9f91f | ||
|
|
6fe1f84c68 | ||
|
|
d72468003f | ||
|
|
100f6a0645 | ||
|
|
bc2073fbd5 | ||
|
|
278d0ea8f3 | ||
|
|
0e16d7cfbb | ||
|
|
419884db7c | ||
|
|
eacfc41665 | ||
|
|
c271aa24d1 | ||
|
|
22f34b3347 | ||
|
|
eba8395146 | ||
|
|
f16dc5f8f8 | ||
|
|
15c8f4c0ef | ||
|
|
e344f616b3 | ||
|
|
ef81caf3ed | ||
|
|
8513f1a899 | ||
|
|
a9a25713e8 | ||
|
|
a5e1986072 | ||
|
|
97c98e3369 | ||
|
|
6effd603e2 | ||
|
|
8a783ef967 | ||
|
|
b74bd30b72 | ||
|
|
9fa09e41cc | ||
|
|
4ef12f91e0 | ||
|
|
0730c667a2 | ||
|
|
4ec451aecb | ||
|
|
883dd7f728 | ||
|
|
38c19eddc3 | ||
|
|
8e4b2e1c06 | ||
|
|
a541afbfba | ||
|
|
df05760769 | ||
|
|
9a1133601a | ||
|
|
2c370cae28 | ||
|
|
f745b8d223 | ||
|
|
f1b6703ab0 | ||
|
|
28d1428c90 | ||
|
|
696a0feb31 | ||
|
|
f29e1eb248 | ||
|
|
d4e599233e | ||
|
|
aaec8e080b | ||
|
|
09442eccd4 | ||
|
|
21b9f51b71 | ||
|
|
ed726c2126 | ||
|
|
23d69d26e0 | ||
|
|
3d0e70e907 | ||
|
|
34e843a4b3 | ||
|
|
924ada0dab | ||
|
|
2d3ed85311 | ||
|
|
3d4f4b4e2b | ||
|
|
338cbacb79 | ||
|
|
0cf574198e | ||
|
|
3000238a3c | ||
|
|
16c38eb344 | ||
|
|
721a959735 | ||
|
|
3c2b14d362 | ||
|
|
2b59d4b87a | ||
|
|
cefdeee495 | ||
|
|
3383327c51 | ||
|
|
38b341ebc5 | ||
|
|
ef0e5b130d | ||
|
|
3092f83a00 | ||
|
|
8daac43e99 | ||
|
|
d5da23ae42 | ||
|
|
eae46d15bf | ||
|
|
f6c518fd8b | ||
|
|
db8a48bba6 | ||
|
|
d877928f11 | ||
|
|
0403ec2a07 | ||
|
|
8d27c77c2c | ||
|
|
f992b5663f | ||
|
|
4e4fcb2304 | ||
|
|
ddb30ceb11 | ||
|
|
67da83c84d | ||
|
|
f8f16d676d | ||
|
|
58b816c2ed | ||
|
|
9b1d5c196f | ||
|
|
a0bed9beeb | ||
|
|
9f4f2f7381 | ||
|
|
433e31acc8 | ||
|
|
b795ad55a3 | ||
|
|
72efc18158 | ||
|
|
93626129b6 | ||
|
|
60178c264d | ||
|
|
de6afa16ec | ||
|
|
fd2df12263 | ||
|
|
37d66a7d41 | ||
|
|
040c7f1e7d | ||
|
|
d4a5508f6a | ||
|
|
036f9d6730 | ||
|
|
1b7f628759 | ||
|
|
5a891fda9e | ||
|
|
f96e2f6c4f | ||
|
|
7a5285ae47 | ||
|
|
ba347bc0b1 | ||
|
|
1bee98af52 | ||
|
|
ff623a8dce | ||
|
|
f28e8118dc | ||
|
|
167fca86d0 | ||
|
|
b828650cc5 | ||
|
|
e6846de0fa | ||
|
|
6c6254a3c3 | ||
|
|
0a9ad4e73a | ||
|
|
9f6eb4174f | ||
|
|
25cc523006 | ||
|
|
4c0000a809 | ||
|
|
0f7193f85d | ||
|
|
715855280e | ||
|
|
c322253fde | ||
|
|
17cea91e10 | ||
|
|
6caa5ee81f | ||
|
|
d46a8cf89f | ||
|
|
7e81a3b895 | ||
|
|
d268075046 | ||
|
|
482f46f3fd | ||
|
|
f0160f5d2a | ||
|
|
feca030c6d | ||
|
|
41138bd665 | ||
|
|
178e42487b | ||
|
|
ae04919585 | ||
|
|
6adba03868 | ||
|
|
609d172259 | ||
|
|
9cf8c92cae | ||
|
|
38c3013ddf | ||
|
|
8f512a40f7 | ||
|
|
b9b6ce066b | ||
|
|
35114be5f7 | ||
|
|
3239be4a4d | ||
|
|
a706cb46fa | ||
|
|
3095bee5d9 | ||
|
|
51c295d1de | ||
|
|
de0cc1f268 | ||
|
|
037f6b606e | ||
|
|
e7f6ba8f35 | ||
|
|
25f68b6c89 | ||
|
|
dc50f672b8 | ||
|
|
d14a6031f0 | ||
|
|
8b20c26e04 | ||
|
|
1ef0869a54 | ||
|
|
ca10e800a9 | ||
|
|
33d5459c20 | ||
|
|
aae43f4452 | ||
|
|
0bd842869b | ||
|
|
394d3b0e67 | ||
|
|
1ef17e2986 | ||
|
|
d4347f20ae | ||
|
|
3319f78de0 | ||
|
|
ee0ae0a06c | ||
|
|
064da8e034 | ||
|
|
74cf0ee1c1 | ||
|
|
c2f40ea8a3 | ||
|
|
f694e471fb | ||
|
|
dc8368c89c | ||
|
|
e55397fcdc | ||
|
|
8260b46e8f | ||
|
|
b59c6c85e0 | ||
|
|
b96ff9c210 | ||
|
|
c758780e38 | ||
|
|
9e35534dad | ||
|
|
5620c58a30 | ||
|
|
5418a6b6b1 | ||
|
|
865bad1550 | ||
|
|
7c3fd38559 | ||
|
|
933052583a | ||
|
|
941e252d44 | ||
|
|
f0a5df7cd7 | ||
|
|
fdc38b5ca5 | ||
|
|
2f8b01015d | ||
|
|
2a302de42f | ||
|
|
681849d174 | ||
|
|
17830d63b4 | ||
|
|
1cc03fdd8c | ||
|
|
dd91f983b5 | ||
|
|
3a7d70c908 | ||
|
|
8181aba61f | ||
|
|
2d0539300d | ||
|
|
f45045d1c0 | ||
|
|
6954e1b4eb | ||
|
|
ef9af6ed1a | ||
|
|
99e269208e | ||
|
|
f980e24868 | ||
|
|
a65c9bbb16 | ||
|
|
d2e4cade62 | ||
|
|
5021c0fd0c | ||
|
|
fea060e4f2 | ||
|
|
7a9b848f38 | ||
|
|
2d8f0a740e | ||
|
|
fa107a6b65 | ||
|
|
2371e9b943 | ||
|
|
f0ee52a98e | ||
|
|
c01d81802d | ||
|
|
890ca64f51 | ||
|
|
bcaf330233 | ||
|
|
ab1c943d1f | ||
|
|
703875b895 | ||
|
|
5f40801a78 | ||
|
|
eb109ebeb4 | ||
|
|
bb9a7fadc0 | ||
|
|
ac5d99c079 | ||
|
|
d9c991e325 | ||
|
|
08cd28af2d | ||
|
|
6563897692 | ||
|
|
04d598819d | ||
|
|
965c04469e | ||
|
|
416ca2c063 | ||
|
|
ab35586b0c | ||
|
|
acb5985127 | ||
|
|
9b75b729ba | ||
|
|
e1968b0953 | ||
|
|
f36e15cfeb | ||
|
|
7547c775fa | ||
|
|
ad21b5f0d0 | ||
|
|
4427900d84 | ||
|
|
0ca70b1e4d | ||
|
|
0292a334fe | ||
|
|
f93e2d0c04 | ||
|
|
3a9324c6ef | ||
|
|
cf692140a9 | ||
|
|
83b8fa14c6 | ||
|
|
de693b8206 | ||
|
|
1686e358fe | ||
|
|
804d969427 | ||
|
|
9d23b191b5 | ||
|
|
eb4c0f0b84 | ||
|
|
c507e344ff | ||
|
|
a6af46dbad |
@@ -1,6 +1,5 @@
|
||||
.DS_Store
|
||||
ui/node_modules
|
||||
Jamstash-master
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
data
|
||||
|
||||
12
.github/FUNDING.yml
vendored
Normal file
12
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: deluan
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
liberapay: deluan
|
||||
ko_fi: deluan
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
BIN
.github/screenshots/ss-desktop-player.png
vendored
BIN
.github/screenshots/ss-desktop-player.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 5.1 MiB |
BIN
.github/screenshots/ss-mobile-album-view.png
vendored
BIN
.github/screenshots/ss-mobile-album-view.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 283 KiB |
BIN
.github/screenshots/ss-mobile-login.png
vendored
BIN
.github/screenshots/ss-mobile-login.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 709 KiB After Width: | Height: | Size: 1.2 MiB |
7
.github/workflows/pipeline.dockerfile
vendored
7
.github/workflows/pipeline.dockerfile
vendored
@@ -6,7 +6,7 @@ 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/amd64" ]; then cp navidrome_linux_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
|
||||
@@ -27,11 +27,8 @@ 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
|
||||
ENV GODEBUG "asyncpreemptoff=1"
|
||||
|
||||
EXPOSE ${ND_PORT}
|
||||
HEALTHCHECK CMD wget -O- http://localhost:${ND_PORT}/ping || exit 1
|
||||
|
||||
52
.github/workflows/pipeline.yml
vendored
52
.github/workflows/pipeline.yml
vendored
@@ -13,27 +13,29 @@ jobs:
|
||||
name: Lint Server
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install taglib
|
||||
run: sudo apt-get install libtag1-dev
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v1
|
||||
with:
|
||||
version: v1.27
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
args: --timeout 2m
|
||||
|
||||
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]
|
||||
|
||||
name: Test Server
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Go 1.14
|
||||
- name: Install taglib
|
||||
run: sudo apt-get install libtag1-dev
|
||||
|
||||
- name: Set up Go 1.15
|
||||
uses: actions/setup-go@v1
|
||||
with:
|
||||
go-version: 1.14
|
||||
go-version: 1.15
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
@@ -85,30 +87,35 @@ jobs:
|
||||
cd ui
|
||||
npm run build
|
||||
|
||||
- uses: actions/upload-artifact@v1
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: js-bundle
|
||||
path: ui/build
|
||||
|
||||
binaries:
|
||||
name: Binaries
|
||||
needs: [js, go, golangci-lint]
|
||||
needs: [js]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Unshallow
|
||||
run: git fetch --prune --unshallow
|
||||
|
||||
- uses: actions/download-artifact@v1
|
||||
- uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: js-bundle
|
||||
path: ui/build
|
||||
|
||||
- name: Show Tags
|
||||
run: git tag
|
||||
|
||||
- name: Show Version
|
||||
run: git describe --tags
|
||||
|
||||
- name: Run GoReleaser - SNAPSHOT
|
||||
if: startsWith(github.ref, 'refs/tags/') != true
|
||||
uses: docker://deluan/ci-goreleaser:1.14.3-0
|
||||
uses: docker://deluan/ci-goreleaser:1.15.2-1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -116,16 +123,19 @@ jobs:
|
||||
|
||||
- name: Run GoReleaser - RELEASE
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: docker://deluan/ci-goreleaser:1.14.3-0
|
||||
uses: docker://deluan/ci-goreleaser:1.15.2-1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: goreleaser release --rm-dist
|
||||
|
||||
- uses: actions/upload-artifact@v1
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: binaries
|
||||
path: dist
|
||||
path: |
|
||||
dist
|
||||
!dist/*.tar.gz
|
||||
!dist/*.zip
|
||||
|
||||
docker:
|
||||
name: Docker images
|
||||
@@ -145,7 +155,7 @@ jobs:
|
||||
- uses: actions/checkout@v1
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
|
||||
- uses: actions/download-artifact@v1
|
||||
- uses: actions/download-artifact@v2
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
with:
|
||||
name: binaries
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
.DS_Store
|
||||
.idea
|
||||
.vscode
|
||||
.envrc
|
||||
/navidrome
|
||||
/iTunes*.xml
|
||||
@@ -11,14 +12,13 @@ TODO.md
|
||||
var
|
||||
navidrome.toml
|
||||
master.zip
|
||||
Jamstash-master
|
||||
testDB
|
||||
navidrome.db
|
||||
*.swp
|
||||
*_gen.go
|
||||
embedded_gen.go
|
||||
dist
|
||||
music
|
||||
docker-compose.override.yml
|
||||
docker-compose.yml
|
||||
navidrome.db-shm
|
||||
navidrome.db-wal
|
||||
tags
|
||||
|
||||
@@ -7,20 +7,6 @@ before:
|
||||
- go-bindata -fs -prefix ui/build -tags embed -nocompress -pkg assets -o assets/embedded_gen.go ui/build/...
|
||||
|
||||
builds:
|
||||
- id: navidrome_darwin
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=o64-clang
|
||||
- CXX=o64-clang++
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
flags:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_linux_amd64
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
@@ -31,44 +17,46 @@ builds:
|
||||
flags:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
- "-extldflags '-static -lz'"
|
||||
- -s -w -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_linux_musl_amd64
|
||||
- id: navidrome_linux_386
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=musl-gcc
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
- 386
|
||||
flags:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
- -s -w -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_linux_arm
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=arm-linux-gnueabi-gcc
|
||||
- CXX=arm-linux-gnueabi-g++
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- arm
|
||||
goarm:
|
||||
- 5
|
||||
- 6
|
||||
- 7
|
||||
flags:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
- -s -w -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_linux_arm64
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=aarch64-linux-gnu-gcc
|
||||
- CXX=aarch64-linux-gnu-g++
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
@@ -77,13 +65,14 @@ builds:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
- -s -w -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_windows_i686
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=i686-w64-mingw32-gcc
|
||||
- CXX=i686-w64-mingw32-g++
|
||||
- PKG_CONFIG_PATH=/mingw32/lib/pkgconfig
|
||||
goos:
|
||||
- windows
|
||||
goarch:
|
||||
@@ -92,13 +81,14 @@ builds:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
- -s -w -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_windows_x64
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=x86_64-w64-mingw32-gcc
|
||||
- CXX=x86_64-w64-mingw32-g++
|
||||
- PKG_CONFIG_PATH=/mingw64/lib/pkgconfig
|
||||
goos:
|
||||
- windows
|
||||
goarch:
|
||||
@@ -107,25 +97,25 @@ builds:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
- -s -w -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_darwin
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=o64-clang
|
||||
- CXX=o64-clang++
|
||||
- PKG_CONFIG_PATH=/darwin/lib/pkgconfig
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
flags:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- -s -w -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:
|
||||
- format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
replacements:
|
||||
|
||||
129
CODE_OF_CONDUCT.md
Normal file
129
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,129 @@
|
||||
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
navidrome@navidrome.org.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
66
Dockerfile
66
Dockerfile
@@ -1,66 +0,0 @@
|
||||
#####################################################
|
||||
### Build UI bundles
|
||||
FROM node:14-alpine AS jsbuilder
|
||||
WORKDIR /src
|
||||
COPY ui/package.json ui/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY ui/ .
|
||||
RUN npm run build
|
||||
|
||||
|
||||
#####################################################
|
||||
### Build executable
|
||||
FROM golang:1.14-alpine AS gobuilder
|
||||
|
||||
# Download build tools
|
||||
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 project dependencies
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source, test it
|
||||
COPY . .
|
||||
RUN go test ./...
|
||||
|
||||
# Copy UI bundle, build executable
|
||||
COPY --from=jsbuilder /src/build/* /src/ui/build/
|
||||
COPY --from=jsbuilder /src/build/static/css/* /src/ui/build/static/css/
|
||||
COPY --from=jsbuilder /src/build/static/js/* /src/ui/build/static/js/
|
||||
RUN rm -rf /src/build/css /src/build/js
|
||||
RUN GIT_TAG=$(git 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
|
||||
LABEL maintainer="deluan@navidrome.org"
|
||||
|
||||
COPY --from=gobuilder /src/navidrome /app/
|
||||
|
||||
# Install ffmpeg and output build config
|
||||
RUN apk add --no-cache ffmpeg
|
||||
RUN ffmpeg -buildconf
|
||||
|
||||
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"]
|
||||
64
Makefile
64
Makefile
@@ -1,4 +1,4 @@
|
||||
GO_VERSION=$(shell grep -e "^go " go.mod | cut -f 2 -d ' ')
|
||||
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
||||
NODE_VERSION=$(shell cat .nvmrc)
|
||||
|
||||
GIT_SHA=$(shell git rev-parse --short HEAD)
|
||||
@@ -37,42 +37,37 @@ 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
|
||||
migration:
|
||||
@if [ -z "${name}" ]; then echo "Usage: make migration name=name_of_migration_file"; exit 1; fi
|
||||
goose -dir db/migrations create ${name}
|
||||
.PHONY: migration
|
||||
|
||||
setup:
|
||||
@which go-bindata || (echo "Installing BinData" && GO111MODULE=off go get -u github.com/go-bindata/go-bindata/...)
|
||||
go mod download
|
||||
setup: download-deps
|
||||
@echo Installing tools from tools.go
|
||||
@cat tools.go | grep _ | awk -F'"' '{print $$2}' | xargs -tI % go install %
|
||||
.PHONY: setup
|
||||
|
||||
download-deps:
|
||||
@echo Download Go dependencies
|
||||
@go mod download
|
||||
@echo Download Node dependencies
|
||||
@(cd ./ui && npm ci)
|
||||
.PHONY: setup
|
||||
.PHONY: download-deps
|
||||
|
||||
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)
|
||||
setup-dev: setup setup-git
|
||||
@echo Installing golangci-lint
|
||||
@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
|
||||
.PHONY: setup-dev
|
||||
|
||||
Jamstash-master:
|
||||
wget -N https://github.com/tsquillario/Jamstash/archive/master.zip
|
||||
unzip -o master.zip
|
||||
rm master.zip
|
||||
(cd Jamstash-master && npm ci && npx bower install && npx grunt build)
|
||||
rm -rf Jamstash-master/node_modules Jamstash-master/bower_components
|
||||
setup-git:
|
||||
@echo Setting up git hooks
|
||||
@mkdir -p .git/hooks
|
||||
@(cd .git/hooks && ln -sf ../../git/* .)
|
||||
.PHONY: setup-git
|
||||
|
||||
check_env: check_go_env check_node_env
|
||||
.PHONY: check_env
|
||||
|
||||
check_hooks:
|
||||
@lefthook add pre-commit
|
||||
@lefthook add pre-push
|
||||
.PHONY: check_hooks
|
||||
|
||||
check_go_env:
|
||||
@(hash go) || (echo "\nERROR: GO environment not setup properly!\n"; exit 1)
|
||||
@go version | grep -q $(GO_VERSION) || (echo "\nERROR: Please upgrade your GO version\nThis project requires version $(GO_VERSION)"; exit 1)
|
||||
@@ -94,15 +89,22 @@ buildall: check_env
|
||||
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
|
||||
|
||||
pre-push:
|
||||
golangci-lint run -v
|
||||
|
||||
@echo
|
||||
make test
|
||||
.PHONY: pre-push
|
||||
|
||||
release:
|
||||
@if [[ ! "${V}" =~ ^[0-9]+\.[0-9]+\.[0-9]+.*$$ ]]; then echo "Usage: make release V=X.X.X"; exit 1; fi
|
||||
go mod tidy
|
||||
@if [ -n "`git status -s`" ]; then echo "\n\nThere are pending changes. Please commit or stash first"; exit 1; fi
|
||||
make test
|
||||
make pre-push
|
||||
git tag v${V}
|
||||
git push origin v${V}
|
||||
git push origin v${V} --no-verify
|
||||
.PHONY: release
|
||||
|
||||
snapshot:
|
||||
docker run -it -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:1.14.3-0 goreleaser release --rm-dist --skip-publish --snapshot
|
||||
docker run -it -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:1.15.2-1 goreleaser release --rm-dist --skip-publish --snapshot
|
||||
.PHONY: snapshot
|
||||
|
||||
11
README.md
11
README.md
@@ -1,4 +1,4 @@
|
||||
# Navidrome Music Streamer
|
||||
# Navidrome Music Server
|
||||
|
||||
[](https://github.com/deluan/navidrome/releases)
|
||||
[](https://github.com/deluan/navidrome/actions)
|
||||
@@ -6,12 +6,15 @@
|
||||
[](https://hub.docker.com/r/deluan/navidrome)
|
||||
[](https://discord.gg/xh7j7yF)
|
||||
[](https://www.reddit.com/r/navidrome/)
|
||||
[](code_of_conduct.md)
|
||||
|
||||
## [Check out our Live Demo!](https://www.navidrome.org/demo/)
|
||||
|
||||
Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your
|
||||
music collection from any browser or mobile device. It's like your personal Spotify!
|
||||
|
||||
__Any feedback is welcome!__ If you need/want a new feature, find a bug or think of any way to improve Navidrome,
|
||||
please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join the discussion in our
|
||||
please file 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](https://www.navidrome.org/docs/developers/),
|
||||
[translations](https://www.navidrome.org/docs/developers/translations/),
|
||||
@@ -27,11 +30,11 @@ See instructions in the [project's website](https://www.navidrome.org/docs/insta
|
||||
- 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)
|
||||
- Great support for **compilations** (Various Artists albums) and **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
|
||||
- Ready to use binaries for all major platforms, including **Raspberry Pi**
|
||||
- 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)
|
||||
|
||||
97
cmd/root.go
Normal file
97
cmd/root.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/db"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var (
|
||||
cfgFile string
|
||||
noBanner bool
|
||||
|
||||
rootCmd = &cobra.Command{
|
||||
Use: "navidrome",
|
||||
Short: "Navidrome is a self-hosted music server and streamer",
|
||||
Long: `Navidrome is a self-hosted music server and streamer.
|
||||
Complete documentation is available at https://www.navidrome.org/docs`,
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
preRun()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
startServer()
|
||||
},
|
||||
Version: consts.Version(),
|
||||
}
|
||||
)
|
||||
|
||||
func Execute() {
|
||||
rootCmd.SetVersionTemplate(`{{println .Version}}`)
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func preRun() {
|
||||
if !noBanner {
|
||||
println(consts.Banner())
|
||||
}
|
||||
conf.Load()
|
||||
}
|
||||
|
||||
func startServer() {
|
||||
db.EnsureLatestVersion()
|
||||
|
||||
subsonic, err := CreateSubsonicAPIRouter()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Could not create the Subsonic API router. Aborting! err=%v", err))
|
||||
}
|
||||
a := CreateServer(conf.Server.MusicFolder)
|
||||
a.MountRouter(consts.URLPathSubsonicAPI, subsonic)
|
||||
a.MountRouter(consts.URLPathUI, CreateAppRouter())
|
||||
a.Run(fmt.Sprintf("%s:%d", conf.Server.Address, conf.Server.Port))
|
||||
}
|
||||
|
||||
// TODO: Implemement some struct tags to map flags to viper
|
||||
func init() {
|
||||
cobra.OnInitialize(func() {
|
||||
conf.InitConfig(cfgFile)
|
||||
})
|
||||
|
||||
rootCmd.PersistentFlags().StringVarP(&cfgFile, "configfile", "c", "", `config file (default "./navidrome.toml")`)
|
||||
rootCmd.PersistentFlags().BoolVarP(&noBanner, "nobanner", "n", false, `don't show banner`)
|
||||
rootCmd.PersistentFlags().String("musicfolder", viper.GetString("musicfolder"), "folder where your music is stored")
|
||||
rootCmd.PersistentFlags().String("datafolder", viper.GetString("datafolder"), "folder to store application data (DB, cache...), needs write access")
|
||||
rootCmd.PersistentFlags().StringP("loglevel", "l", viper.GetString("loglevel"), "log level, possible values: error, info, debug, trace")
|
||||
|
||||
_ = viper.BindPFlag("musicfolder", rootCmd.PersistentFlags().Lookup("musicfolder"))
|
||||
_ = viper.BindPFlag("datafolder", rootCmd.PersistentFlags().Lookup("datafolder"))
|
||||
_ = viper.BindPFlag("loglevel", rootCmd.PersistentFlags().Lookup("loglevel"))
|
||||
|
||||
rootCmd.Flags().StringP("address", "a", viper.GetString("address"), "IP address to bind")
|
||||
rootCmd.Flags().IntP("port", "p", viper.GetInt("port"), "HTTP port Navidrome will use")
|
||||
rootCmd.Flags().Duration("sessiontimeout", viper.GetDuration("sessiontimeout"), "how long Navidrome will wait before closing web ui idle sessions")
|
||||
rootCmd.Flags().Duration("scaninterval", viper.GetDuration("scaninterval"), "how frequently to scan for changes in your music library")
|
||||
rootCmd.Flags().String("baseurl", viper.GetString("baseurl"), "base URL (only the path part) to configure Navidrome behind a proxy (ex: /music)")
|
||||
rootCmd.Flags().String("uiloginbackgroundurl", viper.GetString("uiloginbackgroundurl"), "URL to a backaground image used in the Login page")
|
||||
rootCmd.Flags().Bool("enabletranscodingconfig", viper.GetBool("enabletranscodingconfig"), "enables transcoding configuration in the UI")
|
||||
rootCmd.Flags().String("transcodingcachesize", viper.GetString("transcodingcachesize"), "size of transcoding cache")
|
||||
rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache")
|
||||
rootCmd.Flags().Bool("autoimportplaylists", viper.GetBool("autoimportplaylists"), "enable/disable .m3u playlist auto-import`")
|
||||
|
||||
_ = viper.BindPFlag("address", rootCmd.Flags().Lookup("address"))
|
||||
_ = viper.BindPFlag("port", rootCmd.Flags().Lookup("port"))
|
||||
_ = viper.BindPFlag("sessiontimeout", rootCmd.Flags().Lookup("sessiontimeout"))
|
||||
_ = viper.BindPFlag("scaninterval", rootCmd.Flags().Lookup("scaninterval"))
|
||||
_ = viper.BindPFlag("baseurl", rootCmd.Flags().Lookup("baseurl"))
|
||||
_ = viper.BindPFlag("uiloginbackgroundurl", rootCmd.Flags().Lookup("uiloginbackgroundurl"))
|
||||
_ = viper.BindPFlag("enabletranscodingconfig", rootCmd.Flags().Lookup("enabletranscodingconfig"))
|
||||
_ = viper.BindPFlag("transcodingcachesize", rootCmd.Flags().Lookup("transcodingcachesize"))
|
||||
_ = viper.BindPFlag("imagecachesize", rootCmd.Flags().Lookup("imagecachesize"))
|
||||
}
|
||||
36
cmd/scan.go
Normal file
36
cmd/scan.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var fullRescan bool
|
||||
|
||||
func init() {
|
||||
scanCmd.Flags().BoolVarP(&fullRescan, "full", "f", false, "check all subfolders, ignoring timestamps")
|
||||
rootCmd.AddCommand(scanCmd)
|
||||
}
|
||||
|
||||
var scanCmd = &cobra.Command{
|
||||
Use: "scan",
|
||||
Short: "Scan music folder",
|
||||
Long: "Scan music folder for updates",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runScanner()
|
||||
},
|
||||
}
|
||||
|
||||
func runScanner() {
|
||||
scanner := CreateScanner(conf.Server.MusicFolder)
|
||||
err := scanner.RescanAll(fullRescan)
|
||||
if err != nil {
|
||||
log.Error("Error scanning media folder", "folder", conf.Server.MusicFolder, err)
|
||||
}
|
||||
if fullRescan {
|
||||
log.Info("Finished full rescan")
|
||||
} else {
|
||||
log.Info("Finished rescan")
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,17 @@
|
||||
//go:generate wire
|
||||
//+build !wireinject
|
||||
|
||||
package main
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/deluan/navidrome/engine"
|
||||
"github.com/deluan/navidrome/engine/transcoder"
|
||||
"github.com/deluan/navidrome/core"
|
||||
"github.com/deluan/navidrome/core/transcoder"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
"github.com/deluan/navidrome/scanner"
|
||||
"github.com/deluan/navidrome/server"
|
||||
"github.com/deluan/navidrome/server/app"
|
||||
"github.com/deluan/navidrome/server/subsonic"
|
||||
"github.com/deluan/navidrome/server/subsonic/engine"
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
@@ -25,6 +26,12 @@ func CreateServer(musicFolder string) *server.Server {
|
||||
return serverServer
|
||||
}
|
||||
|
||||
func CreateScanner(musicFolder string) *scanner.Scanner {
|
||||
dataStore := persistence.New()
|
||||
scannerScanner := scanner.New(dataStore)
|
||||
return scannerScanner
|
||||
}
|
||||
|
||||
func CreateAppRouter() *app.Router {
|
||||
dataStore := persistence.New()
|
||||
router := app.New(dataStore)
|
||||
@@ -33,30 +40,20 @@ func CreateAppRouter() *app.Router {
|
||||
|
||||
func CreateSubsonicAPIRouter() (*subsonic.Router, error) {
|
||||
dataStore := persistence.New()
|
||||
browser := engine.NewBrowser(dataStore)
|
||||
imageCache, err := engine.NewImageCache()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cover := engine.NewCover(dataStore, imageCache)
|
||||
artworkCache := core.NewImageCache()
|
||||
artwork := core.NewArtwork(dataStore, artworkCache)
|
||||
nowPlayingRepository := engine.NewNowPlayingRepository()
|
||||
listGenerator := engine.NewListGenerator(dataStore, nowPlayingRepository)
|
||||
users := engine.NewUsers(dataStore)
|
||||
playlists := engine.NewPlaylists(dataStore)
|
||||
ratings := engine.NewRatings(dataStore)
|
||||
scrobbler := engine.NewScrobbler(dataStore, nowPlayingRepository)
|
||||
search := engine.NewSearch(dataStore)
|
||||
transcoderTranscoder := transcoder.New()
|
||||
transcodingCache, err := engine.NewTranscodingCache()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mediaStreamer := engine.NewMediaStreamer(dataStore, transcoderTranscoder, transcodingCache)
|
||||
transcodingCache := core.NewTranscodingCache()
|
||||
mediaStreamer := core.NewMediaStreamer(dataStore, transcoderTranscoder, transcodingCache)
|
||||
archiver := core.NewArchiver(dataStore)
|
||||
players := engine.NewPlayers(dataStore)
|
||||
router := subsonic.New(browser, cover, listGenerator, users, playlists, ratings, scrobbler, search, mediaStreamer, players)
|
||||
router := subsonic.New(artwork, listGenerator, playlists, mediaStreamer, archiver, players, dataStore)
|
||||
return router, nil
|
||||
}
|
||||
|
||||
// wire_injectors.go:
|
||||
|
||||
var allProviders = wire.NewSet(engine.Set, scanner.New, subsonic.New, app.New, persistence.New)
|
||||
var allProviders = wire.NewSet(engine.Set, core.Set, scanner.New, subsonic.New, app.New, persistence.New)
|
||||
@@ -1,19 +1,21 @@
|
||||
//+build wireinject
|
||||
|
||||
package main
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/deluan/navidrome/engine"
|
||||
"github.com/deluan/navidrome/core"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
"github.com/deluan/navidrome/scanner"
|
||||
"github.com/deluan/navidrome/server"
|
||||
"github.com/deluan/navidrome/server/app"
|
||||
"github.com/deluan/navidrome/server/subsonic"
|
||||
"github.com/deluan/navidrome/server/subsonic/engine"
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
var allProviders = wire.NewSet(
|
||||
engine.Set,
|
||||
core.Set,
|
||||
scanner.New,
|
||||
subsonic.New,
|
||||
app.New,
|
||||
@@ -27,6 +29,12 @@ func CreateServer(musicFolder string) *server.Server {
|
||||
))
|
||||
}
|
||||
|
||||
func CreateScanner(musicFolder string) *scanner.Scanner {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
||||
func CreateAppRouter() *app.Router {
|
||||
panic(wire.Build(allProviders))
|
||||
}
|
||||
@@ -1,123 +1,146 @@
|
||||
package conf
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/koding/multiconfig"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
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:"24h"`
|
||||
BaseURL string `default:""`
|
||||
type configOptions struct {
|
||||
ConfigFile string
|
||||
Address string
|
||||
Port int
|
||||
MusicFolder string
|
||||
DataFolder string
|
||||
DbPath string
|
||||
LogLevel string
|
||||
ScanInterval time.Duration
|
||||
SessionTimeout time.Duration
|
||||
BaseURL string
|
||||
UILoginBackgroundURL string
|
||||
EnableTranscodingConfig bool
|
||||
TranscodingCacheSize string
|
||||
ImageCacheSize string
|
||||
AutoImportPlaylists bool
|
||||
|
||||
UILoginBackgroundURL string `default:"https://source.unsplash.com/random/1600x900?music"`
|
||||
SearchFullString bool
|
||||
IgnoredArticles string
|
||||
IndexGroups string
|
||||
ProbeCommand string
|
||||
CoverArtPriority string
|
||||
CoverJpegQuality int
|
||||
UIWelcomeMessage string
|
||||
GATrackingID string
|
||||
AuthRequestLimit int
|
||||
AuthWindowLength time.Duration
|
||||
|
||||
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]([)"`
|
||||
|
||||
EnableTranscodingConfig bool `default:"false"`
|
||||
TranscodingCacheSize string `default:"100MB"` // in MB
|
||||
ImageCacheSize string `default:"100MB"` // in MB
|
||||
ProbeCommand string `default:"ffmpeg %s -f ffmetadata"`
|
||||
Scanner scannerOptions
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
DevLogSourceLine bool `default:"false"`
|
||||
DevAutoCreateAdminPassword string `default:""`
|
||||
DevLogSourceLine bool
|
||||
DevAutoCreateAdminPassword string
|
||||
}
|
||||
|
||||
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
|
||||
type scannerOptions struct {
|
||||
Extractor string
|
||||
}
|
||||
|
||||
func newWithPath(path string, skipFlags ...bool) *multiconfig.DefaultLoader {
|
||||
var loaders []multiconfig.Loader
|
||||
var Server = &configOptions{}
|
||||
|
||||
// Read default values defined via tag fields "default"
|
||||
loaders = append(loaders, &multiconfig.TagLoader{})
|
||||
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
if strings.HasSuffix(path, "toml") {
|
||||
loaders = append(loaders, &multiconfig.TOMLLoader{Path: path})
|
||||
}
|
||||
|
||||
if strings.HasSuffix(path, "json") {
|
||||
loaders = append(loaders, &multiconfig.JSONLoader{Path: path})
|
||||
}
|
||||
|
||||
if strings.HasSuffix(path, "yml") || strings.HasSuffix(path, "yaml") {
|
||||
loaders = append(loaders, &multiconfig.YAMLLoader{Path: path})
|
||||
}
|
||||
}
|
||||
|
||||
e := &multiconfig.EnvironmentLoader{}
|
||||
loaders = append(loaders, e)
|
||||
if len(skipFlags) == 0 || !skipFlags[0] {
|
||||
f := &multiconfig.FlagLoader{}
|
||||
loaders = append(loaders, f)
|
||||
}
|
||||
|
||||
loader := multiconfig.MultiLoader(loaders...)
|
||||
|
||||
d := &multiconfig.DefaultLoader{}
|
||||
d.Loader = loader
|
||||
d.Validator = multiconfig.MultiValidator(&multiconfig.RequiredValidator{})
|
||||
return d
|
||||
}
|
||||
|
||||
func LoadFromFile(confFile string, skipFlags ...bool) {
|
||||
m := newWithPath(confFile, skipFlags...)
|
||||
err := m.Load(Server)
|
||||
if err == flag.ErrHelp {
|
||||
os.Exit(1)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Printf("Error trying to load config '%s'. Error: %v", confFile, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if Server.DbPath == "" {
|
||||
Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath)
|
||||
}
|
||||
if os.Getenv("PORT") != "" {
|
||||
Server.Port = os.Getenv("PORT")
|
||||
}
|
||||
log.SetLevelString(Server.LogLevel)
|
||||
log.SetLogSourceLine(Server.DevLogSourceLine)
|
||||
log.Debug("Loaded configuration", "file", confFile, "config", fmt.Sprintf("%#v", Server))
|
||||
func LoadFromFile(confFile string) {
|
||||
viper.SetConfigFile(confFile)
|
||||
Load()
|
||||
}
|
||||
|
||||
func Load() {
|
||||
LoadFromFile(configFile())
|
||||
err := viper.Unmarshal(&Server)
|
||||
if err != nil {
|
||||
fmt.Println("Error parsing config:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
|
||||
if err != nil {
|
||||
fmt.Println("Error creating data path:", "path", Server.DataFolder, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
Server.ConfigFile = viper.GetViper().ConfigFileUsed()
|
||||
if Server.DbPath == "" {
|
||||
Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath)
|
||||
}
|
||||
|
||||
log.SetLevelString(Server.LogLevel)
|
||||
log.SetLogSourceLine(Server.DevLogSourceLine)
|
||||
log.Debug("Loaded configuration", "file", Server.ConfigFile, "config", fmt.Sprintf("%#v", Server))
|
||||
}
|
||||
|
||||
func init() {
|
||||
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
|
||||
viper.SetDefault("datafolder", ".")
|
||||
viper.SetDefault("loglevel", "info")
|
||||
viper.SetDefault("address", "0.0.0.0")
|
||||
viper.SetDefault("port", 4533)
|
||||
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
|
||||
viper.SetDefault("scaninterval", time.Minute)
|
||||
viper.SetDefault("baseurl", "")
|
||||
viper.SetDefault("uiloginbackgroundurl", "https://source.unsplash.com/random/1600x900?music")
|
||||
viper.SetDefault("enabletranscodingconfig", false)
|
||||
viper.SetDefault("transcodingcachesize", "100MB")
|
||||
viper.SetDefault("imagecachesize", "100MB")
|
||||
viper.SetDefault("autoimportplaylists", true)
|
||||
|
||||
// Config options only valid for file/env configuration
|
||||
viper.SetDefault("searchfullstring", false)
|
||||
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
|
||||
viper.SetDefault("indexgroups", "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]([)")
|
||||
viper.SetDefault("probecommand", "ffmpeg %s -f ffmetadata")
|
||||
viper.SetDefault("coverartpriority", "embedded, cover.*, folder.*, front.*")
|
||||
viper.SetDefault("coverjpegquality", 75)
|
||||
viper.SetDefault("uiwelcomemessage", "")
|
||||
viper.SetDefault("gatrackingid", "")
|
||||
viper.SetDefault("authrequestlimit", 5)
|
||||
viper.SetDefault("authwindowlength", 20*time.Second)
|
||||
|
||||
viper.SetDefault("scanner.extractor", "taglib")
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
viper.SetDefault("devlogsourceline", false)
|
||||
viper.SetDefault("devautocreateadminpassword", "")
|
||||
viper.SetDefault("devoldscanner", false)
|
||||
}
|
||||
|
||||
func InitConfig(cfgFile string) {
|
||||
cfgFile = getConfigFile(cfgFile)
|
||||
if cfgFile != "" {
|
||||
// Use config file from the flag.
|
||||
viper.SetConfigFile(cfgFile)
|
||||
} else {
|
||||
// Search config in local directory with name "navidrome" (without extension).
|
||||
viper.AddConfigPath(".")
|
||||
viper.SetConfigName("navidrome")
|
||||
}
|
||||
|
||||
_ = viper.BindEnv("port")
|
||||
viper.SetEnvPrefix("ND")
|
||||
replacer := strings.NewReplacer(".", "_")
|
||||
viper.SetEnvKeyReplacer(replacer)
|
||||
viper.AutomaticEnv()
|
||||
|
||||
err := viper.ReadInConfig()
|
||||
if cfgFile != "" && err != nil {
|
||||
fmt.Println("Navidrome could not open config file: ", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func getConfigFile(cfgFile string) string {
|
||||
if cfgFile != "" {
|
||||
return cfgFile
|
||||
}
|
||||
return os.Getenv("ND_CONFIGFILE")
|
||||
}
|
||||
|
||||
@@ -10,14 +10,13 @@ import (
|
||||
const (
|
||||
AppName = "navidrome"
|
||||
|
||||
LocalConfigFile = "./navidrome.toml"
|
||||
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on"
|
||||
InitialSetupFlagKey = "InitialSetup"
|
||||
|
||||
UIAuthorizationHeader = "X-ND-Authorization"
|
||||
JWTSecretKey = "JWTSecret"
|
||||
JWTIssuer = "ND"
|
||||
DefaultSessionTimeout = 30 * time.Minute
|
||||
DefaultSessionTimeout = 24 * time.Hour
|
||||
|
||||
DevInitialUserName = "admin"
|
||||
DevInitialName = "Dev Admin"
|
||||
@@ -56,7 +55,7 @@ var (
|
||||
},
|
||||
{
|
||||
"name": "opus audio",
|
||||
"targetFormat": "oga",
|
||||
"targetFormat": "opus",
|
||||
"defaultBitRate": 128,
|
||||
"command": "ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -c:a libopus -f opus -",
|
||||
},
|
||||
|
||||
@@ -19,9 +19,13 @@ func init() {
|
||||
".shn": "audio/x-shn",
|
||||
".aif": "audio/x-aiff",
|
||||
".aiff": "audio/x-aiff",
|
||||
".m3u": "audio/x-mpegurl",
|
||||
".pls": "audio/x-scpls",
|
||||
".dsf": "audio/dsd",
|
||||
".gif": "image/gif",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".png": "image/png",
|
||||
".bmp": "image/bmp",
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package consts
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
// This will be set in build time. If not, version will be set to "dev"
|
||||
@@ -11,10 +14,12 @@ var (
|
||||
// Formats:
|
||||
// dev
|
||||
// v0.2.0 (5b84188)
|
||||
// v0.3.2-SNAPSHOT (715f552)
|
||||
// master (9ed35cb)
|
||||
func Version() string {
|
||||
if gitSha == "" {
|
||||
return "dev"
|
||||
}
|
||||
gitTag = strings.TrimPrefix(gitTag, "v")
|
||||
return fmt.Sprintf("%s (%s)", gitTag, gitSha)
|
||||
}
|
||||
|
||||
110
core/archiver.go
Normal file
110
core/archiver.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
type Archiver interface {
|
||||
ZipAlbum(ctx context.Context, id string, w io.Writer) error
|
||||
ZipArtist(ctx context.Context, id string, w io.Writer) error
|
||||
ZipPlaylist(ctx context.Context, id string, w io.Writer) error
|
||||
}
|
||||
|
||||
func NewArchiver(ds model.DataStore) Archiver {
|
||||
return &archiver{ds: ds}
|
||||
}
|
||||
|
||||
type archiver struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
type createHeader func(idx int, mf model.MediaFile) *zip.FileHeader
|
||||
|
||||
func (a *archiver) ZipAlbum(ctx context.Context, id string, out io.Writer) error {
|
||||
mfs, err := a.ds.MediaFile(ctx).FindByAlbum(id)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading mediafiles from album", "id", id, err)
|
||||
return err
|
||||
}
|
||||
return a.zipTracks(ctx, id, out, mfs, a.createHeader)
|
||||
}
|
||||
|
||||
func (a *archiver) ZipArtist(ctx context.Context, id string, out io.Writer) error {
|
||||
mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Sort: "album",
|
||||
Filters: squirrel.Eq{"album_artist_id": id},
|
||||
})
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading mediafiles from artist", "id", id, err)
|
||||
return err
|
||||
}
|
||||
return a.zipTracks(ctx, id, out, mfs, a.createHeader)
|
||||
}
|
||||
|
||||
func (a *archiver) ZipPlaylist(ctx context.Context, id string, out io.Writer) error {
|
||||
pls, err := a.ds.Playlist(ctx).Get(id)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err)
|
||||
return err
|
||||
}
|
||||
return a.zipTracks(ctx, id, out, pls.Tracks, a.createPlaylistHeader)
|
||||
}
|
||||
|
||||
func (a *archiver) zipTracks(ctx context.Context, id string, out io.Writer, mfs model.MediaFiles, ch createHeader) error {
|
||||
z := zip.NewWriter(out)
|
||||
for idx, mf := range mfs {
|
||||
_ = a.addFileToZip(ctx, z, mf, ch(idx, mf))
|
||||
}
|
||||
err := z.Close()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error closing zip file", "id", id, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *archiver) createHeader(idx int, mf model.MediaFile) *zip.FileHeader {
|
||||
_, file := filepath.Split(mf.Path)
|
||||
return &zip.FileHeader{
|
||||
Name: fmt.Sprintf("%s/%s", mf.Album, file),
|
||||
Modified: mf.UpdatedAt,
|
||||
Method: zip.Store,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *archiver) createPlaylistHeader(idx int, mf model.MediaFile) *zip.FileHeader {
|
||||
_, file := filepath.Split(mf.Path)
|
||||
return &zip.FileHeader{
|
||||
Name: fmt.Sprintf("%d - %s-%s", idx, mf.AlbumArtist, file),
|
||||
Modified: mf.UpdatedAt,
|
||||
Method: zip.Store,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, zh *zip.FileHeader) error {
|
||||
w, err := z.CreateHeader(zh)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error creating zip entry", "file", mf.Path, err)
|
||||
return err
|
||||
}
|
||||
f, err := os.Open(mf.Path)
|
||||
defer func() { _ = f.Close() }()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error opening file for zipping", "file", mf.Path, err)
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(w, f)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error zipping file", "file", mf.Path, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
209
core/artwork.go
Normal file
209
core/artwork.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "golang.org/x/image/webp"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/resources"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/disintegration/imaging"
|
||||
)
|
||||
|
||||
type Artwork interface {
|
||||
Get(ctx context.Context, id string, size int, out io.Writer) error
|
||||
}
|
||||
|
||||
type ArtworkCache FileCache
|
||||
|
||||
func NewArtwork(ds model.DataStore, cache ArtworkCache) Artwork {
|
||||
return &artwork{ds: ds, cache: cache}
|
||||
}
|
||||
|
||||
type artwork struct {
|
||||
ds model.DataStore
|
||||
cache FileCache
|
||||
}
|
||||
|
||||
type imageInfo struct {
|
||||
c *artwork
|
||||
path string
|
||||
size int
|
||||
lastUpdate time.Time
|
||||
}
|
||||
|
||||
func (ci *imageInfo) String() string {
|
||||
return fmt.Sprintf("%s.%d.%s.%d", ci.path, ci.size, ci.lastUpdate.Format(time.RFC3339Nano), conf.Server.CoverJpegQuality)
|
||||
}
|
||||
|
||||
func (c *artwork) Get(ctx context.Context, id string, size int, out io.Writer) error {
|
||||
path, lastUpdate, err := c.getImagePath(ctx, id)
|
||||
if err != nil && err != model.ErrNotFound {
|
||||
return err
|
||||
}
|
||||
|
||||
info := &imageInfo{
|
||||
c: c,
|
||||
path: path,
|
||||
size: size,
|
||||
lastUpdate: lastUpdate,
|
||||
}
|
||||
|
||||
r, err := c.cache.Get(ctx, info)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error accessing image cache", "path", path, "size", size, err)
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
_, err = io.Copy(out, r)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *artwork) getImagePath(ctx context.Context, id string) (path string, lastUpdated time.Time, err error) {
|
||||
// If id is an album cover ID
|
||||
if strings.HasPrefix(id, "al-") {
|
||||
log.Trace(ctx, "Looking for album art", "id", id)
|
||||
id = strings.TrimPrefix(id, "al-")
|
||||
var al *model.Album
|
||||
al, err = c.ds.Album(ctx).Get(id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if al.CoverArtId == "" {
|
||||
err = model.ErrNotFound
|
||||
}
|
||||
return al.CoverArtPath, al.UpdatedAt, err
|
||||
}
|
||||
|
||||
log.Trace(ctx, "Looking for media file art", "id", id)
|
||||
|
||||
// Check if id is a mediaFile cover id
|
||||
var mf *model.MediaFile
|
||||
mf, err = c.ds.MediaFile(ctx).Get(id)
|
||||
|
||||
// If it is not, may be an albumId
|
||||
if err == model.ErrNotFound {
|
||||
return c.getImagePath(ctx, "al-"+id)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// If it is a mediaFile and it has cover art, return it
|
||||
if mf.HasCoverArt {
|
||||
return mf.Path, mf.UpdatedAt, nil
|
||||
}
|
||||
|
||||
// if the mediaFile does not have a coverArt, fallback to the album cover
|
||||
log.Trace(ctx, "Media file does not contain art. Falling back to album art", "id", id, "albumId", "al-"+mf.AlbumID)
|
||||
return c.getImagePath(ctx, "al-"+mf.AlbumID)
|
||||
}
|
||||
|
||||
func (c *artwork) getArtwork(ctx context.Context, path string, size int) (reader io.Reader, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error extracting image", "path", path, "size", size, err)
|
||||
reader, err = resources.AssetFile().Open(consts.PlaceholderAlbumArt)
|
||||
}
|
||||
}()
|
||||
|
||||
if path == "" {
|
||||
return nil, errors.New("empty path given for artwork")
|
||||
}
|
||||
|
||||
var data []byte
|
||||
if utils.IsAudioFile(path) {
|
||||
data, err = readFromTag(path)
|
||||
} else {
|
||||
data, err = readFromFile(path)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
} else if size > 0 {
|
||||
data, err = resizeImage(bytes.NewReader(data), size)
|
||||
}
|
||||
|
||||
// Confirm the image is valid. Costly, but necessary
|
||||
_, _, err = image.Decode(bytes.NewReader(data))
|
||||
if err == nil {
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func resizeImage(reader io.Reader, size int) ([]byte, error) {
|
||||
img, _, err := image.Decode(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := imaging.Resize(img, size, size, imaging.Lanczos)
|
||||
buf := new(bytes.Buffer)
|
||||
err = jpeg.Encode(buf, m, &jpeg.Options{Quality: conf.Server.CoverJpegQuality})
|
||||
return buf.Bytes(), err
|
||||
}
|
||||
|
||||
func readFromTag(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
m, err := tag.ReadFrom(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
picture := m.Picture()
|
||||
if picture == nil {
|
||||
return nil, errors.New("file does not contain embedded art")
|
||||
}
|
||||
return picture.Data, nil
|
||||
}
|
||||
|
||||
func readFromFile(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
var buf bytes.Buffer
|
||||
if _, err := buf.ReadFrom(f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func NewImageCache() ArtworkCache {
|
||||
return NewFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems,
|
||||
func(ctx context.Context, arg fmt.Stringer) (io.Reader, error) {
|
||||
info := arg.(*imageInfo)
|
||||
reader, err := info.c.getArtwork(ctx, info.path, info.size)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading artwork art", "path", info.path, "size", info.size, err)
|
||||
return nil, err
|
||||
}
|
||||
return reader, nil
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package engine
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
@@ -12,66 +15,74 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Cover", func() {
|
||||
var cover Cover
|
||||
var _ = Describe("Artwork", func() {
|
||||
var artwork Artwork
|
||||
var ds model.DataStore
|
||||
ctx := log.NewContext(context.TODO())
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &persistence.MockDataStore{MockedTranscoding: &mockTranscodingRepository{}}
|
||||
ds.Album(ctx).(*persistence.MockAlbum).SetData(`[{"id": "222", "coverArtId": "123"}, {"id": "333", "coverArtId": ""}]`)
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "hasCoverArt": true, "updatedAt":"2020-04-02T21:29:31.6377Z"}]`)
|
||||
ds.Album(ctx).(*persistence.MockAlbum).SetData(`[{"id": "222", "coverArtId": "123", "coverArtPath":"tests/fixtures/test.mp3"}, {"id": "333", "coverArtId": ""}, {"id": "444", "coverArtId": "444", "coverArtPath": "tests/fixtures/cover.jpg"}]`)
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "albumId": "222", "path": "tests/fixtures/test.mp3", "hasCoverArt": true, "updatedAt":"2020-04-02T21:29:31.6377Z"},{"id": "456", "albumId": "222", "path": "tests/fixtures/test.ogg", "hasCoverArt": false, "updatedAt":"2020-04-02T21:29:31.6377Z"}]`)
|
||||
})
|
||||
|
||||
Context("Cache is configured", func() {
|
||||
BeforeEach(func() {
|
||||
cover = NewCover(ds, testCache)
|
||||
conf.Server.DataFolder, _ = ioutil.TempDir("", "file_caches")
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
cache := NewImageCache()
|
||||
Eventually(func() bool { return cache.Ready() }).Should(BeTrue())
|
||||
artwork = NewArtwork(ds, cache)
|
||||
})
|
||||
|
||||
It("retrieves the original cover art from an album", func() {
|
||||
AfterEach(func() {
|
||||
os.RemoveAll(conf.Server.DataFolder)
|
||||
})
|
||||
|
||||
It("retrieves the external artwork art for an album", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "222", 0, buf)).To(BeNil())
|
||||
Expect(artwork.Get(ctx, "al-444", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
|
||||
It("accepts albumIds with 'al-' prefix", func() {
|
||||
It("retrieves the embedded artwork art for an album", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "al-222", 0, buf)).To(BeNil())
|
||||
Expect(artwork.Get(ctx, "al-222", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
|
||||
It("returns the default cover if album does not have cover", func() {
|
||||
It("returns the default artwork if album does not have artwork", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "333", 0, buf)).To(BeNil())
|
||||
Expect(artwork.Get(ctx, "al-333", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("png"))
|
||||
})
|
||||
|
||||
It("returns the default cover if album is not found", func() {
|
||||
It("returns the default artwork if album is not found", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "444", 0, buf)).To(BeNil())
|
||||
Expect(artwork.Get(ctx, "al-0101", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("png"))
|
||||
})
|
||||
|
||||
It("retrieves the original cover art from a media_file", func() {
|
||||
It("retrieves the original artwork art from a media_file", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 0, buf)).To(BeNil())
|
||||
Expect(artwork.Get(ctx, "123", 0, buf)).To(BeNil())
|
||||
|
||||
img, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
@@ -80,10 +91,30 @@ var _ = Describe("Cover", func() {
|
||||
Expect(img.Bounds().Size().Y).To(Equal(600))
|
||||
})
|
||||
|
||||
It("resized cover art as requested", func() {
|
||||
It("retrieves the album artwork art if media_file does not have one", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 200, buf)).To(BeNil())
|
||||
Expect(artwork.Get(ctx, "456", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
|
||||
It("retrieves the album artwork by album id", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(artwork.Get(ctx, "222", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
|
||||
It("resized artwork art as requested", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(artwork.Get(ctx, "123", 200, buf)).To(BeNil())
|
||||
|
||||
img, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
@@ -97,30 +128,15 @@ var _ = Describe("Cover", func() {
|
||||
ds.Album(ctx).(*persistence.MockAlbum).SetError(true)
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "222", 0, buf)).To(MatchError("Error!"))
|
||||
Expect(artwork.Get(ctx, "al-222", 0, buf)).To(MatchError("Error!"))
|
||||
})
|
||||
|
||||
It("returns err if gets error from media_file table", func() {
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetError(true)
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 0, buf)).To(MatchError("Error!"))
|
||||
Expect(artwork.Get(ctx, "123", 0, buf)).To(MatchError("Error!"))
|
||||
})
|
||||
})
|
||||
})
|
||||
Context("Cache is NOT configured", func() {
|
||||
BeforeEach(func() {
|
||||
cover = NewCover(ds, nil)
|
||||
})
|
||||
|
||||
It("retrieves the original cover art from an album", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "222", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -44,11 +44,7 @@ func CreateToken(u *model.User) (string, error) {
|
||||
|
||||
func getSessionTimeOut() time.Duration {
|
||||
if sessionTimeOut == 0 {
|
||||
if to, err := time.ParseDuration(conf.Server.SessionTimeout); err != nil {
|
||||
sessionTimeOut = consts.DefaultSessionTimeout
|
||||
} else {
|
||||
sessionTimeOut = to
|
||||
}
|
||||
sessionTimeOut = conf.Server.SessionTimeout
|
||||
log.Info("Setting Session Timeout", "value", sessionTimeOut)
|
||||
}
|
||||
return sessionTimeOut
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/engine/auth"
|
||||
"github.com/deluan/navidrome/core/auth"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
. "github.com/onsi/ginkgo"
|
||||
15
core/common.go
Normal file
15
core/common.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
)
|
||||
|
||||
func userName(ctx context.Context) string {
|
||||
if user, ok := request.UserFrom(ctx); !ok {
|
||||
return "UNKNOWN"
|
||||
} else {
|
||||
return user.UserName
|
||||
}
|
||||
}
|
||||
17
core/core_suite_test.go
Normal file
17
core/core_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestEngine(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelCritical)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Core Suite")
|
||||
}
|
||||
195
core/file_caches.go
Normal file
195
core/file_caches.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/djherbis/fscache"
|
||||
"github.com/dustin/go-humanize"
|
||||
)
|
||||
|
||||
type ReadFunc func(ctx context.Context, arg fmt.Stringer) (io.Reader, error)
|
||||
|
||||
type FileCache interface {
|
||||
Get(ctx context.Context, arg fmt.Stringer) (*CachedStream, error)
|
||||
Ready() bool
|
||||
}
|
||||
|
||||
func NewFileCache(name, cacheSize, cacheFolder string, maxItems int, getReader ReadFunc) *fileCache {
|
||||
fc := &fileCache{
|
||||
name: name,
|
||||
cacheSize: cacheSize,
|
||||
cacheFolder: filepath.FromSlash(cacheFolder),
|
||||
maxItems: maxItems,
|
||||
getReader: getReader,
|
||||
mutex: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
go func() {
|
||||
cache, err := newFSCache(fc.name, fc.cacheSize, fc.cacheFolder, fc.maxItems)
|
||||
fc.mutex.Lock()
|
||||
defer fc.mutex.Unlock()
|
||||
if err == nil {
|
||||
fc.cache = cache
|
||||
fc.disabled = cache == nil
|
||||
}
|
||||
fc.ready = true
|
||||
if fc.disabled {
|
||||
log.Debug("Cache disabled", "cache", fc.name, "size", fc.cacheSize)
|
||||
}
|
||||
}()
|
||||
|
||||
return fc
|
||||
}
|
||||
|
||||
type fileCache struct {
|
||||
name string
|
||||
cacheSize string
|
||||
cacheFolder string
|
||||
maxItems int
|
||||
cache fscache.Cache
|
||||
getReader ReadFunc
|
||||
disabled bool
|
||||
ready bool
|
||||
mutex *sync.RWMutex
|
||||
}
|
||||
|
||||
func (fc *fileCache) Ready() bool {
|
||||
fc.mutex.RLock()
|
||||
defer fc.mutex.RUnlock()
|
||||
return fc.ready
|
||||
}
|
||||
|
||||
func (fc *fileCache) available(ctx context.Context) bool {
|
||||
fc.mutex.RLock()
|
||||
defer fc.mutex.RUnlock()
|
||||
|
||||
if !fc.ready {
|
||||
log.Debug(ctx, "Cache not initialized yet", "cache", fc.name)
|
||||
}
|
||||
|
||||
return fc.ready && !fc.disabled
|
||||
}
|
||||
|
||||
func (fc *fileCache) Get(ctx context.Context, arg fmt.Stringer) (*CachedStream, error) {
|
||||
if !fc.available(ctx) {
|
||||
reader, err := fc.getReader(ctx, arg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CachedStream{Reader: reader}, nil
|
||||
}
|
||||
|
||||
key := arg.String()
|
||||
r, w, err := fc.cache.Get(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cached := w == nil
|
||||
|
||||
if !cached {
|
||||
log.Trace(ctx, "Cache MISS", "cache", fc.name, "key", key)
|
||||
reader, err := fc.getReader(ctx, arg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
go copyAndClose(ctx, w, reader)
|
||||
}
|
||||
|
||||
// If it is in the cache, check if the stream is done being written. If so, return a ReaderSeeker
|
||||
if cached {
|
||||
size := getFinalCachedSize(r)
|
||||
if size >= 0 {
|
||||
log.Trace(ctx, "Cache HIT", "cache", fc.name, "key", key, "size", size)
|
||||
sr := io.NewSectionReader(r, 0, size)
|
||||
return &CachedStream{
|
||||
Reader: sr,
|
||||
Seeker: sr,
|
||||
Cached: true,
|
||||
}, nil
|
||||
} else {
|
||||
log.Trace(ctx, "Cache HIT", "cache", fc.name, "key", key)
|
||||
}
|
||||
}
|
||||
|
||||
// All other cases, just return a Reader, without Seek capabilities
|
||||
return &CachedStream{Reader: r, Cached: cached}, nil
|
||||
}
|
||||
|
||||
type CachedStream struct {
|
||||
io.Reader
|
||||
io.Seeker
|
||||
Cached bool
|
||||
}
|
||||
|
||||
func (s *CachedStream) Seekable() bool { return s.Seeker != nil }
|
||||
func (s *CachedStream) Close() error {
|
||||
if c, ok := s.Reader.(io.Closer); ok {
|
||||
return c.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getFinalCachedSize(r fscache.ReadAtCloser) int64 {
|
||||
cr, ok := r.(*fscache.CacheReader)
|
||||
if ok {
|
||||
size, final, err := cr.Size()
|
||||
if final && err == nil {
|
||||
return size
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func copyAndClose(ctx context.Context, w io.WriteCloser, r io.Reader) {
|
||||
_, err := io.Copy(w, r)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error copying data to cache", err)
|
||||
}
|
||||
if c, ok := r.(io.Closer); ok {
|
||||
err = c.Close()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error closing source stream", err)
|
||||
}
|
||||
}
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error closing cache writer", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cache, error) {
|
||||
size, err := humanize.ParseBytes(cacheSize)
|
||||
if err != nil {
|
||||
log.Error("Invalid cache size. Using default size", "cache", name, "size", cacheSize,
|
||||
"defaultSize", humanize.Bytes(consts.DefaultCacheSize))
|
||||
size = consts.DefaultCacheSize
|
||||
}
|
||||
if size == 0 {
|
||||
log.Warn(fmt.Sprintf("%s cache disabled", name))
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
lru := fscache.NewLRUHaunter(maxItems, int64(size), consts.DefaultCacheCleanUpInterval)
|
||||
h := fscache.NewLRUHaunterStrategy(lru)
|
||||
cacheFolder = filepath.Join(conf.Server.DataFolder, cacheFolder)
|
||||
|
||||
log.Info(fmt.Sprintf("Creating %s cache", name), "path", cacheFolder, "maxSize", humanize.Bytes(size))
|
||||
fs, err := fscache.NewFs(cacheFolder, 0755)
|
||||
if err != nil {
|
||||
log.Error(fmt.Sprintf("Error initializing %s cache", name), err, "elapsedTime", time.Since(start))
|
||||
return nil, err
|
||||
}
|
||||
log.Debug(fmt.Sprintf("%s cache initialized", name), "elapsedTime", time.Since(start))
|
||||
|
||||
return fscache.NewCacheWithHaunter(fs, h)
|
||||
}
|
||||
100
core/file_caches_test.go
Normal file
100
core/file_caches_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Call NewFileCache and wait for it to be ready
|
||||
func callNewFileCache(name, cacheSize, cacheFolder string, maxItems int, getReader ReadFunc) *fileCache {
|
||||
fc := NewFileCache(name, cacheSize, cacheFolder, maxItems, getReader)
|
||||
Eventually(func() bool { return fc.Ready() }).Should(BeTrue())
|
||||
return fc
|
||||
}
|
||||
|
||||
var _ = Describe("File Caches", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DataFolder, _ = ioutil.TempDir("", "file_caches")
|
||||
})
|
||||
AfterEach(func() {
|
||||
os.RemoveAll(conf.Server.DataFolder)
|
||||
})
|
||||
|
||||
Describe("NewFileCache", func() {
|
||||
It("creates the cache folder", func() {
|
||||
Expect(callNewFileCache("test", "1k", "test", 0, nil)).ToNot(BeNil())
|
||||
|
||||
_, err := os.Stat(filepath.Join(conf.Server.DataFolder, "test"))
|
||||
Expect(os.IsNotExist(err)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("creates the cache folder with invalid size", func() {
|
||||
fc := callNewFileCache("test", "abc", "test", 0, nil)
|
||||
Expect(fc.cache).ToNot(BeNil())
|
||||
Expect(fc.disabled).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns empty if cache size is '0'", func() {
|
||||
fc := callNewFileCache("test", "0", "test", 0, nil)
|
||||
Expect(fc.cache).To(BeNil())
|
||||
Expect(fc.disabled).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("FileCache", func() {
|
||||
It("caches data if cache is enabled", func() {
|
||||
called := false
|
||||
fc := callNewFileCache("test", "1KB", "test", 0, func(ctx context.Context, arg fmt.Stringer) (io.Reader, error) {
|
||||
called = true
|
||||
return strings.NewReader(arg.String()), nil
|
||||
})
|
||||
// First call is a MISS
|
||||
s, err := fc.Get(context.TODO(), &testArg{"test"})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s.Cached).To(BeFalse())
|
||||
Expect(ioutil.ReadAll(s)).To(Equal([]byte("test")))
|
||||
|
||||
// Second call is a HIT
|
||||
called = false
|
||||
s, err = fc.Get(context.TODO(), &testArg{"test"})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(ioutil.ReadAll(s)).To(Equal([]byte("test")))
|
||||
Expect(s.Cached).To(BeTrue())
|
||||
Expect(called).To(BeFalse())
|
||||
})
|
||||
|
||||
It("does not cache data if cache is disabled", func() {
|
||||
called := false
|
||||
fc := callNewFileCache("test", "0", "test", 0, func(ctx context.Context, arg fmt.Stringer) (io.Reader, error) {
|
||||
called = true
|
||||
return strings.NewReader(arg.String()), nil
|
||||
})
|
||||
// First call is a MISS
|
||||
s, err := fc.Get(context.TODO(), &testArg{"test"})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s.Cached).To(BeFalse())
|
||||
Expect(ioutil.ReadAll(s)).To(Equal([]byte("test")))
|
||||
|
||||
// Second call is also a MISS
|
||||
called = false
|
||||
s, err = fc.Get(context.TODO(), &testArg{"test"})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(ioutil.ReadAll(s)).To(Equal([]byte("test")))
|
||||
Expect(s.Cached).To(BeFalse())
|
||||
Expect(called).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type testArg struct{ s string }
|
||||
|
||||
func (t *testArg) String() string { return t.s }
|
||||
@@ -1,4 +1,4 @@
|
||||
package engine
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -10,18 +10,17 @@ import (
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/engine/transcoder"
|
||||
"github.com/deluan/navidrome/core/transcoder"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
"github.com/djherbis/fscache"
|
||||
)
|
||||
|
||||
type MediaStreamer interface {
|
||||
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error)
|
||||
}
|
||||
|
||||
type TranscodingCache fscache.Cache
|
||||
type TranscodingCache FileCache
|
||||
|
||||
func NewMediaStreamer(ds model.DataStore, ffm transcoder.Transcoder, cache TranscodingCache) MediaStreamer {
|
||||
return &mediaStreamer{ds: ds, ffm: ffm, cache: cache}
|
||||
@@ -30,7 +29,18 @@ func NewMediaStreamer(ds model.DataStore, ffm transcoder.Transcoder, cache Trans
|
||||
type mediaStreamer struct {
|
||||
ds model.DataStore
|
||||
ffm transcoder.Transcoder
|
||||
cache fscache.Cache
|
||||
cache FileCache
|
||||
}
|
||||
|
||||
type streamJob struct {
|
||||
ms *mediaStreamer
|
||||
mf *model.MediaFile
|
||||
format string
|
||||
bitRate int
|
||||
}
|
||||
|
||||
func (j *streamJob) String() string {
|
||||
return fmt.Sprintf("%s.%s.%d.%s", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format)
|
||||
}
|
||||
|
||||
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error) {
|
||||
@@ -49,92 +59,47 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat str
|
||||
}()
|
||||
|
||||
format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate)
|
||||
log.Trace(ctx, "Selected transcoding options",
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
||||
"selectedBitrate", bitRate, "selectedFormat", format,
|
||||
)
|
||||
s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
|
||||
|
||||
if format == "raw" {
|
||||
log.Debug(ctx, "Streaming raw file", "id", mf.ID, "path", mf.Path,
|
||||
log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", mf.Path,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
||||
"selectedBitrate", bitRate, "selectedFormat", format)
|
||||
f, err := os.Open(mf.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Reader = f
|
||||
s.Closer = f
|
||||
s.ReadCloser = f
|
||||
s.Seeker = f
|
||||
s.format = mf.Suffix
|
||||
return s, nil
|
||||
}
|
||||
|
||||
key := cacheKey(id, bitRate, format)
|
||||
r, w, err := ms.cache.Get(key)
|
||||
job := &streamJob{
|
||||
ms: ms,
|
||||
mf: mf,
|
||||
format: format,
|
||||
bitRate: bitRate,
|
||||
}
|
||||
r, err := ms.cache.Get(ctx, job)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error creating stream caching buffer", "id", mf.ID, err)
|
||||
log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err)
|
||||
return nil, err
|
||||
}
|
||||
cached = r.Cached
|
||||
|
||||
cached = w == nil
|
||||
|
||||
// If this is a brand new transcoding request, not in the cache, start transcoding
|
||||
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 {
|
||||
log.Error(ctx, "Error loading transcoding command", "format", format, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
out, err := ms.ffm.Start(ctx, t.Command, mf.Path, bitRate)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error starting transcoder", "id", mf.ID, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
go copyAndClose(ctx, w, out)
|
||||
}
|
||||
|
||||
// If it is in the cache, check if the stream is done being written. If so, return a ReaderSeeker
|
||||
if cached {
|
||||
size := getFinalCachedSize(r)
|
||||
if size > 0 {
|
||||
log.Debug(ctx, "Streaming cached file", "id", mf.ID, "path", mf.Path,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix, "size", size)
|
||||
sr := io.NewSectionReader(r, 0, size)
|
||||
s.Reader = sr
|
||||
s.Closer = r
|
||||
s.Seeker = sr
|
||||
s.format = format
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Streaming transcoded file", "id", mf.ID, "path", mf.Path,
|
||||
log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", mf.Path,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
|
||||
// All other cases, just return a ReadCloser, without Seek capabilities
|
||||
s.Reader = r
|
||||
s.Closer = r
|
||||
s.format = format
|
||||
return s, nil
|
||||
}
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
||||
"selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
|
||||
|
||||
func copyAndClose(ctx context.Context, w io.WriteCloser, r io.ReadCloser) {
|
||||
_, err := io.Copy(w, r)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error copying data to cache", err)
|
||||
}
|
||||
err = r.Close()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error closing transcode output", err)
|
||||
}
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error closing cache", err)
|
||||
s.ReadCloser = r
|
||||
if r.Seekable() {
|
||||
s.Seeker = r
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
type Stream struct {
|
||||
@@ -142,8 +107,7 @@ type Stream struct {
|
||||
mf *model.MediaFile
|
||||
bitRate int
|
||||
format string
|
||||
io.Reader
|
||||
io.Closer
|
||||
io.ReadCloser
|
||||
io.Seeker
|
||||
}
|
||||
|
||||
@@ -202,21 +166,21 @@ func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model
|
||||
return
|
||||
}
|
||||
|
||||
func cacheKey(id string, bitRate int, format string) string {
|
||||
return fmt.Sprintf("%s.%d.%s", id, bitRate, format)
|
||||
}
|
||||
|
||||
func getFinalCachedSize(r fscache.ReadAtCloser) int64 {
|
||||
cr, ok := r.(*fscache.CacheReader)
|
||||
if ok {
|
||||
size, final, err := cr.Size()
|
||||
if final && err == nil {
|
||||
return size
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func NewTranscodingCache() (TranscodingCache, error) {
|
||||
return newFileCache("Transcoding", conf.Server.TranscodingCacheSize, consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems)
|
||||
func NewTranscodingCache() TranscodingCache {
|
||||
return NewFileCache("Transcoding", conf.Server.TranscodingCacheSize,
|
||||
consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems,
|
||||
func(ctx context.Context, arg fmt.Stringer) (io.Reader, error) {
|
||||
job := arg.(*streamJob)
|
||||
t, err := job.ms.ds.Transcoding(ctx).FindByFormat(job.format)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
out, err := job.ms.ffm.Start(ctx, t.Command, job.mf.Path, job.bitRate)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package engine
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
@@ -20,11 +23,19 @@ var _ = Describe("MediaStreamer", func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
|
||||
BeforeEach(func() {
|
||||
conf.Server.DataFolder, _ = ioutil.TempDir("", "file_caches")
|
||||
conf.Server.TranscodingCacheSize = "100MB"
|
||||
ds = &persistence.MockDataStore{MockedTranscoding: &mockTranscodingRepository{}}
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "suffix": "mp3", "bitRate": 128, "duration": 257.0}]`)
|
||||
testCache := NewTranscodingCache()
|
||||
Eventually(func() bool { return testCache.Ready() }).Should(BeTrue())
|
||||
streamer = NewMediaStreamer(ds, ffmpeg, testCache)
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
os.RemoveAll(conf.Server.DataFolder)
|
||||
})
|
||||
|
||||
Context("NewStream", func() {
|
||||
It("returns a seekable stream if format is 'raw'", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "raw", 0)
|
||||
@@ -48,8 +59,13 @@ var _ = Describe("MediaStreamer", func() {
|
||||
Expect(s.Duration()).To(Equal(float32(257.0)))
|
||||
})
|
||||
It("returns a seekable stream if the file is complete in the cache", func() {
|
||||
Eventually(func() bool { return ffmpeg.closed }).Should(BeTrue())
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 64)
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 32)
|
||||
Expect(err).To(BeNil())
|
||||
_, _ = ioutil.ReadAll(s)
|
||||
_ = s.Close()
|
||||
Eventually(func() bool { return ffmpeg.closed }, "3s").Should(BeTrue())
|
||||
|
||||
s, err = streamer.NewStream(ctx, "123", "mp3", 32)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
22
core/mock_transcoding_repo_test.go
Normal file
22
core/mock_transcoding_repo_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package core
|
||||
|
||||
import "github.com/deluan/navidrome/model"
|
||||
|
||||
type mockTranscodingRepository struct {
|
||||
model.TranscodingRepository
|
||||
}
|
||||
|
||||
func (m *mockTranscodingRepository) Get(id string) (*model.Transcoding, error) {
|
||||
return &model.Transcoding{ID: id, TargetFormat: "mp3", DefaultBitRate: 160}, nil
|
||||
}
|
||||
|
||||
func (m *mockTranscodingRepository) FindByFormat(format string) (*model.Transcoding, error) {
|
||||
switch format {
|
||||
case "mp3":
|
||||
return &model.Transcoding{ID: "mp31", TargetFormat: "mp3", DefaultBitRate: 160}, nil
|
||||
case "oga":
|
||||
return &model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 128}, nil
|
||||
default:
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
}
|
||||
15
core/wire_providers.go
Normal file
15
core/wire_providers.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"github.com/deluan/navidrome/core/transcoder"
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
var Set = wire.NewSet(
|
||||
NewArtwork,
|
||||
NewMediaStreamer,
|
||||
NewTranscodingCache,
|
||||
NewImageCache,
|
||||
NewArchiver,
|
||||
transcoder.New,
|
||||
)
|
||||
14
db/db.go
14
db/db.go
@@ -42,7 +42,19 @@ func Db() *sql.DB {
|
||||
func EnsureLatestVersion() {
|
||||
db := Db()
|
||||
|
||||
err := goose.SetDialect(Driver)
|
||||
// Disable foreign_keys to allow re-creating tables in migrations
|
||||
_, err := db.Exec("PRAGMA foreign_keys=off")
|
||||
defer func() {
|
||||
_, err := db.Exec("PRAGMA foreign_keys=on")
|
||||
if err != nil {
|
||||
log.Error("Error re-enabling foreign_keys", err)
|
||||
}
|
||||
}()
|
||||
if err != nil {
|
||||
log.Error("Error disabling foreign_keys", err)
|
||||
}
|
||||
|
||||
err = goose.SetDialect(Driver)
|
||||
if err != nil {
|
||||
log.Error("Invalid DB driver", "driver", Driver, err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -18,6 +18,20 @@ delete from player where user_name not in (select user_name from user)`)
|
||||
return err
|
||||
}
|
||||
|
||||
// Also delete dangling players
|
||||
_, err = tx.Exec(`
|
||||
delete from playlist where owner not in (select user_name from user)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Also delete dangling playlist tracks
|
||||
_, err = tx.Exec(`
|
||||
delete from playlist_tracks where playlist_id not in (select id from playlist)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add foreign key to player table
|
||||
err = updatePlayer_20200608153717(tx)
|
||||
if err != nil {
|
||||
|
||||
43
db/migration/20200706231659_add_default_transcodings.go
Normal file
43
db/migration/20200706231659_add_default_transcodings.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upAddDefaultTranscodings, downAddDefaultTranscodings)
|
||||
}
|
||||
|
||||
func upAddDefaultTranscodings(tx *sql.Tx) error {
|
||||
row := tx.QueryRow("SELECT COUNT(*) FROM transcoding")
|
||||
var count int
|
||||
err := row.Scan(&count)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
stmt, err := tx.Prepare("insert into transcoding (id, name, target_format, default_bit_rate, command) values (?, ?, ?, ?, ?)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, t := range consts.DefaultTranscodings {
|
||||
r, _ := uuid.NewRandom()
|
||||
_, err := stmt.Exec(r.String(), t["name"], t["targetFormat"], t["defaultBitRate"], t["command"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func downAddDefaultTranscodings(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
27
db/migration/20200710211442_add_playlist_path.go
Normal file
27
db/migration/20200710211442_add_playlist_path.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upAddPlaylistPath, downAddPlaylistPath)
|
||||
}
|
||||
|
||||
func upAddPlaylistPath(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table playlist
|
||||
add path string default '' not null;
|
||||
|
||||
alter table playlist
|
||||
add sync bool default false not null;
|
||||
`)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddPlaylistPath(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
36
db/migration/20200731095603_create_play_queues_table.go
Normal file
36
db/migration/20200731095603_create_play_queues_table.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upCreatePlayQueuesTable, downCreatePlayQueuesTable)
|
||||
}
|
||||
|
||||
func upCreatePlayQueuesTable(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table playqueue
|
||||
(
|
||||
id varchar(255) not null primary key,
|
||||
user_id varchar(255) not null
|
||||
references user (id)
|
||||
on update cascade on delete cascade,
|
||||
comment varchar(255),
|
||||
current varchar(255) not null,
|
||||
position integer,
|
||||
changed_by varchar(255),
|
||||
items varchar(255),
|
||||
created_at datetime,
|
||||
updated_at datetime
|
||||
);
|
||||
`)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func downCreatePlayQueuesTable(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
53
db/migration/20200801101355_create_bookmark_table.go
Normal file
53
db/migration/20200801101355_create_bookmark_table.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upCreateBookmarkTable, downCreateBookmarkTable)
|
||||
}
|
||||
|
||||
func upCreateBookmarkTable(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table bookmark
|
||||
(
|
||||
user_id varchar(255) not null
|
||||
references user
|
||||
on update cascade on delete cascade,
|
||||
item_id varchar(255) not null,
|
||||
item_type varchar(255) not null,
|
||||
comment varchar(255),
|
||||
position integer,
|
||||
changed_by varchar(255),
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
constraint bookmark_pk
|
||||
unique (user_id, item_id, item_type)
|
||||
);
|
||||
|
||||
create table playqueue_dg_tmp
|
||||
(
|
||||
id varchar(255) not null,
|
||||
user_id varchar(255) not null
|
||||
references user
|
||||
on update cascade on delete cascade,
|
||||
current varchar(255),
|
||||
position real,
|
||||
changed_by varchar(255),
|
||||
items varchar(255),
|
||||
created_at datetime,
|
||||
updated_at datetime
|
||||
);
|
||||
drop table playqueue;
|
||||
alter table playqueue_dg_tmp rename to playqueue;
|
||||
`)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func downCreateBookmarkTable(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
42
db/migration/20200819111809_drop_email_unique_constraint.go
Normal file
42
db/migration/20200819111809_drop_email_unique_constraint.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upDropEmailUniqueConstraint, downDropEmailUniqueConstraint)
|
||||
}
|
||||
|
||||
func upDropEmailUniqueConstraint(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table user_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
user_name varchar(255) default '' not null
|
||||
unique,
|
||||
name varchar(255) default '' not null,
|
||||
email varchar(255) default '' not null,
|
||||
password varchar(255) default '' not null,
|
||||
is_admin bool default FALSE not null,
|
||||
last_login_at datetime,
|
||||
last_access_at datetime,
|
||||
created_at datetime not null,
|
||||
updated_at datetime not null
|
||||
);
|
||||
|
||||
insert into user_dg_tmp(id, user_name, name, email, password, is_admin, last_login_at, last_access_at, created_at, updated_at) select id, user_name, name, email, password, is_admin, last_login_at, last_access_at, created_at, updated_at from user;
|
||||
|
||||
drop table user;
|
||||
|
||||
alter table user_dg_tmp rename to user;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downDropEmailUniqueConstraint(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
23
db/migration/20201003111749_add_starred_at_index.go
Normal file
23
db/migration/20201003111749_add_starred_at_index.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20201003111749, Down20201003111749)
|
||||
}
|
||||
|
||||
func Up20201003111749(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create index if not exists annotation_starred_at
|
||||
on annotation (starred_at);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20201003111749(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
# 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"
|
||||
- "./music:/music"
|
||||
@@ -1,213 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
type Browser interface {
|
||||
MediaFolders(ctx context.Context) (model.MediaFolders, error)
|
||||
Indexes(ctx context.Context, mediaFolderId string, ifModifiedSince time.Time) (model.ArtistIndexes, time.Time, error)
|
||||
Directory(ctx context.Context, id string) (*DirectoryInfo, error)
|
||||
Artist(ctx context.Context, id string) (*DirectoryInfo, error)
|
||||
Album(ctx context.Context, id string) (*DirectoryInfo, error)
|
||||
GetSong(ctx context.Context, id string) (*Entry, error)
|
||||
GetGenres(ctx context.Context) (model.Genres, error)
|
||||
}
|
||||
|
||||
func NewBrowser(ds model.DataStore) Browser {
|
||||
return &browser{ds}
|
||||
}
|
||||
|
||||
type browser struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func (b *browser) MediaFolders(ctx context.Context) (model.MediaFolders, error) {
|
||||
return b.ds.MediaFolder(ctx).GetAll()
|
||||
}
|
||||
|
||||
func (b *browser) Indexes(ctx context.Context, mediaFolderId string, ifModifiedSince time.Time) (model.ArtistIndexes, time.Time, error) {
|
||||
// TODO Proper handling of mediaFolderId param
|
||||
folder, _ := b.ds.MediaFolder(ctx).Get(mediaFolderId)
|
||||
|
||||
l, err := b.ds.Property(ctx).DefaultGet(model.PropLastScan+"-"+folder.Path, "-1")
|
||||
ms, _ := strconv.ParseInt(l, 10, 64)
|
||||
lastModified := utils.ToTime(ms)
|
||||
|
||||
if err != nil {
|
||||
return nil, time.Time{}, fmt.Errorf("error retrieving LastScan property: %v", err)
|
||||
}
|
||||
|
||||
if lastModified.After(ifModifiedSince) {
|
||||
indexes, err := b.ds.Artist(ctx).GetIndex()
|
||||
return indexes, lastModified, err
|
||||
}
|
||||
|
||||
return nil, lastModified, nil
|
||||
}
|
||||
|
||||
type DirectoryInfo struct {
|
||||
Id string
|
||||
Name string
|
||||
Entries Entries
|
||||
Parent string
|
||||
Starred time.Time
|
||||
PlayCount int64
|
||||
UserRating int
|
||||
AlbumCount int
|
||||
CoverArt string
|
||||
Artist string
|
||||
ArtistId string
|
||||
SongCount int
|
||||
Duration int
|
||||
Created time.Time
|
||||
Year int
|
||||
Genre string
|
||||
}
|
||||
|
||||
func (b *browser) Artist(ctx context.Context, id string) (*DirectoryInfo, error) {
|
||||
a, albums, err := b.retrieveArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Debug(ctx, "Found Artist", "id", id, "name", a.Name)
|
||||
return b.buildArtistDir(a, albums), nil
|
||||
}
|
||||
|
||||
func (b *browser) Album(ctx context.Context, id string) (*DirectoryInfo, error) {
|
||||
al, tracks, err := b.retrieveAlbum(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Debug(ctx, "Found Album", "id", id, "name", al.Name)
|
||||
return b.buildAlbumDir(al, tracks), nil
|
||||
}
|
||||
|
||||
func (b *browser) Directory(ctx context.Context, id string) (*DirectoryInfo, error) {
|
||||
switch {
|
||||
case b.isArtist(ctx, id):
|
||||
return b.Artist(ctx, id)
|
||||
case b.isAlbum(ctx, id):
|
||||
return b.Album(ctx, id)
|
||||
default:
|
||||
log.Debug(ctx, "Directory not found", "id", id)
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
}
|
||||
|
||||
func (b *browser) GetSong(ctx context.Context, id string) (*Entry, error) {
|
||||
mf, err := b.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entry := FromMediaFile(mf)
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
func (b *browser) GetGenres(ctx context.Context) (model.Genres, error) {
|
||||
genres, err := b.ds.Genre(ctx).GetAll()
|
||||
for i, g := range genres {
|
||||
if strings.TrimSpace(g.Name) == "" {
|
||||
genres[i].Name = "<Empty>"
|
||||
}
|
||||
}
|
||||
sort.Slice(genres, func(i, j int) bool {
|
||||
return genres[i].Name < genres[j].Name
|
||||
})
|
||||
return genres, err
|
||||
}
|
||||
|
||||
func (b *browser) buildArtistDir(a *model.Artist, albums model.Albums) *DirectoryInfo {
|
||||
dir := &DirectoryInfo{
|
||||
Id: a.ID,
|
||||
Name: a.Name,
|
||||
AlbumCount: a.AlbumCount,
|
||||
}
|
||||
|
||||
dir.Entries = make(Entries, len(albums))
|
||||
for i := range albums {
|
||||
al := albums[i]
|
||||
dir.Entries[i] = FromAlbum(&al)
|
||||
dir.PlayCount += al.PlayCount
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func (b *browser) buildAlbumDir(al *model.Album, tracks model.MediaFiles) *DirectoryInfo {
|
||||
dir := &DirectoryInfo{
|
||||
Id: al.ID,
|
||||
Name: al.Name,
|
||||
Parent: al.AlbumArtistID,
|
||||
Artist: al.AlbumArtist,
|
||||
ArtistId: al.AlbumArtistID,
|
||||
SongCount: al.SongCount,
|
||||
Duration: int(al.Duration),
|
||||
Created: al.CreatedAt,
|
||||
Year: al.MaxYear,
|
||||
Genre: al.Genre,
|
||||
CoverArt: al.CoverArtId,
|
||||
PlayCount: al.PlayCount,
|
||||
UserRating: al.Rating,
|
||||
}
|
||||
|
||||
if al.Starred {
|
||||
dir.Starred = al.StarredAt
|
||||
}
|
||||
|
||||
dir.Entries = FromMediaFiles(tracks)
|
||||
return dir
|
||||
}
|
||||
|
||||
func (b *browser) isArtist(ctx context.Context, id string) bool {
|
||||
found, err := b.ds.Artist(ctx).Exists(id)
|
||||
if err != nil {
|
||||
log.Debug(ctx, "Error searching for Artist", "id", id, err)
|
||||
return false
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func (b *browser) isAlbum(ctx context.Context, id string) bool {
|
||||
found, err := b.ds.Album(ctx).Exists(id)
|
||||
if err != nil {
|
||||
log.Debug(ctx, "Error searching for Album", "id", id, err)
|
||||
return false
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func (b *browser) retrieveArtist(ctx context.Context, id string) (a *model.Artist, as model.Albums, err error) {
|
||||
a, err = b.ds.Artist(ctx).Get(id)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("Error reading Artist %s from DB: %v", id, err)
|
||||
return
|
||||
}
|
||||
|
||||
if as, err = b.ds.Album(ctx).FindByArtist(id); err != nil {
|
||||
err = fmt.Errorf("Error reading %s's albums from DB: %v", a.Name, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (b *browser) retrieveAlbum(ctx context.Context, id string) (al *model.Album, mfs model.MediaFiles, err error) {
|
||||
al, err = b.ds.Album(ctx).Get(id)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("Error reading Album %s from DB: %v", id, err)
|
||||
return
|
||||
}
|
||||
|
||||
if mfs, err = b.ds.MediaFile(ctx).FindByAlbum(id); err != nil {
|
||||
err = fmt.Errorf("Error reading %s's tracks from DB: %v", al.Name, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Browser", func() {
|
||||
var repo *mockGenreRepository
|
||||
var b Browser
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = &mockGenreRepository{data: model.Genres{
|
||||
{Name: "Rock", SongCount: 1000, AlbumCount: 100},
|
||||
{Name: "", SongCount: 13, AlbumCount: 13},
|
||||
{Name: "Electronic", SongCount: 4000, AlbumCount: 40},
|
||||
}}
|
||||
var ds = &persistence.MockDataStore{MockedGenre: repo}
|
||||
b = &browser{ds: ds}
|
||||
})
|
||||
|
||||
It("returns sorted data", func() {
|
||||
Expect(b.GetGenres(context.TODO())).To(Equal(model.Genres{
|
||||
{Name: "<Empty>", SongCount: 13, AlbumCount: 13},
|
||||
{Name: "Electronic", SongCount: 4000, AlbumCount: 40},
|
||||
{Name: "Rock", SongCount: 1000, AlbumCount: 100},
|
||||
}))
|
||||
})
|
||||
|
||||
It("bubbles up errors", func() {
|
||||
repo.err = errors.New("generic error")
|
||||
_, err := b.GetGenres(context.TODO())
|
||||
Expect(err).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
type mockGenreRepository struct {
|
||||
data model.Genres
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *mockGenreRepository) GetAll() (model.Genres, error) {
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
return r.data, nil
|
||||
}
|
||||
176
engine/cover.go
176
engine/cover.go
@@ -1,176 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/resources"
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/djherbis/fscache"
|
||||
)
|
||||
|
||||
type Cover interface {
|
||||
Get(ctx context.Context, id string, size int, out io.Writer) error
|
||||
}
|
||||
|
||||
type ImageCache fscache.Cache
|
||||
|
||||
func NewCover(ds model.DataStore, cache ImageCache) Cover {
|
||||
return &cover{ds: ds, cache: cache}
|
||||
}
|
||||
|
||||
type cover struct {
|
||||
ds model.DataStore
|
||||
cache fscache.Cache
|
||||
}
|
||||
|
||||
func (c *cover) Get(ctx context.Context, id string, size int, out io.Writer) error {
|
||||
id = strings.TrimPrefix(id, "al-")
|
||||
path, lastUpdate, err := c.getCoverPath(ctx, id)
|
||||
if err != nil && err != model.ErrNotFound {
|
||||
return err
|
||||
}
|
||||
|
||||
// If cache is disabled, just read the coverart directly from file
|
||||
if c.cache == nil {
|
||||
log.Trace(ctx, "Retrieving cover art from file", "path", path, "size", size, err)
|
||||
reader, err := c.getCover(ctx, path, size)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading cover art", "path", path, "size", size, err)
|
||||
} else {
|
||||
_, err = io.Copy(out, reader)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
cacheKey := imageCacheKey(path, size, lastUpdate)
|
||||
r, w, err := c.cache.Get(cacheKey)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error reading from image cache", "path", path, "size", size, err)
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
if w != nil {
|
||||
log.Trace(ctx, "Image cache miss", "path", path, "size", size, "lastUpdate", lastUpdate)
|
||||
go func() {
|
||||
defer w.Close()
|
||||
reader, err := c.getCover(ctx, path, size)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading cover art", "path", path, "size", size, err)
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
_, err = io.Copy(out, r)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *cover) getCoverPath(ctx context.Context, id string) (path string, lastUpdated time.Time, err error) {
|
||||
var found bool
|
||||
if found, err = c.ds.Album(ctx).Exists(id); err != nil {
|
||||
return
|
||||
}
|
||||
if found {
|
||||
var al *model.Album
|
||||
al, err = c.ds.Album(ctx).Get(id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if al.CoverArtId == "" {
|
||||
err = model.ErrNotFound
|
||||
return
|
||||
}
|
||||
id = al.CoverArtId
|
||||
}
|
||||
var mf *model.MediaFile
|
||||
mf, err = c.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if mf.HasCoverArt {
|
||||
return mf.Path, mf.UpdatedAt, nil
|
||||
}
|
||||
return "", time.Time{}, model.ErrNotFound
|
||||
}
|
||||
|
||||
func imageCacheKey(path string, size int, lastUpdate time.Time) string {
|
||||
return fmt.Sprintf("%s.%d.%s", path, size, lastUpdate.Format(time.RFC3339Nano))
|
||||
}
|
||||
|
||||
func (c *cover) getCover(ctx context.Context, path string, size int) (reader io.Reader, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error extracting image", "path", path, "size", size, err)
|
||||
reader, err = resources.AssetFile().Open(consts.PlaceholderAlbumArt)
|
||||
}
|
||||
}()
|
||||
var data []byte
|
||||
data, err = readFromTag(path)
|
||||
|
||||
if err == nil && size > 0 {
|
||||
data, err = resizeImage(bytes.NewReader(data), size)
|
||||
}
|
||||
|
||||
// Confirm the image is valid. Costly, but necessary
|
||||
_, _, err = image.Decode(bytes.NewReader(data))
|
||||
if err == nil {
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func resizeImage(reader io.Reader, size int) ([]byte, error) {
|
||||
img, _, err := image.Decode(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := imaging.Resize(img, size, size, imaging.Lanczos)
|
||||
buf := new(bytes.Buffer)
|
||||
err = jpeg.Encode(buf, m, &jpeg.Options{Quality: 75})
|
||||
return buf.Bytes(), err
|
||||
}
|
||||
|
||||
func readFromTag(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
m, err := tag.ReadFrom(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
picture := m.Picture()
|
||||
if picture == nil {
|
||||
return nil, errors.New("file does not contain embedded art")
|
||||
}
|
||||
return picture.Data, nil
|
||||
}
|
||||
|
||||
func NewImageCache() (ImageCache, error) {
|
||||
return newFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/tests"
|
||||
"github.com/djherbis/fscache"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestEngine(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelCritical)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Engine Suite")
|
||||
}
|
||||
|
||||
var testCache fscache.Cache
|
||||
var testCacheDir string
|
||||
|
||||
var _ = Describe("Engine Suite Setup", func() {
|
||||
BeforeSuite(func() {
|
||||
testCacheDir, _ = ioutil.TempDir("", "engine_test_cache")
|
||||
fs, _ := fscache.NewFs(testCacheDir, 0755)
|
||||
testCache, _ = fscache.NewCache(fs, nil)
|
||||
})
|
||||
|
||||
AfterSuite(func() {
|
||||
os.RemoveAll(testCacheDir)
|
||||
})
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/djherbis/fscache"
|
||||
"github.com/dustin/go-humanize"
|
||||
)
|
||||
|
||||
func newFileCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cache, error) {
|
||||
if cacheSize == "0" {
|
||||
log.Warn(fmt.Sprintf("%s cache disabled", name))
|
||||
return nil, nil
|
||||
}
|
||||
size, err := humanize.ParseBytes(cacheSize)
|
||||
if err != nil {
|
||||
size = consts.DefaultCacheSize
|
||||
}
|
||||
lru := fscache.NewLRUHaunter(maxItems, int64(size), consts.DefaultCacheCleanUpInterval)
|
||||
h := fscache.NewLRUHaunterStrategy(lru)
|
||||
cacheFolder = filepath.Join(conf.Server.DataFolder, cacheFolder)
|
||||
log.Info(fmt.Sprintf("Creating %s cache", name), "path", cacheFolder, "maxSize", humanize.Bytes(size))
|
||||
fs, err := fscache.NewFs(cacheFolder, 0755)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fscache.NewCacheWithHaunter(fs, h)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("File Caches", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DataFolder, _ = ioutil.TempDir("", "file_caches")
|
||||
})
|
||||
AfterEach(func() {
|
||||
os.RemoveAll(conf.Server.DataFolder)
|
||||
})
|
||||
|
||||
Describe("newFileCache", func() {
|
||||
It("creates the cache folder", func() {
|
||||
Expect(newFileCache("test", "1k", "test", 10)).ToNot(BeNil())
|
||||
|
||||
_, err := os.Stat(filepath.Join(conf.Server.DataFolder, "test"))
|
||||
Expect(os.IsNotExist(err)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("creates the cache folder with invalid size", func() {
|
||||
Expect(newFileCache("test", "abc", "test", 10)).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("returns empty if cache size is '0'", func() {
|
||||
Expect(newFileCache("test", "0", "test", 10)).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,71 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
type Ratings interface {
|
||||
SetStar(ctx context.Context, star bool, ids ...string) error
|
||||
SetRating(ctx context.Context, id string, rating int) error
|
||||
}
|
||||
|
||||
func NewRatings(ds model.DataStore) Ratings {
|
||||
return &ratings{ds}
|
||||
}
|
||||
|
||||
type ratings struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func (r ratings) SetRating(ctx context.Context, id string, rating int) error {
|
||||
exist, err := r.ds.Album(ctx).Exists(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exist {
|
||||
return r.ds.Album(ctx).SetRating(rating, id)
|
||||
}
|
||||
return r.ds.MediaFile(ctx).SetRating(rating, id)
|
||||
}
|
||||
|
||||
func (r ratings) SetStar(ctx context.Context, star bool, ids ...string) error {
|
||||
if len(ids) == 0 {
|
||||
log.Warn(ctx, "Cannot star/unstar an empty list of ids")
|
||||
return nil
|
||||
}
|
||||
|
||||
return r.ds.WithTx(func(tx model.DataStore) error {
|
||||
for _, id := range ids {
|
||||
exist, err := r.ds.Album(ctx).Exists(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exist {
|
||||
err = tx.Album(ctx).SetStar(star, ids...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
exist, err = r.ds.Artist(ctx).Exists(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exist {
|
||||
err = tx.Artist(ctx).SetStar(star, ids...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
err = tx.MediaFile(ctx).SetStar(star, ids...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
type Scrobbler interface {
|
||||
Register(ctx context.Context, playerId int, trackId string, playDate time.Time) (*model.MediaFile, error)
|
||||
NowPlaying(ctx context.Context, playerId int, playerName, trackId, username string) (*model.MediaFile, error)
|
||||
}
|
||||
|
||||
func NewScrobbler(ds model.DataStore, npr NowPlayingRepository) Scrobbler {
|
||||
return &scrobbler{ds: ds, npRepo: npr}
|
||||
}
|
||||
|
||||
type scrobbler struct {
|
||||
ds model.DataStore
|
||||
npRepo NowPlayingRepository
|
||||
}
|
||||
|
||||
func (s *scrobbler) Register(ctx context.Context, playerId int, trackId string, playTime time.Time) (*model.MediaFile, error) {
|
||||
var mf *model.MediaFile
|
||||
var err error
|
||||
err = s.ds.WithTx(func(tx model.DataStore) error {
|
||||
mf, err = s.ds.MediaFile(ctx).Get(trackId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.ds.MediaFile(ctx).IncPlayCount(trackId, playTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.ds.Album(ctx).IncPlayCount(mf.AlbumID, playTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.ds.Artist(ctx).IncPlayCount(mf.ArtistID, playTime)
|
||||
return err
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// TODO Validate if NowPlaying still works after all refactorings
|
||||
func (s *scrobbler) NowPlaying(ctx context.Context, playerId int, playerName, trackId, username string) (*model.MediaFile, error) {
|
||||
mf, err := s.ds.MediaFile(ctx).Get(trackId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if mf == nil {
|
||||
return nil, fmt.Errorf(`ID "%s" not found`, trackId)
|
||||
}
|
||||
|
||||
log.Info("Now Playing", "title", mf.Title, "artist", mf.Artist, "user", userName(ctx))
|
||||
|
||||
info := &NowPlayingInfo{TrackID: trackId, Username: username, Start: time.Now(), PlayerId: playerId, PlayerName: playerName}
|
||||
return mf, s.npRepo.Enqueue(info)
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/kennygrant/sanitize"
|
||||
)
|
||||
|
||||
type Search interface {
|
||||
SearchArtist(ctx context.Context, q string, offset int, size int) (Entries, error)
|
||||
SearchAlbum(ctx context.Context, q string, offset int, size int) (Entries, error)
|
||||
SearchSong(ctx context.Context, q string, offset int, size int) (Entries, error)
|
||||
}
|
||||
|
||||
type search struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func NewSearch(ds model.DataStore) Search {
|
||||
s := &search{ds}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *search) SearchArtist(ctx context.Context, q string, offset int, size int) (Entries, error) {
|
||||
q = sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*")))
|
||||
artists, err := s.ds.Artist(ctx).Search(q, offset, size)
|
||||
if len(artists) == 0 || err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
artistIds := make([]string, len(artists))
|
||||
for i, al := range artists {
|
||||
artistIds[i] = al.ID
|
||||
}
|
||||
return FromArtists(artists), nil
|
||||
}
|
||||
|
||||
func (s *search) SearchAlbum(ctx context.Context, q string, offset int, size int) (Entries, error) {
|
||||
q = sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*")))
|
||||
albums, err := s.ds.Album(ctx).Search(q, offset, size)
|
||||
if len(albums) == 0 || err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
albumIds := make([]string, len(albums))
|
||||
for i, al := range albums {
|
||||
albumIds[i] = al.ID
|
||||
}
|
||||
|
||||
return FromAlbums(albums), nil
|
||||
}
|
||||
|
||||
func (s *search) SearchSong(ctx context.Context, q string, offset int, size int) (Entries, error) {
|
||||
q = sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*")))
|
||||
mediaFiles, err := s.ds.MediaFile(ctx).Search(q, offset, size)
|
||||
if len(mediaFiles) == 0 || err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
trackIds := make([]string, len(mediaFiles))
|
||||
for i, mf := range mediaFiles {
|
||||
trackIds[i] = mf.ID
|
||||
}
|
||||
|
||||
return FromMediaFiles(mediaFiles), nil
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/engine/auth"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
type Users interface {
|
||||
Authenticate(ctx context.Context, username, password, token, salt, jwt string) (*model.User, error)
|
||||
}
|
||||
|
||||
func NewUsers(ds model.DataStore) Users {
|
||||
return &users{ds}
|
||||
}
|
||||
|
||||
type users struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func (u *users) Authenticate(ctx context.Context, username, pass, token, salt, jwt string) (*model.User, error) {
|
||||
user, err := u.ds.User(ctx).FindByUsername(username)
|
||||
if err == model.ErrNotFound {
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
valid := false
|
||||
|
||||
switch {
|
||||
case jwt != "":
|
||||
claims, err := auth.Validate(jwt)
|
||||
valid = err == nil && claims["sub"] == username
|
||||
case pass != "":
|
||||
if strings.HasPrefix(pass, "enc:") {
|
||||
if dec, err := hex.DecodeString(pass[4:]); err == nil {
|
||||
pass = string(dec)
|
||||
}
|
||||
}
|
||||
valid = pass == user.Password
|
||||
case token != "":
|
||||
t := fmt.Sprintf("%x", md5.Sum([]byte(user.Password+salt)))
|
||||
valid = t == token
|
||||
}
|
||||
|
||||
if !valid {
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
// TODO: Find a way to update LastAccessAt without causing too much retention in the DB
|
||||
//go func() {
|
||||
// err := u.ds.User(ctx).UpdateLastAccessAt(user.ID)
|
||||
// if err != nil {
|
||||
// log.Error(ctx, "Could not update user's lastAccessAt", "user", user.UserName)
|
||||
// }
|
||||
//}()
|
||||
return user, nil
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/navidrome/engine/auth"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Users", func() {
|
||||
Describe("Authenticate", func() {
|
||||
var users Users
|
||||
BeforeEach(func() {
|
||||
ds := &persistence.MockDataStore{}
|
||||
users = NewUsers(ds)
|
||||
})
|
||||
|
||||
Context("Plaintext password", func() {
|
||||
It("authenticates with plaintext password ", func() {
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "wordpass", "", "", "")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
|
||||
})
|
||||
|
||||
It("fails authentication with wrong password", func() {
|
||||
_, err := users.Authenticate(context.TODO(), "admin", "INVALID", "", "", "")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Encoded password", func() {
|
||||
It("authenticates with simple encoded password ", func() {
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "enc:776f726470617373", "", "", "")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Token based authentication", func() {
|
||||
It("authenticates with token based authentication", func() {
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "", "23b342970e25c7928831c3317edd0b67", "retnlmjetrymazgkt", "")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
|
||||
})
|
||||
|
||||
It("fails if salt is missing", func() {
|
||||
_, err := users.Authenticate(context.TODO(), "admin", "", "23b342970e25c7928831c3317edd0b67", "", "")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
})
|
||||
|
||||
Context("JWT based authentication", func() {
|
||||
var validToken string
|
||||
BeforeEach(func() {
|
||||
u := &model.User{UserName: "admin"}
|
||||
var err error
|
||||
validToken, err = auth.CreateToken(u)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
It("authenticates with JWT token based authentication", func() {
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "", "", "", validToken)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
|
||||
})
|
||||
|
||||
It("fails if JWT token is invalid", func() {
|
||||
_, err := users.Authenticate(context.TODO(), "admin", "", "", "", "invalid.token")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
|
||||
It("fails if JWT token sub is different than username", func() {
|
||||
_, err := users.Authenticate(context.TODO(), "not_admin", "", "", "", validToken)
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"github.com/deluan/navidrome/engine/transcoder"
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
var Set = wire.NewSet(
|
||||
NewBrowser,
|
||||
NewCover,
|
||||
NewListGenerator,
|
||||
NewPlaylists,
|
||||
NewRatings,
|
||||
NewScrobbler,
|
||||
NewSearch,
|
||||
NewNowPlayingRepository,
|
||||
NewUsers,
|
||||
NewMediaStreamer,
|
||||
transcoder.New,
|
||||
NewTranscodingCache,
|
||||
NewImageCache,
|
||||
NewPlayers,
|
||||
)
|
||||
32
git/pre-commit
Executable file
32
git/pre-commit
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/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.
|
||||
|
||||
if which goimports > /dev/null; then
|
||||
gofmtcmd=goimports
|
||||
else
|
||||
gofmtcmd=gofmt
|
||||
fi
|
||||
|
||||
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 '$gofmtcmd'. Please run:"
|
||||
for fn in $unformatted; do
|
||||
echo >&2 " $gofmtcmd -w $PWD/$fn"
|
||||
done
|
||||
|
||||
exit 1
|
||||
4
git/pre-push
Executable file
4
git/pre-push
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
make pre-push
|
||||
43
go.mod
43
go.mod
@@ -1,42 +1,53 @@
|
||||
module github.com/deluan/navidrome
|
||||
|
||||
go 1.14
|
||||
go 1.15
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v0.3.1 // indirect
|
||||
github.com/Masterminds/squirrel v1.4.0
|
||||
github.com/astaxie/beego v1.12.1
|
||||
github.com/astaxie/beego v1.12.2
|
||||
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible
|
||||
github.com/cespare/reflex v0.3.0
|
||||
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.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-bindata/go-bindata v3.1.2+incompatible
|
||||
github.com/go-chi/chi v4.1.2+incompatible
|
||||
github.com/go-chi/cors v1.1.1
|
||||
github.com/go-chi/httprate v0.4.0
|
||||
github.com/go-chi/jwtauth v4.0.4+incompatible
|
||||
github.com/go-sql-driver/mysql v1.5.0 // indirect
|
||||
github.com/google/uuid v1.1.1
|
||||
github.com/google/uuid v1.1.2
|
||||
github.com/google/wire v0.4.0
|
||||
github.com/kennygrant/sanitize v0.0.0-20170120101633-6a0bfdde8629
|
||||
github.com/koding/multiconfig v0.0.0-20170327155832-26b6dfd3a84a
|
||||
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.3
|
||||
github.com/onsi/gomega v1.10.1
|
||||
github.com/microcosm-cc/bluemonday v1.0.4
|
||||
github.com/mitchellh/mapstructure v1.3.2 // indirect
|
||||
github.com/onsi/ginkgo v1.14.1
|
||||
github.com/onsi/gomega v1.10.2
|
||||
github.com/pelletier/go-toml v1.8.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pressly/goose v2.6.0+incompatible
|
||||
github.com/sirupsen/logrus v1.6.0
|
||||
github.com/stretchr/testify v1.4.0 // 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
|
||||
github.com/sirupsen/logrus v1.7.0
|
||||
github.com/spf13/afero v1.3.1 // indirect
|
||||
github.com/spf13/cast v1.3.1 // indirect
|
||||
github.com/spf13/cobra v1.0.0
|
||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/spf13/viper v1.7.1
|
||||
github.com/unrolled/secure v1.0.8
|
||||
github.com/ziutek/mymysql v1.5.4 // indirect
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8
|
||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344 // indirect
|
||||
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae // indirect
|
||||
golang.org/x/text v0.3.3 // indirect
|
||||
golang.org/x/tools v0.0.0-20200117065230-39095c1d176c
|
||||
google.golang.org/protobuf v1.25.0 // indirect
|
||||
gopkg.in/djherbis/atime.v1 v1.0.0 // indirect
|
||||
gopkg.in/djherbis/stream.v1 v1.3.1 // indirect
|
||||
gopkg.in/ini.v1 v1.57.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/dhowden/tag => github.com/wader/tag v0.0.0-20200426234345-d072771f6a51
|
||||
|
||||
434
go.sum
434
go.sum
@@ -1,21 +1,73 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
||||
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
||||
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||
cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
|
||||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/Knetic/govaluate v3.0.0+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
|
||||
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=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alicebob/gopher-json v0.0.0-20180125190556-5a6b3ba71ee6/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
|
||||
github.com/alicebob/miniredis v2.5.0+incompatible/go.mod h1:8HZjEj4yU0dwhYHky+DxYx+6BMjkBbe5ONFIF1MXffk=
|
||||
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
|
||||
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/astaxie/beego v1.12.2 h1:CajUexhSX5ONWDiSCpeQBNVfTzOtPb9e9d+3vuU5FuU=
|
||||
github.com/astaxie/beego v1.12.2/go.mod h1:TMcqhsbhN3UFpN+RCfysaxPAbrhox6QSS3NIAEp/uzE=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/beego/goyaml2 v0.0.0-20130207012346-5545475820dd/go.mod h1:1b+Y/CofkYwXMUU0OhQqGvsY2Bvgr4j6jfT699wyZKQ=
|
||||
github.com/beego/x2j v0.0.0-20131220205130-a0352aadc542/go.mod h1:kSeGC/p1AbBiEp5kat81+DSQrZenVBZXklMLaELspWU=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||
github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60=
|
||||
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible h1:UafIjBvWQmS9i/xRg+CamMrnLTKNzo+bdmT/oH34c2Y=
|
||||
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible/go.mod h1:Au1Xw1sgaJ5iSFktEhYsS0dbQiS1B0/XMXl+42y9Ilk=
|
||||
github.com/casbin/casbin v1.7.0/go.mod h1:c67qKN6Oum3UF5Q1+BByfFxkwKvhwW57ITjqwtzR1KE=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/reflex v0.3.0 h1:9q8ZXMh+oW4quohPBcLsT56TFl4/DeQsTjZASe4wgoY=
|
||||
github.com/cespare/reflex v0.3.0/go.mod h1:I+0Pnu2W693i7Hv6ZZG76qHTY0mgUa7uCIfCtikXojE=
|
||||
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chris-ramon/douceur v0.2.0 h1:IDMEdxlEUUBYBKE4z/mJnFyVXox+MjuEVDJNN27glkU=
|
||||
github.com/chris-ramon/douceur v0.2.0/go.mod h1:wDW5xjJdeoMm1mRt4sD4c/LbF/mWdEpRXQKjTR8nIBE=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58/go.mod h1:EOBUe0h4xcZ5GoxqC5SDxFQ8gwyZPKQoEzownBlhI80=
|
||||
github.com/couchbase/go-couchbase v0.0.0-20181122212707-3e9b6e1258bb/go.mod h1:TWI8EKQMs5u5jLKW/tsb9VwauIrMIxQG1r5fMsswK5U=
|
||||
github.com/couchbase/gomemcached v0.0.0-20181122193126-5125a94a666c/go.mod h1:srVSlQLB8iXBVXHgnqemxUXqN6FCvClgCMPCsjBDR7c=
|
||||
github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
|
||||
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
|
||||
github.com/couchbase/go-couchbase v0.0.0-20200519150804-63f3cdb75e0d/go.mod h1:TWI8EKQMs5u5jLKW/tsb9VwauIrMIxQG1r5fMsswK5U=
|
||||
github.com/couchbase/gomemcached v0.0.0-20200526233749-ec430f949808/go.mod h1:srVSlQLB8iXBVXHgnqemxUXqN6FCvClgCMPCsjBDR7c=
|
||||
github.com/couchbase/goutils v0.0.0-20180530154633-e865a1461c8a/go.mod h1:BQwMFlJzDjFDG3DJUdU0KORxn88UlsOULuxLExMh3Hs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw=
|
||||
github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/cupcake/rdb v0.0.0-20161107195141-43ba34106c76 h1:Lgdd/Qp96Qj8jqLpq2cI1I1X7BJnu06efS+XkhRoLUQ=
|
||||
github.com/cupcake/rdb v0.0.0-20161107195141-43ba34106c76/go.mod h1:vYwsqCOLxGiisLwp9rITslkFNpZD5rz43tf41QFkTWY=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
@@ -26,6 +78,7 @@ 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/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
|
||||
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.1 h1:hDv+RGyvD+UDKyRYuLoVNbuRTnf2SrA2K3VyR1br9lk=
|
||||
@@ -34,63 +87,134 @@ github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8=
|
||||
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
|
||||
github.com/elastic/go-elasticsearch/v6 v6.8.5/go.mod h1:UwaDJsD3rWLM5rKNFzv9hgox93HoX8utj1kxD9aFUcI=
|
||||
github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
|
||||
github.com/fatih/camelcase v0.0.0-20160318181535-f6a740d52f96 h1:5e8GDOdG6jKeeqNGbR+tlmqhf4vQVs3atTTMEWeEcAk=
|
||||
github.com/fatih/camelcase v0.0.0-20160318181535-f6a740d52f96/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc=
|
||||
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/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
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/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/glendc/gopher-json v0.0.0-20170414221815-dc4743023d0c/go.mod h1:Gja1A+xZ9BoviGJNA2E9vFkPjjsl+CoJxSXiQM1UXtw=
|
||||
github.com/go-bindata/go-bindata v3.1.2+incompatible h1:5vjJMVhowQdPzjE1LdxyFF7YFTXg5IgGVW4gBr5IbvE=
|
||||
github.com/go-bindata/go-bindata v3.1.2+incompatible/go.mod h1:xK8Dsgwmeed+BBsSy2XTopBn/8uK2HWuGSnA11C3Joo=
|
||||
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/httprate v0.4.0 h1:M2qVV0w6ksgLs6L8lTrvqNeaVm0ZJNVdbYM8u2T8HaE=
|
||||
github.com/go-chi/httprate v0.4.0/go.mod h1:7e7qjQtHzEbdyW5TYQrl4X2uNRCnlTajictc7B4ftgc=
|
||||
github.com/go-chi/jwtauth v4.0.4+incompatible h1:LGIxg6YfvSBzxU2BljXbrzVc1fMlgqSKBQgKOGAVtPY=
|
||||
github.com/go-chi/jwtauth v4.0.4+incompatible/go.mod h1:Q5EIArY/QnD6BdS+IyDw7B2m6iNbnPxtfd6/BcmtWbs=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-redis/redis v6.14.2+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
|
||||
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
|
||||
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||
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/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/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.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
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-20170215233205-553a64147049/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
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/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
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/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/subcommands v1.0.1 h1:/eqq+otEXm5vhfBrbREPCSVQbvofip6kIz+mX5TUH7k=
|
||||
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=
|
||||
github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/wire v0.4.0 h1:kXcsA/rIGzJImVqPdhfnr6q0xsS9gU0515q1EPpJ9fE=
|
||||
github.com/google/wire v0.4.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
|
||||
github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
|
||||
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
|
||||
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
|
||||
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
|
||||
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
|
||||
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
|
||||
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
|
||||
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
||||
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
|
||||
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
|
||||
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
|
||||
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/kennygrant/sanitize v0.0.0-20170120101633-6a0bfdde8629 h1:m1E9veL+2sjZOMSM7y3a6jJ9fNVaGyIJCXYDPm9U+/0=
|
||||
github.com/kennygrant/sanitize v0.0.0-20170120101633-6a0bfdde8629/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak=
|
||||
github.com/koding/multiconfig v0.0.0-20170327155832-26b6dfd3a84a h1:KZAp4Cn6Wybs23MKaIrKyb/6+qs2rncDspTuRYwOmvU=
|
||||
github.com/koding/multiconfig v0.0.0-20170327155832-26b6dfd3a84a/go.mod h1:Y2SaZf2Rzd0pXkLVhLlCiAXFCLSXAIbTKDivVgff/AM=
|
||||
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
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/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
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=
|
||||
@@ -100,126 +224,374 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
|
||||
github.com/ledisdb/ledisdb v0.0.0-20200510135210-d35789ec47e6/go.mod h1:n931TsDuKuq+uX4v1fulaMbA/7ZLLhjc85h7chZGBCQ=
|
||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
|
||||
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/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4=
|
||||
github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
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/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/microcosm-cc/bluemonday v1.0.4 h1:p0L+CTpo/PLFdkoPcJemLXG+fpMD7pYOoDEq1axMbGg=
|
||||
github.com/microcosm-cc/bluemonday v1.0.4/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w=
|
||||
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
|
||||
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
|
||||
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
|
||||
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
|
||||
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
|
||||
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.3.2 h1:mRS76wmkOn3KkKAyXDu42V+6ebnXWIztFSYGN7GeoRg=
|
||||
github.com/mitchellh/mapstructure v1.3.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
|
||||
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
|
||||
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
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/ginkgo v1.14.1 h1:jMU0WaQrP0a/YAEq8eJmJKjBoMs+pClEr1vDMlM/Do4=
|
||||
github.com/onsi/ginkgo v1.14.1/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
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/onsi/gomega v1.10.2 h1:aY/nuoWlKJud2J6U0E3NWsjlg+0GtwXxgEqthRdzlcs=
|
||||
github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
github.com/pelletier/go-toml v1.0.1/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
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/pelletier/go-toml v1.8.0 h1:Keo9qb7iRJs2voHvunFtuuYFsbWeOBh8/P9v/kVMFtw=
|
||||
github.com/pelletier/go-toml v1.8.0/go.mod h1:D6yutnOGMveHEPV7VQOuvI/gXY61bv+9bAOTRnLElKs=
|
||||
github.com/peterh/liner v1.0.1-0.20171122030339-3681c2a91233/go.mod h1:xIteQHvHuaLYG9IFj6mSxM0fCKrs34IrEQUhOYuGPHc=
|
||||
github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||
github.com/pressly/goose v2.6.0+incompatible h1:3f8zIQ8rfgP9tyI0Hmcs2YNAqUCL1c+diLe3iU8Qd/k=
|
||||
github.com/pressly/goose v2.6.0+incompatible/go.mod h1:m+QHWCqxR3k8D9l7qfzuC/djtlfzxr34mozWDYEu1z8=
|
||||
github.com/siddontang/go v0.0.0-20180604090527-bdc77568d726 h1:xT+JlYxNGqyT+XcU8iUrN18JYed2TvG9yN5ULG2jATM=
|
||||
github.com/siddontang/go v0.0.0-20180604090527-bdc77568d726/go.mod h1:3yhqj7WBBfRhbBlzyOC3gUxftwsU0u8gqevxwIHQpMw=
|
||||
github.com/siddontang/ledisdb v0.0.0-20181029004158-becf5f38d373 h1:p6IxqQMjab30l4lb9mmkIkkcE1yv6o0SKbPhW5pxqHI=
|
||||
github.com/siddontang/ledisdb v0.0.0-20181029004158-becf5f38d373/go.mod h1:mF1DpOSOUiJRMR+FDqaqu3EBqrybQtrDDszLUZ6oxPg=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
|
||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
github.com/prometheus/client_golang v1.7.0 h1:wCi7urQOGBsYcQROHqpUUX4ct84xp40t9R9JX0FuA/U=
|
||||
github.com/prometheus/client_golang v1.7.0/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
|
||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M=
|
||||
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
|
||||
github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||
github.com/prometheus/common v0.10.0 h1:RyRA7RzGXQZiW+tGMr7sxa85G1z0yOpM1qq5c8lNawc=
|
||||
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
|
||||
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||
github.com/prometheus/procfs v0.1.3 h1:F0+tqvhOksq22sc6iCHF5WGlWjdwj92p0udFh1VFBS8=
|
||||
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
|
||||
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
|
||||
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||
github.com/shiena/ansicolor v0.0.0-20151119151921-a422bbe96644 h1:X+yvsM2yrEktyI+b2qND5gpH8YhURn0k8OCaeRnkINo=
|
||||
github.com/shiena/ansicolor v0.0.0-20151119151921-a422bbe96644/go.mod h1:nkxAfR/5quYxwPZhyDxgasBMnRtBZd0FCEpawpjMUFg=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/siddontang/go v0.0.0-20170517070808-cb568a3e5cc0/go.mod h1:3yhqj7WBBfRhbBlzyOC3gUxftwsU0u8gqevxwIHQpMw=
|
||||
github.com/siddontang/goredis v0.0.0-20150324035039-760763f78400/go.mod h1:DDcKzU3qCuvj/tPnimWSsZZzvk9qvkvrIL5naVBPh5s=
|
||||
github.com/siddontang/rdb v0.0.0-20150307021120-fc89ed2e418d h1:NVwnfyR3rENtlz62bcrkXME3INVUa4lcdGt+opvxExs=
|
||||
github.com/siddontang/rdb v0.0.0-20150307021120-fc89ed2e418d/go.mod h1:AMEsy7v5z92TR1JKMkLLoaOQk++LVnOKL3ScbJ8GNGA=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
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.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I=
|
||||
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
|
||||
github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
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=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
|
||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI=
|
||||
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||
github.com/spf13/afero v1.3.1 h1:GPTpEAuNr98px18yNQ66JllNil98wfRZ/5Ukny8FeQA=
|
||||
github.com/spf13/afero v1.3.1/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
|
||||
github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8=
|
||||
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
|
||||
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8=
|
||||
github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
|
||||
github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk=
|
||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
|
||||
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
|
||||
github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU=
|
||||
github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
|
||||
github.com/spf13/viper v1.7.1 h1:pM5oEahlgWv/WnHXpgbKz7iLIxRf65tye2Ci+XFK5sk=
|
||||
github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
|
||||
github.com/ssdb/gossdb v0.0.0-20180723034631-88f6b59b84ec/go.mod h1:QBvMkMya+gXctz3kmljlUCu/yB3GZ6oee+dUozsezQE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
|
||||
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
||||
github.com/syndtr/goleveldb v0.0.0-20160425020131-cfa635847112/go.mod h1:Z4AUp2Km+PwemOoO/VB5AOx9XSsIItzFjoJlOSiYmn0=
|
||||
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/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
||||
github.com/ugorji/go v0.0.0-20171122102828-84cb69a8af83/go.mod h1:hnLbHMwcvSihnDhEfx2/BzKp2xb0Y+ErdfYcrs9tkJQ=
|
||||
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
|
||||
github.com/unrolled/secure v1.0.8 h1:JaMvKbe4CRt8oyxVXn+xY+6jlqd7pyJNSVkmsBxxQsM=
|
||||
github.com/unrolled/secure v1.0.8/go.mod h1:fO+mEan+FLB0CdEnHf6Q4ZZVNqG+5fuLFnP8p0BXDPI=
|
||||
github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc=
|
||||
github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4=
|
||||
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=
|
||||
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
github.com/yuin/gopher-lua v0.0.0-20171031051903-609c9cd26973/go.mod h1:aEV29XrmTYFr3CiRxZeGHpkvbwq+prZduBqMaascyCU=
|
||||
github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs=
|
||||
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
|
||||
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
||||
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
||||
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/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/net v0.0.0-20200625001655-4c5254603344 h1:vGXIOMxbNfDTk/aXCmfdLgkrSV+Z2tcbze+pEc3v5W4=
|
||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
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-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
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-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/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-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/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/sys v0.0.0-20200615200032-f1bc736245b1 h1:ogLJMz+qpzav7lGMh10LMvAkM/fAoGlaiiHYiFYdm80=
|
||||
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae h1:Ih9Yo4hSPImZOpfGuA4bR/ORKTAbhZo2AbWNRCnevdo=
|
||||
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/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.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384 h1:TFlARGu6Czu1z7q93HTxcP1P+/ZFC/IKythI5RzrnRg=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b h1:NVD8gBK33xpdqCaZVVtd6OFJp+3dxkXuz7+U7KaVN6s=
|
||||
golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
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/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/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
|
||||
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
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.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
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/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/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.3.1 h1:uGfmsOY1qqMjQQphhRBSGLyA9qumJ56exkRu9ASTjCw=
|
||||
gopkg.in/djherbis/stream.v1 v1.3.1/go.mod h1:aEV8CBVRmSpLamVJfM903Npic1IKmb2qS30VAZ+sssg=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno=
|
||||
gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/ini.v1 v1.57.0 h1:9unxIsFcTt4I55uWluz+UmL95q4kdJ0buvQ1ZIqVQww=
|
||||
gopkg.in/ini.v1 v1.57.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
|
||||
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
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.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
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=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||
|
||||
17
lefthook.yml
17
lefthook.yml
@@ -1,17 +0,0 @@
|
||||
pre-push:
|
||||
parallel: true
|
||||
commands:
|
||||
unit-tests:
|
||||
tags: tests
|
||||
run: go test ./...
|
||||
lint:
|
||||
tags: tests
|
||||
run: golangci-lint run
|
||||
|
||||
pre-commit:
|
||||
parallel: false
|
||||
commands:
|
||||
gofmt:
|
||||
tags: style
|
||||
glob: "*.go"
|
||||
run: gofmt -w {staged_files}; git add {staged_files}
|
||||
22
main.go
22
main.go
@@ -1,25 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/db"
|
||||
)
|
||||
import "github.com/deluan/navidrome/cmd"
|
||||
|
||||
func main() {
|
||||
println(consts.Banner())
|
||||
|
||||
conf.Load()
|
||||
db.EnsureLatestVersion()
|
||||
|
||||
subsonic, err := CreateSubsonicAPIRouter()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Could not create the Subsonic API router. Aborting! err=%v", err))
|
||||
}
|
||||
a := CreateServer(conf.Server.MusicFolder)
|
||||
a.MountRouter(consts.URLPathSubsonicAPI, subsonic)
|
||||
a.MountRouter(consts.URLPathUI, CreateAppRouter())
|
||||
a.Run(":" + conf.Server.Port)
|
||||
cmd.Execute()
|
||||
}
|
||||
|
||||
28
model/bookmark.go
Normal file
28
model/bookmark.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Bookmarkable struct {
|
||||
BookmarkPosition int64 `json:"bookmarkPosition"`
|
||||
}
|
||||
|
||||
type BookmarkableRepository interface {
|
||||
AddBookmark(id, comment string, position int64) error
|
||||
DeleteBookmark(id string) error
|
||||
GetBookmarks() (Bookmarks, error)
|
||||
}
|
||||
|
||||
type Bookmark struct {
|
||||
Item MediaFile `json:"item"`
|
||||
Comment string `json:"comment"`
|
||||
Position int64 `json:"position"`
|
||||
ChangedBy string `json:"changed_by"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Bookmarks []Bookmark
|
||||
|
||||
// While I can't find a better way to make these fields optional in the models, I keep this list here
|
||||
// to be used in other packages
|
||||
var BookmarkFields = []string{"bookmarkPosition"}
|
||||
@@ -26,6 +26,7 @@ type DataStore interface {
|
||||
MediaFolder(ctx context.Context) MediaFolderRepository
|
||||
Genre(ctx context.Context) GenreRepository
|
||||
Playlist(ctx context.Context) PlaylistRepository
|
||||
PlayQueue(ctx context.Context) PlayQueueRepository
|
||||
Property(ctx context.Context) PropertyRepository
|
||||
User(ctx context.Context) UserRepository
|
||||
Transcoding(ctx context.Context) TranscodingRepository
|
||||
@@ -34,5 +35,5 @@ type DataStore interface {
|
||||
Resource(ctx context.Context, model interface{}) ResourceRepository
|
||||
|
||||
WithTx(func(tx DataStore) error) error
|
||||
GC(ctx context.Context) error
|
||||
GC(ctx context.Context, rootFolder string) error
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
type MediaFile struct {
|
||||
Annotations
|
||||
Bookmarkable
|
||||
|
||||
ID string `json:"id" orm:"pk;column(id)"`
|
||||
Path string `json:"path"`
|
||||
@@ -53,15 +54,17 @@ type MediaFileRepository interface {
|
||||
Get(id string) (*MediaFile, error)
|
||||
GetAll(options ...QueryOptions) (MediaFiles, error)
|
||||
FindByAlbum(albumId string) (MediaFiles, error)
|
||||
FindByPath(path string) (MediaFiles, error)
|
||||
FindAllByPath(path string) (MediaFiles, error)
|
||||
FindByPath(path string) (*MediaFile, 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)
|
||||
Delete(id string) error
|
||||
DeleteByPath(path string) error
|
||||
DeleteByPath(path string) (int64, error)
|
||||
|
||||
AnnotatedRepository
|
||||
BookmarkableRepository
|
||||
}
|
||||
|
||||
func (mf MediaFile) GetAnnotations() Annotations {
|
||||
|
||||
@@ -13,6 +13,8 @@ type Playlist struct {
|
||||
Owner string `json:"owner"`
|
||||
Public bool `json:"public"`
|
||||
Tracks MediaFiles `json:"tracks,omitempty"`
|
||||
Path string `json:"path"`
|
||||
Sync bool `json:"sync"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
@@ -25,6 +27,7 @@ type PlaylistRepository interface {
|
||||
Put(pls *Playlist) error
|
||||
Get(id string) (*Playlist, error)
|
||||
GetAll(options ...QueryOptions) (Playlists, error)
|
||||
FindByPath(path string) (*Playlist, error)
|
||||
Delete(id string) error
|
||||
Tracks(playlistId string) PlaylistTrackRepository
|
||||
}
|
||||
|
||||
23
model/playqueue.go
Normal file
23
model/playqueue.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type PlayQueue struct {
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
UserID string `json:"userId" orm:"column(user_id)"`
|
||||
Current string `json:"current"`
|
||||
Position int64 `json:"position"`
|
||||
ChangedBy string `json:"changedBy"`
|
||||
Items MediaFiles `json:"items,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type PlayQueues []PlayQueue
|
||||
|
||||
type PlayQueueRepository interface {
|
||||
Store(queue *PlayQueue) error
|
||||
Retrieve(userId string) (*PlayQueue, error)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ type UserRepository interface {
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
Get(id string) (*User, error)
|
||||
Put(*User) error
|
||||
FindFirstAdmin() (*User, error)
|
||||
// FindByUsername must be case-insensitive
|
||||
FindByUsername(username string) (*User, error)
|
||||
UpdateLastLoginAt(id string) error
|
||||
|
||||
@@ -2,6 +2,8 @@ package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -9,9 +11,11 @@ import (
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
"github.com/deluan/rest"
|
||||
)
|
||||
|
||||
@@ -26,21 +30,27 @@ func NewAlbumRepository(ctx context.Context, o orm.Ormer) model.AlbumRepository
|
||||
r.ormer = o
|
||||
r.tableName = "album"
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "order_album_name",
|
||||
"name": "order_album_name asc, order_album_artist_name asc",
|
||||
"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,
|
||||
"compilation": booleanFilter,
|
||||
"artist_id": artistFilter,
|
||||
"year": yearFilter,
|
||||
"name": fullTextFilter,
|
||||
"compilation": booleanFilter,
|
||||
"artist_id": artistFilter,
|
||||
"year": yearFilter,
|
||||
"recently_played": recentlyPlayedFilter,
|
||||
"starred": booleanFilter,
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func recentlyPlayedFilter(field string, value interface{}) Sqlizer {
|
||||
return Gt{"play_count": 0}
|
||||
}
|
||||
|
||||
func yearFilter(field string, value interface{}) Sqlizer {
|
||||
return Or{
|
||||
And{
|
||||
@@ -63,7 +73,7 @@ func artistFilter(field string, value interface{}) Sqlizer {
|
||||
}
|
||||
|
||||
func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
return r.count(Select(), options...)
|
||||
return r.count(r.selectAlbum(), options...)
|
||||
}
|
||||
|
||||
func (r *albumRepository) Exists(id string) (bool, error) {
|
||||
@@ -109,37 +119,87 @@ func (r *albumRepository) GetRandom(options ...model.QueryOptions) (model.Albums
|
||||
return results, err
|
||||
}
|
||||
|
||||
// Return a map of mediafiles that have embedded covers for the given album ids
|
||||
func (r *albumRepository) getEmbeddedCovers(ids []string) (map[string]model.MediaFile, error) {
|
||||
var mfs model.MediaFiles
|
||||
coverSql := Select("album_id", "id", "path").Distinct().From("media_file").
|
||||
Where(And{Eq{"has_cover_art": true}, Eq{"album_id": ids}}).
|
||||
GroupBy("album_id")
|
||||
err := r.queryAll(coverSql, &mfs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := map[string]model.MediaFile{}
|
||||
for _, mf := range mfs {
|
||||
result[mf.AlbumID] = mf
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *albumRepository) Refresh(ids ...string) error {
|
||||
chunks := utils.BreakUpStringSlice(ids, 100)
|
||||
for _, chunk := range chunks {
|
||||
err := r.refresh(chunk...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *albumRepository) refresh(ids ...string) error {
|
||||
type refreshAlbum struct {
|
||||
model.Album
|
||||
CurrentId string
|
||||
HasCoverArt bool
|
||||
SongArtists string
|
||||
Years string
|
||||
DiscSubtitles string
|
||||
Path 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,
|
||||
sel := Select(`f.album_id as id, f.album as name, f.artist, f.album_artist, f.artist_id, f.album_artist_id,
|
||||
f.sort_album_name, f.sort_artist_name, f.sort_album_artist_name,
|
||||
f.order_album_name, f.order_album_artist_name,
|
||||
f.order_album_name, f.order_album_artist_name, f.path,
|
||||
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,
|
||||
count(f.id) as song_count, a.id as current_id,
|
||||
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")
|
||||
Where(Eq{"f.album_id": ids}).GroupBy("f.album_id")
|
||||
err := r.queryAll(sel, &albums)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
covers, err := r.getEmbeddedCovers(ids)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
toInsert := 0
|
||||
toUpdate := 0
|
||||
for _, al := range albums {
|
||||
if !al.HasCoverArt {
|
||||
al.CoverArtId = ""
|
||||
embedded, hasCoverArt := covers[al.ID]
|
||||
if hasCoverArt {
|
||||
al.CoverArtId = embedded.ID
|
||||
al.CoverArtPath = embedded.Path
|
||||
}
|
||||
|
||||
if !hasCoverArt || !strings.HasPrefix(conf.Server.CoverArtPriority, "embedded") {
|
||||
if path := getCoverFromPath(al.Path, al.CoverArtPath); path != "" {
|
||||
al.CoverArtId = "al-" + al.ID
|
||||
al.CoverArtPath = path
|
||||
}
|
||||
}
|
||||
|
||||
if al.CoverArtId != "" {
|
||||
log.Trace(r.ctx, "Found album art", "id", al.ID, "name", al.Name, "coverArtPath", al.CoverArtPath, "coverArtId", al.CoverArtId, "hasCoverArt", hasCoverArt)
|
||||
} else {
|
||||
log.Trace(r.ctx, "Could not find album art", "id", al.ID, "name", al.Name)
|
||||
}
|
||||
|
||||
if al.Compilation {
|
||||
al.AlbumArtist = consts.VariousArtists
|
||||
al.AlbumArtistID = consts.VariousArtistsID
|
||||
@@ -184,6 +244,43 @@ func getMinYear(years string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetCoverFromPath accepts a path to a file, and returns a path to an eligible cover image from the
|
||||
// file's directory (as configured with CoverArtPriority). If no cover file is found, among
|
||||
// available choices, or an error occurs, an empty string is returned. If HasEmbeddedCover is true,
|
||||
// and 'embedded' is matched among eligible choices, GetCoverFromPath will return early with an
|
||||
// empty path.
|
||||
func getCoverFromPath(mediaPath string, embeddedPath string) string {
|
||||
n, err := os.Open(filepath.Dir(mediaPath))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
defer n.Close()
|
||||
names, err := n.Readdirnames(-1)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, p := range strings.Split(conf.Server.CoverArtPriority, ",") {
|
||||
pat := strings.ToLower(strings.TrimSpace(p))
|
||||
if pat == "embedded" {
|
||||
if embeddedPath != "" {
|
||||
return ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
match, _ := filepath.Match(pat, strings.ToLower(name))
|
||||
if match && utils.IsImageFile(name) {
|
||||
return filepath.Join(filepath.Dir(mediaPath), name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -228,5 +325,22 @@ func (r *albumRepository) NewInstance() interface{} {
|
||||
return &model.Album{}
|
||||
}
|
||||
|
||||
func (r albumRepository) Delete(id string) error {
|
||||
return r.delete(Eq{"id": id})
|
||||
}
|
||||
|
||||
func (r albumRepository) Save(entity interface{}) (string, error) {
|
||||
album := entity.(*model.Album)
|
||||
id, err := r.put(album.ID, album)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (r albumRepository) Update(entity interface{}, cols ...string) error {
|
||||
album := entity.(*model.Album)
|
||||
_, err := r.put(album.ID, album)
|
||||
return err
|
||||
}
|
||||
|
||||
var _ model.AlbumRepository = (*albumRepository)(nil)
|
||||
var _ model.ResourceRepository = (*albumRepository)(nil)
|
||||
var _ rest.Persistable = (*albumRepository)(nil)
|
||||
|
||||
@@ -2,8 +2,12 @@ package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
@@ -86,4 +90,52 @@ var _ = Describe("AlbumRepository", func() {
|
||||
Expect(getMinYear("2000 0 1800")).To(Equal(1800))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("getCoverFromPath", func() {
|
||||
testFolder, _ := ioutil.TempDir("", "album_persistence_tests")
|
||||
if err := os.MkdirAll(testFolder, 0777); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if _, err := os.Create(filepath.Join(testFolder, "Cover.jpeg")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if _, err := os.Create(filepath.Join(testFolder, "FRONT.PNG")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
testPath := filepath.Join(testFolder, "somefile.test")
|
||||
embeddedPath := filepath.Join(testFolder, "somefile.mp3")
|
||||
It("returns audio file for embedded cover", func() {
|
||||
conf.Server.CoverArtPriority = "embedded, cover.*, front.*"
|
||||
Expect(getCoverFromPath(testPath, embeddedPath)).To(Equal(""))
|
||||
})
|
||||
|
||||
It("returns external file when no embedded cover exists", func() {
|
||||
conf.Server.CoverArtPriority = "embedded, cover.*, front.*"
|
||||
Expect(getCoverFromPath(testPath, "")).To(Equal(filepath.Join(testFolder, "Cover.jpeg")))
|
||||
})
|
||||
|
||||
It("returns embedded cover even if not first choice", func() {
|
||||
conf.Server.CoverArtPriority = "something.png, embedded, cover.*, front.*"
|
||||
Expect(getCoverFromPath(testPath, embeddedPath)).To(Equal(""))
|
||||
})
|
||||
|
||||
It("returns first correct match case-insensitively", func() {
|
||||
conf.Server.CoverArtPriority = "embedded, cover.jpg, front.svg, front.png"
|
||||
Expect(getCoverFromPath(testPath, "")).To(Equal(filepath.Join(testFolder, "FRONT.PNG")))
|
||||
})
|
||||
|
||||
It("returns match for embedded pattern", func() {
|
||||
conf.Server.CoverArtPriority = "embedded, cover.jp?g, front.png"
|
||||
Expect(getCoverFromPath(testPath, "")).To(Equal(filepath.Join(testFolder, "Cover.jpeg")))
|
||||
})
|
||||
|
||||
It("returns empty string if no match was found", func() {
|
||||
conf.Server.CoverArtPriority = "embedded, cover.jpg, front.apng"
|
||||
Expect(getCoverFromPath(testPath, "")).To(Equal(""))
|
||||
})
|
||||
|
||||
// Reset configuration to default.
|
||||
conf.Server.CoverArtPriority = "embedded, cover.*, front.*"
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,7 +30,8 @@ func NewArtistRepository(ctx context.Context, o orm.Ormer) model.ArtistRepositor
|
||||
"name": "order_artist_name",
|
||||
}
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"name": fullTextFilter,
|
||||
"name": fullTextFilter,
|
||||
"starred": booleanFilter,
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -40,7 +41,7 @@ func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBui
|
||||
}
|
||||
|
||||
func (r *artistRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
return r.count(Select(), options...)
|
||||
return r.count(r.newSelectWithAnnotation("artist.id"), options...)
|
||||
}
|
||||
|
||||
func (r *artistRepository) Exists(id string) (bool, error) {
|
||||
@@ -114,6 +115,17 @@ func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
|
||||
}
|
||||
|
||||
func (r *artistRepository) Refresh(ids ...string) error {
|
||||
chunks := utils.BreakUpStringSlice(ids, 100)
|
||||
for _, chunk := range chunks {
|
||||
err := r.refresh(chunk...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *artistRepository) refresh(ids ...string) error {
|
||||
type refreshArtist struct {
|
||||
model.Artist
|
||||
CurrentId string
|
||||
@@ -197,6 +209,21 @@ func (r *artistRepository) NewInstance() interface{} {
|
||||
return &model.Artist{}
|
||||
}
|
||||
|
||||
var _ model.ArtistRepository = (*artistRepository)(nil)
|
||||
func (r artistRepository) Delete(id string) error {
|
||||
return r.delete(Eq{"id": id})
|
||||
}
|
||||
|
||||
func (r artistRepository) Save(entity interface{}) (string, error) {
|
||||
artist := entity.(*model.Artist)
|
||||
err := r.Put(artist)
|
||||
return artist.ID, err
|
||||
}
|
||||
|
||||
func (r artistRepository) Update(entity interface{}, cols ...string) error {
|
||||
artist := entity.(*model.Artist)
|
||||
return r.Put(artist)
|
||||
}
|
||||
|
||||
var _ model.ArtistRepository = (*artistRepository)(nil)
|
||||
var _ model.ResourceRepository = (*artistRepository)(nil)
|
||||
var _ rest.Persistable = (*artistRepository)(nil)
|
||||
|
||||
@@ -23,7 +23,9 @@ func toSqlArgs(rec interface{}) (map[string]interface{}, error) {
|
||||
err = json.Unmarshal(b, &m)
|
||||
r := make(map[string]interface{}, len(m))
|
||||
for f, v := range m {
|
||||
if !utils.StringInSlice(f, model.AnnotationFields) && v != nil {
|
||||
isAnnotationField := utils.StringInSlice(f, model.AnnotationFields)
|
||||
isBookmarkField := utils.StringInSlice(f, model.BookmarkFields)
|
||||
if !isAnnotationField && !isBookmarkField && v != nil {
|
||||
r[toSnakeCase(f)] = v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
@@ -51,7 +53,8 @@ func (r mediaFileRepository) Put(m *model.MediaFile) error {
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder {
|
||||
return r.newSelectWithAnnotation("media_file.id", options...).Columns("media_file.*")
|
||||
sql := r.newSelectWithAnnotation("media_file.id", options...).Columns("media_file.*")
|
||||
return r.withBookmark(sql, "media_file.id")
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) Get(id string) (*model.MediaFile, error) {
|
||||
@@ -80,11 +83,38 @@ 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) {
|
||||
func (r mediaFileRepository) FindByPath(path string) (*model.MediaFile, error) {
|
||||
sel := r.selectMediaFile().Where(Eq{"path": path})
|
||||
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 cleanPath(path string) string {
|
||||
path = filepath.Clean(path)
|
||||
if !strings.HasSuffix(path, string(os.PathSeparator)) {
|
||||
path = path + string(os.PathSeparator)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func pathStartsWith(path string) Eq {
|
||||
substr := fmt.Sprintf("substr(path, 1, %d)", utf8.RuneCountInString(path))
|
||||
return Eq{substr: path}
|
||||
}
|
||||
|
||||
// FindAllByPath only return mediafiles that are direct children of requested path
|
||||
func (r mediaFileRepository) FindAllByPath(path string) (model.MediaFiles, error) {
|
||||
// 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, "%")})
|
||||
path = cleanPath(path)
|
||||
pathLen := utf8.RuneCountInString(path)
|
||||
sel0 := r.selectMediaFile().Columns(fmt.Sprintf("substr(path, %d) AS item", pathLen+2)).
|
||||
Where(pathStartsWith(path))
|
||||
sel := r.newSelect().Columns("*", "item NOT GLOB '*"+string(os.PathSeparator)+"*' AS isLast").
|
||||
Where(Eq{"isLast": 1}).FromSelect(sel0, "sel0")
|
||||
|
||||
@@ -95,14 +125,27 @@ func (r mediaFileRepository) FindByPath(path string) (model.MediaFiles, error) {
|
||||
|
||||
// FindPathsRecursively returns a list of all subfolders of basePath, recursively
|
||||
func (r mediaFileRepository) FindPathsRecursively(basePath string) ([]string, error) {
|
||||
path := cleanPath(basePath)
|
||||
// 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, "%")})
|
||||
Where(pathStartsWith(path))
|
||||
var res []string
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) deleteNotInPath(basePath string) error {
|
||||
path := cleanPath(basePath)
|
||||
sel := Delete(r.tableName).Where(NotEq(pathStartsWith(path)))
|
||||
c, err := r.executeSQL(sel)
|
||||
if err == nil {
|
||||
if c > 0 {
|
||||
log.Debug(r.ctx, "Deleted dangling tracks", "totalDeleted", c)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) GetStarred(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
sq := r.selectMediaFile(options...).Where("starred = true")
|
||||
starred := model.MediaFiles{}
|
||||
@@ -124,14 +167,14 @@ func (r mediaFileRepository) Delete(id string) error {
|
||||
}
|
||||
|
||||
// DeleteByPath delete from the DB all mediafiles that are direct children of path
|
||||
func (r mediaFileRepository) DeleteByPath(path string) error {
|
||||
path = filepath.Clean(path)
|
||||
func (r mediaFileRepository) DeleteByPath(basePath string) (int64, error) {
|
||||
path := cleanPath(basePath)
|
||||
pathLen := utf8.RuneCountInString(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}})
|
||||
Where(And{pathStartsWith(path),
|
||||
Eq{fmt.Sprintf("substr(path, %d) glob '*%s*'", pathLen+2, string(os.PathSeparator)): 0}})
|
||||
log.Debug(r.ctx, "Deleting mediafiles by path", "path", path)
|
||||
_, err := r.executeSQL(del)
|
||||
return err
|
||||
return r.executeSQL(del)
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) Search(q string, offset int, size int) (model.MediaFiles, error) {
|
||||
|
||||
@@ -51,10 +51,38 @@ var _ = Describe("MediaRepository", func() {
|
||||
Expect(mr.FindByAlbum("67")).To(Equal(model.MediaFiles{}))
|
||||
})
|
||||
|
||||
It("finds tracks by path", func() {
|
||||
Expect(mr.FindByPath(P("/beatles/1/sgt"))).To(Equal(model.MediaFiles{
|
||||
songDayInALife,
|
||||
}))
|
||||
It("finds tracks by path when using wildcards chars", func() {
|
||||
Expect(mr.Put(&model.MediaFile{ID: "7001", Path: P("/Find:By'Path/_/123.mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{ID: "7002", Path: P("/Find:By'Path/1/123.mp3")})).To(BeNil())
|
||||
|
||||
found, err := mr.FindAllByPath(P("/Find:By'Path/_/"))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(found).To(HaveLen(1))
|
||||
Expect(found[0].ID).To(Equal("7001"))
|
||||
})
|
||||
|
||||
It("finds tracks by path when using UTF8 chars", func() {
|
||||
Expect(mr.Put(&model.MediaFile{ID: "7010", Path: P("/Пётр Ильич Чайковский/123.mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{ID: "7011", Path: P("/Пётр Ильич Чайковский/222.mp3")})).To(BeNil())
|
||||
|
||||
found, err := mr.FindAllByPath(P("/Пётр Ильич Чайковский/"))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(found).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("finds tracks by path case sensitively", func() {
|
||||
Expect(mr.Put(&model.MediaFile{ID: "7003", Path: P("/Casesensitive/file1.mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{ID: "7004", Path: P("/casesensitive/file2.mp3")})).To(BeNil())
|
||||
|
||||
found, err := mr.FindAllByPath(P("/Casesensitive"))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(found).To(HaveLen(1))
|
||||
Expect(found[0].ID).To(Equal("7003"))
|
||||
|
||||
found, err = mr.FindAllByPath(P("/casesensitive/"))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(found).To(HaveLen(1))
|
||||
Expect(found[0].ID).To(Equal("7004"))
|
||||
})
|
||||
|
||||
It("returns starred tracks", func() {
|
||||
@@ -75,21 +103,53 @@ var _ = Describe("MediaRepository", func() {
|
||||
})
|
||||
|
||||
It("delete tracks by path", func() {
|
||||
id1 := "1111"
|
||||
id1 := "6001"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id1, Path: P("/abc/123/" + id1 + ".mp3")})).To(BeNil())
|
||||
id2 := "2222"
|
||||
id2 := "6002"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id2, Path: P("/abc/123/" + id2 + ".mp3")})).To(BeNil())
|
||||
id3 := "3333"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id3, Path: P("/abc/" + id3 + ".mp3")})).To(BeNil())
|
||||
id3 := "6003"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id3, Path: P("/ab_/" + id3 + ".mp3")})).To(BeNil())
|
||||
id4 := "6004"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id4, Path: P("/abc/" + id4 + ".mp3")})).To(BeNil())
|
||||
id5 := "6005"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id5, Path: P("/Ab_/" + id5 + ".mp3")})).To(BeNil())
|
||||
|
||||
Expect(mr.DeleteByPath(P("/abc"))).To(BeNil())
|
||||
Expect(mr.DeleteByPath(P("/ab_"))).To(Equal(int64(1)))
|
||||
|
||||
Expect(mr.Get(id1)).ToNot(BeNil())
|
||||
Expect(mr.Get(id2)).ToNot(BeNil())
|
||||
Expect(mr.Get(id4)).ToNot(BeNil())
|
||||
Expect(mr.Get(id5)).ToNot(BeNil())
|
||||
_, err := mr.Get(id3)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("delete tracks by path containing UTF8 chars", func() {
|
||||
id1 := "6011"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id1, Path: P("/Legião Urbana/" + id1 + ".mp3")})).To(BeNil())
|
||||
id2 := "6012"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id2, Path: P("/Legião Urbana/" + id2 + ".mp3")})).To(BeNil())
|
||||
id3 := "6003"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id3, Path: P("/Legião Urbana/" + id3 + ".mp3")})).To(BeNil())
|
||||
|
||||
Expect(mr.FindAllByPath(P("/Legião Urbana"))).To(HaveLen(3))
|
||||
Expect(mr.DeleteByPath(P("/Legião Urbana"))).To(Equal(int64(3)))
|
||||
Expect(mr.FindAllByPath(P("/Legião Urbana"))).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("only deletes tracks that match exact path", func() {
|
||||
id1 := "6021"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id1, Path: P("/music/overlap/Ella Fitzgerald/" + id1 + ".mp3")})).To(BeNil())
|
||||
id2 := "6022"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id2, Path: P("/music/overlap/Ella Fitzgerald/" + id2 + ".mp3")})).To(BeNil())
|
||||
id3 := "6023"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id3, Path: P("/music/overlap/Ella Fitzgerald & Louis Armstrong - They Can't Take That Away From Me.mp3")})).To(BeNil())
|
||||
|
||||
Expect(mr.FindAllByPath(P("/music/overlap/Ella Fitzgerald"))).To(HaveLen(2))
|
||||
Expect(mr.DeleteByPath(P("/music/overlap/Ella Fitzgerald"))).To(Equal(int64(2)))
|
||||
Expect(mr.FindAllByPath(P("/music/overlap"))).To(HaveLen(1))
|
||||
})
|
||||
|
||||
Context("Annotations", func() {
|
||||
It("increments play count when the tracks does not have annotations", func() {
|
||||
id := "incplay.firsttime"
|
||||
|
||||
@@ -52,6 +52,10 @@ func (db *MockDataStore) Playlist(context.Context) model.PlaylistRepository {
|
||||
return struct{ model.PlaylistRepository }{}
|
||||
}
|
||||
|
||||
func (db *MockDataStore) PlayQueue(context.Context) model.PlayQueueRepository {
|
||||
return struct{ model.PlayQueueRepository }{}
|
||||
}
|
||||
|
||||
func (db *MockDataStore) Property(context.Context) model.PropertyRepository {
|
||||
return struct{ model.PropertyRepository }{}
|
||||
}
|
||||
@@ -85,7 +89,7 @@ func (db *MockDataStore) Resource(ctx context.Context, m interface{}) model.Reso
|
||||
return struct{ model.ResourceRepository }{}
|
||||
}
|
||||
|
||||
func (db *MockDataStore) GC(ctx context.Context) error {
|
||||
func (db *MockDataStore) GC(ctx context.Context, rootFolder string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -94,6 +98,9 @@ type mockedUserRepo struct {
|
||||
}
|
||||
|
||||
func (u *mockedUserRepo) FindByUsername(username string) (*model.User, error) {
|
||||
if username != "admin" {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return &model.User{UserName: "admin", Password: "wordpass"}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,10 @@ func (s *SQLStore) Genre(ctx context.Context) model.GenreRepository {
|
||||
return NewGenreRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (s *SQLStore) PlayQueue(ctx context.Context) model.PlayQueueRepository {
|
||||
return NewPlayQueueRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (s *SQLStore) Playlist(ctx context.Context) model.PlaylistRepository {
|
||||
return NewPlaylistRepository(ctx, s.getOrmer())
|
||||
}
|
||||
@@ -107,28 +111,47 @@ func (s *SQLStore) WithTx(block func(tx model.DataStore) error) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) GC(ctx context.Context) error {
|
||||
err := s.Album(ctx).(*albumRepository).purgeEmpty()
|
||||
func (s *SQLStore) GC(ctx context.Context, rootFolder string) error {
|
||||
err := s.MediaFile(ctx).(*mediaFileRepository).deleteNotInPath(rootFolder)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error removing dangling tracks", err)
|
||||
return err
|
||||
}
|
||||
err = s.Album(ctx).(*albumRepository).purgeEmpty()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error removing empty albums", err)
|
||||
return err
|
||||
}
|
||||
err = s.Artist(ctx).(*artistRepository).purgeEmpty()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error removing empty artists", err)
|
||||
return err
|
||||
}
|
||||
err = s.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error removing orphan mediafile annotations", err)
|
||||
return err
|
||||
}
|
||||
err = s.Album(ctx).(*albumRepository).cleanAnnotations()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error removing orphan album annotations", err)
|
||||
return err
|
||||
}
|
||||
err = s.Artist(ctx).(*artistRepository).cleanAnnotations()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error removing orphan artist annotations", err)
|
||||
return err
|
||||
}
|
||||
return s.Playlist(ctx).(*playlistRepository).removeOrphans()
|
||||
err = s.MediaFile(ctx).(*mediaFileRepository).cleanBookmarks()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error removing orphan bookmarks", err)
|
||||
return err
|
||||
}
|
||||
err = s.Playlist(ctx).(*playlistRepository).removeOrphans()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error tidying up playlists", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) getOrmer() orm.Ormer {
|
||||
|
||||
@@ -103,6 +103,16 @@ func (r *playlistRepository) Get(id string) (*model.Playlist, error) {
|
||||
return &pls, err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) FindByPath(path string) (*model.Playlist, error) {
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"path": path})
|
||||
var pls model.Playlist
|
||||
err := r.queryOne(sel, &pls)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pls, err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playlists, error) {
|
||||
sel := r.newSelect(options...).Columns("*").Where(r.userFilter())
|
||||
res := model.Playlists{}
|
||||
|
||||
153
persistence/playqueue_repository.go
Normal file
153
persistence/playqueue_repository.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
type playQueueRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewPlayQueueRepository(ctx context.Context, o orm.Ormer) model.PlayQueueRepository {
|
||||
r := &playQueueRepository{}
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
r.tableName = "playqueue"
|
||||
return r
|
||||
}
|
||||
|
||||
type playQueue struct {
|
||||
ID string `orm:"column(id)"`
|
||||
UserID string `orm:"column(user_id)"`
|
||||
Current string
|
||||
Position int64
|
||||
ChangedBy string
|
||||
Items string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (r *playQueueRepository) Store(q *model.PlayQueue) error {
|
||||
u := loggedUser(r.ctx)
|
||||
err := r.clearPlayQueue(q.UserID)
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Error deleting previous playqueue", "user", u.UserName, err)
|
||||
return err
|
||||
}
|
||||
pq := r.fromModel(q)
|
||||
if pq.ID == "" {
|
||||
pq.CreatedAt = time.Now()
|
||||
}
|
||||
pq.UpdatedAt = time.Now()
|
||||
_, err = r.put(pq.ID, pq)
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Error saving playqueue", "user", u.UserName, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *playQueueRepository) Retrieve(userId string) (*model.PlayQueue, error) {
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"user_id": userId})
|
||||
var res playQueue
|
||||
err := r.queryOne(sel, &res)
|
||||
pls := r.toModel(&res)
|
||||
return &pls, err
|
||||
}
|
||||
|
||||
func (r *playQueueRepository) fromModel(q *model.PlayQueue) playQueue {
|
||||
pq := playQueue{
|
||||
ID: q.ID,
|
||||
UserID: q.UserID,
|
||||
Current: q.Current,
|
||||
Position: q.Position,
|
||||
ChangedBy: q.ChangedBy,
|
||||
CreatedAt: q.CreatedAt,
|
||||
UpdatedAt: q.UpdatedAt,
|
||||
}
|
||||
var itemIDs []string
|
||||
for _, t := range q.Items {
|
||||
itemIDs = append(itemIDs, t.ID)
|
||||
}
|
||||
pq.Items = strings.Join(itemIDs, ",")
|
||||
return pq
|
||||
}
|
||||
|
||||
func (r *playQueueRepository) toModel(pq *playQueue) model.PlayQueue {
|
||||
q := model.PlayQueue{
|
||||
ID: pq.ID,
|
||||
UserID: pq.UserID,
|
||||
Current: pq.Current,
|
||||
Position: pq.Position,
|
||||
ChangedBy: pq.ChangedBy,
|
||||
CreatedAt: pq.CreatedAt,
|
||||
UpdatedAt: pq.UpdatedAt,
|
||||
}
|
||||
if strings.TrimSpace(pq.Items) != "" {
|
||||
tracks := strings.Split(pq.Items, ",")
|
||||
for _, t := range tracks {
|
||||
q.Items = append(q.Items, model.MediaFile{ID: t})
|
||||
}
|
||||
}
|
||||
q.Items = r.loadTracks(q.Items)
|
||||
return q
|
||||
}
|
||||
|
||||
func (r *playQueueRepository) loadTracks(tracks model.MediaFiles) model.MediaFiles {
|
||||
if len(tracks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect all ids
|
||||
ids := make([]string, len(tracks))
|
||||
for i, t := range tracks {
|
||||
ids[i] = t.ID
|
||||
}
|
||||
|
||||
// Break the list in chunks, up to 50 items, to avoid hitting SQLITE_MAX_FUNCTION_ARG limit
|
||||
const chunkSize = 50
|
||||
var chunks [][]string
|
||||
for i := 0; i < len(ids); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
|
||||
chunks = append(chunks, ids[i:end])
|
||||
}
|
||||
|
||||
// Query each chunk of media_file ids and store results in a map
|
||||
mfRepo := NewMediaFileRepository(r.ctx, r.ormer)
|
||||
trackMap := map[string]model.MediaFile{}
|
||||
for i := range chunks {
|
||||
idsFilter := Eq{"id": chunks[i]}
|
||||
tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: idsFilter})
|
||||
if err != nil {
|
||||
u := loggedUser(r.ctx)
|
||||
log.Error(r.ctx, "Could not load playqueue/bookmark's tracks", "user", u.UserName, err)
|
||||
}
|
||||
for _, t := range tracks {
|
||||
trackMap[t.ID] = t
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new list of tracks with the same order as the original
|
||||
newTracks := make(model.MediaFiles, len(tracks))
|
||||
for i, t := range tracks {
|
||||
newTracks[i] = trackMap[t.ID]
|
||||
}
|
||||
return newTracks
|
||||
}
|
||||
|
||||
func (r *playQueueRepository) clearPlayQueue(userId string) error {
|
||||
return r.delete(Eq{"user_id": userId})
|
||||
}
|
||||
|
||||
var _ model.PlayQueueRepository = (*playQueueRepository)(nil)
|
||||
92
persistence/playqueue_repository_test.go
Normal file
92
persistence/playqueue_repository_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"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"
|
||||
)
|
||||
|
||||
var _ = Describe("PlayQueueRepository", func() {
|
||||
var repo model.PlayQueueRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user1", UserName: "user1", IsAdmin: true})
|
||||
repo = NewPlayQueueRepository(ctx, orm.NewOrm())
|
||||
})
|
||||
|
||||
Describe("PlayQueues", func() {
|
||||
It("returns notfound error if there's no playqueue for the user", func() {
|
||||
_, err := repo.Retrieve("user999")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("stores and retrieves the playqueue for the user", func() {
|
||||
By("Storing a playqueue for the user")
|
||||
|
||||
expected := aPlayQueue("user1", songDayInALife.ID, 123, songComeTogether, songDayInALife)
|
||||
Expect(repo.Store(expected)).To(BeNil())
|
||||
|
||||
actual, err := repo.Retrieve("user1")
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
AssertPlayQueue(expected, actual)
|
||||
|
||||
By("Storing a new playqueue for the same user")
|
||||
|
||||
new := aPlayQueue("user1", songRadioactivity.ID, 321, songAntenna, songRadioactivity)
|
||||
Expect(repo.Store(new)).To(BeNil())
|
||||
|
||||
actual, err = repo.Retrieve("user1")
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
AssertPlayQueue(new, actual)
|
||||
Expect(countPlayQueues(repo, "user1")).To(Equal(1))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func countPlayQueues(repo model.PlayQueueRepository, userId string) int {
|
||||
r := repo.(*playQueueRepository)
|
||||
c, err := r.count(squirrel.Select().Where(squirrel.Eq{"user_id": userId}))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return int(c)
|
||||
}
|
||||
|
||||
func AssertPlayQueue(expected, actual *model.PlayQueue) {
|
||||
Expect(actual.ID).To(Equal(expected.ID))
|
||||
Expect(actual.UserID).To(Equal(expected.UserID))
|
||||
Expect(actual.Current).To(Equal(expected.Current))
|
||||
Expect(actual.Position).To(Equal(expected.Position))
|
||||
Expect(actual.ChangedBy).To(Equal(expected.ChangedBy))
|
||||
Expect(actual.Items).To(HaveLen(len(expected.Items)))
|
||||
for i, item := range actual.Items {
|
||||
Expect(item.Title).To(Equal(expected.Items[i].Title))
|
||||
}
|
||||
}
|
||||
|
||||
func aPlayQueue(userId, current string, position int64, items ...model.MediaFile) *model.PlayQueue {
|
||||
createdAt := time.Now()
|
||||
updatedAt := createdAt.Add(time.Minute)
|
||||
id, _ := uuid.NewRandom()
|
||||
return &model.PlayQueue{
|
||||
ID: id.String(),
|
||||
UserID: userId,
|
||||
Current: current,
|
||||
Position: position,
|
||||
ChangedBy: "test",
|
||||
Items: items,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,9 @@ func (r sqlRepository) newSelectWithAnnotation(idField string, options ...model.
|
||||
|
||||
func (r sqlRepository) annId(itemID ...string) And {
|
||||
return And{
|
||||
Eq{"user_id": userId(r.ctx)},
|
||||
Eq{"item_type": r.tableName},
|
||||
Eq{"item_id": itemID},
|
||||
Eq{annotationTable + ".user_id": userId(r.ctx)},
|
||||
Eq{annotationTable + ".item_type": r.tableName},
|
||||
Eq{annotationTable + ".item_id": itemID},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,12 +96,3 @@ 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)
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ 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)
|
||||
// Note: Due to a bug in the QueryRow method, this function 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()
|
||||
@@ -171,9 +171,6 @@ func (r sqlRepository) put(id string, m interface{}) (newId string, err error) {
|
||||
return "", err
|
||||
}
|
||||
if count > 0 {
|
||||
if _, ok := m.(model.AnnotatedModel); ok {
|
||||
err = r.updateAnnotations(id, m)
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
}
|
||||
|
||||
151
persistence/sql_bookmarks.go
Normal file
151
persistence/sql_bookmarks.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
)
|
||||
|
||||
const bookmarkTable = "bookmark"
|
||||
|
||||
func (r sqlRepository) withBookmark(sql SelectBuilder, idField string) SelectBuilder {
|
||||
return sql.
|
||||
LeftJoin("bookmark on (" +
|
||||
"bookmark.item_id = " + idField +
|
||||
" AND bookmark.item_type = '" + r.tableName + "'" +
|
||||
" AND bookmark.user_id = '" + userId(r.ctx) + "')").
|
||||
Columns("position as bookmark_position")
|
||||
}
|
||||
|
||||
func (r sqlRepository) bmkID(itemID ...string) And {
|
||||
return And{
|
||||
Eq{bookmarkTable + ".user_id": userId(r.ctx)},
|
||||
Eq{bookmarkTable + ".item_type": r.tableName},
|
||||
Eq{bookmarkTable + ".item_id": itemID},
|
||||
}
|
||||
}
|
||||
|
||||
func (r sqlRepository) bmkUpsert(itemID, comment string, position int64) error {
|
||||
client, _ := request.ClientFrom(r.ctx)
|
||||
user, _ := request.UserFrom(r.ctx)
|
||||
values := map[string]interface{}{
|
||||
"comment": comment,
|
||||
"position": position,
|
||||
"updated_at": time.Now(),
|
||||
"changed_by": client,
|
||||
}
|
||||
|
||||
upd := Update(bookmarkTable).Where(r.bmkID(itemID)).SetMap(values)
|
||||
c, err := r.executeSQL(upd)
|
||||
if err == nil {
|
||||
log.Debug(r.ctx, "Updated bookmark", "id", itemID, "user", user.UserName, "position", position, "comment", comment)
|
||||
}
|
||||
if c == 0 || err == orm.ErrNoRows {
|
||||
values["user_id"] = user.ID
|
||||
values["item_type"] = r.tableName
|
||||
values["item_id"] = itemID
|
||||
values["created_at"] = time.Now()
|
||||
values["updated_at"] = time.Now()
|
||||
ins := Insert(bookmarkTable).SetMap(values)
|
||||
_, err = r.executeSQL(ins)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Debug(r.ctx, "Added bookmark", "id", itemID, "user", user.UserName, "position", position, "comment", comment)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (r sqlRepository) AddBookmark(id, comment string, position int64) error {
|
||||
user, _ := request.UserFrom(r.ctx)
|
||||
err := r.bmkUpsert(id, comment, position)
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Error adding bookmark", "id", id, "user", user.UserName, "position", position, "comment", comment)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r sqlRepository) DeleteBookmark(id string) error {
|
||||
user, _ := request.UserFrom(r.ctx)
|
||||
del := Delete(bookmarkTable).Where(r.bmkID(id))
|
||||
_, err := r.executeSQL(del)
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Error removing bookmark", "id", id, "user", user.UserName)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type bookmark struct {
|
||||
UserID string `json:"user_id" orm:"column(user_id)"`
|
||||
ItemID string `json:"item_id" orm:"column(item_id)"`
|
||||
ItemType string `json:"item_type"`
|
||||
Comment string `json:"comment"`
|
||||
Position int64 `json:"position"`
|
||||
ChangedBy string `json:"changed_by"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (r sqlRepository) GetBookmarks() (model.Bookmarks, error) {
|
||||
user, _ := request.UserFrom(r.ctx)
|
||||
|
||||
idField := r.tableName + ".id"
|
||||
sql := r.newSelectWithAnnotation(idField).Columns("*")
|
||||
sql = r.withBookmark(sql, idField).Where(NotEq{bookmarkTable + ".item_id": nil})
|
||||
var mfs model.MediaFiles
|
||||
err := r.queryAll(sql, &mfs)
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Error getting mediafiles with bookmarks", "user", user.UserName, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]string, len(mfs))
|
||||
mfMap := make(map[string]int)
|
||||
for i, mf := range mfs {
|
||||
ids[i] = mf.ID
|
||||
mfMap[mf.ID] = i
|
||||
}
|
||||
|
||||
sql = Select("*").From(bookmarkTable).Where(r.bmkID(ids...))
|
||||
var bmks []bookmark
|
||||
err = r.queryAll(sql, &bmks)
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Error getting bookmarks", "user", user.UserName, "ids", ids, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := make(model.Bookmarks, len(bmks))
|
||||
for i, bmk := range bmks {
|
||||
if itemIdx, ok := mfMap[bmk.ItemID]; !ok {
|
||||
log.Debug(r.ctx, "Invalid bookmark", "id", bmk.ItemID, "user", user.UserName)
|
||||
continue
|
||||
} else {
|
||||
resp[i] = model.Bookmark{
|
||||
Comment: bmk.Comment,
|
||||
Position: bmk.Position,
|
||||
CreatedAt: bmk.CreatedAt,
|
||||
UpdatedAt: bmk.UpdatedAt,
|
||||
ChangedBy: bmk.ChangedBy,
|
||||
Item: mfs[itemIdx],
|
||||
}
|
||||
}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (r sqlRepository) cleanBookmarks() error {
|
||||
del := Delete(bookmarkTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")")
|
||||
c, err := r.executeSQL(del)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c > 0 {
|
||||
log.Debug(r.ctx, "Clean-up bookmarks", "totalDeleted", c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
72
persistence/sql_bookmarks_test.go
Normal file
72
persistence/sql_bookmarks_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
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"
|
||||
)
|
||||
|
||||
var _ = Describe("sqlBookmarks", func() {
|
||||
var mr model.MediaFileRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user1"})
|
||||
mr = NewMediaFileRepository(ctx, orm.NewOrm())
|
||||
})
|
||||
|
||||
Describe("Bookmarks", func() {
|
||||
It("returns an empty collection if there are no bookmarks", func() {
|
||||
Expect(mr.GetBookmarks()).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("saves and overrides bookmarks", func() {
|
||||
By("Saving the bookmark")
|
||||
Expect(mr.AddBookmark(songAntenna.ID, "this is a comment", 123)).To(BeNil())
|
||||
|
||||
bms, err := mr.GetBookmarks()
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(bms).To(HaveLen(1))
|
||||
Expect(bms[0].Item.ID).To(Equal(songAntenna.ID))
|
||||
Expect(bms[0].Item.Title).To(Equal(songAntenna.Title))
|
||||
Expect(bms[0].Comment).To(Equal("this is a comment"))
|
||||
Expect(bms[0].Position).To(Equal(int64(123)))
|
||||
created := bms[0].CreatedAt
|
||||
updated := bms[0].UpdatedAt
|
||||
Expect(created.IsZero()).To(BeFalse())
|
||||
Expect(updated).To(BeTemporally(">=", created))
|
||||
|
||||
By("Overriding the bookmark")
|
||||
Expect(mr.AddBookmark(songAntenna.ID, "another comment", 333)).To(BeNil())
|
||||
|
||||
bms, err = mr.GetBookmarks()
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(bms[0].Item.ID).To(Equal(songAntenna.ID))
|
||||
Expect(bms[0].Comment).To(Equal("another comment"))
|
||||
Expect(bms[0].Position).To(Equal(int64(333)))
|
||||
Expect(bms[0].CreatedAt).To(Equal(created))
|
||||
Expect(bms[0].UpdatedAt).To(BeTemporally(">=", updated))
|
||||
|
||||
By("Saving another bookmark")
|
||||
Expect(mr.AddBookmark(songComeTogether.ID, "one more comment", 444)).To(BeNil())
|
||||
bms, err = mr.GetBookmarks()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(bms).To(HaveLen(2))
|
||||
|
||||
By("Delete bookmark")
|
||||
Expect(mr.DeleteBookmark(songAntenna.ID))
|
||||
bms, err = mr.GetBookmarks()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(bms).To(HaveLen(1))
|
||||
Expect(bms[0].Item.ID).To(Equal(songComeTogether.ID))
|
||||
Expect(bms[0].Item.Title).To(Equal(songComeTogether.Title))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -62,11 +62,5 @@ func booleanFilter(field string, value interface{}) Sqlizer {
|
||||
}
|
||||
|
||||
func fullTextFilter(field string, value interface{}) Sqlizer {
|
||||
q := sanitizeStrings(value.(string))
|
||||
parts := strings.Split(q, " ")
|
||||
filters := And{}
|
||||
for _, part := range parts {
|
||||
filters = append(filters, Like{"full_text": "% " + part + "%"})
|
||||
}
|
||||
return filters
|
||||
return fullTextExpr(value.(string))
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/kennygrant/sanitize"
|
||||
)
|
||||
|
||||
@@ -37,20 +38,32 @@ func sanitizeStrings(text ...string) string {
|
||||
}
|
||||
|
||||
func (r sqlRepository) doSearch(q string, offset, size int, results interface{}, orderBys ...string) error {
|
||||
q = strings.TrimSpace(q)
|
||||
q = strings.TrimSuffix(q, "*")
|
||||
q = sanitizeStrings(q)
|
||||
if len(q) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
sq := r.newSelectWithAnnotation(r.tableName + ".id").Columns("*")
|
||||
sq = sq.Limit(uint64(size)).Offset(uint64(offset))
|
||||
if len(orderBys) > 0 {
|
||||
sq = sq.OrderBy(orderBys...)
|
||||
}
|
||||
parts := strings.Split(q, " ")
|
||||
for _, part := range parts {
|
||||
sq = sq.Where(Like{"full_text": "% " + part + "%"})
|
||||
}
|
||||
sq = sq.Where(fullTextExpr(q))
|
||||
err := r.queryAll(sq, results)
|
||||
return err
|
||||
}
|
||||
|
||||
func fullTextExpr(value string) Sqlizer {
|
||||
var sep string
|
||||
if !conf.Server.SearchFullString {
|
||||
sep = " "
|
||||
}
|
||||
q := sanitizeStrings(value)
|
||||
parts := strings.Split(q, " ")
|
||||
filters := And{}
|
||||
for _, part := range parts {
|
||||
filters = append(filters, Like{"full_text": "%" + sep + part + "%"})
|
||||
}
|
||||
return filters
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
@@ -48,7 +47,6 @@ func (r *userRepository) Put(u *model.User) error {
|
||||
id, _ := uuid.NewRandom()
|
||||
u.ID = id.String()
|
||||
}
|
||||
u.UserName = strings.ToLower(u.UserName)
|
||||
u.UpdatedAt = time.Now()
|
||||
values, _ := toSqlArgs(*u)
|
||||
update := Update(r.tableName).Where(Eq{"id": u.ID}).SetMap(values)
|
||||
@@ -65,9 +63,15 @@ func (r *userRepository) Put(u *model.User) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *userRepository) FindFirstAdmin() (*model.User, error) {
|
||||
sel := r.newSelect(model.QueryOptions{Sort: "updated_at", Max: 1}).Columns("*").Where(Eq{"is_admin": true})
|
||||
var usr model.User
|
||||
err := r.queryOne(sel, &usr)
|
||||
return &usr, err
|
||||
}
|
||||
|
||||
func (r *userRepository) FindByUsername(username string) (*model.User, error) {
|
||||
username = strings.ToLower(username)
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"user_name": username})
|
||||
sel := r.newSelect().Columns("*").Where(Like{"user_name": username})
|
||||
var usr model.User
|
||||
err := r.queryOne(sel, &usr)
|
||||
return &usr, err
|
||||
|
||||
@@ -1 +1 @@
|
||||
-s -r "(\.go$$|navidrome.toml|resources)" -R "(Jamstash-master|^ui|^data|^db/migration)" -- go run .
|
||||
-s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources)" -R "(^ui|^data|^db/migration)" -- go run .
|
||||
|
||||
@@ -19,12 +19,15 @@
|
||||
"updatedAt": "Nahráno",
|
||||
"bitRate": "Přenosová rychlost",
|
||||
"discSubtitle": "Podtitul disku",
|
||||
"starred": "Hvězdičkované"
|
||||
"starred": "Oblíbené"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Přehrát později",
|
||||
"playNow": "Přehrát nyní",
|
||||
"addToPlaylist": "Přidat do seznamu skladeb"
|
||||
"addToPlaylist": "Přidat do seznamu skladeb",
|
||||
"shuffleAll": "Zamíchat vše",
|
||||
"download": "Stáhnout",
|
||||
"playNext": "Přehrát další"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -45,7 +48,17 @@
|
||||
"playAll": "Přehrát",
|
||||
"playNext": "Přehrát další",
|
||||
"addToQueue": "Přehrát později",
|
||||
"shuffle": "Zamíchat"
|
||||
"shuffle": "Zamíchat",
|
||||
"addToPlaylist": "Přidat do seznamu skladeb",
|
||||
"download": "Stáhnout"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Všechno",
|
||||
"random": "Náhodné",
|
||||
"recentlyAdded": "Nedávno přidané",
|
||||
"recentlyPlayed": "Nedávno přehrané",
|
||||
"mostPlayed": "Nejpřehrávanější",
|
||||
"starred": "Oblíbené"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
@@ -53,7 +66,8 @@
|
||||
"fields": {
|
||||
"name": "Název",
|
||||
"albumCount": "Počet alb",
|
||||
"songCount": "Počet skladeb"
|
||||
"songCount": "Počet skladeb",
|
||||
"playCount": "Přehrání"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -98,11 +112,14 @@
|
||||
"updatedAt": "Nahrán",
|
||||
"createdAt": "Vytvořen",
|
||||
"songCount": "Skladby",
|
||||
"comment": "Komentář"
|
||||
"comment": "Komentář",
|
||||
"sync": "Auto-import",
|
||||
"path": "Importovat z"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Přidat skladby do seznamu:",
|
||||
"addNewPlaylist": "Vytvořit \"%{name}\""
|
||||
"addNewPlaylist": "Vytvořit \"%{name}\"",
|
||||
"export": "Export"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -158,7 +175,8 @@
|
||||
"expand": "Zvětšit",
|
||||
"close": "Zavřít",
|
||||
"open_menu": "Otevřít nabídku",
|
||||
"close_menu": "Zavřít nabídku"
|
||||
"close_menu": "Zavřít nabídku",
|
||||
"unselect": "Zrušit výběr"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Ano",
|
||||
@@ -173,7 +191,7 @@
|
||||
"loading": "Načítání",
|
||||
"not_found": "Nenalezeno",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Zatím žádné %{name}",
|
||||
"empty": "Zatím žádný %{name}",
|
||||
"invite": "Chcete jeden přidat?"
|
||||
},
|
||||
"input": {
|
||||
@@ -218,7 +236,7 @@
|
||||
"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:",
|
||||
"page_rows_per_page": "Položek na stránce:",
|
||||
"next": "Další",
|
||||
"prev": "Předchozí"
|
||||
},
|
||||
@@ -239,21 +257,25 @@
|
||||
"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é"
|
||||
"songsAddedToPlaylist": "1 skladba přidána na seznam skladeb |||| %{smart_count} skladeb přidáno na seznam skladeb",
|
||||
"noPlaylistsAvailable": "Žádné nejsou dostupné",
|
||||
"delete_user_title": "Odstranit uživatele '%{name}'",
|
||||
"delete_user_content": "Jste si jisti že chcete odstranit tohoto uživatele a všechny jejich data (zahrujicí seznamy skladeb a nastavení)?"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Knihovna",
|
||||
"settings": "Nastavení",
|
||||
"version": "Verze %{version}",
|
||||
"version": "Verze",
|
||||
"theme": "Motiv",
|
||||
"personal": {
|
||||
"name": "Osobní",
|
||||
"options": {
|
||||
"theme": "Motiv",
|
||||
"language": "Jazyk"
|
||||
"language": "Jazyk",
|
||||
"defaultView": "Výchozí stránka"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Alba"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Fronta",
|
||||
|
||||
297
resources/i18n/da.json
Normal file
297
resources/i18n/da.json
Normal file
@@ -0,0 +1,297 @@
|
||||
{
|
||||
"languageName": "Dansk",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Sang |||| Sange",
|
||||
"fields": {
|
||||
"albumArtist": "Album kunstner",
|
||||
"duration": "Varighed",
|
||||
"trackNumber": "#",
|
||||
"playCount": "Afspilninger",
|
||||
"title": "Titel",
|
||||
"artist": "Kunstner",
|
||||
"album": "Album navn",
|
||||
"path": "Fil placering",
|
||||
"genre": "Genre",
|
||||
"compilation": "Samling",
|
||||
"year": "År",
|
||||
"size": "Fil størrelse",
|
||||
"updatedAt": "Opdateret den",
|
||||
"bitRate": "Bitrate",
|
||||
"discSubtitle": "Plade undernavn",
|
||||
"starred": "Stjernemarkeret"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Afspil senere",
|
||||
"playNow": "Afspil nu",
|
||||
"addToPlaylist": "Tilføj til afspilningsliste"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Album |||| Albums",
|
||||
"fields": {
|
||||
"albumArtist": "Album kunstner",
|
||||
"artist": "Kunstner",
|
||||
"duration": "Varighed",
|
||||
"songCount": "Sange",
|
||||
"playCount": "Afspilninger",
|
||||
"name": "Navn",
|
||||
"genre": "Genre",
|
||||
"compilation": "Samling",
|
||||
"year": "År",
|
||||
"updatedAt": "Opdateret den"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Afspil",
|
||||
"playNext": "Afspil næste",
|
||||
"addToQueue": "Afspil senere",
|
||||
"shuffle": "Bland"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Alle",
|
||||
"random": "Tilfældig",
|
||||
"recentlyAdded": "Nyligt tilføjet",
|
||||
"recentlyPlayed": "Nyligt Afspillet",
|
||||
"mostPlayed": "Mest Afspillet"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Kunstner |||| Kunstnere",
|
||||
"fields": {
|
||||
"name": "Navn",
|
||||
"albumCount": "Antal album",
|
||||
"songCount": "Antal sange"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Bruger |||| Brugere",
|
||||
"fields": {
|
||||
"userName": "Brugernavn",
|
||||
"isAdmin": "Er administrator",
|
||||
"lastLoginAt": "Sidste login",
|
||||
"updatedAt": "Opdateret den",
|
||||
"name": "Navn",
|
||||
"password": "Kodeord",
|
||||
"createdAt": "Oprettet den"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "Afspiller |||| Afspillere",
|
||||
"fields": {
|
||||
"name": "Navn",
|
||||
"transcodingId": "Omkodning",
|
||||
"maxBitRate": "Maks. bitrate",
|
||||
"client": "Klient",
|
||||
"userName": "Brugernavn",
|
||||
"lastSeen": "Sidst set"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Omkodning |||| Omkodninger",
|
||||
"fields": {
|
||||
"name": "Navn",
|
||||
"targetFormat": "Målformat",
|
||||
"defaultBitRate": "Standard bitrate",
|
||||
"command": "Kommando"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Afspilningsliste |||| Afspilningslister",
|
||||
"fields": {
|
||||
"name": "Navn",
|
||||
"duration": "Varighed",
|
||||
"owner": "Ejer",
|
||||
"public": "Offentlig",
|
||||
"updatedAt": "Opdateret den",
|
||||
"createdAt": "Oprettet den",
|
||||
"songCount": "Sange",
|
||||
"comment": "Kommentar",
|
||||
"sync": "Auto-importér",
|
||||
"path": "Importér fra"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Vælg en afspilningsliste:",
|
||||
"addNewPlaylist": "Opret \"%{name}\""
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Tak fordi du installerede Navidrome!",
|
||||
"welcome2": "Opret administrator for at begynde",
|
||||
"confirmPassword": "Bekræft kodeord",
|
||||
"buttonCreateAdmin": "Opret administrator",
|
||||
"auth_check_error": "Venligst login for at fortsætte",
|
||||
"user_menu": "Profil",
|
||||
"username": "Brugernavn",
|
||||
"password": "Password",
|
||||
"sign_in": "Log ind",
|
||||
"sign_in_error": "Dit log ind fejlede, prøv igen",
|
||||
"logout": "Log ud"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Vær venlig kun at benytte bogstaver og tal",
|
||||
"passwordDoesNotMatch": "Kodeord er ikke ens",
|
||||
"required": "Obligatorisk",
|
||||
"minLength": "Skal være mindst %{min} tegn",
|
||||
"maxLength": "Skal være max %{max} tegn",
|
||||
"minValue": "Skal være mindst %{min}",
|
||||
"maxValue": "Skal være max %{max}",
|
||||
"number": "Skal være et nummer",
|
||||
"email": "Skal være en gyldig e-mail-adresse",
|
||||
"oneOf": "Skal være en af: %{options}",
|
||||
"regex": "Skal matche et bestemt format (regexp): %{pattern}"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Tilføj filter",
|
||||
"add": "Tilføj",
|
||||
"back": "Tilbage",
|
||||
"bulk_actions": "%{smart_count} valgt",
|
||||
"cancel": "Annuller",
|
||||
"clear_input_value": "Ryd",
|
||||
"clone": "Klon",
|
||||
"confirm": "Bekræft",
|
||||
"create": "Opret",
|
||||
"delete": "Slet",
|
||||
"edit": "Rediger",
|
||||
"export": "Eksporter",
|
||||
"list": "Liste",
|
||||
"refresh": "Opdater",
|
||||
"remove_filter": "Slet filter",
|
||||
"remove": "Fjern",
|
||||
"save": "Gem",
|
||||
"search": "Søg",
|
||||
"show": "Vis",
|
||||
"sort": "Sortér",
|
||||
"undo": "Fortryd",
|
||||
"expand": "Udvid",
|
||||
"close": "Luk",
|
||||
"open_menu": "Åben menu",
|
||||
"close_menu": "Luk menu",
|
||||
"unselect": "Fravælg"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Ja",
|
||||
"false": "Nej"
|
||||
},
|
||||
"page": {
|
||||
"create": "Opret %{name}",
|
||||
"dashboard": "Dashboard",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Noget gik galt",
|
||||
"list": "%{name} liste",
|
||||
"loading": "Henter",
|
||||
"not_found": "Ikke fundet",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Ingen %{name} endnu",
|
||||
"invite": "Vil du tilføje en?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Træk og slip filer for at uploade, eller klik for at vælge filer.",
|
||||
"upload_single": "Træk og slip en fil for at uploade, eller klik for at vælge en fil."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Træk og slip filer for at uploade, eller klik for at vælge filer.",
|
||||
"upload_single": "Træk og slip et billede for at uploade, eller klik for at vælge en fil."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "Kan ikke finde nogle referencedata.",
|
||||
"many_missing": "Mindst en af de tilknyttede referencer synes ikke længere at være tilgængelig.",
|
||||
"single_missing": "Tilknyttede referencer synes ikke længere at være tilgængelige."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "Skjul kodeord",
|
||||
"toggle_hidden": "Vis kodeord"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "Om",
|
||||
"are_you_sure": "Er du sikker?",
|
||||
"bulk_delete_content": "Er du sikker på du vil slette %{name}? |||| Er du sikker på du ville slette %{smart_count} poster?",
|
||||
"bulk_delete_title": "Slet %{name} |||| Sletter %{smart_count} %{name} poster",
|
||||
"delete_content": "Er du sikker på du ville slette denne post?",
|
||||
"delete_title": "Slet %{name} #%{id}",
|
||||
"details": "Detaljer",
|
||||
"error": "Der opstod en klientfejl, og din forespørgsel kunne ikke udføres.",
|
||||
"invalid_form": "Formularen er ikke gyldig. Kontroller for fejl",
|
||||
"loading": "Siden indlæses, Vent et øjeblik",
|
||||
"no": "Nej",
|
||||
"not_found": "Enten har du skrevet en forkert URL eller du har fulgt et invalidt link.",
|
||||
"yes": "Ja",
|
||||
"unsaved_changes": "Du har lavet ændringer der ikke er gemt. Er du sikker på at du vil ignorere dem?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Ingen resultater fundet",
|
||||
"no_more_results": "Sidenummeret %{page} eksistere ikke. Gå tilbage til forrige side.",
|
||||
"page_out_of_boundaries": "Sidenummeret %{page} eksistere ikke",
|
||||
"page_out_from_end": "Der findes ikke flere sider",
|
||||
"page_out_from_begin": "Der er ingen side før end side 1",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} af %{total}",
|
||||
"page_rows_per_page": "Rækker pr. side:",
|
||||
"next": "Næste",
|
||||
"prev": "Forrige"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Objekt opdateret |||| %{smart_count} objekter opdateret",
|
||||
"created": "Objekt oprettet",
|
||||
"deleted": "Objekt slettet |||| %{smart_count} objekter slettet",
|
||||
"bad_item": "Incorrect element",
|
||||
"item_doesnt_exist": "Objektet findes ikke",
|
||||
"http_error": "Kommunikationsfejl med serveren",
|
||||
"data_provider_error": "dataProvider fejl. Check din console for detaljer.",
|
||||
"i18n_error": "Kan ikke indlæse oversættelse af det ønskede sprog",
|
||||
"canceled": "Handling blev annulleret",
|
||||
"logged_out": "Din session er udløbet, venligst tilslut igen"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "NOTE",
|
||||
"transcodingDisabled": "Skift af indstillinger for omkodning gennem web platformen er frakoblet af sikkerhedsgrunde. Genstart serveren med %{config} indstilling tilvalgt.",
|
||||
"transcodingEnabled": "Navidrome kører i øjeblikket med %{config}, hvilket gør det muligt at køre system kommandoer fra web platformen. Vi anbefaler at slå det fra af sikkerhedsgrunde og kun slå det til ved indstilling af omkodning.",
|
||||
"songsAddedToPlaylist": "Tilføjede 1 sang til afspilningsliste |||| Tilføjede %{smart_count} sange til afspilningsliste",
|
||||
"noPlaylistsAvailable": "Ingen tilgængelige",
|
||||
"delete_user_title": "Slet bruger '%{name}'",
|
||||
"delete_user_content": "Er du sikker på at du vil slette denne bruger og tilhørende data (inklusive afspilningslister og indstillinger)?"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliotek",
|
||||
"settings": "Indstillinger",
|
||||
"version": "Version",
|
||||
"theme": "Tema",
|
||||
"personal": {
|
||||
"name": "Personligt",
|
||||
"options": {
|
||||
"theme": "Tema",
|
||||
"language": "Sprog",
|
||||
"defaultView": "Standardopsætning"
|
||||
}
|
||||
},
|
||||
"albumList": "Albums"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Afspilnings kø",
|
||||
"openText": "Åben",
|
||||
"closeText": "Luk",
|
||||
"notContentText": "Ingen musik",
|
||||
"clickToPlayText": "Tryk for at afspille",
|
||||
"clickToPauseText": "Tryk for at pause",
|
||||
"nextTrackText": "Næste nummer",
|
||||
"previousTrackText": "Forrige nummer",
|
||||
"reloadText": "Genindlæs",
|
||||
"volumeText": "Lydstyrke",
|
||||
"toggleLyricText": "Skift sangtekst",
|
||||
"toggleMiniModeText": "Minimer",
|
||||
"destroyText": "Fjern",
|
||||
"downloadText": "Hent",
|
||||
"removeAudioListsText": "Slet afspillingsliste",
|
||||
"clickToDeleteText": "Tryk for at slette %{name}",
|
||||
"emptyLyricText": "Ingen sangtekst",
|
||||
"playModeText": {
|
||||
"order": "I rækkefølge",
|
||||
"orderLoop": "Gentag",
|
||||
"singleLoop": "Gentag enkelt",
|
||||
"shufflePlay": "Bland"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,10 @@
|
||||
"actions": {
|
||||
"addToQueue": "Später abspielen",
|
||||
"playNow": "Jetzt abspielen",
|
||||
"addToPlaylist": "Zur Playlist hinzufügen"
|
||||
"addToPlaylist": "Zur Playlist hinzufügen",
|
||||
"shuffleAll": "Zufallswiedergabe",
|
||||
"download": "Herunterladen",
|
||||
"playNext": "Als nächstes abspielen"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -45,7 +48,17 @@
|
||||
"playAll": "Abspielen",
|
||||
"playNext": "Als nächstes abspielen",
|
||||
"addToQueue": "Später abspielen",
|
||||
"shuffle": "Zufallswiedergabe"
|
||||
"shuffle": "Zufallswiedergabe",
|
||||
"addToPlaylist": "Zur Playlist hinzufügen",
|
||||
"download": "Herunterladen"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Alle",
|
||||
"random": "Zufällig",
|
||||
"recentlyAdded": "Kürzlich hinzugefügt",
|
||||
"recentlyPlayed": "Kürzlich gespielt",
|
||||
"mostPlayed": "Meist gespielt",
|
||||
"starred": "Favorit"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
@@ -53,7 +66,8 @@
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"albumCount": "Albumanzahl",
|
||||
"songCount": "Songanzahl"
|
||||
"songCount": "Songanzahl",
|
||||
"playCount": "Wiedergaben"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -98,11 +112,14 @@
|
||||
"updatedAt": "Aktualisiert um",
|
||||
"createdAt": "Erstellt um",
|
||||
"songCount": "Songanzahl",
|
||||
"comment": "Kommentar"
|
||||
"comment": "Kommentar",
|
||||
"sync": "Auto-Import",
|
||||
"path": "Importieren aus"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Songs zur Playlist hinzufügen",
|
||||
"addNewPlaylist": "\"%{name}\" erstellen"
|
||||
"addNewPlaylist": "\"%{name}\" erstellen",
|
||||
"export": "Exportieren"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -158,7 +175,8 @@
|
||||
"expand": "Expandieren",
|
||||
"close": "Schließen",
|
||||
"open_menu": "Menü öffnen",
|
||||
"close_menu": "Menü schließen"
|
||||
"close_menu": "Menü schließen",
|
||||
"unselect": "Abwählen"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Ja",
|
||||
@@ -240,20 +258,24 @@
|
||||
"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"
|
||||
"noPlaylistsAvailable": "Keine Playlist verfügbar",
|
||||
"delete_user_title": "Benutzer \"% {name}\" löschen",
|
||||
"delete_user_content": "Möchtest du diesen Benutzer und alle seine Daten (einschließlich Playlisten und Einstellungen) wirklich löschen?"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliothek",
|
||||
"settings": "Einstellungen",
|
||||
"version": "Version %{version}",
|
||||
"version": "Version",
|
||||
"theme": "Design",
|
||||
"personal": {
|
||||
"name": "Persönlich",
|
||||
"options": {
|
||||
"theme": "Design",
|
||||
"language": "Sprache"
|
||||
"language": "Sprache",
|
||||
"defaultView": "Standard-Ansicht"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Alben"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Wiedergabeliste abspielen",
|
||||
|
||||
313
resources/i18n/es.json
Normal file
313
resources/i18n/es.json
Normal file
@@ -0,0 +1,313 @@
|
||||
{
|
||||
"languageName": "Español",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Canción |||| Canciones",
|
||||
"fields": {
|
||||
"albumArtist": "Artista del Álbum",
|
||||
"duration": "Duración",
|
||||
"trackNumber": "#",
|
||||
"playCount": "Reproducciones",
|
||||
"title": "Título",
|
||||
"artist": "Artista",
|
||||
"album": "Álbum",
|
||||
"path": "Ruta del archivo",
|
||||
"genre": "Género",
|
||||
"compilation": "Compilación",
|
||||
"year": "Año",
|
||||
"size": "Tamaño del archivo",
|
||||
"updatedAt": "Actualizado el",
|
||||
"bitRate": "Tasa de bits",
|
||||
"discSubtitle": "Subtítulo del Disco",
|
||||
"starred": "Favorito"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Reproducir Después",
|
||||
"playNow": "Reproducir Ahora",
|
||||
"addToPlaylist": "Agregar a la lista de reproducción",
|
||||
"shuffleAll": "Todas Aleatorias",
|
||||
"download": "Descarga",
|
||||
"playNext": "Siguiente"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Álbum |||| Álbumes",
|
||||
"fields": {
|
||||
"albumArtist": "Artista del Álbum",
|
||||
"artist": "Artista",
|
||||
"duration": "Duración",
|
||||
"songCount": "Canciones",
|
||||
"playCount": "Reproducciones",
|
||||
"name": "Nombre",
|
||||
"genre": "Género",
|
||||
"compilation": "Compilación",
|
||||
"year": "Año",
|
||||
"updatedAt": "Actualizado el"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Reproducir",
|
||||
"playNext": "Reproducir Siguiente",
|
||||
"addToQueue": "Reproducir Después",
|
||||
"shuffle": "Aletorio",
|
||||
"addToPlaylist": "Agregar a la lista",
|
||||
"download": "Descargar"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Todos",
|
||||
"random": "Aleatorio",
|
||||
"recentlyAdded": "Recientes",
|
||||
"recentlyPlayed": "Recientes",
|
||||
"mostPlayed": "Más Reproducidos",
|
||||
"starred": "Favoritos"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Artista |||| Artistas",
|
||||
"fields": {
|
||||
"name": "Nombre",
|
||||
"albumCount": "Número de álbumes",
|
||||
"songCount": "Número de canciones",
|
||||
"playCount": "Reproducciones"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Usuario |||| Usuarios",
|
||||
"fields": {
|
||||
"userName": "Nombre de usuario",
|
||||
"isAdmin": "Es Administrador",
|
||||
"lastLoginAt": "Último Acceso el",
|
||||
"updatedAt": "Actualizado el",
|
||||
"name": "Nombre",
|
||||
"password": "Contraseña",
|
||||
"createdAt": "Creado el"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "Reproductor |||| Reproductores",
|
||||
"fields": {
|
||||
"name": "Nombre",
|
||||
"transcodingId": "Transcodificación",
|
||||
"maxBitRate": "Tasa de bits Máx.",
|
||||
"client": "Cliente",
|
||||
"userName": "Usuario",
|
||||
"lastSeen": "Última conexión"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Transcodificación |||| Transcodificaciones",
|
||||
"fields": {
|
||||
"name": "Nombre",
|
||||
"targetFormat": "Formato de destino",
|
||||
"defaultBitRate": "Tasa de bits default",
|
||||
"command": "Comando"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Lista |||| Listas",
|
||||
"fields": {
|
||||
"name": "Nombre",
|
||||
"duration": "Duración",
|
||||
"owner": "Dueño",
|
||||
"public": "Público",
|
||||
"updatedAt": "Actualizado el",
|
||||
"createdAt": "Creado el",
|
||||
"songCount": "Canciones",
|
||||
"comment": "Comentario",
|
||||
"sync": "Auto-importados",
|
||||
"path": "Importados de"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Seleccione una lista:",
|
||||
"addNewPlaylist": "Creada \"%{name}\"",
|
||||
"export": "Exportar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "¡Gracias por instalar Navidrome!",
|
||||
"welcome2": "Para empezar, crea un usuario administrador",
|
||||
"confirmPassword": "Confirme la Contraseña",
|
||||
"buttonCreateAdmin": "Crear Admin",
|
||||
"auth_check_error": "Por favor inicie sesión para continuar",
|
||||
"user_menu": "Perfil",
|
||||
"username": "Usuario",
|
||||
"password": "Contraseña",
|
||||
"sign_in": "Acceder",
|
||||
"sign_in_error": "La autenticación falló, por favor, vuelva a intentarlo",
|
||||
"logout": "Cerrar Sesión"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Por favor use solo letras y números",
|
||||
"passwordDoesNotMatch": "La contraseña no coincide",
|
||||
"required": "Requerido",
|
||||
"minLength": "Debe contener %{min} caracteres al menos",
|
||||
"maxLength": "Debe contener %{max} caracteres o menos",
|
||||
"minValue": "Debe ser al menos %{min}",
|
||||
"maxValue": "Debe ser %{max} o menos",
|
||||
"number": "Debe ser un número",
|
||||
"email": "Debe ser un correo electrónico válido",
|
||||
"oneOf": "Debe ser uno de: %{options}",
|
||||
"regex": "Debe coincidir con un formato específico (regexp): %{pattern}"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Añadir filtro",
|
||||
"add": "Añadir",
|
||||
"back": "Ir Atrás",
|
||||
"bulk_actions": "1 elemento seleccionado |||| %{smart_count} elementos selecccionados",
|
||||
"cancel": "Cancelar",
|
||||
"clear_input_value": "Limpiar valor",
|
||||
"clone": "Duplicar",
|
||||
"confirm": "Confirmar",
|
||||
"create": "Crear",
|
||||
"delete": "Eliminar",
|
||||
"edit": "Editar",
|
||||
"export": "Exportar",
|
||||
"list": "Lista",
|
||||
"refresh": "Refrescar",
|
||||
"remove_filter": "Eliminar este filtro",
|
||||
"remove": "Eliminar",
|
||||
"save": "Guardar",
|
||||
"search": "Buscar",
|
||||
"show": "Mostrar",
|
||||
"sort": "Ordenar",
|
||||
"undo": "Deshacer",
|
||||
"expand": "Expandir",
|
||||
"close": "Cerrar",
|
||||
"open_menu": "Abrir menú",
|
||||
"close_menu": "Cerrar menú",
|
||||
"unselect": "Deseleccionado"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Sí",
|
||||
"false": "No"
|
||||
},
|
||||
"page": {
|
||||
"create": "Crear %{name}",
|
||||
"dashboard": "Tablero",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Algo salió mal",
|
||||
"list": "%{name}",
|
||||
"loading": "Cargando",
|
||||
"not_found": "No encontrado",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Sin %{name} todavía.",
|
||||
"invite": "¿Quiere agregar una?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Arrastre algunos archivos para subir, o haga clic para seleccionar uno.",
|
||||
"upload_single": "Arrastre un archivo para subir, o haga clic para seleccionarlo."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Arrastre algunas imagénes para subir, o haga clic para seleccionar una.",
|
||||
"upload_single": "Arrastre alguna imagen para subir, o haga clic para seleccionarla."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "No se pueden encontrar datos de referencias.",
|
||||
"many_missing": "Al menos una de las referencias asociadas parece no estar disponible.",
|
||||
"single_missing": "La referencia asociada no parece estar disponible."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "Ocultar contraseña",
|
||||
"toggle_hidden": "Mostrar contraseña"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "Acerca de",
|
||||
"are_you_sure": "¿Está seguro?",
|
||||
"bulk_delete_content": "¿Seguro que quiere eliminar este %{name}? |||| ¿Seguro que quiere eliminar estos %{smart_count} elementos?",
|
||||
"bulk_delete_title": "Eliminar %{name} |||| Eliminar %{smart_count} %{name}",
|
||||
"delete_content": "¿Seguro que quiere eliminar este elemento?",
|
||||
"delete_title": "Eliminar %{name} #%{id}",
|
||||
"details": "Detalles",
|
||||
"error": "Se produjo un error en el cliente y su solicitud no se pudo completar",
|
||||
"invalid_form": "El formulario no es válido. Por favor verifique si hay errores",
|
||||
"loading": "La página se está cargando, espere un momento por favor",
|
||||
"no": "No",
|
||||
"not_found": "O bien escribió una URL incorrecta, o siguió un enlace incorrecto.",
|
||||
"yes": "Sí",
|
||||
"unsaved_changes": "Algunos de sus cambios no se guardaron. ¿Está seguro que quiere ignorarlos?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "No se han encontrado resultados",
|
||||
"no_more_results": "El número de página %{page} está fuera de los límites. Pruebe la página anterior.",
|
||||
"page_out_of_boundaries": "Número de página %{page} fuera de los límites",
|
||||
"page_out_from_end": "No puede avanzar después de la última página",
|
||||
"page_out_from_begin": "No puede ir antes de la página 1",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} de %{total}",
|
||||
"page_rows_per_page": "Filas por página:",
|
||||
"next": "Siguiente",
|
||||
"prev": "Anterior"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Elemento actualizado |||| %{smart_count} elementos actualizados",
|
||||
"created": "Elemento creado",
|
||||
"deleted": "Elemento eliminado |||| %{smart_count} elementos eliminados.",
|
||||
"bad_item": "Elemento incorrecto",
|
||||
"item_doesnt_exist": "El elemento no existe",
|
||||
"http_error": "Error de comunicación con el servidor",
|
||||
"data_provider_error": "Error del proveedor de datos. Consulte la consola para más detalles.",
|
||||
"i18n_error": "No se pudieron cargar las traducciones para el idioma especificado",
|
||||
"canceled": "Acción cancelada",
|
||||
"logged_out": "Su sesión ha finalizado, vuelva a conectarse."
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "NOTA",
|
||||
"transcodingDisabled": "Cambiar la configuración de la transcodificación a través de la interfaz web esta deshabilitado por motivos de seguridad. Si quieres cambiar (editar o agregar) opciones de transcodificación, reinicia el servidor con la %{config} opción de configuración.",
|
||||
"transcodingEnabled": "Navidrom se esta ejecutando con %{config}, lo que hace posible ejecutar comandos de sistema desde el apartado de transcodificación en la interfaz web. Recomendamos deshabilitarlo por motivos de seguridad y solo habilitarlo cuando se este configurando opciones de transcodificación.",
|
||||
"songsAddedToPlaylist": "1 canción agregada a la lista |||| %{smart_count} canciones agregadas a la lista",
|
||||
"noPlaylistsAvailable": "Ninguna lista disponible",
|
||||
"delete_user_title": "Eliminar usuario '%{name}'",
|
||||
"delete_user_content": "¿Esta seguro de eliminar a este usuario y todos sus datos (incluyendo listas y preferencias)?"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteca",
|
||||
"settings": "Ajustes",
|
||||
"version": "Versión",
|
||||
"theme": "Tema",
|
||||
"personal": {
|
||||
"name": "Personal",
|
||||
"options": {
|
||||
"theme": "Tema",
|
||||
"language": "Idioma",
|
||||
"defaultView": "Vista por defecto"
|
||||
}
|
||||
},
|
||||
"albumList": "Álbumes",
|
||||
"about": "Acerca de"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Lista de Reproducción",
|
||||
"openText": "Abrir",
|
||||
"closeText": "Cerrar",
|
||||
"notContentText": "Sin música",
|
||||
"clickToPlayText": "Clic para reproducir",
|
||||
"clickToPauseText": "Clic para pausar",
|
||||
"nextTrackText": "Pista Siguiente",
|
||||
"previousTrackText": "Pista anterior",
|
||||
"reloadText": "Refrescar",
|
||||
"volumeText": "Volumen",
|
||||
"toggleLyricText": "Mostrar letras",
|
||||
"toggleMiniModeText": "Minimizar",
|
||||
"destroyText": "Destruir",
|
||||
"downloadText": "Descargar",
|
||||
"removeAudioListsText": "Eliminar listas de audio",
|
||||
"clickToDeleteText": "Clic para eliminar %{name}",
|
||||
"emptyLyricText": "Sin letras",
|
||||
"playModeText": {
|
||||
"order": "En orden",
|
||||
"orderLoop": "Repetir",
|
||||
"singleLoop": "Repetir Una",
|
||||
"shufflePlay": "Aleatorio"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "Página de inicio",
|
||||
"source": "Código Fuente",
|
||||
"featureRequests": "Pedir funcionalidad"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,10 @@
|
||||
"actions": {
|
||||
"addToQueue": "Ajouter à la file",
|
||||
"playNow": "Lire",
|
||||
"addToPlaylist": "Ajouter à la playlist"
|
||||
"addToPlaylist": "Ajouter à la playlist",
|
||||
"shuffleAll": "Tout mélanger",
|
||||
"download": "Télécharger",
|
||||
"playNext": "Lire ensuite"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -45,7 +48,17 @@
|
||||
"playAll": "Lire",
|
||||
"playNext": "Lire ensuite",
|
||||
"addToQueue": "Ajouter à la file",
|
||||
"shuffle": "Mélanger"
|
||||
"shuffle": "Mélanger",
|
||||
"addToPlaylist": "Ajouter à la playlist",
|
||||
"download": "Télécharger"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Tous",
|
||||
"random": "Aléatoire",
|
||||
"recentlyAdded": "Récemment ajouté",
|
||||
"recentlyPlayed": "Récemment joué",
|
||||
"mostPlayed": "Plus joués",
|
||||
"starred": "Favoris"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
@@ -53,7 +66,8 @@
|
||||
"fields": {
|
||||
"name": "Nom",
|
||||
"albumCount": "Nombre d'albums",
|
||||
"songCount": "Nombre de pistes"
|
||||
"songCount": "Nombre de pistes",
|
||||
"playCount": "Lectures"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -65,7 +79,7 @@
|
||||
"updatedAt": "Dernière mise à jour",
|
||||
"name": "Nom",
|
||||
"password": "Mot de passe",
|
||||
"createdAt": "Crée le"
|
||||
"createdAt": "Créé le"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -84,7 +98,7 @@
|
||||
"fields": {
|
||||
"name": "Nom",
|
||||
"targetFormat": "Format",
|
||||
"defaultBitRate": "Bitrate par défaut",
|
||||
"defaultBitRate": "Bitrate par défaut",
|
||||
"command": "Commande"
|
||||
}
|
||||
},
|
||||
@@ -96,13 +110,16 @@
|
||||
"owner": "Propriétaire",
|
||||
"public": "Public",
|
||||
"updatedAt": "Mise à jour le",
|
||||
"createdAt": "Crée le",
|
||||
"createdAt": "Créé le",
|
||||
"songCount": "Titres",
|
||||
"comment": "Commentaire"
|
||||
"comment": "Commentaire",
|
||||
"sync": "Import automatique",
|
||||
"path": "Importer depuis"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Ajouter les pistes à la playlist",
|
||||
"addNewPlaylist": "Créer \"%{name}\""
|
||||
"addNewPlaylist": "Créer \"%{name}\"",
|
||||
"export": "Exporter"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -158,7 +175,8 @@
|
||||
"expand": "Étendre",
|
||||
"close": "Fermer",
|
||||
"open_menu": "Ouvrir le menu",
|
||||
"close_menu": "Fermer le menu"
|
||||
"close_menu": "Fermer le menu",
|
||||
"unselect": "Désélectionner"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Oui",
|
||||
@@ -238,22 +256,27 @@
|
||||
"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 l’exécution de commandes arbitraires depuis l'interface web. Il est recommandé de n'activer cette fonctionnalité uniquement lors de la configuration du Transcodage.",
|
||||
"transcodingEnabled": "Navidrome fonctionne actuellement avec %{config}, rendant possible l’exécution de commandes arbitraires depuis l'interface web. Il est recommandé d'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"
|
||||
"noPlaylistsAvailable": "Aucune playlist",
|
||||
"delete_user_title": "Supprimer l'utilisateur '%{name}'",
|
||||
"delete_user_content": "Êtes-vous sûr de vouloir supprimer cet utilisateur et ses données associées (y compris ses playlists et préférences) ?"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliothèque",
|
||||
"settings": "Paramètres",
|
||||
"version": "Version%{version}",
|
||||
"version": "Version",
|
||||
"theme": "Thème",
|
||||
"personal": {
|
||||
"name": "Paramètres personel",
|
||||
"name": "Paramètres personnels",
|
||||
"options": {
|
||||
"theme": "Thème",
|
||||
"language": "Langue"
|
||||
"language": "Langue",
|
||||
"defaultView": "Vue par défaut"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Albums",
|
||||
"about": "À propos"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "File de lecture",
|
||||
@@ -276,8 +299,15 @@
|
||||
"playModeText": {
|
||||
"order": "Ordonner",
|
||||
"orderLoop": "Tout répéter",
|
||||
"singleLoop": "Repéter",
|
||||
"shufflePlay": "Aleatoire"
|
||||
"singleLoop": "Répéter",
|
||||
"shufflePlay": "Aléatoire"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "Page d'accueil",
|
||||
"source": "Code source",
|
||||
"featureRequests": "Demande de fonctionalités"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,10 @@
|
||||
"actions": {
|
||||
"addToQueue": "Aggiungi alla coda",
|
||||
"playNow": "Riproduci adesso",
|
||||
"addToPlaylist": "Aggiungi alla playlist"
|
||||
"addToPlaylist": "Aggiungi alla playlist",
|
||||
"shuffleAll": "Riproduci casualmente",
|
||||
"download": "Scarica",
|
||||
"playNext": "Riproduci come successivo"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -45,7 +48,17 @@
|
||||
"playAll": "Riproduci",
|
||||
"playNext": "Riproduci come successivo",
|
||||
"addToQueue": "Aggiungi alla coda",
|
||||
"shuffle": "Riprodici casualmente"
|
||||
"shuffle": "Riprodici casualmente",
|
||||
"addToPlaylist": "Aggiungi alla Playlist",
|
||||
"download": "Scarica"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Tutti",
|
||||
"random": "Casuali",
|
||||
"recentlyAdded": "Aggiunti di Recente",
|
||||
"recentlyPlayed": "Riprodotti di Recente",
|
||||
"mostPlayed": "I Più Riprodotti",
|
||||
"starred": "Preferiti"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
@@ -53,7 +66,8 @@
|
||||
"fields": {
|
||||
"name": "Nome",
|
||||
"albumCount": "Album",
|
||||
"songCount": "Numero tracce"
|
||||
"songCount": "Numero tracce",
|
||||
"playCount": "Riproduzioni"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -98,11 +112,14 @@
|
||||
"updatedAt": "Ultimo aggiornamento",
|
||||
"createdAt": "Data creazione",
|
||||
"songCount": "Tracce",
|
||||
"comment": "Commento"
|
||||
"comment": "Commento",
|
||||
"sync": "Importazione automatica",
|
||||
"path": "Importa da"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Aggiungi tracce alla playlist:",
|
||||
"addNewPlaylist": "Aggiungi \"%{name}\""
|
||||
"addNewPlaylist": "Aggiungi \"%{name}\"",
|
||||
"export": "Esporta"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -158,7 +175,8 @@
|
||||
"expand": "Espandi",
|
||||
"close": "Chiudi",
|
||||
"open_menu": "Apri menù",
|
||||
"close_menu": "Chiudi menù"
|
||||
"close_menu": "Chiudi menù",
|
||||
"unselect": "Deseleziona"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Si",
|
||||
@@ -240,20 +258,24 @@
|
||||
"transcodingDisabled": "La possibilità di modificare le opzioni di transcodifica attraverso l’interfaccia web è disabilitata per ragioni di sicurezza. Se desideri cambiare (modificare o aggiungere) opzioni di transcodifica, riavvia il server con l’opzione %{config}.",
|
||||
"transcodingEnabled": "Navidrome è al momento attivo con %{config}, rendendo possibile eseguire comandi remoti attraverso l’interfaccia 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"
|
||||
"noPlaylistsAvailable": "Nessuna playlist",
|
||||
"delete_user_title": "Rimuovi utente '%{name}'",
|
||||
"delete_user_content": "Sei sicuro di voler rimuovere questo utente e tutti i suoi dati, incluse playlist e impostazioni?"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Libreria",
|
||||
"settings": "Impostazioni",
|
||||
"version": "Versione %{version}",
|
||||
"version": "Versione",
|
||||
"theme": "Tema",
|
||||
"personal": {
|
||||
"name": "Personale",
|
||||
"options": {
|
||||
"theme": "Tema",
|
||||
"language": "Lingua"
|
||||
"language": "Lingua",
|
||||
"defaultView": "Vista Predefinita"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Album"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Coda",
|
||||
|
||||
313
resources/i18n/ja.json
Normal file
313
resources/i18n/ja.json
Normal file
@@ -0,0 +1,313 @@
|
||||
{
|
||||
"languageName": "日本語",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "曲",
|
||||
"fields": {
|
||||
"albumArtist": "アルバムアーティスト",
|
||||
"duration": "長さ",
|
||||
"trackNumber": "#",
|
||||
"playCount": "再生数",
|
||||
"title": "タイトル",
|
||||
"artist": "アーティスト",
|
||||
"album": "アルバム",
|
||||
"path": "ファイルパス",
|
||||
"genre": "ジャンル",
|
||||
"compilation": "コンピレーション",
|
||||
"year": "年",
|
||||
"size": "ファイルサイズ",
|
||||
"updatedAt": "更新日",
|
||||
"bitRate": "ビットレート",
|
||||
"discSubtitle": "ディスクサブタイトル",
|
||||
"starred": "お気に入り"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "最後に再生",
|
||||
"playNow": "すぐに再生",
|
||||
"addToPlaylist": "プレイリストに追加",
|
||||
"shuffleAll": "全曲シャッフル",
|
||||
"download": "ダウンロード",
|
||||
"playNext": "次に再生"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "アルバム",
|
||||
"fields": {
|
||||
"albumArtist": "アルバムアーティスト",
|
||||
"artist": "アーティスト",
|
||||
"duration": "長さ",
|
||||
"songCount": "曲",
|
||||
"playCount": "再生数",
|
||||
"name": "名前",
|
||||
"genre": "ジャンル",
|
||||
"compilation": "コンピレーション",
|
||||
"year": "年",
|
||||
"updatedAt": "更新日"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "再生",
|
||||
"playNext": "次に再生",
|
||||
"addToQueue": "最後に再生",
|
||||
"shuffle": "シャッフル",
|
||||
"addToPlaylist": "プレイリストへ追加",
|
||||
"download": "ダウンロード"
|
||||
},
|
||||
"lists": {
|
||||
"all": "全て",
|
||||
"random": "ランダム",
|
||||
"recentlyAdded": "最近追加された",
|
||||
"recentlyPlayed": "最近再生された",
|
||||
"mostPlayed": "最も再生された",
|
||||
"starred": "お気に入り"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "アーティスト",
|
||||
"fields": {
|
||||
"name": "名前",
|
||||
"albumCount": "アルバム枚数",
|
||||
"songCount": "曲数",
|
||||
"playCount": "再生数"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "ユーザー",
|
||||
"fields": {
|
||||
"userName": "ユーザー名",
|
||||
"isAdmin": "管理者",
|
||||
"lastLoginAt": "最終ログイン",
|
||||
"updatedAt": "更新日",
|
||||
"name": "名前",
|
||||
"password": "パスワード",
|
||||
"createdAt": "作成日"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "プレイヤー",
|
||||
"fields": {
|
||||
"name": "名前",
|
||||
"transcodingId": "トランスコード",
|
||||
"maxBitRate": "最大ビットレート",
|
||||
"client": "クライアント",
|
||||
"userName": "ユーザ名",
|
||||
"lastSeen": "最後のシーン"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "トランスコード",
|
||||
"fields": {
|
||||
"name": "名前",
|
||||
"targetFormat": "対象フォーマット",
|
||||
"defaultBitRate": "デフォルトビットレート",
|
||||
"command": "コマンド"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "プレイリスト",
|
||||
"fields": {
|
||||
"name": "名前",
|
||||
"duration": "時間",
|
||||
"owner": "所有者",
|
||||
"public": "公開",
|
||||
"updatedAt": "更新日",
|
||||
"createdAt": "作成日",
|
||||
"songCount": "曲",
|
||||
"comment": "コメント",
|
||||
"sync": "自動インポート",
|
||||
"path": "インポート元"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "プレイリストを選択",
|
||||
"addNewPlaylist": "'%{name}' を作成",
|
||||
"export": "エクスポート"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "次の正規表現形式にする必要があります: %{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": "閉じる",
|
||||
"unselect": "選択解除"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "はい",
|
||||
"false": "いいえ"
|
||||
},
|
||||
"page": {
|
||||
"create": "%{name} を作成",
|
||||
"dashboard": "ダッシュボード",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "問題が発生しました",
|
||||
"list": "%{name}",
|
||||
"loading": "読込中",
|
||||
"not_found": "見つかりませんでした",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "%{name}はありません",
|
||||
"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} を削除 |||| %{name} %{smart_count}件を削除",
|
||||
"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": "1 ページより前に移動できません",
|
||||
"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エラー。詳細はコンソールを確認してください",
|
||||
"i18n_error": "翻訳ファイルが読み込めませんでした",
|
||||
"canceled": "元に戻しました",
|
||||
"logged_out": "認証に失敗しました。再度ログインしてください"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "注意",
|
||||
"transcodingDisabled": "セキュリティ上の理由から、Web インターフェイスからのトランスコード設定は無効になっています。\nこれを設定したい場合、環境変数 %{config} を設定しサーバーを再起動してください。",
|
||||
"transcodingEnabled": "Navidromeは現在 %{config} の設定で実行されており、WebUIのトランスコード設定からコマンドを実行できます。\nセキュリティ上の問題から、この設定はトランスコード設定を変更する時のみ有効にすることを推奨します。",
|
||||
"songsAddedToPlaylist": "プレイリストへ1曲追加しました |||| プレイリストへ%{smart_count}曲追加しました",
|
||||
"noPlaylistsAvailable": "利用不可",
|
||||
"delete_user_title": "'%{name}' を削除",
|
||||
"delete_user_content": "このユーザーとその全てのデータ(プレイリストや設定)を削除してもよろしいですか?"
|
||||
},
|
||||
"menu": {
|
||||
"library": "ライブラリ",
|
||||
"settings": "設定",
|
||||
"version": "バージョン",
|
||||
"theme": "テーマ",
|
||||
"personal": {
|
||||
"name": "個人設定",
|
||||
"options": {
|
||||
"theme": "テーマ",
|
||||
"language": "言語",
|
||||
"defaultView": "デフォルト画面"
|
||||
}
|
||||
},
|
||||
"albumList": "アルバム",
|
||||
"about": "詳細"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "再生リスト",
|
||||
"openText": "開く",
|
||||
"closeText": "閉じる",
|
||||
"notContentText": "音楽がありません",
|
||||
"clickToPlayText": "クリックして再生",
|
||||
"clickToPauseText": "一時停止",
|
||||
"nextTrackText": "次の曲",
|
||||
"previousTrackText": "前の曲",
|
||||
"reloadText": "更新",
|
||||
"volumeText": "音量",
|
||||
"toggleLyricText": "歌詞を切り替え",
|
||||
"toggleMiniModeText": "最小化",
|
||||
"destroyText": "削除",
|
||||
"downloadText": "ダウンロード",
|
||||
"removeAudioListsText": "リストを空にする",
|
||||
"clickToDeleteText": "クリックして%{name}を削除",
|
||||
"emptyLyricText": "歌詞がありません",
|
||||
"playModeText": {
|
||||
"order": "順番に",
|
||||
"orderLoop": "リピート",
|
||||
"singleLoop": "一曲リピート",
|
||||
"shufflePlay": "シャッフル"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "ホームページ",
|
||||
"source": "ソースコード",
|
||||
"featureRequests": "機能リクエスト"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"name": "Nummer |||| Nummers",
|
||||
"fields": {
|
||||
"albumArtist": "Album Artiest",
|
||||
"duration": "Tijd",
|
||||
"duration": "Lengte",
|
||||
"trackNumber": "Nummer #",
|
||||
"playCount": "Aantal keren afgespeeld",
|
||||
"title": "Titel",
|
||||
@@ -17,11 +17,17 @@
|
||||
"year": "Jaar",
|
||||
"size": "Bestandsgrootte",
|
||||
"updatedAt": "Laatst bijgewerkt op",
|
||||
"bitRate": "Bitrate"
|
||||
"bitRate": "Bitrate",
|
||||
"discSubtitle": "Schijfondertitel",
|
||||
"starred": "Met Ster"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Toevoegen aan afspeellijst",
|
||||
"playNow": "Nu Afspelen"
|
||||
"addToQueue": "Voeg toe aan wachtrij",
|
||||
"playNow": "Nu Afspelen",
|
||||
"addToPlaylist": "Voeg toe aan Afspeellijst",
|
||||
"shuffleAll": "Shuffle Alles",
|
||||
"download": "Downloaden",
|
||||
"playNext": "Volgende"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -29,8 +35,8 @@
|
||||
"fields": {
|
||||
"albumArtist": "Album Artiest",
|
||||
"artist": "Artiest",
|
||||
"duration": "Tijd",
|
||||
"songCount": "Nummerss",
|
||||
"duration": "Afspeelduur",
|
||||
"songCount": "Nummers",
|
||||
"playCount": "Aantal keren afgespeeld",
|
||||
"name": "Naam",
|
||||
"genre": "Genre",
|
||||
@@ -41,22 +47,34 @@
|
||||
"actions": {
|
||||
"playAll": "Afspelen",
|
||||
"playNext": "Hierna afspelen",
|
||||
"addToQueue": "Toevoegen aan afspeellijst",
|
||||
"shuffle": "Shuffle"
|
||||
"addToQueue": "Voeg toe aan wachtrij",
|
||||
"shuffle": "Shuffle",
|
||||
"addToPlaylist": "Toevoegen aan Afspeellijst",
|
||||
"download": "Downloaden"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Alle",
|
||||
"random": "Willekeurig",
|
||||
"recentlyAdded": "Onlangs Toegevoegd",
|
||||
"recentlyPlayed": "Onlangs Afgespeeld",
|
||||
"mostPlayed": "Meest Afgespeeld",
|
||||
"starred": "Met Ster"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Artiest |||| Artiesten",
|
||||
"fields": {
|
||||
"name": "Naam",
|
||||
"albumCount": "Aantal albums"
|
||||
"albumCount": "Aantal Albums",
|
||||
"songCount": "Aantal Nummers",
|
||||
"playCount": "Afgespeeld"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Gebruiker |||| Gebruikers",
|
||||
"fields": {
|
||||
"userName": "Gebruikersnaam",
|
||||
"isAdmin": "Is beheerder",
|
||||
"isAdmin": "Is Beheerder",
|
||||
"lastLoginAt": "Laatst ingelogd op",
|
||||
"updatedAt": "Laatst gewijzigd op",
|
||||
"name": "Naam",
|
||||
@@ -68,21 +86,41 @@
|
||||
"name": "Speler |||| Spelers",
|
||||
"fields": {
|
||||
"name": "Naam",
|
||||
"transcodingId": "Transcoderingsidentifier",
|
||||
"maxBitRate": "Maximale bitrate",
|
||||
"transcodingId": "Transcoden",
|
||||
"maxBitRate": "Max. bitrate",
|
||||
"client": "Client",
|
||||
"userName": "Gebruikersnaam",
|
||||
"lastSeen": "Laatst gezien op"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Transcodering |||| Transcoderingen",
|
||||
"name": "Transcoden |||| Transcoderingen",
|
||||
"fields": {
|
||||
"name": "Naam",
|
||||
"targetFormat": "Doel formaat",
|
||||
"targetFormat": "Doelformaat",
|
||||
"defaultBitRate": "Standaard bitrate",
|
||||
"command": "Commando"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Afspeellijst |||| Afspeellijsten",
|
||||
"fields": {
|
||||
"name": "Titel",
|
||||
"duration": "Lengte",
|
||||
"owner": "Eigenaar",
|
||||
"public": "Publiek",
|
||||
"updatedAt": "Laatst gewijzigd op",
|
||||
"createdAt": "Aangemaakt op",
|
||||
"songCount": "Nummers",
|
||||
"comment": "Commentaar",
|
||||
"sync": "Auto-importeren",
|
||||
"path": "Importeer vanuit"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Selecteer een afspeellijst:",
|
||||
"addNewPlaylist": "Creëer \"%{name}\"",
|
||||
"export": "Exporteer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -90,7 +128,7 @@
|
||||
"welcome1": "Bedankt voor het installeren van Navidrome!",
|
||||
"welcome2": "Maak om te beginnen een beheerdersaccount",
|
||||
"confirmPassword": "Bevestig wachtwoord",
|
||||
"buttonCreateAdmin": "Beheerder maken",
|
||||
"buttonCreateAdmin": "Beheerder aanmaken",
|
||||
"auth_check_error": "Log in om door te gaan",
|
||||
"user_menu": "Profiel",
|
||||
"username": "Gebruikersnaam",
|
||||
@@ -104,9 +142,9 @@
|
||||
"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}",
|
||||
"maxLength": "Mag maximaal %{max} karakters bevatten",
|
||||
"minValue": "Moet minstens %{min} zijn",
|
||||
"maxValue": "Mag maximaal %{max} zijn",
|
||||
"number": "Moet een getal zijn",
|
||||
"email": "Moet een geldig e-mailadres zijn",
|
||||
"oneOf": "Moet een zijn van: %{options}",
|
||||
@@ -137,7 +175,8 @@
|
||||
"expand": "Uitklappen",
|
||||
"close": "Sluiten",
|
||||
"open_menu": "Open menu",
|
||||
"close_menu": "Sluit menu"
|
||||
"close_menu": "Sluit menu",
|
||||
"unselect": "Deselecteer"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Ja",
|
||||
@@ -153,16 +192,16 @@
|
||||
"not_found": "Niet gevonden",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Nog geen %{name}.",
|
||||
"invite": "Wilt u er een toevoegen?"
|
||||
"invite": "Wil je 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."
|
||||
"upload_several": "Sleep bestanden hier om te uploaden, of klik om te selecteren.",
|
||||
"upload_single": "Sleep een bestand hier om te uploaden, of klik om 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."
|
||||
"upload_several": "Sleep afbeeldingen hier om te uploaden, of klik om te selecteren.",
|
||||
"upload_single": "Sleep een afbeelding hier om te uploaden, of klik om te selecteren."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "De gerefereerde elementen konden niet gevonden worden.",
|
||||
@@ -176,19 +215,19 @@
|
||||
},
|
||||
"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?",
|
||||
"are_you_sure": "Weet je het zeker?",
|
||||
"bulk_delete_content": "Weet je zeker dat je dit %{name} item wilt verwijderen? |||| Weet je zeker dat je 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",
|
||||
"delete_content": "Weet je zeker dat je dit item wilt verwijderen?",
|
||||
"delete_title": "Verwijder %{name} #%{id}",
|
||||
"details": "Details",
|
||||
"error": "Er is een clientfout opgetreden en uw aanvraag kon niet worden voltooid.",
|
||||
"error": "Er is een clientfout opgetreden en je 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.",
|
||||
"not_found": "Je hebt 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?"
|
||||
"unsaved_changes": "Sommige van uw wijzigingen zijn niet opgeslagen. Weet je zeker dat je ze wilt negeren?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Geen resultaten gevonden",
|
||||
@@ -208,7 +247,7 @@
|
||||
"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.",
|
||||
"data_provider_error": "dataProvider fout. Open de 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."
|
||||
@@ -216,21 +255,27 @@
|
||||
},
|
||||
"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."
|
||||
"transcodingDisabled": "Het wijzigen van de transcoderingsconfiguratie via de web interface is om veiligheidsredenen uitgeschakeld. Als je transcoderopties wilt wijzigen (bewerken of toevoegen), start 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 transcoderinstellingen via de web interface. We raden aan dit om veiligheidsredenen uit te schakelen en alleen in te schakelen bij het configureren van transcoderopties.",
|
||||
"songsAddedToPlaylist": "1 nummer toegevoegd aan afspeellijst |||| %{smart_count} nummers toegevoegd aan afspeellijst",
|
||||
"noPlaylistsAvailable": "Geen beschikbaar",
|
||||
"delete_user_title": "Verwijder gebruiker '%{name}'",
|
||||
"delete_user_content": "Weet je zeker dat je deze gebruiker en al zijn gegevens wilt verwijderen (inclusief afspeellijsten en voorkeuren)?"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliotheek",
|
||||
"settings": "Instellingen",
|
||||
"version": "Versie %{version}",
|
||||
"version": "Versie",
|
||||
"theme": "Thema",
|
||||
"personal": {
|
||||
"name": "Persoonlijk",
|
||||
"options": {
|
||||
"theme": "Thema",
|
||||
"language": "Taal"
|
||||
"language": "Taal",
|
||||
"defaultView": "Standaard Weergave"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Albums"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Afspeellijst afspelen",
|
||||
|
||||
305
resources/i18n/pl.json
Normal file
305
resources/i18n/pl.json
Normal file
@@ -0,0 +1,305 @@
|
||||
{
|
||||
"languageName": "Polski",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Utwór |||| Utwory",
|
||||
"fields": {
|
||||
"albumArtist": "Album Wykonawcy",
|
||||
"duration": "Czas trwania",
|
||||
"trackNumber": "#",
|
||||
"playCount": "Liczba odtworzeń",
|
||||
"title": "Tytuł",
|
||||
"artist": "Wykonawca",
|
||||
"album": "Album",
|
||||
"path": "Ścieżka pliku",
|
||||
"genre": "Gatunek",
|
||||
"compilation": "Kompilacja",
|
||||
"year": "Rok",
|
||||
"size": "Rozmiar pliku",
|
||||
"updatedAt": "Zaktualizowano",
|
||||
"bitRate": "Szybkość transmisji danych",
|
||||
"discSubtitle": "Podtytuł Płyty",
|
||||
"starred": "Oznaczone gwiazdką"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Odtwarzaj Później",
|
||||
"playNow": "Odtwarzaj Teraz",
|
||||
"addToPlaylist": "Dodaj do Listy odtwarzania",
|
||||
"shuffleAll": "Losuj Wszystkie",
|
||||
"download": "Pobierz",
|
||||
"playNext": "Odtwarzaj Następny"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Album |||| Albumy",
|
||||
"fields": {
|
||||
"albumArtist": "Album Artysty",
|
||||
"artist": "Wykonawca",
|
||||
"duration": "Czas trwania",
|
||||
"songCount": "Liczba utworów",
|
||||
"playCount": "Liczba odtworzeń",
|
||||
"name": "Tytuł",
|
||||
"genre": "Gatunek",
|
||||
"compilation": "Kompilacja",
|
||||
"year": "Rok",
|
||||
"updatedAt": "Zaktualizowany"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Odtwarzaj",
|
||||
"playNext": "Odtwarzaj Następny",
|
||||
"addToQueue": "Odtwarzaj Później",
|
||||
"shuffle": "Losowo",
|
||||
"addToPlaylist": "Dodaj do Listy Odtwarzania",
|
||||
"download": "Pobierz"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Wszystkie",
|
||||
"random": "Losowo",
|
||||
"recentlyAdded": "Ostatnio Dodane",
|
||||
"recentlyPlayed": "Ostatnio Odtwarzane",
|
||||
"mostPlayed": "Najczęściej Odtwarzane",
|
||||
"starred": "Oznaczone Gwiazdką"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Wykonawca |||| Wykonawcy",
|
||||
"fields": {
|
||||
"name": "Tytuł",
|
||||
"albumCount": "Liczba Albumów",
|
||||
"songCount": "Liczba Utworów",
|
||||
"playCount": "Liczba Odtworzeń"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Użytkownik |||| Użytkownicy",
|
||||
"fields": {
|
||||
"userName": "Nazwa użytkownika",
|
||||
"isAdmin": "Administrator",
|
||||
"lastLoginAt": "Ostatnio zalogowany",
|
||||
"updatedAt": "Zaktualizowano",
|
||||
"name": "Imię",
|
||||
"password": "Hasło",
|
||||
"createdAt": "Dodany"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "Odtwarzacz |||| Odtwarzacze",
|
||||
"fields": {
|
||||
"name": "Nazwa",
|
||||
"transcodingId": "Transkodowanie",
|
||||
"maxBitRate": "Maks. Bit Rate",
|
||||
"client": "Klient",
|
||||
"userName": "Nazwa użytkownika",
|
||||
"lastSeen": "Ostatnio Widziany"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Transkodowanie |||| Transkodowania",
|
||||
"fields": {
|
||||
"name": "Nazwa",
|
||||
"targetFormat": "Format Docelowy",
|
||||
"defaultBitRate": "Domyślny Bit Rate",
|
||||
"command": "Komenda"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Lista odtwarzania |||| Listy odtwarzania",
|
||||
"fields": {
|
||||
"name": "Nazwa",
|
||||
"duration": "Czas trwania",
|
||||
"owner": "Właściciel",
|
||||
"public": "Publiczna",
|
||||
"updatedAt": "Zaktualizowana",
|
||||
"createdAt": "Stworzona",
|
||||
"songCount": "Liczba utworów",
|
||||
"comment": "Komentarz",
|
||||
"sync": "Import automatyczny",
|
||||
"path": "Zaimportuj z"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Wybierz listę odtwarzania:",
|
||||
"addNewPlaylist": "Stwórz \"%{name}\"",
|
||||
"export": "Wyeksportuj"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Dziękujemy za zainstalowanie Navidrome!",
|
||||
"welcome2": "Stwórz konto administratora, aby rozpocząć",
|
||||
"confirmPassword": "Potwierdź Hasło",
|
||||
"buttonCreateAdmin": "Stwórz Administratora",
|
||||
"auth_check_error": "Proszę się zalogować, aby kontynuować",
|
||||
"user_menu": "Profil",
|
||||
"username": "Nazwa użytkownika",
|
||||
"password": "Hasło",
|
||||
"sign_in": "Zaloguj się",
|
||||
"sign_in_error": "Uwierzytelnianie nie powiodło się, spróbuj ponownie",
|
||||
"logout": "Wyloguj"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Proszę, używaj wyłącznie liter i cyfr",
|
||||
"passwordDoesNotMatch": "Hasło nie pasuje",
|
||||
"required": "Wymagane",
|
||||
"minLength": "Powinno być minimalnie %{min} znaków",
|
||||
"maxLength": "Powinno być %{max} lub mniej znaków",
|
||||
"minValue": "Minimalna wartość to %{min}",
|
||||
"maxValue": "Powinno być %{max} lub mniej",
|
||||
"number": "Musi być liczbą",
|
||||
"email": "Adres e-mail musi być poprawny",
|
||||
"oneOf": "Musi być jedną z: %{options}",
|
||||
"regex": "Musi pasować do określonego formatu (regexp): %{pattern}"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Dodaj filtr",
|
||||
"add": "Dodaj",
|
||||
"back": "Wstecz",
|
||||
"bulk_actions": "1 element wybrany |||| %{smart_count} wybranych elementów",
|
||||
"cancel": "Anuluj",
|
||||
"clear_input_value": "Wyczyść wartość",
|
||||
"clone": "Sklonuj",
|
||||
"confirm": "Potwierdź",
|
||||
"create": "Stwórz",
|
||||
"delete": "Usuń",
|
||||
"edit": "Edytuj",
|
||||
"export": "Wyeksportuj",
|
||||
"list": "Lista",
|
||||
"refresh": "Odśwież",
|
||||
"remove_filter": "Usuń ten filtr",
|
||||
"remove": "Usuń",
|
||||
"save": "Zapisz",
|
||||
"search": "Szukaj",
|
||||
"show": "Pokaż",
|
||||
"sort": "Sortuj",
|
||||
"undo": "Cofnij",
|
||||
"expand": "Rozwiń",
|
||||
"close": "Zamknij",
|
||||
"open_menu": "Otwórz menu",
|
||||
"close_menu": "Zamknij menu",
|
||||
"unselect": "Odznacz"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Tak",
|
||||
"false": "Nie"
|
||||
},
|
||||
"page": {
|
||||
"create": "Stwórz %{name}",
|
||||
"dashboard": "Panel Główny",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Coś poszło nie tak",
|
||||
"list": "%{name}",
|
||||
"loading": "Ładowanie",
|
||||
"not_found": "Nie Znaleziono",
|
||||
"show": "%{name} #%{id}\n",
|
||||
"empty": "Nie ma jeszcze %{name}.",
|
||||
"invite": "Czy chcesz dodać nową?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Upuść kilka plików do przesłania lub kliknij, aby wybrać jeden.",
|
||||
"upload_single": "Upuść plik do przesłania, lub kliknij, aby go wybrać."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Upuść kilka zdjęć do przesłania, lub kliknij, aby wybrać jedno.",
|
||||
"upload_single": "Upuść zdjęcie do przesłania, lub kliknij, aby je wybrać."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "Nie można znaleźć danych referencyjnych.",
|
||||
"many_missing": "Co najmniej jedno z powiązanych odniesień nie jest już dostępne.",
|
||||
"single_missing": "Wygląda na to, że powiązane odniesienie nie jest już dostępne."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "Ukryj hasło",
|
||||
"toggle_hidden": "Pokaż hasło"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "O aplikacji",
|
||||
"are_you_sure": "Czy jesteś pewny?",
|
||||
"bulk_delete_content": "Czy jesteś pewny, że chcesz usunąć %{name}? |||| Czy jesteś pewny, że chcesz usunąć %{smart_count} elementów?",
|
||||
"bulk_delete_title": "Usuń %{name} |||| Usuń %{smart_count} %{name}",
|
||||
"delete_content": "Czy jesteś pewny, że chcesz usunąć ten element?",
|
||||
"delete_title": "Usuń %{name} #%{id}",
|
||||
"details": "Szczegóły",
|
||||
"error": "Wystąpił błąd po stronie klienta i Twoje żądanie nie może być zrealizowane.",
|
||||
"invalid_form": "Formularz jest nieprawidłowy. Proszę sprawdź błędy",
|
||||
"loading": "Ładowanie zawartości, proszę czekać",
|
||||
"no": "Nie",
|
||||
"not_found": "Wpisałeś zły adres URL, albo skorzystałeś ze złego linku.",
|
||||
"yes": "Tak",
|
||||
"unsaved_changes": "Niektóre z Twoich zmian nie zostały zapisane. Czy jesteś pewny, że chcesz je zignorować?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Brak wyników",
|
||||
"no_more_results": "Strona o numerze %{page} jest poza granicami. Proszę spróbować poprzednią stronę.",
|
||||
"page_out_of_boundaries": "Strona o numerze %{page} jest poza granicami",
|
||||
"page_out_from_end": "Nie można przejść za ostatnią stronę",
|
||||
"page_out_from_begin": "Nie można przejść przed 1 stronę",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} z %{total}",
|
||||
"page_rows_per_page": "Wierszy na stronie:",
|
||||
"next": "Następna",
|
||||
"prev": "Poprzednia"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Element zaktualizowany |||| %{smart_count} zaktualizowanych elementów",
|
||||
"created": "Element stworzony",
|
||||
"deleted": "Element usunięty |||| %{smart_count} usuniętych elementów",
|
||||
"bad_item": "Niepoprawny element",
|
||||
"item_doesnt_exist": "Element nie istnieje",
|
||||
"http_error": "Błąd komunikacji z serwerem",
|
||||
"data_provider_error": "Błąd dostawcy danych. Sprawdź konsolę, aby uzyskać szczegółowe informacje.",
|
||||
"i18n_error": "Nie można załadować tłumaczenia dla tego języka",
|
||||
"canceled": "Akcja anulowana",
|
||||
"logged_out": "Twoja sesja została zakończona, proszę o ponowne połączenie."
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "UWAGA",
|
||||
"transcodingDisabled": "Zmiana ustawień transkodowania przez interfejs sieciowy jest zablokowana z powodów bezpieczeństwa. Jeśli chcesz zmienić (edytować lub dodawać) opcje transkodowania, uruchom ponownie serwer z %{config} opcją konfiguracji.",
|
||||
"transcodingEnabled": "Navidrome aktualnie działa z %{config}, co umożliwia korzystanie z komend systemowych ustawień transkodowania poprzez interfejs sieciowy. Rekomendujemy wyłączenie tego ustawienia w celu zwiększenia bezpieczeństwa i aktywowanie go wyłącznie podczas konfiguracji transkodowania.",
|
||||
"songsAddedToPlaylist": "Dodano 1 utwór do listy odtwarzania |||| Dodano %{smart_count} utworów do listy odtwarzania",
|
||||
"noPlaylistsAvailable": "Niedostępne",
|
||||
"delete_user_title": "Usuń użytkownika '%{name}'",
|
||||
"delete_user_content": "Czy jesteś pewien, że chcesz usunąć tego użytkownika oraz wszystkie jego dane (wliczając w to listy odtwarzania oraz ustawienia)?"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteka",
|
||||
"settings": "Ustawienia",
|
||||
"version": "Wersja",
|
||||
"theme": "Wygląd",
|
||||
"personal": {
|
||||
"name": "Personalizacja",
|
||||
"options": {
|
||||
"theme": "Wygląd",
|
||||
"language": "Język",
|
||||
"defaultView": "Widok Podstawowy"
|
||||
}
|
||||
},
|
||||
"albumList": "Albumy"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Kolejka Odtwarzania",
|
||||
"openText": "Otwórz",
|
||||
"closeText": "Zamknij",
|
||||
"notContentText": "Brak muzyki",
|
||||
"clickToPlayText": "Kliknij, aby odtworzyć",
|
||||
"clickToPauseText": "Kliknij, aby zapauzować",
|
||||
"nextTrackText": "Następny utwór",
|
||||
"previousTrackText": "Poprzedni utwór",
|
||||
"reloadText": "Przeładuj",
|
||||
"volumeText": "Głośność",
|
||||
"toggleLyricText": "Pokaż tekst utworu",
|
||||
"toggleMiniModeText": "Zminimalizuj",
|
||||
"destroyText": "Zniszcz",
|
||||
"downloadText": "Pobierz",
|
||||
"removeAudioListsText": "Usuń listy audio",
|
||||
"clickToDeleteText": "Kliknij, aby usunąć %{name}",
|
||||
"emptyLyricText": "Brak tekstu",
|
||||
"playModeText": {
|
||||
"order": "W kolejności",
|
||||
"orderLoop": "Powtarzaj",
|
||||
"singleLoop": "Powtórz Raz",
|
||||
"shufflePlay": "Odtwarzaj losowo"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,12 +18,16 @@
|
||||
"size": "Tamanho",
|
||||
"updatedAt": "Últ. Atualização",
|
||||
"bitRate": "Bitrate",
|
||||
"discSubtitle": "Sub-título do disco"
|
||||
"discSubtitle": "Sub-título do disco",
|
||||
"starred": "Favorita"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Adicionar à fila",
|
||||
"playNow": "Tocar agora",
|
||||
"addToPlaylist": "Adicionar à playlist"
|
||||
"addToPlaylist": "Adicionar à playlist",
|
||||
"shuffleAll": "Aleatório",
|
||||
"download": "Baixar",
|
||||
"playNext": "Toca a seguir"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -44,7 +48,17 @@
|
||||
"playAll": "Tocar",
|
||||
"playNext": "Tocar em seguida",
|
||||
"addToQueue": "Adicionar à fila",
|
||||
"shuffle": "Aleatório"
|
||||
"shuffle": "Aleatório",
|
||||
"addToPlaylist": "Adicionar à playlist",
|
||||
"download": "Baixar"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Todos",
|
||||
"random": "Aleatório",
|
||||
"recentlyAdded": "Recém-adicionados",
|
||||
"recentlyPlayed": "Recém-tocados",
|
||||
"mostPlayed": "Mais tocados",
|
||||
"starred": "Favoritos"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
@@ -52,7 +66,8 @@
|
||||
"fields": {
|
||||
"name": "Nome",
|
||||
"albumCount": "Total de Álbuns",
|
||||
"songCount": "Total de Músicas"
|
||||
"songCount": "Total de Músicas",
|
||||
"playCount": "Execuções"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -91,17 +106,20 @@
|
||||
"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"
|
||||
"songCount": "Músicas",
|
||||
"comment": "Comentário",
|
||||
"sync": "Auto-importar",
|
||||
"path": "Importar de"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Selecione a playlist:",
|
||||
"addNewPlaylist": "Criar \"%{name}\""
|
||||
"addNewPlaylist": "Criar \"%{name}\"",
|
||||
"export": "Exportar"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -154,11 +172,11 @@
|
||||
"show": "Exibir",
|
||||
"sort": "Ordenar",
|
||||
"undo": "Desfazer",
|
||||
"unselect": "Deselecionar",
|
||||
"expand": "Expandir",
|
||||
"close": "Fechar",
|
||||
"open_menu": "Abrir menu",
|
||||
"close_menu": "Fechar menu"
|
||||
"close_menu": "Fechar menu",
|
||||
"unselect": "Deselecionar"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Sim",
|
||||
@@ -245,16 +263,26 @@
|
||||
"delete_user_content": "Você tem certeza que deseja excluir o usuário e todos os seus dados (incluindo suas playlists e preferências)?"
|
||||
},
|
||||
"menu": {
|
||||
"about": "Info",
|
||||
"library": "Biblioteca",
|
||||
"settings": "Configurações",
|
||||
"version": "Versão %{version}",
|
||||
"version": "Versão",
|
||||
"theme": "Tema",
|
||||
"personal": {
|
||||
"name": "Pessoal",
|
||||
"options": {
|
||||
"theme": "Tema",
|
||||
"language": "Língua"
|
||||
"language": "Língua",
|
||||
"defaultView": "Tela inicial"
|
||||
}
|
||||
},
|
||||
"albumList": "Álbuns"
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "Website",
|
||||
"source": "Código fonte",
|
||||
"featureRequests": "Solicitar funcionalidade"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -282,4 +310,4 @@
|
||||
"shufflePlay": "Aleatório"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,11 @@
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Sonra çal",
|
||||
"playNow": "Şimdi cal",
|
||||
"addToPlaylist": "Çalma listesine ekle"
|
||||
"playNow": "Şimdi çal",
|
||||
"addToPlaylist": "Çalma listesine ekle",
|
||||
"shuffleAll": "Tümünü karıştır",
|
||||
"download": "İndir",
|
||||
"playNext": "Sonrakini çal"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -45,7 +48,17 @@
|
||||
"playAll": "Çaldır",
|
||||
"playNext": "Sonrakini çal",
|
||||
"addToQueue": "Sonra çal",
|
||||
"shuffle": "Karıştır"
|
||||
"shuffle": "Karıştır",
|
||||
"addToPlaylist": "Playlist'e ekle",
|
||||
"download": "İndir"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Hepsi",
|
||||
"random": "Rasgele",
|
||||
"recentlyAdded": "Son Eklenenler",
|
||||
"recentlyPlayed": "Son oynatılan",
|
||||
"mostPlayed": "En çok çalanlar",
|
||||
"starred": "Yıldızlı"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
@@ -53,7 +66,8 @@
|
||||
"fields": {
|
||||
"name": "Ad",
|
||||
"albumCount": "Albüm Sayısı",
|
||||
"songCount": "Şarkı sayısı"
|
||||
"songCount": "Şarkı sayısı",
|
||||
"playCount": "Oynatma"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -98,11 +112,14 @@
|
||||
"updatedAt": "Güncelleme tarihi:",
|
||||
"createdAt": "Oluşturma tarihi:",
|
||||
"songCount": "Şarkılar",
|
||||
"comment": "Yorum"
|
||||
"comment": "Yorum",
|
||||
"sync": "otomatik-aktarma",
|
||||
"path": "'dan Aktar"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Bir çalma listesi seç:",
|
||||
"addNewPlaylist": "Oluştur \"%{name}\""
|
||||
"addNewPlaylist": "Oluştur \"%{name}\"",
|
||||
"export": "Aktar"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -158,7 +175,8 @@
|
||||
"expand": "Genişlettir",
|
||||
"close": "Kapat",
|
||||
"open_menu": "Menüyü aç",
|
||||
"close_menu": "Menüyü kapat"
|
||||
"close_menu": "Menüyü kapat",
|
||||
"unselect": "Seçimi kaldır"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Evet",
|
||||
@@ -240,20 +258,24 @@
|
||||
"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"
|
||||
"noPlaylistsAvailable": "Mevcut değil",
|
||||
"delete_user_title": "'%{name}' kullanıcısını sil",
|
||||
"delete_user_content": "Bu kullanıcıyı ve tüm verilerini (çalma listesi ve tercihleri dahil) silmek istediğinden emin misin?"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Müzik kütüphanesi",
|
||||
"settings": "Ayarlar",
|
||||
"version": "Sürüm %{version}",
|
||||
"version": "Sürüm",
|
||||
"theme": "Tema",
|
||||
"personal": {
|
||||
"name": "Kişisel",
|
||||
"options": {
|
||||
"theme": "Tema",
|
||||
"language": "Dil"
|
||||
"language": "Dil",
|
||||
"defaultView": "Varsayılan görünüm"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Albümler"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Oynatma Sırası",
|
||||
|
||||
@@ -2,25 +2,32 @@
|
||||
"languageName": "简体中文",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "歌曲 |||| 曲库",
|
||||
"name": "歌曲 |||| 歌曲",
|
||||
"fields": {
|
||||
"albumArtist": "专辑歌手",
|
||||
"duration": "时长",
|
||||
"trackNumber": "音轨 #",
|
||||
"trackNumber": "歌曲序号",
|
||||
"playCount": "播放次数",
|
||||
"title": "",
|
||||
"artist": "",
|
||||
"album": "",
|
||||
"path": "",
|
||||
"genre": "",
|
||||
"compilation": "",
|
||||
"year": "",
|
||||
"size": "",
|
||||
"updatedAt": ""
|
||||
"title": "曲名",
|
||||
"artist": "歌手",
|
||||
"album": "专辑",
|
||||
"path": "文件路径",
|
||||
"genre": "曲风",
|
||||
"compilation": "合辑",
|
||||
"year": "发布年份",
|
||||
"size": "大小",
|
||||
"updatedAt": "更新于",
|
||||
"bitRate": "比特率",
|
||||
"discSubtitle": "字幕",
|
||||
"starred": "收藏"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "稍后播放",
|
||||
"playNow": ""
|
||||
"addToQueue": "加入播放列表",
|
||||
"playNow": "立即播放",
|
||||
"addToPlaylist": "加入歌单",
|
||||
"shuffleAll": "全部随机播放",
|
||||
"download": "下载",
|
||||
"playNext": "下一首播放"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -29,66 +36,101 @@
|
||||
"albumArtist": "专辑歌手",
|
||||
"artist": "歌手",
|
||||
"duration": "时长",
|
||||
"songCount": "曲目数",
|
||||
"songCount": "歌曲数量",
|
||||
"playCount": "播放次数",
|
||||
"name": "",
|
||||
"genre": "",
|
||||
"compilation": "",
|
||||
"year": ""
|
||||
"name": "名称",
|
||||
"genre": "曲风",
|
||||
"compilation": "合辑",
|
||||
"year": "发布年份",
|
||||
"updatedAt": "更新于"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "播放",
|
||||
"playNext": "播放下一首",
|
||||
"addToQueue": "稍后播放",
|
||||
"shuffle": "刷新"
|
||||
"playAll": "立即播放",
|
||||
"playNext": "下首播放",
|
||||
"addToQueue": "加入播放列表",
|
||||
"shuffle": "随机播放",
|
||||
"addToPlaylist": "加入歌单",
|
||||
"download": "下载"
|
||||
},
|
||||
"lists": {
|
||||
"all": "所有",
|
||||
"random": "随机",
|
||||
"recentlyAdded": "最近添加",
|
||||
"recentlyPlayed": "最近播放",
|
||||
"mostPlayed": "最喜欢听的",
|
||||
"starred": "收藏"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "歌手 |||| 歌手",
|
||||
"fields": {
|
||||
"name": "",
|
||||
"albumCount": ""
|
||||
"name": "名称",
|
||||
"albumCount": "专辑数",
|
||||
"songCount": "歌曲数",
|
||||
"playCount": "播放量"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "用户 |||| 用户",
|
||||
"fields": {
|
||||
"userName": "用户名",
|
||||
"isAdmin": "",
|
||||
"lastLoginAt": "",
|
||||
"updatedAt": "",
|
||||
"name": ""
|
||||
"isAdmin": "是否管理员",
|
||||
"lastLoginAt": "上次登录",
|
||||
"updatedAt": "更新于",
|
||||
"name": "名称",
|
||||
"password": "密码",
|
||||
"createdAt": "创建于"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "用户 |||| 用户",
|
||||
"name": "客户端 |||| 客户端",
|
||||
"fields": {
|
||||
"name": "",
|
||||
"transcodingId": "",
|
||||
"maxBitRate": "",
|
||||
"client": "",
|
||||
"userName": "",
|
||||
"lastSeen": ""
|
||||
"name": "名称",
|
||||
"transcodingId": "转码编号",
|
||||
"maxBitRate": "最大比特率",
|
||||
"client": "客户端",
|
||||
"userName": "用户名",
|
||||
"lastSeen": "上次浏览"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "转码 |||| 转码",
|
||||
"fields": {
|
||||
"name": "",
|
||||
"targetFormat": "",
|
||||
"defaultBitRate": "",
|
||||
"command": ""
|
||||
"name": "名称",
|
||||
"targetFormat": "目标格式",
|
||||
"defaultBitRate": "默认比特率",
|
||||
"command": "命令"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "歌单 |||| 歌单",
|
||||
"fields": {
|
||||
"name": "名称",
|
||||
"duration": "时长",
|
||||
"owner": "所有者",
|
||||
"public": "公共",
|
||||
"updatedAt": "更新于",
|
||||
"createdAt": "创建于",
|
||||
"songCount": "歌曲数",
|
||||
"comment": "评论",
|
||||
"sync": "自动导入",
|
||||
"path": "导入"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "选择歌单",
|
||||
"addNewPlaylist": "新建 %{name}",
|
||||
"export": "导出"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "感谢您安装Navidrome!",
|
||||
"welcome2": "为了开始使用,请创建一个管理员账户",
|
||||
"welcome2": "开始使用前,请创建一个管理员账户",
|
||||
"confirmPassword": "确认密码",
|
||||
"buttonCreateAdmin": "创建管理员",
|
||||
"auth_check_error": "",
|
||||
"user_menu": "设置",
|
||||
"auth_check_error": "请登录访问更多内容",
|
||||
"user_menu": "配置",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"sign_in": "登录",
|
||||
@@ -96,7 +138,7 @@
|
||||
"logout": "退出"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "请只使用字母和数字",
|
||||
"invalidChars": "请使用字母和数字",
|
||||
"passwordDoesNotMatch": "密码不匹配",
|
||||
"required": "必填",
|
||||
"minLength": "必须不少于 %{min} 个字符",
|
||||
@@ -106,34 +148,35 @@
|
||||
"number": "必须为数字",
|
||||
"email": "必须是有效的邮箱",
|
||||
"oneOf": "必须为: %{options}其中一项",
|
||||
"regex": "必须符合指定的格式 (regexp): %{pattern}"
|
||||
"regex": "必须符合指定的格式 (正则表达式): %{pattern}"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "增加检索",
|
||||
"add": "增加",
|
||||
"back": "回退",
|
||||
"add_filter": "添加筛选",
|
||||
"add": "添加",
|
||||
"back": "返回",
|
||||
"bulk_actions": "选中%{smart_count}项",
|
||||
"cancel": "取消",
|
||||
"clear_input_value": "",
|
||||
"clone": "",
|
||||
"confirm": "",
|
||||
"clear_input_value": "清除",
|
||||
"clone": "复制",
|
||||
"confirm": "确认",
|
||||
"create": "新建",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"export": "导出",
|
||||
"list": "列表",
|
||||
"refresh": "刷新",
|
||||
"remove_filter": "移除检索",
|
||||
"remove_filter": "移除此条件",
|
||||
"remove": "删除",
|
||||
"save": "保存",
|
||||
"search": "检索",
|
||||
"search": "搜索",
|
||||
"show": "显示",
|
||||
"sort": "排序",
|
||||
"undo": "撤销",
|
||||
"expand": "",
|
||||
"close": "",
|
||||
"open_menu": "",
|
||||
"close_menu": ""
|
||||
"expand": "展开",
|
||||
"close": "关闭",
|
||||
"open_menu": "打开菜单",
|
||||
"close_menu": "关闭菜单",
|
||||
"unselect": "未选择"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "是",
|
||||
@@ -143,22 +186,22 @@
|
||||
"create": "新建 %{name}",
|
||||
"dashboard": "概览",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "出现错误",
|
||||
"list": "%{name} 列表",
|
||||
"error": "发生错误",
|
||||
"list": "%{name}",
|
||||
"loading": "加载中",
|
||||
"not_found": "未发现",
|
||||
"not_found": "未找到",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "",
|
||||
"invite": ""
|
||||
"empty": "还没有 %{name}.",
|
||||
"invite": "您要创建一个吗?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "将文件集合拖拽到这里, 或点击这里选择文件集合.",
|
||||
"upload_single": "将文件拖拽到这里, 或点击这里选择文件."
|
||||
"upload_several": "拖拽多个文件上传或点击选择一个",
|
||||
"upload_single": "拖拽单个文件上传或点击选择一个"
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "将图片文件集合拖拽到这里, 或点击这里选择图片文件集合.",
|
||||
"upload_single": "将图片文件拖拽到这里, 或点击这里选择图片文件."
|
||||
"upload_several": "拖拽多个图片上传或点击选择一个",
|
||||
"upload_single": "拖拽单个图片上传或点击选择一个"
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "未找到参考数据.",
|
||||
@@ -166,78 +209,85 @@
|
||||
"single_missing": "关联的参考数据不再可用."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "",
|
||||
"toggle_hidden": ""
|
||||
"toggle_visible": "隐藏密码",
|
||||
"toggle_hidden": "显示密码"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "关于",
|
||||
"are_you_sure": "您确定操作?",
|
||||
"bulk_delete_content": "您确定要删除 %{name}? |||| 您确定要删除 %{smart_count} 项?",
|
||||
"bulk_delete_title": "删除 %{name} |||| 删除 %{smart_count}项 %{name} ",
|
||||
"bulk_delete_title": "删除 %{name} |||| 删除 %{smart_count}项 %{name}",
|
||||
"delete_content": "您确定要删除该条目?",
|
||||
"delete_title": "删除 %{name} #%{id}",
|
||||
"details": "",
|
||||
"error": "",
|
||||
"invalid_form": "表单输入无效. 请检查错误提示",
|
||||
"details": "详情",
|
||||
"error": "发生一个客户端错误,您的请求无法完成。",
|
||||
"invalid_form": "提交内容无效. 请检查错误",
|
||||
"loading": "正在加载页面, 请稍候",
|
||||
"no": "否",
|
||||
"not_found": "您输入了错误的URL或者错误的链接.",
|
||||
"not_found": "您输入的链接格式不对或链接丢失",
|
||||
"yes": "是",
|
||||
"unsaved_changes": ""
|
||||
"unsaved_changes": "某些更改尚未保存。您确定要离开此页面吗?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "结果为空",
|
||||
"no_results": "无内容",
|
||||
"no_more_results": "页码 %{page} 超出边界. 试试上一页.",
|
||||
"page_out_of_boundaries": "页码 %{page} 超出边界",
|
||||
"page_out_from_end": "已到最末页",
|
||||
"page_out_from_begin": "已到最前页",
|
||||
"page_out_from_end": "已经最后一页",
|
||||
"page_out_from_begin": "已经是第一页",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}",
|
||||
"page_rows_per_page": "每页行数:",
|
||||
"next": "向后",
|
||||
"prev": "向前"
|
||||
"next": "下一页",
|
||||
"prev": "上一页"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "条目已更新 |||| %{smart_count} 项条目已更新",
|
||||
"created": "条目已新建",
|
||||
"deleted": "条目已删除 |||| %{smart_count} 项条目已删除",
|
||||
"bad_item": "不正确的条目",
|
||||
"item_doesnt_exist": "条目不存在",
|
||||
"updated": "项已更新 |||| %{smart_count} 项已更新",
|
||||
"created": "项已新建",
|
||||
"deleted": "项已删除 |||| %{smart_count} 项已删除",
|
||||
"bad_item": "不正确的项",
|
||||
"item_doesnt_exist": "项不存在",
|
||||
"http_error": "与服务通信出错",
|
||||
"data_provider_error": "dataProvider错误. 请检查console的详细信息.",
|
||||
"i18n_error": "",
|
||||
"canceled": "取消动作",
|
||||
"logged_out": ""
|
||||
"i18n_error": "加载所选语言时出错",
|
||||
"canceled": "操作已取消",
|
||||
"logged_out": "您的会话已结束,请重新登录。"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "",
|
||||
"transcodingDisabled": "",
|
||||
"transcodingEnabled": ""
|
||||
"note": "信息",
|
||||
"transcodingDisabled": "出于安全原因,禁用了从Web界面更改参数。要更改(编辑或删除)转码选项,请在启用%{config}选项的情况下重新启动服务器。",
|
||||
"transcodingEnabled": "Navidrome当前与%{config}一起使用,可以从Web界面执行任意命令。建议仅在配置Transcoding时启用此功能。",
|
||||
"songsAddedToPlaylist": "已添加一首歌到歌单||||已添加%{smart_count}首歌到歌单",
|
||||
"noPlaylistsAvailable": "没有有效的歌单",
|
||||
"delete_user_title": "删除用户 %{name}",
|
||||
"delete_user_content": "您确定要删除该用户及其相关数据(包括歌单和用户配置)吗?"
|
||||
},
|
||||
"menu": {
|
||||
"library": "曲库",
|
||||
"settings": "设置",
|
||||
"version": "版本 %{version}",
|
||||
"version": "版本",
|
||||
"theme": "主题",
|
||||
"personal": {
|
||||
"name": "个性化",
|
||||
"options": {
|
||||
"theme": "主题",
|
||||
"language": "语言"
|
||||
"language": "语言",
|
||||
"defaultView": "默认界面"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "专辑",
|
||||
"about": "关于"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "播放队列",
|
||||
"playListsText": "播放列表",
|
||||
"openText": "打开",
|
||||
"closeText": "关闭",
|
||||
"notContentText": "无音乐",
|
||||
"notContentText": "没有音乐",
|
||||
"clickToPlayText": "点击播放",
|
||||
"clickToPauseText": "点击暂停",
|
||||
"nextTrackText": "下一首",
|
||||
"previousTrackText": "上一首",
|
||||
"reloadText": "Reload",
|
||||
"reloadText": "重新播放",
|
||||
"volumeText": "音量",
|
||||
"toggleLyricText": "切换歌词",
|
||||
"toggleMiniModeText": "最小化",
|
||||
@@ -252,5 +302,12 @@
|
||||
"singleLoop": "单曲循环",
|
||||
"shufflePlay": "随机播放"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "主页",
|
||||
"source": "源代码",
|
||||
"featureRequests": "功能需求"
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user