mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-06 13:58:09 -05:00
Compare commits
336 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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 |
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
|
||||
|
||||
48
.github/workflows/pipeline.yml
vendored
48
.github/workflows/pipeline.yml
vendored
@@ -13,23 +13,25 @@ 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: Install taglib
|
||||
run: sudo apt-get install libtag1-dev
|
||||
|
||||
- name: Set up Go 1.14
|
||||
uses: actions/setup-go@v1
|
||||
with:
|
||||
@@ -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.14.9-3
|
||||
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.14.9-3
|
||||
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
|
||||
- CC=arm-linux-gnueabihf-gcc
|
||||
- CXX=arm-linux-gnueabihf-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"]
|
||||
45
Makefile
45
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,10 +37,10 @@ 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/...)
|
||||
@@ -48,31 +48,23 @@ setup:
|
||||
@(cd ./ui && npm ci)
|
||||
.PHONY: setup
|
||||
|
||||
setup-dev: setup
|
||||
setup-dev: setup setup-git
|
||||
@which wire || (echo "Installing Wire" && GO111MODULE=off go get -u github.com/google/wire/cmd/wire)
|
||||
@which ginkgo || (echo "Installing Ginkgo" && GO111MODULE=off go get -u github.com/onsi/ginkgo/ginkgo)
|
||||
@which goose || (echo "Installing Goose" && GO111MODULE=off go get -u github.com/pressly/goose/cmd/goose)
|
||||
@which reflex || (echo "Installing Reflex" && GO111MODULE=off go get -u github.com/cespare/reflex)
|
||||
@which golangci-lint || (echo "Installing GolangCI-Lint" && cd .. && GO111MODULE=on go get github.com/golangci/golangci-lint/cmd/golangci-lint@v1.27.0)
|
||||
@which lefthook || (echo "Installing Lefthook" && GO111MODULE=off go get -u github.com/Arkweid/lefthook)
|
||||
@lefthook install
|
||||
.PHONY: setup
|
||||
@which goimports || (echo "Installing goimports" && GO111MODULE=off go get -u golang.org/x/tools/cmd/goimports)
|
||||
.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:
|
||||
@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 +86,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.14.9-3 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", "ffmpeg")
|
||||
|
||||
// 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,
|
||||
)
|
||||
16
db/db.go
16
db/db.go
@@ -6,7 +6,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
_ "github.com/deluan/navidrome/db/migration"
|
||||
_ "github.com/deluan/navidrome/db/migrations"
|
||||
"github.com/deluan/navidrome/log"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/pressly/goose"
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -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/migrations/20200706231659_add_default_transcodings.go
Normal file
43
db/migrations/20200706231659_add_default_transcodings.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package migrations
|
||||
|
||||
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/migrations/20200710211442_add_playlist_path.go
Normal file
27
db/migrations/20200710211442_add_playlist_path.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package migrations
|
||||
|
||||
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/migrations/20200731095603_create_play_queues_table.go
Normal file
36
db/migrations/20200731095603_create_play_queues_table.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package migrations
|
||||
|
||||
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/migrations/20200801101355_create_bookmark_table.go
Normal file
53
db/migrations/20200801101355_create_bookmark_table.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package migrations
|
||||
|
||||
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/migrations/20200819111809_drop_email_unique_constraint.go
Normal file
42
db/migrations/20200819111809_drop_email_unique_constraint.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package migrations
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -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
|
||||
34
go.mod
34
go.mod
@@ -3,9 +3,8 @@ module github.com/deluan/navidrome
|
||||
go 1.14
|
||||
|
||||
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/deluan/rest v0.0.0-20200327222046-b71e558c45d0
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||
@@ -13,30 +12,37 @@ require (
|
||||
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-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.13.0
|
||||
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/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
|
||||
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
|
||||
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
|
||||
|
||||
411
go.sum
411
go.sum
@@ -1,21 +1,69 @@
|
||||
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/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/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 +74,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 +83,131 @@ 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-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/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/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,39 +217,108 @@ 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/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.13.0 h1:M76yO2HkZASFjXL0HSoZJ1AYEmQxNJmY41Jx1zNUq1Y=
|
||||
github.com/onsi/ginkgo v1.13.0/go.mod h1:+REjRxOmWfHCjfv9TTWB1jD1Frx4XydAD3zm1lskyM0=
|
||||
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/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
|
||||
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=
|
||||
@@ -141,86 +327,255 @@ github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykE
|
||||
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/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=
|
||||
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-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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package request
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
@@ -23,7 +22,7 @@ func WithUser(ctx context.Context, u model.User) context.Context {
|
||||
}
|
||||
|
||||
func WithUsername(ctx context.Context, username string) context.Context {
|
||||
return context.WithValue(ctx, Username, strings.ToLower(username))
|
||||
return context.WithValue(ctx, Username, username)
|
||||
}
|
||||
|
||||
func WithClient(ctx context.Context, client string) context.Context {
|
||||
|
||||
@@ -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,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"unicode/utf8"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
@@ -51,7 +52,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 +82,24 @@ 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
|
||||
}
|
||||
|
||||
// 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, "%")})
|
||||
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")
|
||||
|
||||
@@ -93,11 +108,17 @@ func (r mediaFileRepository) FindByPath(path string) (model.MediaFiles, error) {
|
||||
return res, err
|
||||
}
|
||||
|
||||
func pathStartsWith(path string) Sqlizer {
|
||||
cleanPath := filepath.Clean(path)
|
||||
substr := fmt.Sprintf("substr(path, 1, %d)", utf8.RuneCountInString(cleanPath))
|
||||
return Eq{substr: cleanPath}
|
||||
}
|
||||
|
||||
// FindPathsRecursively returns a list of all subfolders of basePath, recursively
|
||||
func (r mediaFileRepository) FindPathsRecursively(basePath string) ([]string, error) {
|
||||
// Query based on https://stackoverflow.com/a/38330814/653632
|
||||
sel := r.newSelect().Columns(fmt.Sprintf("distinct rtrim(path, replace(path, '%s', ''))", string(os.PathSeparator))).
|
||||
Where(Like{"path": filepath.Join(basePath, "%")})
|
||||
Where(pathStartsWith(basePath))
|
||||
var res []string
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
@@ -124,14 +145,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 {
|
||||
func (r mediaFileRepository) DeleteByPath(path string) (int64, error) {
|
||||
path = filepath.Clean(path)
|
||||
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,40 @@ 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))
|
||||
})
|
||||
|
||||
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 }{}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -110,25 +114,39 @@ func (s *SQLStore) WithTx(block func(tx model.DataStore) error) error {
|
||||
func (s *SQLStore) GC(ctx context.Context) error {
|
||||
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},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user