Compare commits
305 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e6c0e3894 | ||
|
|
b930c7253a | ||
|
|
c1afe70d98 | ||
|
|
3f9ddb915e | ||
|
|
c3edc7f449 | ||
|
|
9b272c8021 | ||
|
|
6d1221164b | ||
|
|
647132625c | ||
|
|
a17a98a75f | ||
|
|
59707b3a8f | ||
|
|
fa378ab4e4 | ||
|
|
05ffb1acad | ||
|
|
a1ba5c59b2 | ||
|
|
1bc68c20fc | ||
|
|
d308e7ca46 | ||
|
|
2b5433dc6e | ||
|
|
86a23f9b14 | ||
|
|
0ba5840a65 | ||
|
|
93646b964e | ||
|
|
13be8d297c | ||
|
|
a4b97121ab | ||
|
|
660f9c205b | ||
|
|
28852ce7d7 | ||
|
|
656ca1f3b5 | ||
|
|
b8f7715a74 | ||
|
|
096ed396c8 | ||
|
|
3b6d0b3d15 | ||
|
|
75cd21da1f | ||
|
|
b8eb22d162 | ||
|
|
9b461735f4 | ||
|
|
63bf49b3c4 | ||
|
|
559848299c | ||
|
|
8510273216 | ||
|
|
2392060bc1 | ||
|
|
2d7998de59 | ||
|
|
40638688b2 | ||
|
|
ea22b2fc6d | ||
|
|
1182218787 | ||
|
|
14f7c5610e | ||
|
|
27579b99a3 | ||
|
|
c58021e645 | ||
|
|
1810cc7ac7 | ||
|
|
86f73eecca | ||
|
|
3d6ce8a77f | ||
|
|
670be29d7b | ||
|
|
2b3e506583 | ||
|
|
6e6cfdd02b | ||
|
|
a18093e255 | ||
|
|
a35636999d | ||
|
|
13a3d38e4f | ||
|
|
9f00fb0f05 | ||
|
|
6cddcd6f0d | ||
|
|
c6d1cfeceb | ||
|
|
de43c27b3c | ||
|
|
747b5ea25e | ||
|
|
dd2e98fca2 | ||
|
|
eb621be646 | ||
|
|
d223a4f4db | ||
|
|
7aa182e33d | ||
|
|
7fec503b72 | ||
|
|
083a11a563 | ||
|
|
944f3695c4 | ||
|
|
dfc8691262 | ||
|
|
395b598bb1 | ||
|
|
d04b434d96 | ||
|
|
f041503a85 | ||
|
|
500207f7b8 | ||
|
|
1e0a79ebb7 | ||
|
|
301fa2a957 | ||
|
|
46f4f63212 | ||
|
|
fec8b5f731 | ||
|
|
777231ea79 | ||
|
|
0e36ed35a3 | ||
|
|
f1af646cee | ||
|
|
fc0621646b | ||
|
|
575800dcff | ||
|
|
0ca849a61a | ||
|
|
53e8a92fed | ||
|
|
fc650cd127 | ||
|
|
b03519b09c | ||
|
|
39b9f818be | ||
|
|
7febe05ed5 | ||
|
|
2c42e4e12e | ||
|
|
dcb3b3b5d1 | ||
|
|
5331732236 | ||
|
|
dc973ae670 | ||
|
|
100db2bcfd | ||
|
|
c84a58ff7d | ||
|
|
2d7fda1b2f | ||
|
|
3cba5f70fd | ||
|
|
b4c7cac964 | ||
|
|
5ef80d2490 | ||
|
|
3b798cf943 | ||
|
|
50b7756159 | ||
|
|
15606770ca | ||
|
|
f403a8da34 | ||
|
|
20075ae68d | ||
|
|
91a743623a | ||
|
|
e23a290812 | ||
|
|
dee68559ab | ||
|
|
9f42e330b4 | ||
|
|
ad63b8b1b4 | ||
|
|
0d8a2b310f | ||
|
|
3977575563 | ||
|
|
47244cb770 | ||
|
|
57aaf5a26b | ||
|
|
352d686d94 | ||
|
|
f6e448c1ba | ||
|
|
270b0ae74e | ||
|
|
8401d85f78 | ||
|
|
32fbf2e9eb | ||
|
|
8cdd4e317d | ||
|
|
97d95ea794 | ||
|
|
cbbebb3264 | ||
|
|
8b108905a3 | ||
|
|
5b40ec400e | ||
|
|
29e661e1fe | ||
|
|
b466ec75a4 | ||
|
|
c8cd755451 | ||
|
|
faac303eff | ||
|
|
ced87be57b | ||
|
|
811703ab60 | ||
|
|
bc1f767123 | ||
|
|
7055dc514b | ||
|
|
e02f3d3ec9 | ||
|
|
68a49befc8 | ||
|
|
c8b0d2bfae | ||
|
|
39993810b3 | ||
|
|
45180115a6 | ||
|
|
353c48d8d8 | ||
|
|
da36941252 | ||
|
|
8ec78900c5 | ||
|
|
a0e0fbad58 | ||
|
|
75e7ba8b1e | ||
|
|
74c30b5a66 | ||
|
|
e67bdbbc32 | ||
|
|
9554c8f783 | ||
|
|
e36a42f356 | ||
|
|
9d1960232c | ||
|
|
d3547544bf | ||
|
|
9cb42606ba | ||
|
|
7772afce1c | ||
|
|
10e76257c6 | ||
|
|
9235ab6414 | ||
|
|
59356f0029 | ||
|
|
9ae14015a1 | ||
|
|
0b131e91c1 | ||
|
|
77b12eafde | ||
|
|
050778460d | ||
|
|
28bc9c1d4f | ||
|
|
5e7aaa667b | ||
|
|
1afc495920 | ||
|
|
cf7d877714 | ||
|
|
81831da67a | ||
|
|
fcd2fcae67 | ||
|
|
1c33b0aea8 | ||
|
|
fc06163b5a | ||
|
|
72f0a6fb66 | ||
|
|
6f5a322927 | ||
|
|
a7f8e4ee2b | ||
|
|
0850872b0f | ||
|
|
1d886156d5 | ||
|
|
faa2a978c0 | ||
|
|
38faffa907 | ||
|
|
65a792be3a | ||
|
|
876354e58e | ||
|
|
14b33bc34d | ||
|
|
9044aa8740 | ||
|
|
07ac14f810 | ||
|
|
0370f0a3ea | ||
|
|
33ede13eef | ||
|
|
e032bfcf6b | ||
|
|
f4014c475d | ||
|
|
f394de664a | ||
|
|
d2eea64528 | ||
|
|
d7b5e6a36c | ||
|
|
b49b9e3ca0 | ||
|
|
1322bb3bf3 | ||
|
|
13a046a679 | ||
|
|
e6d2056438 | ||
|
|
a6b0c57ce0 | ||
|
|
fc14e346b9 | ||
|
|
5525145906 | ||
|
|
74d87790b8 | ||
|
|
8ce796756f | ||
|
|
a412989f7e | ||
|
|
ae02dc203e | ||
|
|
fc7595a464 | ||
|
|
4ceaea7732 | ||
|
|
894536c8ec | ||
|
|
92f6e55821 | ||
|
|
c3bd181648 | ||
|
|
3b12c92ad5 | ||
|
|
272d897ec9 | ||
|
|
e6d717cbbc | ||
|
|
b7f1fc0374 | ||
|
|
de525edde0 | ||
|
|
7f94660183 | ||
|
|
b2d022b823 | ||
|
|
ba08f00c20 | ||
|
|
d9993c5877 | ||
|
|
edb839a41d | ||
|
|
9fa73e3b7b | ||
|
|
8ebb85b0af | ||
|
|
a37beac753 | ||
|
|
8a31e80b7a | ||
|
|
ce11a2f3be | ||
|
|
5a95feeedc | ||
|
|
400fa65326 | ||
|
|
ab10719d27 | ||
|
|
029290f304 | ||
|
|
2c146ea1fe | ||
|
|
10ead1f5f2 | ||
|
|
730722cfe3 | ||
|
|
dc352834b9 | ||
|
|
313a3342a0 | ||
|
|
0f13bbdbd0 | ||
|
|
4310f2c94f | ||
|
|
6ce4811460 | ||
|
|
52cd17963f | ||
|
|
8f0c07d29f | ||
|
|
a50735a94c | ||
|
|
f0e7f3ef25 | ||
|
|
2ca98d8e81 | ||
|
|
81e1a7088f | ||
|
|
d37351610a | ||
|
|
99361c0d9f | ||
|
|
8673533cd4 | ||
|
|
d9dd9fe587 | ||
|
|
abb99a8501 | ||
|
|
690f92a671 | ||
|
|
c57007db52 | ||
|
|
cc229dcee6 | ||
|
|
7aab82c246 | ||
|
|
989deb1200 | ||
|
|
6aaee4342e | ||
|
|
b5dadf55f4 | ||
|
|
18c7397709 | ||
|
|
4a82a6cb02 | ||
|
|
220ffd5324 | ||
|
|
e33d2305a1 | ||
|
|
7815b57920 | ||
|
|
18cbb153f3 | ||
|
|
9f086b5f7b | ||
|
|
c8d6f2d506 | ||
|
|
6619b0986a | ||
|
|
2dbd645292 | ||
|
|
6978790e96 | ||
|
|
e0308acef3 | ||
|
|
5fbde33b97 | ||
|
|
19fb29e520 | ||
|
|
e5e35516d7 | ||
|
|
28bad95e66 | ||
|
|
9260957271 | ||
|
|
79b0f1f57b | ||
|
|
4dffcb7b46 | ||
|
|
d1f8d39866 | ||
|
|
0996272943 | ||
|
|
d093191659 | ||
|
|
998323b364 | ||
|
|
6dfe56c1c4 | ||
|
|
fd5548f890 | ||
|
|
6e2454f6cc | ||
|
|
8372dee000 | ||
|
|
41fd5862b8 | ||
|
|
a6b5be7b0a | ||
|
|
4d06d250e6 | ||
|
|
694b5d1d39 | ||
|
|
5329ac7b72 | ||
|
|
464880dd31 | ||
|
|
0e01f9a0f9 | ||
|
|
d9eb3e58cd | ||
|
|
0d64fb05c7 | ||
|
|
0849d6b901 | ||
|
|
40ad6a7bef | ||
|
|
ddae5588d4 | ||
|
|
67c20f36b1 | ||
|
|
ff8c18e0f4 | ||
|
|
203754726b | ||
|
|
e97d805444 | ||
|
|
d4365b9f64 | ||
|
|
b62b78edfe | ||
|
|
7c4511e33a | ||
|
|
7e65bb8f20 | ||
|
|
76ca8afc84 | ||
|
|
a6b8f40ac3 | ||
|
|
0d0787e656 | ||
|
|
88e01d05f6 | ||
|
|
de1fea64bc | ||
|
|
5d1df19291 | ||
|
|
0b91d8a30e | ||
|
|
cdbbb2f596 | ||
|
|
44671c59c0 | ||
|
|
d9f61a278c | ||
|
|
a260e65307 | ||
|
|
5a4c763510 | ||
|
|
d755609d13 | ||
|
|
4f4af34595 | ||
|
|
f5071d1614 | ||
|
|
d389d40db1 | ||
|
|
72d9ddf532 | ||
|
|
67ed830a68 | ||
|
|
71c1844bca | ||
|
|
b26a5ef2d0 | ||
|
|
b286034977 |
@@ -3,10 +3,10 @@ ui/node_modules
|
||||
ui/build
|
||||
Jamstash-master
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
data
|
||||
*.db
|
||||
testDB
|
||||
*_test.go
|
||||
navidrome
|
||||
navidrome.db
|
||||
navidrome.toml
|
||||
|
||||
BIN
.github/screenshots/screenshot-desktop.png
vendored
|
Before Width: | Height: | Size: 264 KiB |
BIN
.github/screenshots/ss-desktop-player.png
vendored
Normal file
|
After Width: | Height: | Size: 364 KiB |
BIN
.github/screenshots/ss-mobile-album-view.png
vendored
Normal file
|
After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 709 KiB After Width: | Height: | Size: 709 KiB |
BIN
.github/screenshots/ss-mobile-player.png
vendored
Normal file
|
After Width: | Height: | Size: 411 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
6
.github/workflows/build.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: Build
|
||||
on: [push]
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
go:
|
||||
name: Test Server on ${{ matrix.os }}
|
||||
@@ -11,10 +11,10 @@ jobs:
|
||||
os: [macOS-latest, ubuntu-latest]
|
||||
|
||||
steps:
|
||||
- name: Set up Go 1.13
|
||||
- name: Set up Go 1.14
|
||||
uses: actions/setup-go@v1
|
||||
with:
|
||||
go-version: 1.13
|
||||
go-version: 1.14
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
|
||||
10
.github/workflows/release.yml
vendored
@@ -1,9 +1,8 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
create:
|
||||
push:
|
||||
tags:
|
||||
- v*.*.*
|
||||
- '*'
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
@@ -15,7 +14,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 13
|
||||
node-version: 13.12
|
||||
- name: Build UI
|
||||
run: |
|
||||
cd ui
|
||||
@@ -24,7 +23,8 @@ jobs:
|
||||
- name: Fetch tags
|
||||
run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
|
||||
- name: Run GoReleaser
|
||||
uses: docker://bepsays/ci-goreleaser:1.13-4
|
||||
uses: docker://bepsays/ci-goreleaser:1.14-1
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
|
||||
2
.gitignore
vendored
@@ -18,3 +18,5 @@ navidrome.db
|
||||
*.swp
|
||||
*_gen.go
|
||||
dist
|
||||
music
|
||||
docker-compose.override.yml
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- apt-get update
|
||||
- apt-get install -y gcc-arm-linux-gnueabi gcc-aarch64-linux-gnu
|
||||
- go get -u github.com/go-bindata/go-bindata/...
|
||||
- go-bindata -fs -prefix ui/build -tags embed -nocompress -pkg assets -o assets/embedded_gen.go ui/build/...
|
||||
- git checkout .
|
||||
@@ -21,7 +23,7 @@ builds:
|
||||
ldflags:
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
|
||||
|
||||
- id: navidrome_linux
|
||||
- id: navidrome_linux_amd64
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
goos:
|
||||
@@ -34,6 +36,38 @@ builds:
|
||||
- "-extldflags '-static'"
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
|
||||
|
||||
- id: navidrome_linux_arm
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=arm-linux-gnueabi-gcc
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- arm
|
||||
goarm:
|
||||
- 6
|
||||
- 7
|
||||
flags:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- "-extld=$CC"
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
|
||||
|
||||
- id: navidrome_linux_arm64
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=aarch64-linux-gnu-gcc
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- arm64
|
||||
flags:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
|
||||
|
||||
- id: navidrome_windows_i686
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
@@ -69,8 +103,15 @@ archives:
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
replacements:
|
||||
darwin: macOS
|
||||
linux: Linux
|
||||
windows: Windows
|
||||
386: i386
|
||||
amd64: x86_64
|
||||
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
name_template: '{{ .ProjectName }}_checksums.txt'
|
||||
|
||||
snapshot:
|
||||
name_template: "{{ .Tag }}-next"
|
||||
@@ -83,6 +124,3 @@ changelog:
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- '^test:'
|
||||
- '^chore:'
|
||||
- '^ci:'
|
||||
|
||||
@@ -10,7 +10,7 @@ Navidrome and Subsonic:
|
||||
* Right now, Navidrome only works with a single Music Library (Music Folder)
|
||||
* Navidrome does not mark songs as played by calls to `stream`, only when
|
||||
`scrobble` is called with `submission=true`
|
||||
* Next features to be implemented: Playlists (WIP), MultiUser (WIP), Jukebox, Sharing, Podcasts, Bookmarks, Internet Radio.
|
||||
* Next features to be implemented: Last.FM integration, Jukebox, Sharing, Bookmarks, Podcasts, Internet Radio.
|
||||
|
||||
Navidrome is actively being tested with:
|
||||
[DSub](http://www.subsonic.org/pages/apps.jsp#dsub),
|
||||
@@ -25,7 +25,7 @@ Navidrome is actively being tested with:
|
||||
| `getLicense` | Always valid ;) |
|
||||
| ||
|
||||
| _BROWSING_ ||
|
||||
| `getMusicFolders` | Hardcoded to just one, configured in `app.conf` |
|
||||
| `getMusicFolders` | Hardcoded to just one, set with ND_MUSICFOLDER configuration |
|
||||
| `getIndexes` | Doesn't support shortcuts, nor direct children |
|
||||
| `getMusicDirectory` | |
|
||||
| `getSong` | |
|
||||
@@ -40,7 +40,7 @@ Navidrome is actively being tested with:
|
||||
| `getStarred` | |
|
||||
| `getStarred2` | |
|
||||
| `getNowPlaying` | |
|
||||
| `getRandomSongs` | Ignores `year` parameter |
|
||||
| `getRandomSongs` | Ignores `fromYear` and `toYear` parameters |
|
||||
| ||
|
||||
| _SEARCHING_ ||
|
||||
| `search2` | Doesn't support Lucene queries, only simple auto complete queries |
|
||||
@@ -54,7 +54,7 @@ Navidrome is actively being tested with:
|
||||
| `deletePlaylist` | |
|
||||
| ||
|
||||
| _MEDIA RETRIEVAL_ ||
|
||||
| `stream` | Returns wrong content-length when downsampling |
|
||||
| `stream` | |
|
||||
| `download` | |
|
||||
| `getCoverArt` | Only gets embedded artwork |
|
||||
| `getAvatar` | Always returns the same image |
|
||||
@@ -62,7 +62,7 @@ Navidrome is actively being tested with:
|
||||
| _MEDIA ANNOTATION_ ||
|
||||
| `star` | |
|
||||
| `unstar` | |
|
||||
| `setRating` | Doesn't work with artists |
|
||||
| `setRating` | |
|
||||
| `scrobble` | No Last.FM support yet. It is used to update play count and last played |
|
||||
| ||
|
||||
| _USER MANAGEMENT_ ||
|
||||
|
||||
19
Dockerfile
@@ -1,6 +1,6 @@
|
||||
#####################################################
|
||||
### Build UI bundles
|
||||
FROM node:13.7-alpine AS jsbuilder
|
||||
FROM node:13.12-alpine AS jsbuilder
|
||||
WORKDIR /src
|
||||
COPY ui/package.json ui/package-lock.json ./
|
||||
RUN npm ci
|
||||
@@ -10,7 +10,7 @@ RUN npm run build
|
||||
|
||||
#####################################################
|
||||
### Build executable
|
||||
FROM golang:1.13-alpine AS gobuilder
|
||||
FROM golang:1.14-alpine AS gobuilder
|
||||
|
||||
# Download build tools
|
||||
RUN mkdir -p /src/ui/build
|
||||
@@ -36,7 +36,7 @@ COPY --from=jsbuilder /src/build/* /src/ui/build/
|
||||
COPY --from=jsbuilder /src/build/static/css/* /src/ui/build/static/css/
|
||||
COPY --from=jsbuilder /src/build/static/js/* /src/ui/build/static/js/
|
||||
RUN rm -rf /src/build/css /src/build/js
|
||||
RUN GIT_TAG=$(git name-rev --name-only HEAD) && \
|
||||
RUN GIT_TAG=$(git describe --tags `git rev-list --tags --max-count=1`) && \
|
||||
GIT_TAG=${GIT_TAG#"tags/"} && \
|
||||
GIT_SHA=$(git rev-parse --short HEAD) && \
|
||||
echo "Building version: ${GIT_TAG} (${GIT_SHA})" && \
|
||||
@@ -48,6 +48,11 @@ RUN GIT_TAG=$(git name-rev --name-only HEAD) && \
|
||||
FROM alpine as release
|
||||
MAINTAINER Deluan Quintao <navidrome@deluan.com>
|
||||
|
||||
# Download Tini
|
||||
ENV TINI_VERSION v0.18.0
|
||||
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-static /tini
|
||||
RUN chmod +x /tini
|
||||
|
||||
COPY --from=gobuilder /src/navidrome /app/
|
||||
COPY --from=gobuilder /tmp/ffmpeg*/ffmpeg /usr/bin/
|
||||
|
||||
@@ -58,10 +63,14 @@ VOLUME ["/data", "/music"]
|
||||
ENV ND_MUSICFOLDER /music
|
||||
ENV ND_DATAFOLDER /data
|
||||
ENV ND_SCANINTERVAL 1m
|
||||
ENV ND_TRANSCODINGCACHESIZE 100MB
|
||||
ENV ND_SESSIONTIMEOUT 30m
|
||||
ENV ND_LOGLEVEL info
|
||||
ENV ND_PORT 4533
|
||||
|
||||
EXPOSE 4533
|
||||
EXPOSE ${ND_PORT}
|
||||
HEALTHCHECK CMD wget -O- http://localhost:${ND_PORT}/ping || exit 1
|
||||
WORKDIR /app
|
||||
|
||||
ENTRYPOINT "/app/navidrome"
|
||||
ENTRYPOINT ["/tini", "--"]
|
||||
CMD ["/app/navidrome"]
|
||||
|
||||
71
Makefile
@@ -1,88 +1,99 @@
|
||||
GO_VERSION=1.13
|
||||
NODE_VERSION=v13.7.0
|
||||
GO_VERSION=$(shell grep -e "^go " go.mod | cut -f 2 -d ' ')
|
||||
NODE_VERSION=$(shell cat .nvmrc)
|
||||
|
||||
GIT_SHA=$(shell git rev-parse --short HEAD)
|
||||
|
||||
.PHONY: dev
|
||||
## Default target just build the Go project.
|
||||
default:
|
||||
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=master"
|
||||
.PHONY: default
|
||||
|
||||
dev: check_env
|
||||
@goreman -f Procfile.dev -b 4533 start
|
||||
.PHONY: dev
|
||||
|
||||
.PHONY: server
|
||||
server: check_go_env
|
||||
@reflex -d none -c reflex.conf
|
||||
.PHONY: server
|
||||
|
||||
wire: check_go_env
|
||||
wire ./...
|
||||
.PHONY: wire
|
||||
|
||||
.PHONY: watch
|
||||
watch: check_go_env
|
||||
ginkgo watch -notify ./...
|
||||
.PHONY: watch
|
||||
|
||||
.PHONY: test
|
||||
test: check_go_env
|
||||
go test ./... -v
|
||||
# @(cd ./ui && npm test -- --watchAll=false)
|
||||
.PHONY: test
|
||||
|
||||
.PHONY: testall
|
||||
testall: check_go_env test
|
||||
@(cd ./ui && npm test -- --watchAll=false)
|
||||
.PHONY: testall
|
||||
|
||||
.PHONY: setup
|
||||
setup: Jamstash-master
|
||||
@which goconvey || (echo "Installing GoConvey" && GO111MODULE=off go get -u github.com/smartystreets/goconvey)
|
||||
@which wire || (echo "Installing Wire" && GO111MODULE=off go get -u github.com/google/wire/cmd/wire)
|
||||
@which go-bindata || (echo "Installing BinData" && GO111MODULE=off go get -u github.com/go-bindata/go-bindata/...)
|
||||
@which reflex || (echo "Installing Reflex" && GO111MODULE=off go get -u github.com/cespare/reflex)
|
||||
@which goreman || (echo "Installing Goreman" && GO111MODULE=off go get -u github.com/mattn/goreman)
|
||||
@which ginkgo || (echo "Installing Ginkgo" && GO111MODULE=off go get -u github.com/onsi/ginkgo/ginkgo)
|
||||
@which goose || (echo "Installing Goose" && GO111MODULE=off go get -u github.com/pressly/goose/cmd/goose)
|
||||
@which lefthook || (echo "Installing Lefthook" && GO111MODULE=off go get -u github.com/Arkweid/lefthook)
|
||||
@lefthook install
|
||||
go mod download
|
||||
@(cd ./ui && npm ci)
|
||||
.PHONY: setup
|
||||
|
||||
.PHONY: static
|
||||
static:
|
||||
cd static && go-bindata -fs -prefix "static" -nocompress -ignore="\\\*.go" -pkg static .
|
||||
.PHONY: static
|
||||
|
||||
Jamstash-master:
|
||||
wget -N https://github.com/tsquillario/Jamstash/archive/master.zip
|
||||
unzip -o master.zip
|
||||
rm master.zip
|
||||
(cd Jamstash-master && npm ci && npx bower install && npx grunt build)
|
||||
rm -rf Jamstash-master/node_modules Jamstash-master/bower_components
|
||||
|
||||
.PHONE: check_env
|
||||
check_env: check_go_env check_node_env
|
||||
.PHONE: check_env
|
||||
|
||||
check_hooks:
|
||||
@lefthook add pre-commit
|
||||
@lefthook add pre-push
|
||||
.PHONE: check_hooks
|
||||
|
||||
.PHONY: check_go_env
|
||||
check_go_env:
|
||||
@(hash go) || (echo "\nERROR: GO environment not setup properly!\n"; exit 1)
|
||||
@go version | grep -q $(GO_VERSION) || (echo "\nERROR: Please upgrade your GO version\n"; exit 1)
|
||||
@go version | grep -q $(GO_VERSION) || (echo "\nERROR: Please upgrade your GO version\nThis project requires version $(GO_VERSION)"; exit 1)
|
||||
.PHONY: check_go_env
|
||||
|
||||
.PHONY: check_node_env
|
||||
check_node_env:
|
||||
@(hash node) || (echo "\nERROR: Node environment not setup properly!\n"; exit 1)
|
||||
@node --version | grep -q $(NODE_VERSION) || (echo "\nERROR: Please check your Node version. Should be $(NODE_VERSION)\n"; exit 1)
|
||||
.PHONY: check_node_env
|
||||
|
||||
UI_SRC = $(shell find ui/src ui/public -name "*.js")
|
||||
ui/build: $(UI_SRC) $(UI_PUBLIC) ui/package-lock.json
|
||||
@(cd ./ui && npm run build)
|
||||
|
||||
assets/embedded_gen.go: ui/build
|
||||
go-bindata -fs -prefix "ui/build" -tags embed -nocompress -pkg assets -o assets/embedded_gen.go ui/build/...
|
||||
|
||||
.PHONY: build
|
||||
build: check_go_env
|
||||
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=master"
|
||||
.PHONY: build
|
||||
|
||||
.PHONY: buildall
|
||||
buildall: check_env assets/embedded_gen.go
|
||||
buildall: check_env
|
||||
@(cd ./ui && npm run build)
|
||||
go-bindata -fs -prefix "ui/build" -tags embed -nocompress -pkg assets -o assets/embedded_gen.go ui/build/...
|
||||
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=master" -tags=embed
|
||||
.PHONY: buildall
|
||||
|
||||
.PHONY: release
|
||||
release:
|
||||
@if [[ ! "${V}" =~ ^[0-9]+\.[0-9]+\.[0-9]+.*$$ ]]; then echo "Usage: make release V=X.X.X"; exit 1; fi
|
||||
go mod tidy
|
||||
make test
|
||||
@if [ -n "`git status -s`" ]; then echo "\n\nThere are pending changes. Please commit or stash first"; exit 1; fi
|
||||
make test
|
||||
git tag v${V}
|
||||
git push origin v${V}
|
||||
git push origin master
|
||||
.PHONY: release
|
||||
|
||||
.PHONY: dist
|
||||
dist:
|
||||
docker run -it -v $(PWD):/github/workspace -w /github/workspace bepsays/ci-goreleaser:1.13-4 goreleaser release --rm-dist --skip-publish --snapshot
|
||||
snapshot:
|
||||
docker run -it -v $(PWD):/workspace -w /workspace bepsays/ci-goreleaser:1.14-1 goreleaser release --rm-dist --skip-publish --snapshot
|
||||
.PHONY: snapshot
|
||||
|
||||
84
README.md
@@ -1,16 +1,19 @@
|
||||
# Navidrome Music Streamer
|
||||
|
||||
[](https://github.com/deluan/navidrome/actions)
|
||||
[](https://github.com/deluan/navidrome/releases)
|
||||
[](https://hub.docker.com/r/deluan/navidrome)
|
||||
[](https://github.com/deluan/navidrome/releases)
|
||||
[](https://github.com/deluan/navidrome/actions)
|
||||
[](https://hub.docker.com/r/deluan/navidrome)
|
||||
[](https://discord.gg/xh7j7yF)
|
||||
[](https://www.reddit.com/r/navidrome/)
|
||||
|
||||
Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your
|
||||
music collection from any browser or mobile device.
|
||||
|
||||
This is a fully functional _alpha quality_ software. Expect some changes in the feature set and the way it works.
|
||||
music collection from any browser or mobile device. It's like your personal Spotify!
|
||||
|
||||
__Any feedback is welcome!__ If you need/want a new feature, find a bug or think of any way to improve Navidrome,
|
||||
please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join the chat in our [Discord server](https://discord.gg/xh7j7yF)
|
||||
please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join the discussion in our
|
||||
[Subreddit](https://www.reddit.com/r/navidrome/). If you want to contribute to the project in any other way
|
||||
(ui/backend dev, translations, [themes](ui/src/themes/README.md)), please join the chat in our
|
||||
[Discord server](https://discord.gg/xh7j7yF).
|
||||
|
||||
|
||||
## Features
|
||||
@@ -21,43 +24,63 @@ please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join
|
||||
- Multi-user, each user has their own play counts, playlists, favourites, etc..
|
||||
- Very low resource usage: Ex: with a library of 300GB (~29000 songs), it uses less than 50MB of RAM
|
||||
- Multi-platform, runs on macOS, Linux and Windows. Docker images are also provided
|
||||
- Ready to use Raspberry Pi binaries available
|
||||
- Automatically monitors your library for changes, importing new files and reloading new metadata
|
||||
- Modern and responsive Web interface based on Material UI, to manage users and browse your library
|
||||
- Compatible with the huge selection of clients for [Subsonic](http://www.subsonic.org),
|
||||
[Airsonic](https://airsonic.github.io/) and [Madsonic](https://www.madsonic.org/).
|
||||
See the [complete list of available mobile and web apps](https://airsonic.github.io/docs/apps/)
|
||||
- [Themeable](ui/src/themes/README.md), modern and responsive Web interface based on Material UI, to manage users and
|
||||
browse your library
|
||||
- Compatible with all Subsonic/Madsonic/Airsonic clients. See bellow for a list of tested clients
|
||||
- Transcoding/Downsampling on-the-fly. Can be set per user/player. Opus encoding is supported
|
||||
- Integrated music player (WIP)
|
||||
|
||||
Navidrome should be compatible with all Subsonic clients. The following clients are tested and confirmed to work properly:
|
||||
- Android: [DSub](https://play.google.com/store/apps/details?id=github.daneren2005.dsub),
|
||||
[Ultrasonic](https://play.google.com/store/apps/details?id=org.moire.ultrasonic) and
|
||||
[Music Stash](https://play.google.com/store/apps/details?id=com.ghenry22.mymusicstash)
|
||||
- iOS: [play:Sub](http://michaelsapps.dk/playsubapp/)
|
||||
- Web: [Jamstash](http://jamstash.com),
|
||||
[Aurial](http://shrimpza.github.io/aurial/),
|
||||
[Subfire](http://p.subfireplayer.net/) and
|
||||
[Subplayer](https://github.com/peguerosdc/subplayer)
|
||||
|
||||
For more options, look at the [list of clients](https://airsonic.github.io/docs/apps/) maintained by
|
||||
the Airsonic project. Please open an [issue](https://github.com/deluan/navidrome/issues) if you have any
|
||||
trouble with the client of your choice.
|
||||
|
||||
|
||||
## Road map
|
||||
|
||||
This project is being actively worked on. Expect a more polished experience and new features/releases
|
||||
on a frequent basis. Some upcoming features planned:
|
||||
|
||||
- Transcoding/Downsampling on-the-fly
|
||||
- Complete WebUI, to browse and listen to your library
|
||||
- Last.FM integration
|
||||
- Integrated music player
|
||||
- Pre-build binaries for Raspberry Pi
|
||||
- Smart/dynamic playlists (similar to iTunes)
|
||||
- Support for audiobooks (bookmarking)
|
||||
- Jukebox mode
|
||||
- Sharing links to albums/songs/playlists
|
||||
- Podcasts
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
Various options are available:
|
||||
|
||||
### Pre-build executables
|
||||
### Pre-built executables
|
||||
|
||||
Just head to the [releases page](https://github.com/deluan/navidrome/releases) and download the latest version for you
|
||||
platform. There are builds available for Linux, macOS and Windows (32 and 64 bits).
|
||||
platform. There are builds available for Linux (amd64 and arm), macOS and Windows (32 and 64 bits).
|
||||
For Raspberry Pi (tested with Raspbian Buster on Pi 4), use the Linux arm builds.
|
||||
|
||||
Remember to install [ffmpeg](https://ffmpeg.org/download.html) in your system, a requirement for Navidrome to work properly.
|
||||
Remember to install [ffmpeg](https://ffmpeg.org/download.html) in your system, a requirement for Navidrome to work
|
||||
properly. You may find the latest static build for your platform here: https://johnvansickle.com/ffmpeg/
|
||||
|
||||
If you have any issues with these binaries, or need a binary for a different platform, please
|
||||
[open an issue](https://github.com/deluan/navidrome/issues)
|
||||
|
||||
### Docker
|
||||
|
||||
[Docker images](https://hub.docker.com/r/deluan/navidrome) are available. They include everything needed to run Navidrome. Example of usage:
|
||||
[Docker images](https://hub.docker.com/r/deluan/navidrome) are available. They include everything needed
|
||||
to run Navidrome. Example of usage:
|
||||
|
||||
```yaml
|
||||
# This is just an example. Customize it to your needs.
|
||||
@@ -75,14 +98,19 @@ services:
|
||||
ND_SCANINTERVAL: 1m
|
||||
ND_LOGLEVEL: info
|
||||
ND_PORT: 4533
|
||||
ND_TRANSCODINGCACHESIZE: 100MB
|
||||
ND_SESSIONTIMEOUT: 30m
|
||||
ND_BASEURL: ""
|
||||
volumes:
|
||||
- "./data:/data"
|
||||
- "/Users/deluan/Music/iTunes/iTunes Media/Music:/music"
|
||||
- "/path/to/your/music/folder:/music:ro"
|
||||
```
|
||||
|
||||
To get the cutting-edge, latest version from master, use the image `deluan/navidrome:develop`
|
||||
|
||||
### Build from source
|
||||
|
||||
You will need to install [Go 1.13](https://golang.org/dl/) and [Node 13.7.0](http://nodejs.org).
|
||||
You will need to install [Go 1.14](https://golang.org/dl/) and [Node 13.12.0](http://nodejs.org).
|
||||
You'll also need [ffmpeg](https://ffmpeg.org) installed in your system. The setup is very strict, and
|
||||
the steps bellow only work with these specific versions (enforced in the Makefile)
|
||||
|
||||
@@ -95,7 +123,7 @@ $ make setup # Install tools required for Navidrome's development
|
||||
$ make buildall # Build UI and server, generates a single executable
|
||||
```
|
||||
|
||||
This will generate the `navidrome` binary executable in the project's root folder.
|
||||
This will generate the `navidrome` executable binary in the project's root folder.
|
||||
|
||||
### Running for the first time
|
||||
|
||||
@@ -110,14 +138,20 @@ user.
|
||||
|
||||
For more options, run `navidrome --help`
|
||||
|
||||
### Running as a service
|
||||
|
||||
Check the [contrib](https://github.com/deluan/navidrome/tree/master/contrib)
|
||||
folder for startup files for your init system.
|
||||
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<p float="left">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/screenshot-login-mobile.png">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/screenshot-mobile.png">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/screenshot-users-mobile.png">
|
||||
<img width="900"src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/screenshot-desktop.png">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-login.png">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-player.png">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-album-view.png">
|
||||
<img width="900" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-desktop-player.png">
|
||||
</p>
|
||||
</p>
|
||||
|
||||
|
||||
20
banner.go
@@ -1,20 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/static"
|
||||
)
|
||||
|
||||
func getBanner() string {
|
||||
data, _ := static.Asset("banner.txt")
|
||||
return strings.TrimSuffix(string(data), "\n")
|
||||
}
|
||||
|
||||
func ShowBanner() {
|
||||
version := "Version: " + consts.Version()
|
||||
padding := strings.Repeat(" ", 52-len(version))
|
||||
fmt.Printf("%s%s%s\n\n", getBanner(), padding, version)
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Script to transfort .itc files into images (JPG or PNG)
|
||||
#
|
||||
# .itc files are located in ~/Music/iTunes/Album Artwork
|
||||
#
|
||||
# This script uses (/!\ needs ) ImageMagick's convert, hexdump, printf and dd.
|
||||
#
|
||||
# This script might be a little slow, You might want to look at Simon Kennedy's work at http://www.sffjunkie.co.uk/python-itc.html
|
||||
#
|
||||
# ~/{Library Path}/Album Artwork/Cache/D989408F65D05F99/04/13/04/D989408F65D05F99-EB5B7A9086F4B4D4.itc
|
||||
#
|
||||
# The filenames are an amalgam of the library ID (D989408F65D05F99) and the track's ID (EB5B7A9086F4B4D4).
|
||||
# The directory structure comes from the library ID and the last three digits of the track's ID converted to decimal,
|
||||
# ie 4D4 becomes 04, 13, 04.
|
||||
#
|
||||
|
||||
AlbumArtwork="${HOME}/Music/iTunes 1/Album Artwork"
|
||||
DestinationDir="Artwork"
|
||||
IFS=$'\n'
|
||||
|
||||
|
||||
if [ ! -d "$DestinationDir" ]; then
|
||||
mkdir "$DestinationDir"
|
||||
echo "new Images dir"
|
||||
fi
|
||||
|
||||
for file in `find "$AlbumArtwork" -name '*.itc'`; do
|
||||
start=0x11C
|
||||
exit=0;
|
||||
i=1;
|
||||
echo $file
|
||||
while [ 1 ]; do
|
||||
|
||||
typeOffset=$(($start+0x30))
|
||||
imageType=$(hexdump -n 4 -s $typeOffset -e '"0x"4/1 "%02x" "\n"' $file)
|
||||
|
||||
#If there is no next byte, jump to the next itc file.
|
||||
if [[ -z $imageType ]]; then
|
||||
break
|
||||
fi
|
||||
|
||||
imageOffsetOffset=$(($start+8))
|
||||
|
||||
itemSize=$(hexdump -n 4 -s $start -e '"0x"4/1 "%02x" "\n"' $file)
|
||||
imageOffset=$(hexdump -n 4 -s $imageOffsetOffset -e '"0x"4/1 "%02x" "\n"' $file)
|
||||
|
||||
imageStart=$(($start+$imageOffset))
|
||||
imageSize=$(($itemSize-imageOffset))
|
||||
|
||||
imageWidth=$(hexdump -n 4 -s $(($start+56)) -e '"0x"4/1 "%02x" "\n"' $file)
|
||||
imageWidth=$(printf "%d" $imageWidth)
|
||||
imageHeight=$(hexdump -n 4 -s $(($start+60)) -e '"0x"4/1 "%02x" "\n"' $file)
|
||||
imageHeight=$(printf "%d" $imageHeight)
|
||||
|
||||
dir=$(dirname "$file")
|
||||
xbase=${file##*/} #file.etc
|
||||
xpref=${xbase%.*} #file prefix
|
||||
|
||||
#echo $file
|
||||
#echo itemsize $itemSize
|
||||
#echo start $start
|
||||
#echo imageOffset $imageOffset
|
||||
#echo imageStart $imageStart
|
||||
#echo imageSize $imageSize
|
||||
#echo imageWidth $imageWidth
|
||||
#echo imageHeight $imageHeight
|
||||
|
||||
if [[ $imageType -eq 0x504E4766 ]] || [[ $imageType -eq 0x0000000E ]] ; then
|
||||
targetFile="$DestinationDir/$xpref-$i.png"
|
||||
if [ ! -f "$targetFile" ]; then
|
||||
echo PNG
|
||||
dd skip=$imageStart count=$imageSize if="$file" of="$targetFile" bs=1 &> /dev/null
|
||||
fi
|
||||
elif [[ $imageType -eq 0x41524762 ]] ; then
|
||||
targetFile="$DestinationDir/$xpref-$i.png"
|
||||
if [ ! -f "$targetFile" ]; then
|
||||
echo ARGB
|
||||
dd skip=$imageStart count=$imageSize if="$file" of="$TMPDIR/test$i" bs=1 &> /dev/null
|
||||
|
||||
#Using a matrix to convert ARGB to RGBA since imagemagick does only support rgba input
|
||||
convert -size $imageWidth"x"$imageHeight -depth 8 -color-matrix '0 1 0 0 0 0 1 0 0 0 0 1 1 0 0 0' rgba:"$TMPDIR/test$i" "$targetFile"
|
||||
fi
|
||||
elif [[ $imageType -eq 0x0000000D ]] ; then
|
||||
targetFile="$DestinationDir/$xpref-$i.jpg"
|
||||
if [ ! -f "$targetFile" ]; then
|
||||
echo JPG
|
||||
dd skip=$imageStart count=$imageSize if="$file" of="$targetFile" bs=1 &> /dev/null
|
||||
fi
|
||||
else
|
||||
echo $imageType
|
||||
exit=1
|
||||
break;
|
||||
fi
|
||||
|
||||
start=$(($start+$itemSize))
|
||||
i=$(($i+1))
|
||||
done
|
||||
done
|
||||
@@ -13,23 +13,25 @@ import (
|
||||
)
|
||||
|
||||
type nd struct {
|
||||
Port string `default:"4533"`
|
||||
MusicFolder string `default:"./music"`
|
||||
DataFolder string `default:"./"`
|
||||
DbPath string
|
||||
LogLevel string `default:"info"`
|
||||
Port string `default:"4533"`
|
||||
MusicFolder string `default:"./music"`
|
||||
DataFolder string `default:"./"`
|
||||
ScanInterval string `default:"1m"`
|
||||
DbPath string ``
|
||||
LogLevel string `default:"info"`
|
||||
SessionTimeout string `default:"30m"`
|
||||
BaseURL string `default:""`
|
||||
|
||||
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]([)"`
|
||||
|
||||
DisableDownsampling bool `default:"false"`
|
||||
DownsampleCommand string `default:"ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -"`
|
||||
ProbeCommand string `default:"ffmpeg %s -f ffmetadata"`
|
||||
ScanInterval string `default:"1m"`
|
||||
TranscodingCacheSize string `default:"100MB"` // in MB
|
||||
ImageCacheSize string `default:"100MB"` // in MB
|
||||
ProbeCommand string `default:"ffmpeg -i %s -f ffmetadata"`
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
DevDisableAuthentication bool `default:"false"`
|
||||
DevDisableBanner bool `default:"false"`
|
||||
DevLogSourceLine bool `default:"false"`
|
||||
DevAutoCreateAdminPassword string `default:""`
|
||||
}
|
||||
|
||||
var Server = &nd{}
|
||||
@@ -80,12 +82,13 @@ func LoadFromFile(confFile string, skipFlags ...bool) {
|
||||
os.Exit(2)
|
||||
}
|
||||
if Server.DbPath == "" {
|
||||
Server.DbPath = filepath.Join(Server.DataFolder, "navidrome.db")
|
||||
Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath)
|
||||
}
|
||||
if os.Getenv("PORT") != "" {
|
||||
Server.Port = os.Getenv("PORT")
|
||||
}
|
||||
log.SerLevelString(Server.LogLevel)
|
||||
log.SetLevelString(Server.LogLevel)
|
||||
log.SetLogSourceLine(Server.DevLogSourceLine)
|
||||
log.Trace("Loaded configuration", "file", confFile, "config", fmt.Sprintf("%#v", Server))
|
||||
}
|
||||
|
||||
|
||||
20
consts/banner.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package consts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/deluan/navidrome/static"
|
||||
)
|
||||
|
||||
func getBanner() string {
|
||||
data, _ := static.Asset("banner.txt")
|
||||
return strings.TrimRightFunc(string(data), unicode.IsSpace)
|
||||
}
|
||||
|
||||
func Banner() string {
|
||||
version := "Version: " + Version()
|
||||
padding := strings.Repeat(" ", 52-len(version))
|
||||
return fmt.Sprintf("%s\n%s%s\n", getBanner(), padding, version)
|
||||
}
|
||||
@@ -1,16 +1,63 @@
|
||||
package consts
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
AppName = "navidrome"
|
||||
|
||||
LocalConfigFile = "./navidrome.toml"
|
||||
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL"
|
||||
InitialSetupFlagKey = "InitialSetup"
|
||||
|
||||
JWTSecretKey = "JWTSecret"
|
||||
JWTIssuer = "ND"
|
||||
JWTTokenExpiration = 30 * time.Minute
|
||||
UIAuthorizationHeader = "X-ND-Authorization"
|
||||
JWTSecretKey = "JWTSecret"
|
||||
JWTIssuer = "ND"
|
||||
DefaultSessionTimeout = 30 * time.Minute
|
||||
|
||||
UIAssetsLocalPath = "ui/build"
|
||||
|
||||
TranscodingCacheDir = "cache/transcoding"
|
||||
DefaultTranscodingCacheSize = 100 * 1024 * 1024 // 100MB
|
||||
DefaultTranscodingCacheMaxItems = 0 // Unlimited
|
||||
DefaultTranscodingCacheCleanUpInterval = 10 * time.Minute
|
||||
|
||||
ImageCacheDir = "cache/images"
|
||||
DefaultImageCacheSize = 100 * 1024 * 1024 // 100MB
|
||||
DefaultImageCacheMaxItems = 0 // Unlimited
|
||||
DefaultImageCacheCleanUpInterval = 10 * time.Minute
|
||||
|
||||
DevInitialUserName = "admin"
|
||||
DevInitialName = "Dev Admin"
|
||||
|
||||
URLPathUI = "/app"
|
||||
URLPathSubsonicAPI = "/rest"
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultTranscodings = []map[string]interface{}{
|
||||
{
|
||||
"name": "mp3 audio",
|
||||
"targetFormat": "mp3",
|
||||
"defaultBitRate": 192,
|
||||
"command": "ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -",
|
||||
},
|
||||
{
|
||||
"name": "opus audio",
|
||||
"targetFormat": "oga",
|
||||
"defaultBitRate": 128,
|
||||
"command": "ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -c:a libopus -f opus -",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
VariousArtists = "Various Artists"
|
||||
VariousArtistsID = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(VariousArtists))))
|
||||
UnknownArtist = "[Unknown Artist]"
|
||||
UnknownArtistID = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(UnknownArtist))))
|
||||
)
|
||||
|
||||
@@ -1,36 +1,24 @@
|
||||
package server
|
||||
package consts
|
||||
|
||||
import "mime"
|
||||
|
||||
func initMimeTypes() {
|
||||
func init() {
|
||||
mt := map[string]string{
|
||||
".mp3": "audio/mpeg",
|
||||
".ogg": "audio/ogg",
|
||||
".oga": "audio/ogg",
|
||||
".opus": "audio/ogg",
|
||||
".ogx": "application/ogg",
|
||||
".aac": "audio/mp4",
|
||||
".m4a": "audio/mp4",
|
||||
".m4b": "audio/mp4",
|
||||
".flac": "audio/flac",
|
||||
".wav": "audio/x-wav",
|
||||
".wma": "audio/x-ms-wma",
|
||||
".ape": "audio/x-monkeys-audio",
|
||||
".mpc": "audio/x-musepack",
|
||||
".shn": "audio/x-shn",
|
||||
".flv": "video/x-flv",
|
||||
".avi": "video/avi",
|
||||
".mpg": "video/mpeg",
|
||||
".mpeg": "video/mpeg",
|
||||
".mp4": "video/mp4",
|
||||
".m4v": "video/x-m4v",
|
||||
".mkv": "video/x-matroska",
|
||||
".mov": "video/quicktime",
|
||||
".wmv": "video/x-ms-wmv",
|
||||
".ogv": "video/ogg",
|
||||
".divx": "video/divx",
|
||||
".m2ts": "video/MP2T",
|
||||
".ts": "video/MP2T",
|
||||
".webm": "video/webm",
|
||||
".aif": "audio/x-aiff",
|
||||
".aiff": "audio/x-aiff",
|
||||
".gif": "image/gif",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
21
contrib/navidrome
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/sbin/openrc-run
|
||||
|
||||
name=$RC_SVCNAME
|
||||
command="/opt/navidrome/navidrome"
|
||||
command_args="-datafolder=/opt/navidrome"
|
||||
command_user="navidrome"
|
||||
pidfile="/var/run/$RC_SVCNAME.pid"
|
||||
command_background="yes"
|
||||
|
||||
depend() {
|
||||
need net
|
||||
}
|
||||
|
||||
start_pre() {
|
||||
checkpath --directory --owner $command_user:$command_user --mode 0775 \
|
||||
/run/$RC_SVCNAME /var/log/$RC_SVCNAME
|
||||
}
|
||||
|
||||
stop() {
|
||||
kill `cat /var/run/$RC_SVCNAME.pid`
|
||||
|
||||
35
contrib/navidrome.service
Normal file
@@ -0,0 +1,35 @@
|
||||
# This file ususaly goes in /etc/systemd/system
|
||||
|
||||
[Unit]
|
||||
Description=Navidrome Daemon
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=navidrome
|
||||
Group=navidrome
|
||||
Type=simple
|
||||
ExecStart=/opt/navidrome/navidrome
|
||||
WorkingDirectory=/opt/navidrome
|
||||
TimeoutStopSec=20
|
||||
KillMode=process
|
||||
Restart=on-failure
|
||||
|
||||
# See https://www.freedesktop.org/software/systemd/man/systemd.exec.html
|
||||
DevicePolicy=closed
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
PrivateUsers=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelTunables=yes
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
RestrictNamespaces=yes
|
||||
RestrictRealtime=yes
|
||||
SystemCallFilter=~@clock @debug @module @mount @obsolete @privileged @reboot @setuid @swap
|
||||
ReadWritePaths=/opt/navidrome/
|
||||
PrivateDevices=yes
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
55
db/db.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
_ "github.com/deluan/navidrome/db/migration"
|
||||
"github.com/deluan/navidrome/log"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
var (
|
||||
Driver = "sqlite3"
|
||||
Path string
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
db *sql.DB
|
||||
)
|
||||
|
||||
func Db() *sql.DB {
|
||||
once.Do(func() {
|
||||
var err error
|
||||
Path = conf.Server.DbPath
|
||||
if Path == ":memory:" {
|
||||
Path = "file::memory:?cache=shared"
|
||||
conf.Server.DbPath = Path
|
||||
}
|
||||
log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver)
|
||||
db, err = sql.Open(Driver, Path)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
return db
|
||||
}
|
||||
|
||||
func EnsureLatestVersion() {
|
||||
db := Db()
|
||||
|
||||
err := goose.SetDialect(Driver)
|
||||
if err != nil {
|
||||
log.Error("Invalid DB driver", "driver", Driver, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
err = goose.Run("up", db, "./")
|
||||
if err != nil {
|
||||
log.Error("Failed to apply new migrations", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
183
db/migration/20200130083147_create_schema.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200130083147, Down20200130083147)
|
||||
}
|
||||
|
||||
func Up20200130083147(tx *sql.Tx) error {
|
||||
log.Info("Creating DB Schema")
|
||||
_, err := tx.Exec(`
|
||||
create table if not exists album
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar(255) default '' not null,
|
||||
artist_id varchar(255) default '' not null,
|
||||
cover_art_path varchar(255) default '' not null,
|
||||
cover_art_id varchar(255) default '' not null,
|
||||
artist varchar(255) default '' not null,
|
||||
album_artist varchar(255) default '' not null,
|
||||
year integer default 0 not null,
|
||||
compilation bool default FALSE not null,
|
||||
song_count integer default 0 not null,
|
||||
duration integer default 0 not null,
|
||||
genre varchar(255) default '' not null,
|
||||
created_at datetime,
|
||||
updated_at datetime
|
||||
);
|
||||
|
||||
create index if not exists album_artist
|
||||
on album (artist);
|
||||
|
||||
create index if not exists album_artist_id
|
||||
on album (artist_id);
|
||||
|
||||
create index if not exists album_genre
|
||||
on album (genre);
|
||||
|
||||
create index if not exists album_name
|
||||
on album (name);
|
||||
|
||||
create index if not exists album_year
|
||||
on album (year);
|
||||
|
||||
create table if not exists annotation
|
||||
(
|
||||
ann_id varchar(255) not null
|
||||
primary key,
|
||||
user_id varchar(255) default '' not null,
|
||||
item_id varchar(255) default '' not null,
|
||||
item_type varchar(255) default '' not null,
|
||||
play_count integer,
|
||||
play_date datetime,
|
||||
rating integer,
|
||||
starred bool default FALSE not null,
|
||||
starred_at datetime,
|
||||
unique (user_id, item_id, item_type)
|
||||
);
|
||||
|
||||
create index if not exists annotation_play_count
|
||||
on annotation (play_count);
|
||||
|
||||
create index if not exists annotation_play_date
|
||||
on annotation (play_date);
|
||||
|
||||
create index if not exists annotation_rating
|
||||
on annotation (rating);
|
||||
|
||||
create index if not exists annotation_starred
|
||||
on annotation (starred);
|
||||
|
||||
create table if not exists artist
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar(255) default '' not null,
|
||||
album_count integer default 0 not null
|
||||
);
|
||||
|
||||
create index if not exists artist_name
|
||||
on artist (name);
|
||||
|
||||
create table if not exists media_file
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
path varchar(255) default '' not null,
|
||||
title varchar(255) default '' not null,
|
||||
album varchar(255) default '' not null,
|
||||
artist varchar(255) default '' not null,
|
||||
artist_id varchar(255) default '' not null,
|
||||
album_artist varchar(255) default '' not null,
|
||||
album_id varchar(255) default '' not null,
|
||||
has_cover_art bool default FALSE not null,
|
||||
track_number integer default 0 not null,
|
||||
disc_number integer default 0 not null,
|
||||
year integer default 0 not null,
|
||||
size integer default 0 not null,
|
||||
suffix varchar(255) default '' not null,
|
||||
duration integer default 0 not null,
|
||||
bit_rate integer default 0 not null,
|
||||
genre varchar(255) default '' not null,
|
||||
compilation bool default FALSE not null,
|
||||
created_at datetime,
|
||||
updated_at datetime
|
||||
);
|
||||
|
||||
create index if not exists media_file_album_id
|
||||
on media_file (album_id);
|
||||
|
||||
create index if not exists media_file_genre
|
||||
on media_file (genre);
|
||||
|
||||
create index if not exists media_file_path
|
||||
on media_file (path);
|
||||
|
||||
create index if not exists media_file_title
|
||||
on media_file (title);
|
||||
|
||||
create table if not exists playlist
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar(255) default '' not null,
|
||||
comment varchar(255) default '' not null,
|
||||
duration integer default 0 not null,
|
||||
owner varchar(255) default '' not null,
|
||||
public bool default FALSE not null,
|
||||
tracks text not null
|
||||
);
|
||||
|
||||
create index if not exists playlist_name
|
||||
on playlist (name);
|
||||
|
||||
create table if not exists property
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
value varchar(255) default '' not null
|
||||
);
|
||||
|
||||
create table if not exists search
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
"table" varchar(255) default '' not null,
|
||||
full_text varchar(255) default '' not null
|
||||
);
|
||||
|
||||
create index if not exists search_full_text
|
||||
on search (full_text);
|
||||
|
||||
create index if not exists search_table
|
||||
on search ("table");
|
||||
|
||||
create table if not exists user
|
||||
(
|
||||
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
|
||||
unique,
|
||||
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
|
||||
);`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200130083147(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
63
db/migration/20200131183653_standardize_item_type.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200131183653, Down20200131183653)
|
||||
}
|
||||
|
||||
func Up20200131183653(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table search_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
item_type varchar(255) default '' not null,
|
||||
full_text varchar(255) default '' not null
|
||||
);
|
||||
|
||||
insert into search_dg_tmp(id, item_type, full_text) select id, "table", full_text from search;
|
||||
|
||||
drop table search;
|
||||
|
||||
alter table search_dg_tmp rename to search;
|
||||
|
||||
create index search_full_text
|
||||
on search (full_text);
|
||||
create index search_table
|
||||
on search (item_type);
|
||||
|
||||
update annotation set item_type = 'media_file' where item_type = 'mediaFile';
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200131183653(tx *sql.Tx) error {
|
||||
tx.Exec(`
|
||||
create table search_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
"table" varchar(255) default '' not null,
|
||||
full_text varchar(255) default '' not null
|
||||
);
|
||||
|
||||
insert into search_dg_tmp(id, "table", full_text) select id, item_type, full_text from search;
|
||||
|
||||
drop table search;
|
||||
|
||||
alter table search_dg_tmp rename to search;
|
||||
|
||||
create index search_full_text
|
||||
on search (full_text);
|
||||
create index search_table
|
||||
on search ("table");
|
||||
|
||||
update annotation set item_type = 'mediaFile' where item_type = 'media_file';
|
||||
`)
|
||||
return nil
|
||||
}
|
||||
56
db/migration/20200208222418_add_defaults_to_annotations.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200208222418, Down20200208222418)
|
||||
}
|
||||
|
||||
func Up20200208222418(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
update annotation set play_count = 0 where play_count is null;
|
||||
update annotation set rating = 0 where rating is null;
|
||||
create table annotation_dg_tmp
|
||||
(
|
||||
ann_id varchar(255) not null
|
||||
primary key,
|
||||
user_id varchar(255) default '' not null,
|
||||
item_id varchar(255) default '' not null,
|
||||
item_type varchar(255) default '' not null,
|
||||
play_count integer default 0,
|
||||
play_date datetime,
|
||||
rating integer default 0,
|
||||
starred bool default FALSE not null,
|
||||
starred_at datetime,
|
||||
unique (user_id, item_id, item_type)
|
||||
);
|
||||
|
||||
insert into annotation_dg_tmp(ann_id, user_id, item_id, item_type, play_count, play_date, rating, starred, starred_at) select ann_id, user_id, item_id, item_type, play_count, play_date, rating, starred, starred_at from annotation;
|
||||
|
||||
drop table annotation;
|
||||
|
||||
alter table annotation_dg_tmp rename to annotation;
|
||||
|
||||
create index annotation_play_count
|
||||
on annotation (play_count);
|
||||
|
||||
create index annotation_play_date
|
||||
on annotation (play_date);
|
||||
|
||||
create index annotation_rating
|
||||
on annotation (rating);
|
||||
|
||||
create index annotation_starred
|
||||
on annotation (starred);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200208222418(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
129
db/migration/20200220143731_change_duration_to_float.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200220143731, Down20200220143731)
|
||||
}
|
||||
|
||||
func Up20200220143731(tx *sql.Tx) error {
|
||||
notice(tx, "This migration will force the next scan to be a full rescan!")
|
||||
_, err := tx.Exec(`
|
||||
create table media_file_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
path varchar(255) default '' not null,
|
||||
title varchar(255) default '' not null,
|
||||
album varchar(255) default '' not null,
|
||||
artist varchar(255) default '' not null,
|
||||
artist_id varchar(255) default '' not null,
|
||||
album_artist varchar(255) default '' not null,
|
||||
album_id varchar(255) default '' not null,
|
||||
has_cover_art bool default FALSE not null,
|
||||
track_number integer default 0 not null,
|
||||
disc_number integer default 0 not null,
|
||||
year integer default 0 not null,
|
||||
size integer default 0 not null,
|
||||
suffix varchar(255) default '' not null,
|
||||
duration real default 0 not null,
|
||||
bit_rate integer default 0 not null,
|
||||
genre varchar(255) default '' not null,
|
||||
compilation bool default FALSE not null,
|
||||
created_at datetime,
|
||||
updated_at datetime
|
||||
);
|
||||
|
||||
insert into media_file_dg_tmp(id, path, title, album, artist, artist_id, album_artist, album_id, has_cover_art, track_number, disc_number, year, size, suffix, duration, bit_rate, genre, compilation, created_at, updated_at) select id, path, title, album, artist, artist_id, album_artist, album_id, has_cover_art, track_number, disc_number, year, size, suffix, duration, bit_rate, genre, compilation, created_at, updated_at from media_file;
|
||||
|
||||
drop table media_file;
|
||||
|
||||
alter table media_file_dg_tmp rename to media_file;
|
||||
|
||||
create index media_file_album_id
|
||||
on media_file (album_id);
|
||||
|
||||
create index media_file_genre
|
||||
on media_file (genre);
|
||||
|
||||
create index media_file_path
|
||||
on media_file (path);
|
||||
|
||||
create index media_file_title
|
||||
on media_file (title);
|
||||
|
||||
create table album_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar(255) default '' not null,
|
||||
artist_id varchar(255) default '' not null,
|
||||
cover_art_path varchar(255) default '' not null,
|
||||
cover_art_id varchar(255) default '' not null,
|
||||
artist varchar(255) default '' not null,
|
||||
album_artist varchar(255) default '' not null,
|
||||
year integer default 0 not null,
|
||||
compilation bool default FALSE not null,
|
||||
song_count integer default 0 not null,
|
||||
duration real default 0 not null,
|
||||
genre varchar(255) default '' not null,
|
||||
created_at datetime,
|
||||
updated_at datetime
|
||||
);
|
||||
|
||||
insert into album_dg_tmp(id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, year, compilation, song_count, duration, genre, created_at, updated_at) select id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, year, compilation, song_count, duration, genre, created_at, updated_at from album;
|
||||
|
||||
drop table album;
|
||||
|
||||
alter table album_dg_tmp rename to album;
|
||||
|
||||
create index album_artist
|
||||
on album (artist);
|
||||
|
||||
create index album_artist_id
|
||||
on album (artist_id);
|
||||
|
||||
create index album_genre
|
||||
on album (genre);
|
||||
|
||||
create index album_name
|
||||
on album (name);
|
||||
|
||||
create index album_year
|
||||
on album (year);
|
||||
|
||||
create table playlist_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar(255) default '' not null,
|
||||
comment varchar(255) default '' not null,
|
||||
duration real default 0 not null,
|
||||
owner varchar(255) default '' not null,
|
||||
public bool default FALSE not null,
|
||||
tracks text not null
|
||||
);
|
||||
|
||||
insert into playlist_dg_tmp(id, name, comment, duration, owner, public, tracks) select id, name, comment, duration, owner, public, tracks from playlist;
|
||||
|
||||
drop table playlist;
|
||||
|
||||
alter table playlist_dg_tmp rename to playlist;
|
||||
|
||||
create index playlist_name
|
||||
on playlist (name);
|
||||
|
||||
-- Force a full rescan
|
||||
delete from property where id like 'LastScan%';
|
||||
update media_file set updated_at = '0001-01-01';
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200220143731(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
21
db/migration/20200310171621_enable_search_by_albumartist.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200310171621, Down20200310171621)
|
||||
}
|
||||
|
||||
func Up20200310171621(tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to enable search by Album Artist!")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200310171621(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200310181627, Down20200310181627)
|
||||
}
|
||||
|
||||
func Up20200310181627(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table transcoding
|
||||
(
|
||||
id varchar(255) not null primary key,
|
||||
name varchar(255) not null,
|
||||
target_format varchar(255) not null,
|
||||
command varchar(255) default '' not null,
|
||||
default_bit_rate int default 192,
|
||||
unique (name),
|
||||
unique (target_format)
|
||||
);
|
||||
|
||||
create table player
|
||||
(
|
||||
id varchar(255) not null primary key,
|
||||
name varchar not null,
|
||||
type varchar,
|
||||
user_name varchar not null,
|
||||
client varchar not null,
|
||||
ip_address varchar,
|
||||
last_seen timestamp,
|
||||
max_bit_rate int default 0,
|
||||
transcoding_id varchar,
|
||||
unique (name),
|
||||
foreign key (transcoding_id)
|
||||
references transcoding(id)
|
||||
on update restrict
|
||||
on delete restrict
|
||||
);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200310181627(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
drop table transcoding;
|
||||
drop table player;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
42
db/migration/20200319211049_merge_search_into_main_tables.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200319211049, Down20200319211049)
|
||||
}
|
||||
|
||||
func Up20200319211049(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table media_file
|
||||
add full_text varchar(255) default '';
|
||||
create index if not exists media_file_full_text
|
||||
on media_file (full_text);
|
||||
|
||||
alter table album
|
||||
add full_text varchar(255) default '';
|
||||
create index if not exists album_full_text
|
||||
on album (full_text);
|
||||
|
||||
alter table artist
|
||||
add full_text varchar(255) default '';
|
||||
create index if not exists artist_full_text
|
||||
on artist (full_text);
|
||||
|
||||
drop table if exists search;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200319211049(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
33
db/migration/20200325185135_add_album_artist_id.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200325185135, Down20200325185135)
|
||||
}
|
||||
|
||||
func Up20200325185135(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table album
|
||||
add album_artist_id varchar(255) default '';
|
||||
create index album_artist_album_id
|
||||
on album (album_artist_id);
|
||||
|
||||
alter table media_file
|
||||
add album_artist_id varchar(255) default '';
|
||||
create index media_file_artist_album_id
|
||||
on media_file (album_artist_id);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200325185135(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
19
db/migration/20200326090707_fix_album_artists_importing.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200326090707, Down20200326090707)
|
||||
}
|
||||
|
||||
func Up20200326090707(tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200326090707(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
80
db/migration/20200327193744_add_year_range_to_album.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200327193744, Down20200327193744)
|
||||
}
|
||||
|
||||
func Up20200327193744(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table album_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar(255) default '' not null,
|
||||
artist_id varchar(255) default '' not null,
|
||||
cover_art_path varchar(255) default '' not null,
|
||||
cover_art_id varchar(255) default '' not null,
|
||||
artist varchar(255) default '' not null,
|
||||
album_artist varchar(255) default '' not null,
|
||||
min_year int default 0 not null,
|
||||
max_year integer default 0 not null,
|
||||
compilation bool default FALSE not null,
|
||||
song_count integer default 0 not null,
|
||||
duration real default 0 not null,
|
||||
genre varchar(255) default '' not null,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
full_text varchar(255) default '',
|
||||
album_artist_id varchar(255) default ''
|
||||
);
|
||||
|
||||
insert into album_dg_tmp(id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, max_year, compilation, song_count, duration, genre, created_at, updated_at, full_text, album_artist_id) select id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, year, compilation, song_count, duration, genre, created_at, updated_at, full_text, album_artist_id from album;
|
||||
|
||||
drop table album;
|
||||
|
||||
alter table album_dg_tmp rename to album;
|
||||
|
||||
create index album_artist
|
||||
on album (artist);
|
||||
|
||||
create index album_artist_album
|
||||
on album (artist);
|
||||
|
||||
create index album_artist_album_id
|
||||
on album (album_artist_id);
|
||||
|
||||
create index album_artist_id
|
||||
on album (artist_id);
|
||||
|
||||
create index album_full_text
|
||||
on album (full_text);
|
||||
|
||||
create index album_genre
|
||||
on album (genre);
|
||||
|
||||
create index album_name
|
||||
on album (name);
|
||||
|
||||
create index album_min_year
|
||||
on album (min_year);
|
||||
|
||||
create index album_max_year
|
||||
on album (max_year);
|
||||
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200327193744(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
30
db/migration/20200404214704_add_indexes.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200404214704, Down20200404214704)
|
||||
}
|
||||
|
||||
func Up20200404214704(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create index if not exists media_file_year
|
||||
on media_file (year);
|
||||
|
||||
create index if not exists media_file_duration
|
||||
on media_file (duration);
|
||||
|
||||
create index if not exists media_file_track_number
|
||||
on media_file (disc_number, track_number);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200404214704(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
55
db/migration/migration.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
)
|
||||
|
||||
// Use this in migrations that need to communicate something important (braking changes, forced reindexes, etc...)
|
||||
func notice(tx *sql.Tx, msg string) {
|
||||
if isDBInitialized(tx) {
|
||||
fmt.Printf(`
|
||||
*************************************************************************************
|
||||
NOTICE: %s
|
||||
*************************************************************************************
|
||||
|
||||
`, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Call this in migrations that requires a full rescan
|
||||
func forceFullRescan(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
delete from property where id like 'LastScan%';
|
||||
update media_file set updated_at = '0001-01-01';
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
var once sync.Once
|
||||
|
||||
func isDBInitialized(tx *sql.Tx) (initialized bool) {
|
||||
once.Do(func() {
|
||||
rows, err := tx.Query("select count(*) from property where id='" + consts.InitialSetupFlagKey + "'")
|
||||
checkErr(err)
|
||||
initialized = checkCount(rows) > 0
|
||||
})
|
||||
return initialized
|
||||
}
|
||||
|
||||
func checkCount(rows *sql.Rows) (count int) {
|
||||
for rows.Next() {
|
||||
err := rows.Scan(&count)
|
||||
checkErr(err)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func checkErr(err error) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,19 @@
|
||||
version: "3"
|
||||
services:
|
||||
navidrome:
|
||||
build: .
|
||||
image: deluan/navidrome:latest
|
||||
ports:
|
||||
- "4533:4533"
|
||||
environment:
|
||||
# See all options and defaults in conf/configuration.go
|
||||
# All options with their default values:
|
||||
ND_MUSICFOLDER: /music
|
||||
ND_DATAFOLDER: /data
|
||||
ND_SCANINTERVAL: 1m
|
||||
ND_LOGLEVEL: info
|
||||
ND_PORT: 4533
|
||||
ND_SCANINTERVAL: 5s
|
||||
ND_LOGLEVEL: debug
|
||||
ND_TRANSCODINGCACHESIZE: 100MB
|
||||
ND_SESSIONTIMEOUT: 30m
|
||||
ND_BASEURL: ""
|
||||
volumes:
|
||||
- "./data:/data"
|
||||
- "/Users/deluan/Music/iTunes/iTunes Media/Music:/music"
|
||||
- "./music:/music"
|
||||
|
||||
79
engine/auth/auth.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/go-chi/jwtauth"
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
JwtSecret []byte
|
||||
TokenAuth *jwtauth.JWTAuth
|
||||
sessionTimeOut time.Duration
|
||||
)
|
||||
|
||||
func InitTokenAuth(ds model.DataStore) {
|
||||
once.Do(func() {
|
||||
secret, err := ds.Property(nil).DefaultGet(consts.JWTSecretKey, "not so secret")
|
||||
if err != nil {
|
||||
log.Error("No JWT secret found in DB. Setting a temp one, but please report this error", err)
|
||||
}
|
||||
JwtSecret = []byte(secret)
|
||||
TokenAuth = jwtauth.New("HS256", JwtSecret, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func CreateToken(u *model.User) (string, error) {
|
||||
token := jwt.New(jwt.SigningMethodHS256)
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims["iss"] = consts.JWTIssuer
|
||||
claims["sub"] = u.UserName
|
||||
claims["adm"] = u.IsAdmin
|
||||
|
||||
return TouchToken(token)
|
||||
}
|
||||
|
||||
func getSessionTimeOut() time.Duration {
|
||||
if sessionTimeOut == 0 {
|
||||
if to, err := time.ParseDuration(conf.Server.SessionTimeout); err != nil {
|
||||
sessionTimeOut = consts.DefaultSessionTimeout
|
||||
} else {
|
||||
sessionTimeOut = to
|
||||
}
|
||||
log.Info("Setting Session Timeout", "value", sessionTimeOut)
|
||||
}
|
||||
return sessionTimeOut
|
||||
}
|
||||
|
||||
func TouchToken(token *jwt.Token) (string, error) {
|
||||
timeout := getSessionTimeOut()
|
||||
expireIn := time.Now().Add(timeout).Unix()
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims["exp"] = expireIn
|
||||
|
||||
return token.SignedString(JwtSecret)
|
||||
}
|
||||
|
||||
func Validate(tokenStr string) (jwt.MapClaims, error) {
|
||||
token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) {
|
||||
// Don't forget to validate the alg is what you expect:
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
// hmacSampleSecret is a []byte containing your secret, e.g. []byte("my_secret_key")
|
||||
return JwtSecret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return token.Claims.(jwt.MapClaims), err
|
||||
}
|
||||
55
engine/auth/auth_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/engine/auth"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestAuth(t *testing.T) {
|
||||
log.SetLevel(log.LevelCritical)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Auth Test Suite")
|
||||
}
|
||||
|
||||
const testJWTSecret = "not so secret"
|
||||
|
||||
var _ = Describe("Auth", func() {
|
||||
BeforeEach(func() {
|
||||
auth.JwtSecret = []byte(testJWTSecret)
|
||||
})
|
||||
Context("Validate", func() {
|
||||
It("returns error with an invalid JWT token", func() {
|
||||
_, err := auth.Validate("invalid.token")
|
||||
Expect(err).To(Not(BeNil()))
|
||||
})
|
||||
|
||||
It("returns the claims from a valid JWT token", func() {
|
||||
token := jwt.New(jwt.SigningMethodHS256)
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims["iss"] = "issuer"
|
||||
claims["exp"] = time.Now().Add(1 * time.Minute).Unix()
|
||||
tokenStr, _ := token.SignedString(auth.JwtSecret)
|
||||
|
||||
decodedClaims, err := auth.Validate(tokenStr)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(decodedClaims["iss"]).To(Equal("issuer"))
|
||||
})
|
||||
|
||||
It("returns ErrExpired if the `exp` field is in the past", func() {
|
||||
token := jwt.New(jwt.SigningMethodHS256)
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims["iss"] = "issuer"
|
||||
claims["exp"] = time.Now().Add(-1 * time.Minute).Unix()
|
||||
tokenStr, _ := token.SignedString(auth.JwtSecret)
|
||||
|
||||
_, err := auth.Validate(tokenStr)
|
||||
Expect(err).To(MatchError("Token is expired"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
type Browser interface {
|
||||
MediaFolders(ctx context.Context) (model.MediaFolders, error)
|
||||
Indexes(ctx context.Context, ifModifiedSince time.Time) (model.ArtistIndexes, time.Time, 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)
|
||||
@@ -35,8 +35,11 @@ func (b *browser) MediaFolders(ctx context.Context) (model.MediaFolders, error)
|
||||
return b.ds.MediaFolder(ctx).GetAll()
|
||||
}
|
||||
|
||||
func (b *browser) Indexes(ctx context.Context, ifModifiedSince time.Time) (model.ArtistIndexes, time.Time, error) {
|
||||
l, err := b.ds.Property(ctx).DefaultGet(model.PropLastScan, "-1")
|
||||
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)
|
||||
|
||||
@@ -81,8 +84,7 @@ func (b *browser) Artist(ctx context.Context, id string) (*DirectoryInfo, error)
|
||||
for _, al := range albums {
|
||||
albumIds = append(albumIds, al.ID)
|
||||
}
|
||||
annMap, err := b.ds.Annotation(ctx).GetMap(getUserID(ctx), model.AlbumItemType, albumIds)
|
||||
return b.buildArtistDir(a, albums, annMap), nil
|
||||
return b.buildArtistDir(a, albums), nil
|
||||
}
|
||||
|
||||
func (b *browser) Album(ctx context.Context, id string) (*DirectoryInfo, error) {
|
||||
@@ -96,16 +98,7 @@ func (b *browser) Album(ctx context.Context, id string) (*DirectoryInfo, error)
|
||||
mfIds = append(mfIds, mf.ID)
|
||||
}
|
||||
|
||||
userID := getUserID(ctx)
|
||||
trackAnnMap, err := b.ds.Annotation(ctx).GetMap(userID, model.MediaItemType, mfIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ann, err := b.ds.Annotation(ctx).Get(userID, model.AlbumItemType, al.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.buildAlbumDir(al, ann, tracks, trackAnnMap), nil
|
||||
return b.buildAlbumDir(al, tracks), nil
|
||||
}
|
||||
|
||||
func (b *browser) Directory(ctx context.Context, id string) (*DirectoryInfo, error) {
|
||||
@@ -126,13 +119,7 @@ func (b *browser) GetSong(ctx context.Context, id string) (*Entry, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userId := getUserID(ctx)
|
||||
ann, err := b.ds.Annotation(ctx).Get(userId, model.MediaItemType, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entry := FromMediaFile(mf, ann)
|
||||
entry := FromMediaFile(mf)
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
@@ -149,7 +136,7 @@ func (b *browser) GetGenres(ctx context.Context) (model.Genres, error) {
|
||||
return genres, err
|
||||
}
|
||||
|
||||
func (b *browser) buildArtistDir(a *model.Artist, albums model.Albums, albumAnnMap model.AnnotationMap) *DirectoryInfo {
|
||||
func (b *browser) buildArtistDir(a *model.Artist, albums model.Albums) *DirectoryInfo {
|
||||
dir := &DirectoryInfo{
|
||||
Id: a.ID,
|
||||
Name: a.Name,
|
||||
@@ -158,39 +145,34 @@ func (b *browser) buildArtistDir(a *model.Artist, albums model.Albums, albumAnnM
|
||||
|
||||
dir.Entries = make(Entries, len(albums))
|
||||
for i, al := range albums {
|
||||
ann := albumAnnMap[al.ID]
|
||||
dir.Entries[i] = FromAlbum(&al, &ann)
|
||||
dir.PlayCount += int32(ann.PlayCount)
|
||||
dir.Entries[i] = FromAlbum(&al)
|
||||
dir.PlayCount += int32(al.PlayCount)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func (b *browser) buildAlbumDir(al *model.Album, albumAnn *model.Annotation, tracks model.MediaFiles, trackAnnMap model.AnnotationMap) *DirectoryInfo {
|
||||
func (b *browser) buildAlbumDir(al *model.Album, tracks model.MediaFiles) *DirectoryInfo {
|
||||
dir := &DirectoryInfo{
|
||||
Id: al.ID,
|
||||
Name: al.Name,
|
||||
Parent: al.ArtistID,
|
||||
Artist: al.Artist,
|
||||
ArtistId: al.ArtistID,
|
||||
SongCount: al.SongCount,
|
||||
Duration: al.Duration,
|
||||
Created: al.CreatedAt,
|
||||
Year: al.Year,
|
||||
Genre: al.Genre,
|
||||
CoverArt: al.CoverArtId,
|
||||
}
|
||||
if albumAnn != nil {
|
||||
dir.PlayCount = int32(albumAnn.PlayCount)
|
||||
dir.Starred = albumAnn.StarredAt
|
||||
dir.UserRating = albumAnn.Rating
|
||||
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: int32(al.PlayCount),
|
||||
UserRating: al.Rating,
|
||||
}
|
||||
|
||||
dir.Entries = make(Entries, len(tracks))
|
||||
for i, mf := range tracks {
|
||||
mfId := mf.ID
|
||||
ann := trackAnnMap[mfId]
|
||||
dir.Entries[i] = FromMediaFile(&mf, &ann)
|
||||
if al.Starred {
|
||||
dir.Starred = al.StarredAt
|
||||
}
|
||||
|
||||
dir.Entries = FromMediaFiles(tracks)
|
||||
return dir
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ var _ = Describe("Browser", func() {
|
||||
var repo *mockGenreRepository
|
||||
var b Browser
|
||||
|
||||
BeforeSuite(func() {
|
||||
BeforeEach(func() {
|
||||
repo = &mockGenreRepository{data: model.Genres{
|
||||
{Name: "Rock", SongCount: 1000, AlbumCount: 100},
|
||||
{Name: "", SongCount: 13, AlbumCount: 13},
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
@@ -46,43 +47,43 @@ type Entry struct {
|
||||
|
||||
type Entries []Entry
|
||||
|
||||
func FromArtist(ar *model.Artist, ann *model.Annotation) Entry {
|
||||
func FromArtist(ar *model.Artist) Entry {
|
||||
e := Entry{}
|
||||
e.Id = ar.ID
|
||||
e.Title = ar.Name
|
||||
e.AlbumCount = ar.AlbumCount
|
||||
e.IsDir = true
|
||||
if ann != nil {
|
||||
e.Starred = ann.StarredAt
|
||||
if ar.Starred {
|
||||
e.Starred = ar.StarredAt
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func FromAlbum(al *model.Album, ann *model.Annotation) Entry {
|
||||
func FromAlbum(al *model.Album) Entry {
|
||||
e := Entry{}
|
||||
e.Id = al.ID
|
||||
e.Title = al.Name
|
||||
e.IsDir = true
|
||||
e.Parent = al.ArtistID
|
||||
e.Parent = al.AlbumArtistID
|
||||
e.Album = al.Name
|
||||
e.Year = al.Year
|
||||
e.Year = al.MaxYear
|
||||
e.Artist = al.AlbumArtist
|
||||
e.Genre = al.Genre
|
||||
e.CoverArt = al.CoverArtId
|
||||
e.Created = al.CreatedAt
|
||||
e.AlbumId = al.ID
|
||||
e.ArtistId = al.ArtistID
|
||||
e.Duration = al.Duration
|
||||
e.ArtistId = al.AlbumArtistID
|
||||
e.Duration = int(al.Duration)
|
||||
e.SongCount = al.SongCount
|
||||
if ann != nil {
|
||||
e.Starred = ann.StarredAt
|
||||
e.PlayCount = int32(ann.PlayCount)
|
||||
e.UserRating = ann.Rating
|
||||
if al.Starred {
|
||||
e.Starred = al.StarredAt
|
||||
}
|
||||
e.PlayCount = int32(al.PlayCount)
|
||||
e.UserRating = al.Rating
|
||||
return e
|
||||
}
|
||||
|
||||
func FromMediaFile(mf *model.MediaFile, ann *model.Annotation) Entry {
|
||||
func FromMediaFile(mf *model.MediaFile) Entry {
|
||||
e := Entry{}
|
||||
e.Id = mf.ID
|
||||
e.Title = mf.Title
|
||||
@@ -93,7 +94,7 @@ func FromMediaFile(mf *model.MediaFile, ann *model.Annotation) Entry {
|
||||
e.Artist = mf.Artist
|
||||
e.Genre = mf.Genre
|
||||
e.Track = mf.TrackNumber
|
||||
e.Duration = mf.Duration
|
||||
e.Duration = int(mf.Duration)
|
||||
e.Size = mf.Size
|
||||
e.Suffix = mf.Suffix
|
||||
e.BitRate = mf.BitRate
|
||||
@@ -110,19 +111,19 @@ func FromMediaFile(mf *model.MediaFile, ann *model.Annotation) Entry {
|
||||
e.Created = mf.CreatedAt
|
||||
e.AlbumId = mf.AlbumID
|
||||
e.ArtistId = mf.ArtistID
|
||||
e.Type = "music" // TODO Hardcoded for now
|
||||
if ann != nil {
|
||||
e.PlayCount = int32(ann.PlayCount)
|
||||
e.Starred = ann.StarredAt
|
||||
e.UserRating = ann.Rating
|
||||
e.Type = "music"
|
||||
e.PlayCount = int32(mf.PlayCount)
|
||||
if mf.Starred {
|
||||
e.Starred = mf.StarredAt
|
||||
}
|
||||
e.UserRating = mf.Rating
|
||||
return e
|
||||
}
|
||||
|
||||
func realArtistName(mf *model.MediaFile) string {
|
||||
switch {
|
||||
case mf.Compilation:
|
||||
return "Various Artists"
|
||||
return consts.VariousArtists
|
||||
case mf.AlbumArtist != "":
|
||||
return mf.AlbumArtist
|
||||
}
|
||||
@@ -130,37 +131,35 @@ func realArtistName(mf *model.MediaFile) string {
|
||||
return mf.Artist
|
||||
}
|
||||
|
||||
func FromAlbums(albums model.Albums, annMap model.AnnotationMap) Entries {
|
||||
func FromAlbums(albums model.Albums) Entries {
|
||||
entries := make(Entries, len(albums))
|
||||
for i, al := range albums {
|
||||
ann := annMap[al.ID]
|
||||
entries[i] = FromAlbum(&al, &ann)
|
||||
entries[i] = FromAlbum(&al)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func FromMediaFiles(mfs model.MediaFiles, annMap model.AnnotationMap) Entries {
|
||||
func FromMediaFiles(mfs model.MediaFiles) Entries {
|
||||
entries := make(Entries, len(mfs))
|
||||
for i, mf := range mfs {
|
||||
ann := annMap[mf.ID]
|
||||
entries[i] = FromMediaFile(&mf, &ann)
|
||||
entries[i] = FromMediaFile(&mf)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func FromArtists(ars model.Artists, annMap model.AnnotationMap) Entries {
|
||||
func FromArtists(ars model.Artists) Entries {
|
||||
entries := make(Entries, len(ars))
|
||||
for i, ar := range ars {
|
||||
ann := annMap[ar.ID]
|
||||
entries[i] = FromArtist(&ar, &ann)
|
||||
entries[i] = FromArtist(&ar)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func getUserID(ctx context.Context) string {
|
||||
user, ok := ctx.Value("user").(*model.User)
|
||||
if ok {
|
||||
return user.ID
|
||||
func userName(ctx context.Context) string {
|
||||
user := ctx.Value("user")
|
||||
if user == nil {
|
||||
return "UNKNOWN"
|
||||
}
|
||||
return ""
|
||||
usr := user.(model.User)
|
||||
return usr.UserName
|
||||
}
|
||||
|
||||
178
engine/cover.go
@@ -4,95 +4,142 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/static"
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/nfnt/resize"
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/djherbis/fscache"
|
||||
"github.com/dustin/go-humanize"
|
||||
)
|
||||
|
||||
type Cover interface {
|
||||
Get(ctx context.Context, id string, size int, out io.Writer) error
|
||||
}
|
||||
|
||||
type ImageCache fscache.Cache
|
||||
|
||||
func NewCover(ds model.DataStore, cache ImageCache) Cover {
|
||||
return &cover{ds: ds, cache: cache}
|
||||
}
|
||||
|
||||
type cover struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func NewCover(ds model.DataStore) Cover {
|
||||
return &cover{ds}
|
||||
}
|
||||
|
||||
func (c *cover) getCoverPath(ctx context.Context, id string) (string, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(id, "al-"):
|
||||
id = id[3:]
|
||||
al, err := c.ds.Album(ctx).Get(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return al.CoverArtPath, nil
|
||||
default:
|
||||
mf, err := c.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if mf.HasCoverArt {
|
||||
return mf.Path, nil
|
||||
}
|
||||
}
|
||||
return "", model.ErrNotFound
|
||||
ds model.DataStore
|
||||
cache fscache.Cache
|
||||
}
|
||||
|
||||
func (c *cover) Get(ctx context.Context, id string, size int, out io.Writer) error {
|
||||
path, err := c.getCoverPath(ctx, id)
|
||||
id = strings.TrimPrefix(id, "al-")
|
||||
path, lastUpdate, err := c.getCoverPath(ctx, id)
|
||||
if err != nil && err != model.ErrNotFound {
|
||||
return err
|
||||
}
|
||||
|
||||
var reader io.Reader
|
||||
|
||||
if err != model.ErrNotFound {
|
||||
reader, err = readFromTag(path)
|
||||
} else {
|
||||
var f http.File
|
||||
f, err = static.AssetFile().Open("default_cover.jpg")
|
||||
if err == nil {
|
||||
defer f.Close()
|
||||
reader = f
|
||||
}
|
||||
}
|
||||
|
||||
cacheKey := imageCacheKey(path, size, lastUpdate)
|
||||
r, w, err := c.cache.Get(cacheKey)
|
||||
if err != nil {
|
||||
return model.ErrNotFound
|
||||
log.Error(ctx, "Error reading from image cache", "path", path, "size", size, err)
|
||||
}
|
||||
defer r.Close()
|
||||
if w != nil {
|
||||
log.Trace(ctx, "Image cache miss", "path", path, "size", size, "lastUpdate", lastUpdate)
|
||||
go func() {
|
||||
defer w.Close()
|
||||
reader, err := c.getCover(ctx, path, size)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading cover art", "path", path, "size", size, err)
|
||||
return
|
||||
}
|
||||
io.Copy(w, reader)
|
||||
}()
|
||||
} else {
|
||||
log.Trace(ctx, "Loading image from cache", "path", path, "size", size, "lastUpdate", lastUpdate)
|
||||
}
|
||||
|
||||
if size > 0 {
|
||||
return resizeImage(reader, size, out)
|
||||
}
|
||||
_, err = io.Copy(out, reader)
|
||||
_, err = io.Copy(out, r)
|
||||
return err
|
||||
}
|
||||
|
||||
func resizeImage(reader io.Reader, size int, out io.Writer) error {
|
||||
img, _, err := image.Decode(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
func (c *cover) getCoverPath(ctx context.Context, id string) (path string, lastUpdated time.Time, err error) {
|
||||
var found bool
|
||||
if found, err = c.ds.Album(ctx).Exists(id); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
m := resize.Resize(uint(size), 0, img, resize.NearestNeighbor)
|
||||
return jpeg.Encode(out, m, &jpeg.Options{Quality: 75})
|
||||
if found {
|
||||
var al *model.Album
|
||||
al, err = c.ds.Album(ctx).Get(id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if al.CoverArtId == "" {
|
||||
err = model.ErrNotFound
|
||||
return
|
||||
}
|
||||
id = al.CoverArtId
|
||||
}
|
||||
var mf *model.MediaFile
|
||||
mf, err = c.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if mf.HasCoverArt {
|
||||
return mf.Path, mf.UpdatedAt, nil
|
||||
}
|
||||
return "", time.Time{}, model.ErrNotFound
|
||||
}
|
||||
|
||||
func readFromTag(path string) (io.Reader, error) {
|
||||
func imageCacheKey(path string, size int, lastUpdate time.Time) string {
|
||||
return fmt.Sprintf("%s.%d.%s", path, size, lastUpdate.Format(time.RFC3339Nano))
|
||||
}
|
||||
|
||||
func (c *cover) getCover(ctx context.Context, path string, size int) (reader io.Reader, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error extracting image", "path", path, "size", size, err)
|
||||
reader, err = static.AssetFile().Open("navidrome-310x310.png")
|
||||
}
|
||||
}()
|
||||
var data []byte
|
||||
data, err = readFromTag(path)
|
||||
|
||||
if err == nil && size > 0 {
|
||||
data, err = resizeImage(bytes.NewReader(data), size)
|
||||
}
|
||||
|
||||
// Confirm the image is valid. Costly, but necessary
|
||||
_, _, err = image.Decode(bytes.NewReader(data))
|
||||
if err == nil {
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func resizeImage(reader io.Reader, size int) ([]byte, error) {
|
||||
img, _, err := image.Decode(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := imaging.Resize(img, size, size, imaging.Lanczos)
|
||||
buf := new(bytes.Buffer)
|
||||
err = jpeg.Encode(buf, m, &jpeg.Options{Quality: 75})
|
||||
return buf.Bytes(), err
|
||||
}
|
||||
|
||||
func readFromTag(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -106,7 +153,24 @@ func readFromTag(path string) (io.Reader, error) {
|
||||
|
||||
picture := m.Picture()
|
||||
if picture == nil {
|
||||
return nil, errors.New("error extracting art from file " + path)
|
||||
return nil, errors.New("file does not contain embedded art")
|
||||
}
|
||||
return bytes.NewReader(picture.Data), nil
|
||||
return picture.Data, nil
|
||||
}
|
||||
|
||||
func NewImageCache() (ImageCache, error) {
|
||||
cacheSize, err := humanize.ParseBytes(conf.Server.ImageCacheSize)
|
||||
if err != nil {
|
||||
cacheSize = consts.DefaultImageCacheSize
|
||||
}
|
||||
lru := fscache.NewLRUHaunter(consts.DefaultImageCacheMaxItems, int64(cacheSize), consts.DefaultImageCacheCleanUpInterval)
|
||||
h := fscache.NewLRUHaunterStrategy(lru)
|
||||
cacheFolder := filepath.Join(conf.Server.DataFolder, consts.ImageCacheDir)
|
||||
log.Info("Creating image cache", "path", cacheFolder, "maxSize", humanize.Bytes(cacheSize),
|
||||
"cleanUpInterval", consts.DefaultImageCacheCleanUpInterval)
|
||||
fs, err := fscache.NewFs(cacheFolder, 0755)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fscache.NewCacheWithHaunter(fs, h)
|
||||
}
|
||||
|
||||
@@ -1,91 +1,88 @@
|
||||
package engine_test
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"github.com/deluan/navidrome/engine"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
. "github.com/deluan/navidrome/tests"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestCover(t *testing.T) {
|
||||
Init(t, false)
|
||||
var _ = Describe("Cover", func() {
|
||||
var cover Cover
|
||||
var ds model.DataStore
|
||||
ctx := log.NewContext(nil)
|
||||
|
||||
ds := &persistence.MockDataStore{}
|
||||
mockMediaFileRepo := ds.MediaFile(nil).(*persistence.MockMediaFile)
|
||||
mockAlbumRepo := ds.Album(nil).(*persistence.MockAlbum)
|
||||
|
||||
cover := engine.NewCover(ds)
|
||||
out := new(bytes.Buffer)
|
||||
|
||||
Convey("Subject: GetCoverArt Endpoint", t, func() {
|
||||
Convey("When id is not found", func() {
|
||||
mockMediaFileRepo.SetData(`[]`, 1)
|
||||
err := cover.Get(context.TODO(), "1", 0, out)
|
||||
|
||||
Convey("Then return default cover", func() {
|
||||
So(err, ShouldBeNil)
|
||||
So(out.Bytes(), ShouldMatchMD5, "963552b04e87a5a55e993f98a0fbdf82")
|
||||
})
|
||||
})
|
||||
Convey("When id is found", func() {
|
||||
mockMediaFileRepo.SetData(`[{"ID":"2","HasCoverArt":true,"Path":"tests/fixtures/01 Invisible (RED) Edit Version.mp3"}]`, 1)
|
||||
err := cover.Get(context.TODO(), "2", 0, out)
|
||||
|
||||
Convey("Then it should return the cover from the file", func() {
|
||||
So(err, ShouldBeNil)
|
||||
So(out.Bytes(), ShouldMatchMD5, "e859a71cd1b1aaeb1ad437d85b306668")
|
||||
})
|
||||
})
|
||||
Convey("When there is an error accessing the database", func() {
|
||||
mockMediaFileRepo.SetData(`[{"ID":"2","HasCoverArt":true,"Path":"tests/fixtures/01 Invisible (RED) Edit Version.mp3"}]`, 1)
|
||||
mockMediaFileRepo.SetError(true)
|
||||
err := cover.Get(context.TODO(), "2", 0, out)
|
||||
|
||||
Convey("Then error should not be nil", func() {
|
||||
So(err, ShouldNotBeNil)
|
||||
})
|
||||
})
|
||||
Convey("When id is found but file is not present", func() {
|
||||
mockMediaFileRepo.SetData(`[{"ID":"2","HasCoverArt":true,"Path":"tests/fixtures/NOT_FOUND.mp3"}]`, 1)
|
||||
err := cover.Get(context.TODO(), "2", 0, out)
|
||||
|
||||
Convey("Then it should return DatNotFound error", func() {
|
||||
So(err, ShouldEqual, model.ErrNotFound)
|
||||
})
|
||||
})
|
||||
Convey("When specifying a size", func() {
|
||||
mockMediaFileRepo.SetData(`[{"ID":"2","HasCoverArt":true,"Path":"tests/fixtures/01 Invisible (RED) Edit Version.mp3"}]`, 1)
|
||||
err := cover.Get(context.TODO(), "2", 100, out)
|
||||
|
||||
Convey("Then image returned should be 100x100", func() {
|
||||
So(err, ShouldBeNil)
|
||||
So(out.Bytes(), ShouldMatchMD5, "04378f523ca3e8ead33bf7140d39799e")
|
||||
img, _, err := image.Decode(bytes.NewReader(out.Bytes()))
|
||||
So(err, ShouldBeNil)
|
||||
So(img.Bounds().Max.X, ShouldEqual, 100)
|
||||
So(img.Bounds().Max.Y, ShouldEqual, 100)
|
||||
})
|
||||
})
|
||||
Convey("When id is for an album", func() {
|
||||
mockAlbumRepo.SetData(`[{"ID":"1","CoverArtPath":"tests/fixtures/01 Invisible (RED) Edit Version.mp3"}]`, 1)
|
||||
err := cover.Get(context.TODO(), "al-1", 0, out)
|
||||
|
||||
Convey("Then it should return the cover for the album", func() {
|
||||
So(err, ShouldBeNil)
|
||||
So(out.Bytes(), ShouldMatchMD5, "e859a71cd1b1aaeb1ad437d85b306668")
|
||||
})
|
||||
})
|
||||
|
||||
Reset(func() {
|
||||
mockMediaFileRepo.SetData("[]", 0)
|
||||
mockMediaFileRepo.SetError(false)
|
||||
out = new(bytes.Buffer)
|
||||
})
|
||||
BeforeEach(func() {
|
||||
ds = &persistence.MockDataStore{MockedTranscoding: &mockTranscodingRepository{}}
|
||||
ds.Album(ctx).(*persistence.MockAlbum).SetData(`[{"id": "222", "CoverArtId": "222"}, {"id": "333", "CoverArtId": ""}]`, 1)
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "hasCoverArt": true, "updatedAt":"2020-04-02T21:29:31.6377Z"}]`, 1)
|
||||
cover = NewCover(ds, testCache)
|
||||
})
|
||||
}
|
||||
|
||||
It("retrieves the original cover art from an album", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "222", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("png"))
|
||||
})
|
||||
|
||||
It("accepts albumIds with 'al-' prefix", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "al-222", 0, buf)).To(BeNil())
|
||||
|
||||
_, _, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns the default cover if album does not have cover", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "333", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("png"))
|
||||
})
|
||||
|
||||
It("returns the default cover if album is not found", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "444", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("png"))
|
||||
})
|
||||
|
||||
It("retrieves the original cover art from a media_file", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 0, buf)).To(BeNil())
|
||||
|
||||
img, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(600))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(600))
|
||||
})
|
||||
|
||||
It("resized cover art as requested", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 200, buf)).To(BeNil())
|
||||
|
||||
img, _, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
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("", "test_cache")
|
||||
fs, _ := fscache.NewFs(testCacheDir, 0755)
|
||||
testCache, _ = fscache.NewCache(fs, nil)
|
||||
})
|
||||
|
||||
AfterSuite(func() {
|
||||
os.RemoveAll(testCacheDir)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
@@ -39,34 +40,7 @@ func (g *listGenerator) query(ctx context.Context, qo model.QueryOptions) (Entri
|
||||
for i, al := range albums {
|
||||
albumIds[i] = al.ID
|
||||
}
|
||||
annMap, err := g.ds.Annotation(ctx).GetMap(getUserID(ctx), model.AlbumItemType, albumIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return FromAlbums(albums, annMap), err
|
||||
}
|
||||
|
||||
func (g *listGenerator) queryByAnnotation(ctx context.Context, qo model.QueryOptions) (Entries, error) {
|
||||
annotations, err := g.ds.Annotation(ctx).GetAll(getUserID(ctx), model.AlbumItemType, qo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
albumIds := make([]string, len(annotations))
|
||||
for i, ann := range annotations {
|
||||
albumIds[i] = ann.ItemID
|
||||
}
|
||||
|
||||
albumMap, err := g.ds.Album(ctx).GetMap(albumIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var albums Entries
|
||||
for _, ann := range annotations {
|
||||
album := albumMap[ann.ItemID]
|
||||
albums = append(albums, FromAlbum(&album, &ann))
|
||||
}
|
||||
return albums, nil
|
||||
return FromAlbums(albums), err
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetNewest(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
@@ -76,20 +50,20 @@ func (g *listGenerator) GetNewest(ctx context.Context, offset int, size int) (En
|
||||
|
||||
func (g *listGenerator) GetRecent(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
qo := model.QueryOptions{Sort: "PlayDate", Order: "desc", Offset: offset, Max: size,
|
||||
Filters: map[string]interface{}{"play_date__gt": time.Time{}}}
|
||||
return g.queryByAnnotation(ctx, qo)
|
||||
Filters: squirrel.Gt{"play_date": time.Time{}}}
|
||||
return g.query(ctx, qo)
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetFrequent(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
qo := model.QueryOptions{Sort: "PlayCount", Order: "desc", Offset: offset, Max: size,
|
||||
Filters: map[string]interface{}{"play_count__gt": 0}}
|
||||
return g.queryByAnnotation(ctx, qo)
|
||||
Filters: squirrel.Gt{"play_count": 0}}
|
||||
return g.query(ctx, qo)
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetHighest(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
qo := model.QueryOptions{Sort: "Rating", Order: "desc", Offset: offset, Max: size,
|
||||
Filters: map[string]interface{}{"rating__gt": 0}}
|
||||
return g.queryByAnnotation(ctx, qo)
|
||||
Filters: squirrel.Gt{"rating": 0}}
|
||||
return g.query(ctx, qo)
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetByName(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
@@ -108,70 +82,46 @@ func (g *listGenerator) GetRandom(ctx context.Context, offset int, size int) (En
|
||||
return nil, err
|
||||
}
|
||||
|
||||
annMap, err := g.getAnnotationsForAlbums(ctx, albums)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return FromAlbums(albums, annMap), nil
|
||||
}
|
||||
|
||||
func (g *listGenerator) getAnnotationsForAlbums(ctx context.Context, albums model.Albums) (model.AnnotationMap, error) {
|
||||
albumIds := make([]string, len(albums))
|
||||
for i, al := range albums {
|
||||
albumIds[i] = al.ID
|
||||
}
|
||||
return g.ds.Annotation(ctx).GetMap(getUserID(ctx), model.AlbumItemType, albumIds)
|
||||
return FromAlbums(albums), nil
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetRandomSongs(ctx context.Context, size int, genre string) (Entries, error) {
|
||||
options := model.QueryOptions{Max: size}
|
||||
if genre != "" {
|
||||
options.Filters = map[string]interface{}{"genre": genre}
|
||||
options.Filters = squirrel.Eq{"genre": genre}
|
||||
}
|
||||
mediaFiles, err := g.ds.MediaFile(ctx).GetRandom(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r := make(Entries, len(mediaFiles))
|
||||
for i, mf := range mediaFiles {
|
||||
ann, err := g.ds.Annotation(ctx).Get(getUserID(ctx), model.MediaItemType, mf.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r[i] = FromMediaFile(&mf, ann)
|
||||
}
|
||||
return r, nil
|
||||
return FromMediaFiles(mediaFiles), nil
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetStarred(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
qo := model.QueryOptions{Offset: offset, Max: size, Sort: "starred_at", Order: "desc"}
|
||||
albums, err := g.ds.Album(ctx).GetStarred(getUserID(ctx), qo)
|
||||
albums, err := g.ds.Album(ctx).GetStarred(qo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
annMap, err := g.getAnnotationsForAlbums(ctx, albums)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return FromAlbums(albums, annMap), nil
|
||||
return FromAlbums(albums), nil
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetAllStarred(ctx context.Context) (artists Entries, albums Entries, mediaFiles Entries, err error) {
|
||||
options := model.QueryOptions{Sort: "starred_at", Order: "desc"}
|
||||
|
||||
ars, err := g.ds.Artist(ctx).GetStarred(getUserID(ctx), options)
|
||||
ars, err := g.ds.Artist(ctx).GetStarred(options)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
als, err := g.ds.Album(ctx).GetStarred(getUserID(ctx), options)
|
||||
als, err := g.ds.Album(ctx).GetStarred(options)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
mfs, err := g.ds.MediaFile(ctx).GetStarred(getUserID(ctx), options)
|
||||
mfs, err := g.ds.MediaFile(ctx).GetStarred(options)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
@@ -180,28 +130,15 @@ func (g *listGenerator) GetAllStarred(ctx context.Context) (artists Entries, alb
|
||||
for _, mf := range mfs {
|
||||
mfIds = append(mfIds, mf.ID)
|
||||
}
|
||||
trackAnnMap, err := g.ds.Annotation(ctx).GetMap(getUserID(ctx), model.MediaItemType, mfIds)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
albumAnnMap, err := g.getAnnotationsForAlbums(ctx, als)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
var artistIds []string
|
||||
for _, ar := range ars {
|
||||
artistIds = append(artistIds, ar.ID)
|
||||
}
|
||||
artistAnnMap, err := g.ds.Annotation(ctx).GetMap(getUserID(ctx), model.MediaItemType, artistIds)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
artists = FromArtists(ars, artistAnnMap)
|
||||
albums = FromAlbums(als, albumAnnMap)
|
||||
mediaFiles = FromMediaFiles(mfs, trackAnnMap)
|
||||
artists = FromArtists(ars)
|
||||
albums = FromAlbums(als)
|
||||
mediaFiles = FromMediaFiles(mfs)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -217,8 +154,7 @@ func (g *listGenerator) GetNowPlaying(ctx context.Context) (Entries, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ann, err := g.ds.Annotation(ctx).Get(getUserID(ctx), model.MediaItemType, mf.ID)
|
||||
entries[i] = FromMediaFile(mf, ann)
|
||||
entries[i] = FromMediaFile(mf)
|
||||
entries[i].UserName = np.Username
|
||||
entries[i].MinutesAgo = int(time.Now().Sub(np.Start).Minutes())
|
||||
entries[i].PlayerId = np.PlayerId
|
||||
|
||||
232
engine/media_streamer.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/engine/transcoder"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/djherbis/fscache"
|
||||
"github.com/dustin/go-humanize"
|
||||
)
|
||||
|
||||
type MediaStreamer interface {
|
||||
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error)
|
||||
}
|
||||
|
||||
type TranscodingCache fscache.Cache
|
||||
|
||||
func NewMediaStreamer(ds model.DataStore, ffm transcoder.Transcoder, cache TranscodingCache) MediaStreamer {
|
||||
return &mediaStreamer{ds: ds, ffm: ffm, cache: cache}
|
||||
}
|
||||
|
||||
type mediaStreamer struct {
|
||||
ds model.DataStore
|
||||
ffm transcoder.Transcoder
|
||||
cache fscache.Cache
|
||||
}
|
||||
|
||||
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error) {
|
||||
mf, err := ms.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var format string
|
||||
var bitRate int
|
||||
defer func() {
|
||||
log.Info("Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format,
|
||||
"bitRate", bitRate, "user", userName(ctx), "transcoding", format != "raw", "originalFormat", mf.Suffix)
|
||||
}()
|
||||
|
||||
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,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
|
||||
f, err := os.Open(mf.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Reader = f
|
||||
s.Closer = f
|
||||
s.Seeker = f
|
||||
s.format = mf.Suffix
|
||||
return s, nil
|
||||
}
|
||||
|
||||
key := cacheKey(id, bitRate, format)
|
||||
r, w, err := ms.cache.Get(key)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error creating stream caching buffer", "id", mf.ID, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If this is a brand new transcoding request, not in the cache, start transcoding
|
||||
if w != nil {
|
||||
log.Trace(ctx, "Cache miss. Starting new transcoding session", "id", mf.ID)
|
||||
t, err := ms.ds.Transcoding(ctx).FindByFormat(format)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading transcoding command", "format", format, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
out, err := ms.ffm.Start(ctx, t.Command, mf.Path, bitRate, format)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error starting transcoder", "id", mf.ID, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
go copyAndClose(ctx, w, out)
|
||||
}
|
||||
|
||||
// If it is in the cache, check if the stream is done being written. If so, return a ReaderSeeker
|
||||
if w == nil {
|
||||
size := getFinalCachedSize(r)
|
||||
if size > 0 {
|
||||
log.Debug(ctx, "Streaming cached file", "id", mf.ID, "path", mf.Path,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix, "size", size)
|
||||
sr := io.NewSectionReader(r, 0, size)
|
||||
s.Reader = sr
|
||||
s.Closer = r
|
||||
s.Seeker = sr
|
||||
s.format = format
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Streaming transcoded file", "id", mf.ID, "path", mf.Path,
|
||||
"requestBitrate", 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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
type Stream struct {
|
||||
ctx context.Context
|
||||
mf *model.MediaFile
|
||||
bitRate int
|
||||
format string
|
||||
io.Reader
|
||||
io.Closer
|
||||
io.Seeker
|
||||
}
|
||||
|
||||
func (s *Stream) Seekable() bool { return s.Seeker != nil }
|
||||
func (s *Stream) Duration() float32 { return s.mf.Duration }
|
||||
func (s *Stream) ContentType() string { return mime.TypeByExtension("." + s.format) }
|
||||
func (s *Stream) Name() string { return s.mf.Title + "." + s.format }
|
||||
func (s *Stream) ModTime() time.Time { return s.mf.UpdatedAt }
|
||||
func (s *Stream) EstimatedContentLength() int {
|
||||
return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
|
||||
}
|
||||
|
||||
// TODO This function deserves some love (refactoring)
|
||||
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) {
|
||||
format = "raw"
|
||||
if reqFormat == "raw" {
|
||||
return
|
||||
}
|
||||
if reqFormat == mf.Suffix && reqBitRate == 0 {
|
||||
bitRate = mf.BitRate
|
||||
return
|
||||
}
|
||||
trc, hasDefault := ctx.Value("transcoding").(model.Transcoding)
|
||||
var cFormat string
|
||||
var cBitRate int
|
||||
if reqFormat != "" {
|
||||
cFormat = reqFormat
|
||||
} else {
|
||||
if hasDefault {
|
||||
cFormat = trc.TargetFormat
|
||||
cBitRate = trc.DefaultBitRate
|
||||
if p, ok := ctx.Value("player").(model.Player); ok {
|
||||
cBitRate = p.MaxBitRate
|
||||
}
|
||||
}
|
||||
}
|
||||
if reqBitRate > 0 {
|
||||
cBitRate = reqBitRate
|
||||
}
|
||||
if cBitRate == 0 && cFormat == "" {
|
||||
return
|
||||
}
|
||||
t, err := ds.Transcoding(ctx).FindByFormat(cFormat)
|
||||
if err == nil {
|
||||
format = t.TargetFormat
|
||||
if cBitRate != 0 {
|
||||
bitRate = cBitRate
|
||||
} else {
|
||||
bitRate = t.DefaultBitRate
|
||||
}
|
||||
}
|
||||
if format == mf.Suffix && bitRate > mf.BitRate {
|
||||
format = "raw"
|
||||
bitRate = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
cacheSize, err := humanize.ParseBytes(conf.Server.TranscodingCacheSize)
|
||||
if err != nil {
|
||||
cacheSize = consts.DefaultTranscodingCacheSize
|
||||
}
|
||||
lru := fscache.NewLRUHaunter(consts.DefaultTranscodingCacheMaxItems, int64(cacheSize), consts.DefaultTranscodingCacheCleanUpInterval)
|
||||
h := fscache.NewLRUHaunterStrategy(lru)
|
||||
cacheFolder := filepath.Join(conf.Server.DataFolder, consts.TranscodingCacheDir)
|
||||
log.Info("Creating transcoding cache", "path", cacheFolder, "maxSize", humanize.Bytes(cacheSize),
|
||||
"cleanUpInterval", consts.DefaultTranscodingCacheCleanUpInterval)
|
||||
fs, err := fscache.NewFs(cacheFolder, 0755)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fscache.NewCacheWithHaunter(fs, h)
|
||||
}
|
||||
190
engine/media_streamer_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("MediaStreamer", func() {
|
||||
var streamer MediaStreamer
|
||||
var ds model.DataStore
|
||||
ffmpeg := &fakeFFmpeg{Data: "fake data"}
|
||||
ctx := log.NewContext(nil)
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &persistence.MockDataStore{MockedTranscoding: &mockTranscodingRepository{}}
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "suffix": "mp3", "bitRate": 128, "duration": 257.0}]`, 1)
|
||||
streamer = NewMediaStreamer(ds, ffmpeg, testCache)
|
||||
})
|
||||
|
||||
Context("NewStream", func() {
|
||||
It("returns a seekable stream if format is 'raw'", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "raw", 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a seekable stream if maxBitRate is 0", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a seekable stream if maxBitRate is higher than file bitRate", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 320)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a NON seekable stream if transcode is required", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 64)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s.Seekable()).To(BeFalse())
|
||||
Expect(s.Duration()).To(Equal(float32(257.0)))
|
||||
})
|
||||
It("returns a seekable stream if the file is complete in the cache", func() {
|
||||
Eventually(func() bool { return ffmpeg.closed }).Should(BeTrue())
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 64)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("selectTranscodingOptions", func() {
|
||||
mf := &model.MediaFile{}
|
||||
Context("player is not configured", func() {
|
||||
It("returns raw if raw is requested", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns raw if a transcoder does not exists", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns the requested format if a transcoder exists", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
||||
})
|
||||
It("returns raw if requested format is the same as the original and it is not necessary to downsample", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 112
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns the requested format if requested BitRate is lower than original", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 320
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(192))
|
||||
})
|
||||
It("returns raw if requested format is the same as the original, but requested BitRate is 0", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 320
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
Expect(bitRate).To(Equal(320))
|
||||
})
|
||||
})
|
||||
|
||||
Context("player has format configured", func() {
|
||||
BeforeEach(func() {
|
||||
t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
|
||||
ctx = context.WithValue(ctx, "transcoding", t)
|
||||
})
|
||||
It("returns raw if raw is requested", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns configured format/bitrate as default", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(96))
|
||||
})
|
||||
It("returns requested format", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
||||
})
|
||||
It("returns requested bitrate", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(80))
|
||||
})
|
||||
})
|
||||
Context("player has maxBitRate configured", func() {
|
||||
BeforeEach(func() {
|
||||
t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
|
||||
p := model.Player{ID: "player1", TranscodingId: t.ID, MaxBitRate: 80}
|
||||
ctx = context.WithValue(ctx, "transcoding", t)
|
||||
ctx = context.WithValue(ctx, "player", p)
|
||||
})
|
||||
It("returns raw if raw is requested", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns configured format/bitrate as default", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(80))
|
||||
})
|
||||
It("returns requested format", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
||||
})
|
||||
It("returns requested bitrate", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(80))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type fakeFFmpeg struct {
|
||||
Data string
|
||||
r io.Reader
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (ff *fakeFFmpeg) Start(ctx context.Context, cmd, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
|
||||
ff.r = strings.NewReader(ff.Data)
|
||||
return ff, nil
|
||||
}
|
||||
|
||||
func (ff *fakeFFmpeg) Read(p []byte) (n int, err error) {
|
||||
return ff.r.Read(p)
|
||||
}
|
||||
|
||||
func (ff *fakeFFmpeg) Close() error {
|
||||
ff.closed = true
|
||||
return nil
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
func CreateMockNowPlayingRepo() *MockNowPlaying {
|
||||
return &MockNowPlaying{}
|
||||
}
|
||||
|
||||
type MockNowPlaying struct {
|
||||
NowPlayingRepository
|
||||
data []NowPlayingInfo
|
||||
t time.Time
|
||||
err bool
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) SetError(err bool) {
|
||||
m.err = err
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) Enqueue(info *NowPlayingInfo) error {
|
||||
if m.err {
|
||||
return errors.New("Error!")
|
||||
}
|
||||
|
||||
m.data = append(m.data, NowPlayingInfo{})
|
||||
copy(m.data[1:], m.data[0:])
|
||||
m.data[0] = *info
|
||||
|
||||
if !m.t.IsZero() {
|
||||
m.data[0].Start = m.t
|
||||
m.t = time.Time{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) Dequeue(playerId int) (*NowPlayingInfo, error) {
|
||||
if len(m.data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
l := len(m.data)
|
||||
info := m.data[l-1]
|
||||
m.data = m.data[:l-1]
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) Count(playerId int) (int64, error) {
|
||||
return int64(len(m.data)), nil
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) GetAll() ([]*NowPlayingInfo, error) {
|
||||
np, err := m.Head(1)
|
||||
if np == nil || err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []*NowPlayingInfo{np}, err
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) Head(playerId int) (*NowPlayingInfo, error) {
|
||||
if len(m.data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
info := m.data[0]
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) Tail(playerId int) (*NowPlayingInfo, error) {
|
||||
if len(m.data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
info := m.data[len(m.data)-1]
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) ClearAll() {
|
||||
m.data = make([]NowPlayingInfo, 0)
|
||||
m.err = false
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) OverrideNow(t time.Time) {
|
||||
m.t = t
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
func CreateMockPropertyRepo() *MockProperty {
|
||||
return &MockProperty{data: make(map[string]string)}
|
||||
}
|
||||
|
||||
type MockProperty struct {
|
||||
model.PropertyRepository
|
||||
data map[string]string
|
||||
err bool
|
||||
}
|
||||
|
||||
func (m *MockProperty) SetError(err bool) {
|
||||
m.err = err
|
||||
}
|
||||
|
||||
func (m *MockProperty) Put(id string, value string) error {
|
||||
if m.err {
|
||||
return errors.New("Error!")
|
||||
}
|
||||
m.data[id] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockProperty) Get(id string) (string, error) {
|
||||
if m.err {
|
||||
return "", errors.New("Error!")
|
||||
}
|
||||
return m.data[id], nil
|
||||
}
|
||||
|
||||
func (m *MockProperty) DefaultGet(id string, defaultValue string) (string, error) {
|
||||
v, err := m.Get(id)
|
||||
|
||||
if v == "" {
|
||||
v = defaultValue
|
||||
}
|
||||
|
||||
return v, err
|
||||
}
|
||||
22
engine/mock_transcoding_repo_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package engine
|
||||
|
||||
import "github.com/deluan/navidrome/model"
|
||||
|
||||
type mockTranscodingRepository struct {
|
||||
model.TranscodingRepository
|
||||
}
|
||||
|
||||
func (m *mockTranscodingRepository) Get(id string) (*model.Transcoding, error) {
|
||||
return &model.Transcoding{ID: id, TargetFormat: "mp3", DefaultBitRate: 160}, nil
|
||||
}
|
||||
|
||||
func (m *mockTranscodingRepository) FindByFormat(format string) (*model.Transcoding, error) {
|
||||
switch format {
|
||||
case "mp3":
|
||||
return &model.Transcoding{ID: "mp31", TargetFormat: "mp3", DefaultBitRate: 160}, nil
|
||||
case "oga":
|
||||
return &model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 128}, nil
|
||||
default:
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
}
|
||||
67
engine/players.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Players interface {
|
||||
Get(ctx context.Context, playerId string) (*model.Player, error)
|
||||
Register(ctx context.Context, id, client, typ, ip string) (*model.Player, *model.Transcoding, error)
|
||||
}
|
||||
|
||||
func NewPlayers(ds model.DataStore) Players {
|
||||
return &players{ds}
|
||||
}
|
||||
|
||||
type players struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func (p *players) Register(ctx context.Context, id, client, typ, ip string) (*model.Player, *model.Transcoding, error) {
|
||||
var plr *model.Player
|
||||
var trc *model.Transcoding
|
||||
var err error
|
||||
userName := ctx.Value("username").(string)
|
||||
if id != "" {
|
||||
plr, err = p.ds.Player(ctx).Get(id)
|
||||
if err == nil && plr.Client != client {
|
||||
id = ""
|
||||
}
|
||||
}
|
||||
if err != nil || id == "" {
|
||||
plr, err = p.ds.Player(ctx).FindByName(client, userName)
|
||||
if err == nil {
|
||||
log.Debug("Found player by name", "id", plr.ID, "client", client, "userName", userName)
|
||||
} else {
|
||||
r, _ := uuid.NewRandom()
|
||||
plr = &model.Player{
|
||||
ID: r.String(),
|
||||
Name: fmt.Sprintf("%s (%s)", client, userName),
|
||||
UserName: userName,
|
||||
Client: client,
|
||||
}
|
||||
log.Info("Registering new player", "id", plr.ID, "client", client, "userName", userName)
|
||||
}
|
||||
}
|
||||
plr.LastSeen = time.Now()
|
||||
plr.Type = typ
|
||||
plr.IPAddress = ip
|
||||
err = p.ds.Player(ctx).Put(plr)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if plr.TranscodingId != "" {
|
||||
trc, err = p.ds.Transcoding(ctx).Get(plr.TranscodingId)
|
||||
}
|
||||
return plr, trc, err
|
||||
}
|
||||
|
||||
func (p *players) Get(ctx context.Context, playerId string) (*model.Player, error) {
|
||||
return p.ds.Player(ctx).Get(playerId)
|
||||
}
|
||||
138
engine/players_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Players", func() {
|
||||
var players Players
|
||||
var repo *mockPlayerRepository
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid", UserName: "johndoe"})
|
||||
ctx = context.WithValue(ctx, "username", "johndoe")
|
||||
var beforeRegister time.Time
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = &mockPlayerRepository{}
|
||||
ds := &persistence.MockDataStore{MockedPlayer: repo, MockedTranscoding: &mockTranscodingRepository{}}
|
||||
players = NewPlayers(ds)
|
||||
beforeRegister = time.Now()
|
||||
})
|
||||
|
||||
Describe("Register", func() {
|
||||
It("creates a new player when no ID is specified", func() {
|
||||
p, trc, err := players.Register(ctx, "", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).ToNot(BeEmpty())
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(p.Client).To(Equal("client"))
|
||||
Expect(p.UserName).To(Equal("johndoe"))
|
||||
Expect(p.Type).To(Equal("chrome"))
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
Expect(trc).To(BeNil())
|
||||
})
|
||||
|
||||
It("creates a new player if it cannot find any matching player", func() {
|
||||
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).ToNot(BeEmpty())
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
Expect(trc).To(BeNil())
|
||||
})
|
||||
|
||||
It("creates a new player if client does not match the one in DB", func() {
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client1111", LastSeen: time.Time{}}
|
||||
repo.add(plr)
|
||||
p, trc, err := players.Register(ctx, "123", "client2222", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).ToNot(BeEmpty())
|
||||
Expect(p.ID).ToNot(Equal("123"))
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(p.Client).To(Equal("client2222"))
|
||||
Expect(trc).To(BeNil())
|
||||
})
|
||||
|
||||
It("finds players by ID", func() {
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}}
|
||||
repo.add(plr)
|
||||
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).To(Equal("123"))
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
Expect(trc).To(BeNil())
|
||||
})
|
||||
|
||||
It("finds player by client and user names when ID is not found", func() {
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserName: "johndoe", LastSeen: time.Time{}}
|
||||
repo.add(plr)
|
||||
p, _, err := players.Register(ctx, "999", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).To(Equal("123"))
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
})
|
||||
|
||||
It("finds player by client and user names when not ID is provided", func() {
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserName: "johndoe", LastSeen: time.Time{}}
|
||||
repo.add(plr)
|
||||
p, _, err := players.Register(ctx, "", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).To(Equal("123"))
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
})
|
||||
|
||||
It("finds player by ID and return its transcoding", func() {
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}, TranscodingId: "1"}
|
||||
repo.add(plr)
|
||||
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).To(Equal("123"))
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
Expect(trc.ID).To(Equal("1"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockPlayerRepository struct {
|
||||
model.PlayerRepository
|
||||
lastSaved *model.Player
|
||||
data map[string]model.Player
|
||||
}
|
||||
|
||||
func (m *mockPlayerRepository) add(p *model.Player) {
|
||||
if m.data == nil {
|
||||
m.data = make(map[string]model.Player)
|
||||
}
|
||||
m.data[p.ID] = *p
|
||||
}
|
||||
|
||||
func (m *mockPlayerRepository) Get(id string) (*model.Player, error) {
|
||||
if p, ok := m.data[id]; ok {
|
||||
return &p, nil
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockPlayerRepository) FindByName(client, userName string) (*model.Player, error) {
|
||||
for _, p := range m.data {
|
||||
if p.Client == client && p.UserName == userName {
|
||||
return &p, nil
|
||||
}
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockPlayerRepository) Put(p *model.Player) error {
|
||||
m.lastSaved = p
|
||||
return nil
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func (p *playlists) Create(ctx context.Context, playlistId, name string, ids []s
|
||||
}
|
||||
|
||||
func (p *playlists) getUser(ctx context.Context) string {
|
||||
user, ok := ctx.Value("user").(*model.User)
|
||||
user, ok := ctx.Value("user").(model.User)
|
||||
if ok {
|
||||
return user.UserName
|
||||
}
|
||||
@@ -73,15 +73,15 @@ func (p *playlists) Delete(ctx context.Context, playlistId string) error {
|
||||
|
||||
func (p *playlists) Update(ctx context.Context, playlistId string, name *string, idsToAdd []string, idxToRemove []int) error {
|
||||
pls, err := p.ds.Playlist(ctx).Get(playlistId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
owner := p.getUser(ctx)
|
||||
if owner != pls.Owner {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if name != nil {
|
||||
pls.Name = *name
|
||||
}
|
||||
@@ -102,7 +102,11 @@ func (p *playlists) Update(ctx context.Context, playlistId string, name *string,
|
||||
}
|
||||
|
||||
func (p *playlists) GetAll(ctx context.Context) (model.Playlists, error) {
|
||||
return p.ds.Playlist(ctx).GetAll(model.QueryOptions{})
|
||||
all, err := p.ds.Playlist(ctx).GetAll(model.QueryOptions{})
|
||||
for i := range all {
|
||||
all[i].Public = true
|
||||
}
|
||||
return all, err
|
||||
}
|
||||
|
||||
type PlaylistInfo struct {
|
||||
@@ -123,28 +127,16 @@ func (p *playlists) Get(ctx context.Context, id string) (*PlaylistInfo, error) {
|
||||
}
|
||||
|
||||
// TODO Use model.Playlist when got rid of Entries
|
||||
pinfo := &PlaylistInfo{
|
||||
plsInfo := &PlaylistInfo{
|
||||
Id: pl.ID,
|
||||
Name: pl.Name,
|
||||
SongCount: len(pl.Tracks),
|
||||
Duration: pl.Duration,
|
||||
Duration: int(pl.Duration),
|
||||
Public: pl.Public,
|
||||
Owner: pl.Owner,
|
||||
Comment: pl.Comment,
|
||||
}
|
||||
pinfo.Entries = make(Entries, len(pl.Tracks))
|
||||
|
||||
var mfIds []string
|
||||
for _, mf := range pl.Tracks {
|
||||
mfIds = append(mfIds, mf.ID)
|
||||
}
|
||||
|
||||
annMap, err := p.ds.Annotation(ctx).GetMap(getUserID(ctx), model.MediaItemType, mfIds)
|
||||
|
||||
for i, mf := range pl.Tracks {
|
||||
ann := annMap[mf.ID]
|
||||
pinfo.Entries[i] = FromMediaFile(&mf, &ann)
|
||||
}
|
||||
|
||||
return pinfo, nil
|
||||
plsInfo.Entries = FromMediaFiles(pl.Tracks)
|
||||
return plsInfo, nil
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ func (r ratings) SetRating(ctx context.Context, id string, rating int) error {
|
||||
return err
|
||||
}
|
||||
if exist {
|
||||
return r.ds.Annotation(ctx).SetRating(rating, getUserID(ctx), model.AlbumItemType, id)
|
||||
return r.ds.Album(ctx).SetRating(rating, id)
|
||||
}
|
||||
return r.ds.Annotation(ctx).SetRating(rating, getUserID(ctx), model.MediaItemType, id)
|
||||
return r.ds.MediaFile(ctx).SetRating(rating, id)
|
||||
}
|
||||
|
||||
func (r ratings) SetStar(ctx context.Context, star bool, ids ...string) error {
|
||||
@@ -36,7 +36,6 @@ func (r ratings) SetStar(ctx context.Context, star bool, ids ...string) error {
|
||||
log.Warn(ctx, "Cannot star/unstar an empty list of ids")
|
||||
return nil
|
||||
}
|
||||
userId := getUserID(ctx)
|
||||
|
||||
return r.ds.WithTx(func(tx model.DataStore) error {
|
||||
for _, id := range ids {
|
||||
@@ -45,7 +44,7 @@ func (r ratings) SetStar(ctx context.Context, star bool, ids ...string) error {
|
||||
return err
|
||||
}
|
||||
if exist {
|
||||
err = tx.Annotation(ctx).SetStar(star, userId, model.AlbumItemType, ids...)
|
||||
err = tx.Album(ctx).SetStar(star, ids...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -56,13 +55,13 @@ func (r ratings) SetStar(ctx context.Context, star bool, ids ...string) error {
|
||||
return err
|
||||
}
|
||||
if exist {
|
||||
err = tx.Annotation(ctx).SetStar(star, userId, model.ArtistItemType, ids...)
|
||||
err = tx.Artist(ctx).SetStar(star, ids...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
err = tx.Annotation(ctx).SetStar(star, userId, model.MediaItemType, ids...)
|
||||
err = tx.MediaFile(ctx).SetStar(star, ids...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
@@ -24,8 +25,6 @@ type scrobbler struct {
|
||||
}
|
||||
|
||||
func (s *scrobbler) Register(ctx context.Context, playerId int, trackId string, playTime time.Time) (*model.MediaFile, error) {
|
||||
userId := getUserID(ctx)
|
||||
|
||||
var mf *model.MediaFile
|
||||
var err error
|
||||
err = s.ds.WithTx(func(tx model.DataStore) error {
|
||||
@@ -33,13 +32,20 @@ func (s *scrobbler) Register(ctx context.Context, playerId int, trackId string,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.ds.Annotation(ctx).IncPlayCount(userId, model.MediaItemType, trackId, playTime)
|
||||
err = s.ds.MediaFile(ctx).IncPlayCount(trackId, playTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.ds.Annotation(ctx).IncPlayCount(userId, model.AlbumItemType, mf.AlbumID, playTime)
|
||||
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
|
||||
})
|
||||
|
||||
log.Info("Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", userName(ctx))
|
||||
|
||||
return mf, err
|
||||
}
|
||||
|
||||
@@ -54,6 +60,8 @@ func (s *scrobbler) NowPlaying(ctx context.Context, playerId int, playerName, tr
|
||||
return nil, errors.New(fmt.Sprintf(`ID "%s" not found`, trackId))
|
||||
}
|
||||
|
||||
log.Info("Now Playing", "title", mf.Title, "artist", mf.Artist, "user", userName(ctx))
|
||||
|
||||
info := &NowPlayingInfo{TrackID: trackId, Username: username, Start: time.Now(), PlayerId: playerId, PlayerName: playerName}
|
||||
return mf, s.npRepo.Enqueue(info)
|
||||
}
|
||||
|
||||
@@ -34,12 +34,7 @@ func (s *search) SearchArtist(ctx context.Context, q string, offset int, size in
|
||||
for i, al := range artists {
|
||||
artistIds[i] = al.ID
|
||||
}
|
||||
annMap, err := s.ds.Annotation(ctx).GetMap(getUserID(ctx), model.ArtistItemType, artistIds)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return FromArtists(artists, annMap), nil
|
||||
return FromArtists(artists), nil
|
||||
}
|
||||
|
||||
func (s *search) SearchAlbum(ctx context.Context, q string, offset int, size int) (Entries, error) {
|
||||
@@ -53,12 +48,8 @@ func (s *search) SearchAlbum(ctx context.Context, q string, offset int, size int
|
||||
for i, al := range albums {
|
||||
albumIds[i] = al.ID
|
||||
}
|
||||
annMap, err := s.ds.Annotation(ctx).GetMap(getUserID(ctx), model.AlbumItemType, albumIds)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return FromAlbums(albums, annMap), nil
|
||||
return FromAlbums(albums), nil
|
||||
}
|
||||
|
||||
func (s *search) SearchSong(ctx context.Context, q string, offset int, size int) (Entries, error) {
|
||||
@@ -72,10 +63,6 @@ func (s *search) SearchSong(ctx context.Context, q string, offset int, size int)
|
||||
for i, mf := range mediaFiles {
|
||||
trackIds[i] = mf.ID
|
||||
}
|
||||
annMap, err := s.ds.Annotation(ctx).GetMap(getUserID(ctx), model.MediaItemType, trackIds)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return FromMediaFiles(mediaFiles, annMap), nil
|
||||
return FromMediaFiles(mediaFiles), nil
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/log"
|
||||
)
|
||||
|
||||
// TODO Encapsulate as a io.Reader
|
||||
func Stream(ctx context.Context, path string, bitRate int, maxBitRate int, w io.Writer) error {
|
||||
var f io.Reader
|
||||
var err error
|
||||
enabled := !conf.Server.DisableDownsampling
|
||||
if enabled && maxBitRate > 0 && bitRate > maxBitRate {
|
||||
f, err = downsample(ctx, path, maxBitRate)
|
||||
} else {
|
||||
f, err = os.Open(path)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error opening file", "path", path, err)
|
||||
return err
|
||||
}
|
||||
if _, err = io.Copy(w, f); err != nil {
|
||||
log.Error(ctx, "Error copying file", "path", path, err)
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func downsample(ctx context.Context, path string, maxBitRate int) (f io.Reader, err error) {
|
||||
cmdLine, args := createDownsamplingCommand(path, maxBitRate)
|
||||
|
||||
log.Debug(ctx, "Executing command", "cmdLine", cmdLine, "args", args)
|
||||
cmd := exec.Command(cmdLine, args...)
|
||||
cmd.Stderr = os.Stderr
|
||||
if f, err = cmd.StdoutPipe(); err != nil {
|
||||
return f, err
|
||||
}
|
||||
return f, cmd.Start()
|
||||
}
|
||||
|
||||
func createDownsamplingCommand(path string, maxBitRate int) (string, []string) {
|
||||
cmd := conf.Server.DownsampleCommand
|
||||
|
||||
split := strings.Split(cmd, " ")
|
||||
for i, s := range split {
|
||||
s = strings.Replace(s, "%s", path, -1)
|
||||
s = strings.Replace(s, "%b", strconv.Itoa(maxBitRate), -1)
|
||||
split[i] = s
|
||||
}
|
||||
|
||||
return split[0], split[1:]
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/deluan/navidrome/tests"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestDownsampling(t *testing.T) {
|
||||
|
||||
Init(t, false)
|
||||
|
||||
Convey("Subject: createDownsamplingCommand", t, func() {
|
||||
|
||||
Convey("It should create a valid command line", func() {
|
||||
cmd, args := createDownsamplingCommand("/music library/file.mp3", 128)
|
||||
|
||||
So(cmd, ShouldEqual, "ffmpeg")
|
||||
So(args[0], ShouldEqual, "-i")
|
||||
So(args[1], ShouldEqual, "/music library/file.mp3")
|
||||
So(args[2], ShouldEqual, "-b:a")
|
||||
So(args[3], ShouldEqual, "128k")
|
||||
So(args[4], ShouldEqual, "mp3")
|
||||
So(args[5], ShouldEqual, "-")
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
50
engine/transcoder/ffmpeg.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package transcoder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
)
|
||||
|
||||
type Transcoder interface {
|
||||
Start(ctx context.Context, command, path string, maxBitRate int, format string) (f io.ReadCloser, err error)
|
||||
}
|
||||
|
||||
func New() Transcoder {
|
||||
return &ffmpeg{}
|
||||
}
|
||||
|
||||
type ffmpeg struct{}
|
||||
|
||||
func (ff *ffmpeg) Start(ctx context.Context, command, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
|
||||
arg0, args := createTranscodeCommand(command, path, maxBitRate, format)
|
||||
|
||||
log.Trace(ctx, "Executing ffmpeg command", "cmd", arg0, "args", args)
|
||||
cmd := exec.Command(arg0, args...)
|
||||
cmd.Stderr = os.Stderr
|
||||
if f, err = cmd.StdoutPipe(); err != nil {
|
||||
return
|
||||
}
|
||||
if err = cmd.Start(); err != nil {
|
||||
return
|
||||
}
|
||||
go cmd.Wait() // prevent zombies
|
||||
return
|
||||
}
|
||||
|
||||
// Path will always be an absolute path
|
||||
func createTranscodeCommand(cmd, path string, maxBitRate int, format string) (string, []string) {
|
||||
split := strings.Split(cmd, " ")
|
||||
for i, s := range split {
|
||||
s = strings.Replace(s, "%s", path, -1)
|
||||
s = strings.Replace(s, "%b", strconv.Itoa(maxBitRate), -1)
|
||||
split[i] = s
|
||||
}
|
||||
|
||||
return split[0], split[1:]
|
||||
}
|
||||
25
engine/transcoder/ffmpeg_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package transcoder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestTranscoder(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelCritical)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Transcoder Suite")
|
||||
}
|
||||
|
||||
var _ = Describe("createTranscodeCommand", func() {
|
||||
It("creates a valid command line", func() {
|
||||
cmd, args := createTranscodeCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, "")
|
||||
Expect(cmd).To(Equal("ffmpeg"))
|
||||
Expect(args).To(Equal([]string{"-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
})
|
||||
})
|
||||
@@ -7,11 +7,12 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/engine/auth"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
type Users interface {
|
||||
Authenticate(ctx context.Context, username, password, token, salt string) (*model.User, error)
|
||||
Authenticate(ctx context.Context, username, password, token, salt, jwt string) (*model.User, error)
|
||||
}
|
||||
|
||||
func NewUsers(ds model.DataStore) Users {
|
||||
@@ -22,7 +23,7 @@ type users struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func (u *users) Authenticate(ctx context.Context, username, pass, token, salt string) (*model.User, error) {
|
||||
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
|
||||
@@ -33,6 +34,9 @@ func (u *users) Authenticate(ctx context.Context, username, pass, token, salt st
|
||||
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 {
|
||||
|
||||
@@ -3,6 +3,7 @@ package engine
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/navidrome/engine/auth"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo"
|
||||
@@ -19,20 +20,20 @@ var _ = Describe("Users", func() {
|
||||
|
||||
Context("Plaintext password", func() {
|
||||
It("authenticates with plaintext password ", func() {
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "wordpass", "", "")
|
||||
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", "", "")
|
||||
_, 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", "", "")
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "enc:776f726470617373", "", "", "")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
|
||||
})
|
||||
@@ -40,13 +41,41 @@ var _ = Describe("Users", func() {
|
||||
|
||||
Context("Token based authentication", func() {
|
||||
It("authenticates with token based authentication", func() {
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "", "23b342970e25c7928831c3317edd0b67", "retnlmjetrymazgkt")
|
||||
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", "")
|
||||
_, 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,6 +1,9 @@
|
||||
package engine
|
||||
|
||||
import "github.com/google/wire"
|
||||
import (
|
||||
"github.com/deluan/navidrome/engine/transcoder"
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
var Set = wire.NewSet(
|
||||
NewBrowser,
|
||||
@@ -12,4 +15,9 @@ var Set = wire.NewSet(
|
||||
NewSearch,
|
||||
NewNowPlayingRepository,
|
||||
NewUsers,
|
||||
NewMediaStreamer,
|
||||
transcoder.New,
|
||||
NewTranscodingCache,
|
||||
NewImageCache,
|
||||
NewPlayers,
|
||||
)
|
||||
|
||||
41
go.mod
@@ -1,35 +1,44 @@
|
||||
module github.com/deluan/navidrome
|
||||
|
||||
go 1.13
|
||||
go 1.14
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v0.3.1 // indirect
|
||||
github.com/Masterminds/squirrel v1.1.0
|
||||
github.com/astaxie/beego v1.12.0
|
||||
github.com/Masterminds/squirrel v1.2.0
|
||||
github.com/astaxie/beego v1.12.1
|
||||
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible
|
||||
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4
|
||||
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/djherbis/fscache v0.10.0
|
||||
github.com/dustin/go-humanize v1.0.0
|
||||
github.com/fatih/camelcase v0.0.0-20160318181535-f6a740d52f96 // indirect
|
||||
github.com/fatih/structs v1.0.0 // indirect
|
||||
github.com/go-chi/chi v4.0.3+incompatible
|
||||
github.com/go-chi/cors v1.0.0
|
||||
github.com/go-chi/jwtauth v4.0.3+incompatible
|
||||
github.com/go-chi/chi v4.1.0+incompatible
|
||||
github.com/go-chi/cors v1.0.1
|
||||
github.com/go-chi/jwtauth v4.0.4+incompatible
|
||||
github.com/go-sql-driver/mysql v1.5.0 // indirect
|
||||
github.com/golang/protobuf v1.3.1 // indirect
|
||||
github.com/google/uuid v1.1.1
|
||||
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
|
||||
github.com/lib/pq v1.3.0 // indirect
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible
|
||||
github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5
|
||||
github.com/onsi/ginkgo v1.11.0
|
||||
github.com/onsi/gomega v1.8.1
|
||||
github.com/sirupsen/logrus v1.4.2
|
||||
github.com/smartystreets/assertions v1.0.1 // indirect
|
||||
github.com/smartystreets/goconvey v1.6.4
|
||||
github.com/onsi/ginkgo v1.12.0
|
||||
github.com/onsi/gomega v1.9.0
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pressly/goose v2.6.0+incompatible
|
||||
github.com/sirupsen/logrus v1.5.0
|
||||
github.com/stretchr/testify v1.4.0 // indirect
|
||||
golang.org/x/sys v0.0.0-20200107162124-548cf772de50 // indirect
|
||||
google.golang.org/appengine v1.6.5 // indirect
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2 // indirect
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 // indirect
|
||||
golang.org/x/text v0.3.2 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
|
||||
gopkg.in/djherbis/atime.v1 v1.0.0 // indirect
|
||||
gopkg.in/djherbis/stream.v1 v1.2.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.2.8 // indirect
|
||||
)
|
||||
|
||||
85
go.sum
@@ -1,11 +1,11 @@
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/Knetic/govaluate v3.0.0+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
|
||||
github.com/Masterminds/squirrel v1.1.0 h1:baP1qLdoQCeTw3ifCdOq2dkYc6vGcmRdaociKLbEJXs=
|
||||
github.com/Masterminds/squirrel v1.1.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
|
||||
github.com/Masterminds/squirrel v1.2.0 h1:K1NhbTO21BWG47IVR0OnIZuE0LZcXAYqywrC3Ko53KI=
|
||||
github.com/Masterminds/squirrel v1.2.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
|
||||
github.com/OwnLocal/goes v1.0.0/go.mod h1:8rIFjBGTue3lCU0wplczcUgt9Gxgrkkrw7etMIcn8TM=
|
||||
github.com/astaxie/beego v1.12.0 h1:MRhVoeeye5N+Flul5PoVfD9CslfdoH+xqC/xvSQ5u2Y=
|
||||
github.com/astaxie/beego v1.12.0/go.mod h1:fysx+LZNZKnvh4GED/xND7jWtjCR6HzydR2Hh2Im57o=
|
||||
github.com/astaxie/beego v1.12.1 h1:dfpuoxpzLVgclveAXe4PyNKqkzgm5zF4tgF2B3kkM2I=
|
||||
github.com/astaxie/beego v1.12.1/go.mod h1:kPBWpSANNbSdIqOc8SUL9h+1oyBMZhROeYsXQDbidWQ=
|
||||
github.com/beego/goyaml2 v0.0.0-20130207012346-5545475820dd/go.mod h1:1b+Y/CofkYwXMUU0OhQqGvsY2Bvgr4j6jfT699wyZKQ=
|
||||
github.com/beego/x2j v0.0.0-20131220205130-a0352aadc542/go.mod h1:kSeGC/p1AbBiEp5kat81+DSQrZenVBZXklMLaELspWU=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60=
|
||||
@@ -22,12 +22,18 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4 h1:VSoAcWJvj656TSyWbJ5KuGsi/J8dO5+iO9+5/7I8wao=
|
||||
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
||||
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0 h1:qHX6TTBIsrpg0JkYNkoePIpstV37lhRVLj23bHUDNwk=
|
||||
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8 h1:nmFnmD8VZXkjDHIb1Gnfz50cgzUvGN72zLjPRXBW/hU=
|
||||
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8/go.mod h1:SniNVYuaD1jmdEEvi+7ywb1QFR7agjeTdGKyFb0p7Rw=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/djherbis/fscache v0.10.0 h1:+O3s3LwKL1Jfz7txRHpgKOz8/krh9w+NzfzxtFdbsQg=
|
||||
github.com/djherbis/fscache v0.10.0/go.mod h1:yyPYtkNnnPXsW+81lAcQS6yab3G2CRfnPLotBvtbf0c=
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8=
|
||||
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
|
||||
github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
|
||||
@@ -37,15 +43,17 @@ github.com/fatih/structs v1.0.0 h1:BrX964Rv5uQ3wwS+KRUAJCBBw5PQmgJfJ6v4yly5QwU=
|
||||
github.com/fatih/structs v1.0.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/go-chi/chi v4.0.3+incompatible h1:gakN3pDJnzZN5jqFV2TEdF66rTfKeITyR8qu6ekICEY=
|
||||
github.com/go-chi/chi v4.0.3+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||
github.com/go-chi/cors v1.0.0 h1:e6x8k7uWbUwYs+aXDoiUzeQFT6l0cygBYyNhD7/1Tg0=
|
||||
github.com/go-chi/cors v1.0.0/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
|
||||
github.com/go-chi/jwtauth v4.0.3+incompatible h1:hPhobLUgh7fMpA1qUDdId14u2Z93M22fCNPMVLNWeHU=
|
||||
github.com/go-chi/jwtauth v4.0.3+incompatible/go.mod h1:Q5EIArY/QnD6BdS+IyDw7B2m6iNbnPxtfd6/BcmtWbs=
|
||||
github.com/go-chi/chi v4.1.0+incompatible h1:ETj3cggsVIY2Xao5ExCu6YhEh5MD6JTfcBzS37R260w=
|
||||
github.com/go-chi/chi v4.1.0+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||
github.com/go-chi/cors v1.0.1 h1:56TT/uWGoLWZpnMI/AwAmCneikXr5eLsiIq27wrKecw=
|
||||
github.com/go-chi/cors v1.0.1/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
|
||||
github.com/go-chi/jwtauth v4.0.4+incompatible h1:LGIxg6YfvSBzxU2BljXbrzVc1fMlgqSKBQgKOGAVtPY=
|
||||
github.com/go-chi/jwtauth v4.0.4+incompatible/go.mod h1:Q5EIArY/QnD6BdS+IyDw7B2m6iNbnPxtfd6/BcmtWbs=
|
||||
github.com/go-redis/redis v6.14.2+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
|
||||
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/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
@@ -87,18 +95,22 @@ github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5 h1:BvoENQQU+fZ9uukda/RzCAL/191HHwJA5b13R6diVlY=
|
||||
github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.11.0 h1:JAKSXpt1YjtLA7YpPiqO9ss6sNXEsPfSGdwN0UHqzrw=
|
||||
github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.8.1 h1:C5Dqfs/LeauYDX0jJXIe2SWmwCbGzx9yF8C8xy3Lh34=
|
||||
github.com/onsi/gomega v1.8.1/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
|
||||
github.com/onsi/ginkgo v1.12.0 h1:Iw5WCbBcaAAd0fpRb1c9r5YCylv4XDoCSigm1zLevwU=
|
||||
github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.9.0 h1:R1uwffexN6Pr340GtYRIdZmAiN4J+iw6WG4wog1DUXg=
|
||||
github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
|
||||
github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
|
||||
github.com/pkg/errors v0.8.0/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose v2.6.0+incompatible h1:3f8zIQ8rfgP9tyI0Hmcs2YNAqUCL1c+diLe3iU8Qd/k=
|
||||
github.com/pressly/goose v2.6.0+incompatible/go.mod h1:m+QHWCqxR3k8D9l7qfzuC/djtlfzxr34mozWDYEu1z8=
|
||||
github.com/siddontang/go v0.0.0-20180604090527-bdc77568d726 h1:xT+JlYxNGqyT+XcU8iUrN18JYed2TvG9yN5ULG2jATM=
|
||||
github.com/siddontang/go v0.0.0-20180604090527-bdc77568d726/go.mod h1:3yhqj7WBBfRhbBlzyOC3gUxftwsU0u8gqevxwIHQpMw=
|
||||
github.com/siddontang/ledisdb v0.0.0-20181029004158-becf5f38d373 h1:p6IxqQMjab30l4lb9mmkIkkcE1yv6o0SKbPhW5pxqHI=
|
||||
@@ -107,10 +119,10 @@ github.com/siddontang/rdb v0.0.0-20150307021120-fc89ed2e418d h1:NVwnfyR3rENtlz62
|
||||
github.com/siddontang/rdb v0.0.0-20150307021120-fc89ed2e418d/go.mod h1:AMEsy7v5z92TR1JKMkLLoaOQk++LVnOKL3ScbJ8GNGA=
|
||||
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sirupsen/logrus v1.5.0 h1:1N5EYkVAPEywqZRJd7cwnRtCb6xJx7NH3T3WUTF980Q=
|
||||
github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w=
|
||||
github.com/smartystreets/assertions v1.0.1/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM=
|
||||
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/ssdb/gossdb v0.0.0-20180723034631-88f6b59b84ec/go.mod h1:QBvMkMya+gXctz3kmljlUCu/yB3GZ6oee+dUozsezQE=
|
||||
@@ -122,23 +134,31 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
|
||||
github.com/syndtr/goleveldb v0.0.0-20181127023241-353a9fca669c h1:3eGShk3EQf5gJCYW+WzA0TEJQd37HLOmlYF7N0YJwv0=
|
||||
github.com/syndtr/goleveldb v0.0.0-20181127023241-353a9fca669c/go.mod h1:Z4AUp2Km+PwemOoO/VB5AOx9XSsIItzFjoJlOSiYmn0=
|
||||
github.com/wendal/errors v0.0.0-20130201093226-f66c77a7882b/go.mod h1:Q12BUT7DqIlHRmgv3RskH+UCM/4eqVMgI0EMmlSpAXc=
|
||||
golang.org/x/crypto v0.0.0-20181127143415-eb0de9b17e85/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65 h1:+rhAzEzT3f4JtomfC371qB+0Ola2caSKcY69NUBZrRQ=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2 h1:CCH4IOTTfewWjGOlSp+zGcjutRKlBEZQ6wTn8ozI/nI=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200107162124-548cf772de50 h1:YvQ10rzcqWXLlJZ3XCUoO25savxmscf4+SC+ZqiCHhA=
|
||||
golang.org/x/sys v0.0.0-20200107162124-548cf772de50/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 h1:LfCXLvNmTYH9kEmVgqbnsWfruoXZIrh4YBgqVHtDvw0=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||
@@ -148,14 +168,21 @@ golang.org/x/tools v0.0.0-20190328211700-ab21143f2384 h1:TFlARGu6Czu1z7q93HTxcP1
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b h1:NVD8gBK33xpdqCaZVVtd6OFJp+3dxkXuz7+U7KaVN6s=
|
||||
golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20200117065230-39095c1d176c h1:FodBYPZKH5tAN2O60HlglMwXGAeV/4k+NKbli79M/2c=
|
||||
golang.org/x/tools v0.0.0-20200117065230-39095c1d176c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/djherbis/atime.v1 v1.0.0 h1:eMRqB/JrLKocla2PBPKgQYg/p5UG4L6AUAs92aP7F60=
|
||||
gopkg.in/djherbis/atime.v1 v1.0.0/go.mod h1:hQIUStKmJfvf7xdh/wtK84qe+DsTV5LnA9lzxxtPpJ8=
|
||||
gopkg.in/djherbis/stream.v1 v1.2.0 h1:3tZuXO+RK8opjw8/BJr780h+eAPwOFfLHCKRKyYxk3s=
|
||||
gopkg.in/djherbis/stream.v1 v1.2.0/go.mod h1:aEV8CBVRmSpLamVJfM903Npic1IKmb2qS30VAZ+sssg=
|
||||
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
@@ -165,3 +192,5 @@ gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
|
||||
13
lefthook.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
pre-push:
|
||||
commands:
|
||||
unit-tests:
|
||||
tags: tests
|
||||
run: go test ./...
|
||||
|
||||
pre-commit:
|
||||
parallel: false
|
||||
commands:
|
||||
gofmt:
|
||||
tags: style
|
||||
glob: "*.go"
|
||||
run: gofmt -w {staged_files}
|
||||
@@ -27,6 +27,7 @@ const (
|
||||
var (
|
||||
currentLevel Level
|
||||
defaultLogger = logrus.New()
|
||||
logSourceLine = false
|
||||
)
|
||||
|
||||
// SetLevel sets the global log level used by the simple logger.
|
||||
@@ -35,7 +36,7 @@ func SetLevel(l Level) {
|
||||
logrus.SetLevel(logrus.Level(l))
|
||||
}
|
||||
|
||||
func SerLevelString(l string) {
|
||||
func SetLevelString(l string) {
|
||||
envLevel := strings.ToLower(l)
|
||||
var level Level
|
||||
switch envLevel {
|
||||
@@ -55,6 +56,10 @@ func SerLevelString(l string) {
|
||||
SetLevel(level)
|
||||
}
|
||||
|
||||
func SetLogSourceLine(enabled bool) {
|
||||
logSourceLine = enabled
|
||||
}
|
||||
|
||||
func NewContext(ctx context.Context, keyValuePairs ...interface{}) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
@@ -132,7 +137,7 @@ func parseArgs(args []interface{}) (*logrus.Entry, string) {
|
||||
kvPairs := args[1:]
|
||||
l = addFields(l, kvPairs)
|
||||
}
|
||||
if currentLevel >= LevelTrace {
|
||||
if logSourceLine {
|
||||
_, file, line, ok := runtime.Caller(2)
|
||||
if !ok {
|
||||
file = "???"
|
||||
|
||||
162
log/log_test.go
@@ -6,99 +6,175 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestLog(t *testing.T) {
|
||||
SetLevel(LevelInfo)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Log Suite")
|
||||
}
|
||||
|
||||
Convey("Test Logger", t, func() {
|
||||
l, hook := test.NewNullLogger()
|
||||
var _ = Describe("Logger", func() {
|
||||
var l *logrus.Logger
|
||||
var hook *test.Hook
|
||||
|
||||
BeforeEach(func() {
|
||||
l, hook = test.NewNullLogger()
|
||||
SetLevel(LevelInfo)
|
||||
SetDefaultLogger(l)
|
||||
})
|
||||
|
||||
Convey("Plain message", func() {
|
||||
Context("Logging", func() {
|
||||
It("logs a simple message", func() {
|
||||
Error("Simple Message")
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Simple Message")
|
||||
So(hook.LastEntry().Data, ShouldBeEmpty)
|
||||
Expect(hook.LastEntry().Message).To(Equal("Simple Message"))
|
||||
Expect(hook.LastEntry().Data).To(BeEmpty())
|
||||
})
|
||||
|
||||
Convey("Passing nil as context", func() {
|
||||
It("logs a message when context is nil", func() {
|
||||
Error(nil, "Simple Message")
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Simple Message")
|
||||
So(hook.LastEntry().Data, ShouldBeEmpty)
|
||||
Expect(hook.LastEntry().Message).To(Equal("Simple Message"))
|
||||
Expect(hook.LastEntry().Data).To(BeEmpty())
|
||||
})
|
||||
|
||||
Convey("Message with two kv pairs", func() {
|
||||
XIt("Empty context", func() {
|
||||
Error(context.Background(), "Simple Message")
|
||||
Expect(hook.LastEntry().Message).To(Equal("Simple Message"))
|
||||
Expect(hook.LastEntry().Data).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("logs messages with two kv pairs", func() {
|
||||
Error("Simple Message", "key1", "value1", "key2", "value2")
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Simple Message")
|
||||
So(hook.LastEntry().Data["key1"], ShouldEqual, "value1")
|
||||
So(hook.LastEntry().Data["key2"], ShouldEqual, "value2")
|
||||
So(hook.LastEntry().Data, ShouldHaveLength, 2)
|
||||
Expect(hook.LastEntry().Message).To(Equal("Simple Message"))
|
||||
Expect(hook.LastEntry().Data["key1"]).To(Equal("value1"))
|
||||
Expect(hook.LastEntry().Data["key2"]).To(Equal("value2"))
|
||||
Expect(hook.LastEntry().Data).To(HaveLen(2))
|
||||
})
|
||||
|
||||
Convey("Only error", func() {
|
||||
It("logs error objects as simple messages", func() {
|
||||
Error(errors.New("error test"))
|
||||
So(hook.LastEntry().Message, ShouldEqual, "error test")
|
||||
So(hook.LastEntry().Data, ShouldBeEmpty)
|
||||
Expect(hook.LastEntry().Message).To(Equal("error test"))
|
||||
Expect(hook.LastEntry().Data).To(BeEmpty())
|
||||
})
|
||||
|
||||
Convey("Error as last argument", func() {
|
||||
It("logs errors passed as last argument", func() {
|
||||
Error("Error scrobbling track", "id", 1, errors.New("some issue"))
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Error scrobbling track")
|
||||
So(hook.LastEntry().Data["id"], ShouldEqual, 1)
|
||||
So(hook.LastEntry().Data["error"], ShouldEqual, "some issue")
|
||||
So(hook.LastEntry().Data, ShouldHaveLength, 2)
|
||||
Expect(hook.LastEntry().Message).To(Equal("Error scrobbling track"))
|
||||
Expect(hook.LastEntry().Data["id"]).To(Equal(1))
|
||||
Expect(hook.LastEntry().Data["error"]).To(Equal("some issue"))
|
||||
Expect(hook.LastEntry().Data).To(HaveLen(2))
|
||||
})
|
||||
|
||||
Convey("Passing a request", func() {
|
||||
It("can get data from the request's context", func() {
|
||||
ctx := NewContext(nil, "foo", "bar")
|
||||
req := httptest.NewRequest("get", "/", nil).WithContext(ctx)
|
||||
|
||||
Error(req, "Simple Message", "key1", "value1")
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Simple Message")
|
||||
So(hook.LastEntry().Data["foo"], ShouldEqual, "bar")
|
||||
So(hook.LastEntry().Data["key1"], ShouldEqual, "value1")
|
||||
So(hook.LastEntry().Data, ShouldHaveLength, 2)
|
||||
|
||||
Expect(hook.LastEntry().Message).To(Equal("Simple Message"))
|
||||
Expect(hook.LastEntry().Data["foo"]).To(Equal("bar"))
|
||||
Expect(hook.LastEntry().Data["key1"]).To(Equal("value1"))
|
||||
Expect(hook.LastEntry().Data).To(HaveLen(2))
|
||||
})
|
||||
|
||||
Convey("Skip if level is lower", func() {
|
||||
It("does not log anything if level is lower", func() {
|
||||
SetLevel(LevelError)
|
||||
Info("Simple Message")
|
||||
So(hook.LastEntry(), ShouldBeNil)
|
||||
Expect(hook.LastEntry()).To(BeNil())
|
||||
})
|
||||
|
||||
It("logs source file and line number, if requested", func() {
|
||||
SetLogSourceLine(true)
|
||||
Error("A crash happened")
|
||||
Expect(hook.LastEntry().Message).To(Equal("A crash happened"))
|
||||
// NOTE: This assertions breaks if the line number changes
|
||||
Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:92"))
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Test extractLogger", t, func() {
|
||||
Convey("It returns an error if the context is nil", func() {
|
||||
Context("Levels", func() {
|
||||
BeforeEach(func() {
|
||||
SetLevel(LevelTrace)
|
||||
})
|
||||
It("logs error messages", func() {
|
||||
Error("msg")
|
||||
Expect(hook.LastEntry().Level).To(Equal(logrus.ErrorLevel))
|
||||
})
|
||||
It("logs warn messages", func() {
|
||||
Warn("msg")
|
||||
Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel))
|
||||
})
|
||||
It("logs info messages", func() {
|
||||
Info("msg")
|
||||
Expect(hook.LastEntry().Level).To(Equal(logrus.InfoLevel))
|
||||
})
|
||||
It("logs debug messages", func() {
|
||||
Debug("msg")
|
||||
Expect(hook.LastEntry().Level).To(Equal(logrus.DebugLevel))
|
||||
})
|
||||
It("logs info messages", func() {
|
||||
Trace("msg")
|
||||
Expect(hook.LastEntry().Level).To(Equal(logrus.TraceLevel))
|
||||
})
|
||||
})
|
||||
|
||||
Context("extractLogger", func() {
|
||||
It("returns an error if the context is nil", func() {
|
||||
_, err := extractLogger(nil)
|
||||
So(err, ShouldNotBeNil)
|
||||
Expect(err).ToNot(BeNil())
|
||||
})
|
||||
|
||||
Convey("It returns an error if the context is a string", func() {
|
||||
It("returns an error if the context is a string", func() {
|
||||
_, err := extractLogger("any msg")
|
||||
So(err, ShouldNotBeNil)
|
||||
Expect(err).ToNot(BeNil())
|
||||
})
|
||||
|
||||
Convey("It returns the logger from context if it has one", func() {
|
||||
It("returns the logger from context if it has one", func() {
|
||||
logger := logrus.NewEntry(logrus.New())
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, "logger", logger)
|
||||
|
||||
l, err := extractLogger(ctx)
|
||||
So(err, ShouldBeNil)
|
||||
So(l, ShouldEqual, logger)
|
||||
Expect(extractLogger(ctx)).To(Equal(logger))
|
||||
})
|
||||
|
||||
Convey("It returns the logger from request's context if it has one", func() {
|
||||
It("returns the logger from request's context if it has one", func() {
|
||||
logger := logrus.NewEntry(logrus.New())
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, "logger", logger)
|
||||
req := httptest.NewRequest("get", "/", nil).WithContext(ctx)
|
||||
l, err := extractLogger(req)
|
||||
So(err, ShouldBeNil)
|
||||
So(l, ShouldEqual, logger)
|
||||
|
||||
Expect(extractLogger(req)).To(Equal(logger))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Context("SetLevelString", func() {
|
||||
It("converts Critical level", func() {
|
||||
SetLevelString("Critical")
|
||||
Expect(CurrentLevel()).To(Equal(LevelCritical))
|
||||
})
|
||||
It("converts Error level", func() {
|
||||
SetLevelString("ERROR")
|
||||
Expect(CurrentLevel()).To(Equal(LevelError))
|
||||
})
|
||||
It("converts Warn level", func() {
|
||||
SetLevelString("warn")
|
||||
Expect(CurrentLevel()).To(Equal(LevelWarn))
|
||||
})
|
||||
It("converts Info level", func() {
|
||||
SetLevelString("info")
|
||||
Expect(CurrentLevel()).To(Equal(LevelInfo))
|
||||
})
|
||||
It("converts Debug level", func() {
|
||||
SetLevelString("debug")
|
||||
Expect(CurrentLevel()).To(Equal(LevelDebug))
|
||||
})
|
||||
It("converts Trace level", func() {
|
||||
SetLevelString("trace")
|
||||
Expect(CurrentLevel()).To(Equal(LevelTrace))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
17
main.go
@@ -1,18 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/db"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if !conf.Server.DevDisableBanner {
|
||||
ShowBanner()
|
||||
}
|
||||
println(consts.Banner())
|
||||
|
||||
conf.Load()
|
||||
db.EnsureLatestVersion()
|
||||
|
||||
subsonic, err := CreateSubsonicAPIRouter()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Could not create the Subsonic API router. Aborting! err=%v", err))
|
||||
}
|
||||
a := CreateServer(conf.Server.MusicFolder)
|
||||
a.MountRouter("/rest", CreateSubsonicAPIRouter())
|
||||
a.MountRouter("/app", CreateAppRouter("/app"))
|
||||
a.MountRouter(consts.URLPathSubsonicAPI, subsonic)
|
||||
a.MountRouter(consts.URLPathUI, CreateAppRouter())
|
||||
a.Run(":" + conf.Server.Port)
|
||||
}
|
||||
|
||||
@@ -3,35 +3,45 @@ package model
|
||||
import "time"
|
||||
|
||||
type Album struct {
|
||||
ID string
|
||||
Name string
|
||||
ArtistID string
|
||||
CoverArtPath string
|
||||
CoverArtId string
|
||||
Artist string
|
||||
AlbumArtist string
|
||||
Year int
|
||||
Compilation bool
|
||||
SongCount int
|
||||
Duration int
|
||||
Genre string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
CoverArtPath string `json:"coverArtPath"`
|
||||
CoverArtId string `json:"coverArtId"`
|
||||
ArtistID string `json:"artistId" orm:"pk;column(artist_id)"`
|
||||
Artist string `json:"artist"`
|
||||
AlbumArtistID string `json:"albumArtistId" orm:"pk;column(album_artist_id)"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
MaxYear int `json:"maxYear"`
|
||||
MinYear int `json:"minYear"`
|
||||
Compilation bool `json:"compilation"`
|
||||
SongCount int `json:"songCount"`
|
||||
Duration float32 `json:"duration"`
|
||||
Genre string `json:"genre"`
|
||||
FullText string `json:"fullText"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
// Annotations
|
||||
PlayCount int `json:"playCount" orm:"-"`
|
||||
PlayDate time.Time `json:"playDate" orm:"-"`
|
||||
Rating int `json:"rating" orm:"-"`
|
||||
Starred bool `json:"starred" orm:"-"`
|
||||
StarredAt time.Time `json:"starredAt" orm:"-"`
|
||||
}
|
||||
|
||||
type Albums []Album
|
||||
|
||||
type AlbumRepository interface {
|
||||
CountAll() (int64, error)
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
Exists(id string) (bool, error)
|
||||
Put(m *Album) error
|
||||
Get(id string) (*Album, error)
|
||||
FindByArtist(artistId string) (Albums, error)
|
||||
FindByArtist(albumArtistId string) (Albums, error)
|
||||
GetAll(...QueryOptions) (Albums, error)
|
||||
GetMap(ids []string) (map[string]Album, error)
|
||||
GetRandom(...QueryOptions) (Albums, error)
|
||||
GetStarred(userId string, options ...QueryOptions) (Albums, error)
|
||||
GetStarred(options ...QueryOptions) (Albums, error)
|
||||
Search(q string, offset int, size int) (Albums, error)
|
||||
Refresh(ids ...string) error
|
||||
PurgeEmpty() error
|
||||
AnnotatedRepository
|
||||
}
|
||||
|
||||
@@ -2,32 +2,12 @@ package model
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
ArtistItemType = "artist"
|
||||
AlbumItemType = "album"
|
||||
MediaItemType = "mediaFile"
|
||||
)
|
||||
|
||||
type Annotation struct {
|
||||
AnnotationID string
|
||||
UserID string
|
||||
ItemID string
|
||||
ItemType string
|
||||
PlayCount int
|
||||
PlayDate time.Time
|
||||
Rating int
|
||||
Starred bool
|
||||
StarredAt time.Time
|
||||
type AnnotatedRepository interface {
|
||||
IncPlayCount(itemID string, ts time.Time) error
|
||||
SetStar(starred bool, itemIDs ...string) error
|
||||
SetRating(rating int, itemID string) error
|
||||
}
|
||||
|
||||
type AnnotationMap map[string]Annotation
|
||||
|
||||
type AnnotationRepository interface {
|
||||
Get(userID, itemType string, itemID string) (*Annotation, error)
|
||||
GetAll(userID, itemType string, options ...QueryOptions) ([]Annotation, error)
|
||||
GetMap(userID, itemType string, itemID []string) (AnnotationMap, error)
|
||||
Delete(userID, itemType string, itemID ...string) error
|
||||
IncPlayCount(userID, itemType string, itemID string, ts time.Time) error
|
||||
SetStar(starred bool, userID, itemType string, ids ...string) error
|
||||
SetRating(rating int, userID, itemType string, itemID string) error
|
||||
}
|
||||
// While I can't find a better way to make these fields optional in the models, I keep this list here
|
||||
// to be used in other packages
|
||||
var AnnotationFields = []string{"playCount", "playDate", "rating", "starred", "starredAt"}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Artist struct {
|
||||
ID string
|
||||
Name string
|
||||
AlbumCount int
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
AlbumCount int `json:"albumCount" orm:"column(album_count)"`
|
||||
FullText string `json:"fullText"`
|
||||
|
||||
// Annotations
|
||||
PlayCount int `json:"playCount" orm:"-"`
|
||||
PlayDate time.Time `json:"playDate" orm:"-"`
|
||||
Rating int `json:"rating" orm:"-"`
|
||||
Starred bool `json:"starred" orm:"-"`
|
||||
StarredAt time.Time `json:"starredAt" orm:"-"`
|
||||
}
|
||||
|
||||
type Artists []Artist
|
||||
|
||||
type ArtistIndex struct {
|
||||
@@ -14,14 +25,14 @@ type ArtistIndex struct {
|
||||
type ArtistIndexes []ArtistIndex
|
||||
|
||||
type ArtistRepository interface {
|
||||
CountAll() (int64, error)
|
||||
CountAll(options ...QueryOptions) (int64, error)
|
||||
Exists(id string) (bool, error)
|
||||
Put(m *Artist) error
|
||||
Get(id string) (*Artist, error)
|
||||
GetStarred(userId string, options ...QueryOptions) (Artists, error)
|
||||
SetStar(star bool, ids ...string) error
|
||||
GetStarred(options ...QueryOptions) (Artists, error)
|
||||
Search(q string, offset int, size int) (Artists, error)
|
||||
Refresh(ids ...string) error
|
||||
GetIndex() (ArtistIndexes, error)
|
||||
PurgeEmpty() error
|
||||
AnnotatedRepository
|
||||
}
|
||||
|
||||
@@ -3,24 +3,20 @@ package model
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
)
|
||||
|
||||
// Filters use the same operators as Beego ORM: See https://beego.me/docs/mvc/model/query.md#operators
|
||||
// Ex: var q = QueryOptions{Filters: Filters{"name__istartswith": "Deluan","age__gt": 25}}
|
||||
// All conditions will be ANDed together
|
||||
// TODO Implement filter in repositories' methods
|
||||
type QueryOptions struct {
|
||||
Sort string
|
||||
Order string
|
||||
Max int
|
||||
Offset int
|
||||
Filters map[string]interface{}
|
||||
Filters squirrel.Sqlizer
|
||||
}
|
||||
|
||||
type ResourceRepository interface {
|
||||
rest.Repository
|
||||
rest.Persistable
|
||||
}
|
||||
|
||||
type DataStore interface {
|
||||
@@ -32,9 +28,11 @@ type DataStore interface {
|
||||
Playlist(ctx context.Context) PlaylistRepository
|
||||
Property(ctx context.Context) PropertyRepository
|
||||
User(ctx context.Context) UserRepository
|
||||
Annotation(ctx context.Context) AnnotationRepository
|
||||
Transcoding(ctx context.Context) TranscodingRepository
|
||||
Player(ctx context.Context) PlayerRepository
|
||||
|
||||
Resource(ctx context.Context, model interface{}) ResourceRepository
|
||||
|
||||
WithTx(func(tx DataStore) error) error
|
||||
GC(ctx context.Context) error
|
||||
}
|
||||
|
||||
@@ -6,26 +6,35 @@ import (
|
||||
)
|
||||
|
||||
type MediaFile struct {
|
||||
ID string
|
||||
Path string
|
||||
Title string
|
||||
Album string
|
||||
Artist string
|
||||
ArtistID string
|
||||
AlbumArtist string
|
||||
AlbumID string
|
||||
HasCoverArt bool
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
Year int
|
||||
Size int
|
||||
Suffix string
|
||||
Duration int
|
||||
BitRate int
|
||||
Genre string
|
||||
Compilation bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID string `json:"id" orm:"pk;column(id)"`
|
||||
Path string `json:"path"`
|
||||
Title string `json:"title"`
|
||||
Album string `json:"album"`
|
||||
ArtistID string `json:"artistId" orm:"pk;column(artist_id)"`
|
||||
Artist string `json:"artist"`
|
||||
AlbumArtistID string `json:"albumArtistId"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
AlbumID string `json:"albumId" orm:"pk;column(album_id)"`
|
||||
HasCoverArt bool `json:"hasCoverArt"`
|
||||
TrackNumber int `json:"trackNumber"`
|
||||
DiscNumber int `json:"discNumber"`
|
||||
Year int `json:"year"`
|
||||
Size int `json:"size"`
|
||||
Suffix string `json:"suffix"`
|
||||
Duration float32 `json:"duration"`
|
||||
BitRate int `json:"bitRate"`
|
||||
Genre string `json:"genre"`
|
||||
FullText string `json:"fullText"`
|
||||
Compilation bool `json:"compilation"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
// Annotations
|
||||
PlayCount int `json:"playCount" orm:"-"`
|
||||
PlayDate time.Time `json:"playDate" orm:"-"`
|
||||
Rating int `json:"rating" orm:"-"`
|
||||
Starred bool `json:"starred" orm:"-"`
|
||||
StarredAt time.Time `json:"starredAt" orm:"-"`
|
||||
}
|
||||
|
||||
func (mf *MediaFile) ContentType() string {
|
||||
@@ -35,15 +44,17 @@ func (mf *MediaFile) ContentType() string {
|
||||
type MediaFiles []MediaFile
|
||||
|
||||
type MediaFileRepository interface {
|
||||
CountAll() (int64, error)
|
||||
CountAll(options ...QueryOptions) (int64, error)
|
||||
Exists(id string) (bool, error)
|
||||
Put(m *MediaFile) error
|
||||
Get(id string) (*MediaFile, error)
|
||||
FindByAlbum(albumId string) (MediaFiles, error)
|
||||
FindByPath(path string) (MediaFiles, error)
|
||||
GetStarred(userId string, options ...QueryOptions) (MediaFiles, 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
|
||||
|
||||
AnnotatedRepository
|
||||
}
|
||||
|
||||
@@ -9,5 +9,6 @@ type MediaFolder struct {
|
||||
type MediaFolders []MediaFolder
|
||||
|
||||
type MediaFolderRepository interface {
|
||||
Get(id string) (*MediaFolder, error)
|
||||
GetAll() (MediaFolders, error)
|
||||
}
|
||||
|
||||
25
model/player.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Player struct {
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
UserName string `json:"userName"`
|
||||
Client string `json:"client"`
|
||||
IPAddress string `json:"ipAddress"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
TranscodingId string `json:"transcodingId"`
|
||||
MaxBitRate int `json:"maxBitRate"`
|
||||
}
|
||||
|
||||
type Players []Player
|
||||
|
||||
type PlayerRepository interface {
|
||||
Get(id string) (*Player, error)
|
||||
FindByName(client, userName string) (*Player, error)
|
||||
Put(p *Player) error
|
||||
}
|
||||
@@ -4,7 +4,7 @@ type Playlist struct {
|
||||
ID string
|
||||
Name string
|
||||
Comment string
|
||||
Duration int
|
||||
Duration float32
|
||||
Owner string
|
||||
Public bool
|
||||
Tracks MediaFiles
|
||||
|
||||
18
model/transcoding.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package model
|
||||
|
||||
type Transcoding struct {
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
TargetFormat string `json:"targetFormat"`
|
||||
Command string `json:"command"`
|
||||
DefaultBitRate int `json:"defaultBitRate"`
|
||||
}
|
||||
|
||||
type Transcodings []Transcoding
|
||||
|
||||
type TranscodingRepository interface {
|
||||
Get(id string) (*Transcoding, error)
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
Put(*Transcoding) error
|
||||
FindByFormat(format string) (*Transcoding, error)
|
||||
}
|
||||
@@ -3,22 +3,26 @@ package model
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
UserName string
|
||||
Name string
|
||||
Email string
|
||||
Password string
|
||||
IsAdmin bool
|
||||
LastLoginAt *time.Time
|
||||
LastAccessAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
UserName string `json:"userName"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
LastLoginAt *time.Time `json:"lastLoginAt"`
|
||||
LastAccessAt *time.Time `json:"lastAccessAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
// TODO ChangePassword string `json:"password"`
|
||||
}
|
||||
|
||||
type Users []User
|
||||
|
||||
type UserRepository interface {
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
Get(id string) (*User, error)
|
||||
Put(*User) error
|
||||
// FindByUsername must be case-insensitive
|
||||
FindByUsername(username string) (*User, error)
|
||||
UpdateLastLoginAt(id string) error
|
||||
UpdateLastAccessAt(id string) error
|
||||
|
||||
@@ -1,220 +1,209 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/rest"
|
||||
)
|
||||
|
||||
type album struct {
|
||||
ID string `json:"id" orm:"pk;column(id)"`
|
||||
Name string `json:"name" orm:"index"`
|
||||
ArtistID string `json:"artistId" orm:"column(artist_id);index"`
|
||||
CoverArtPath string `json:"-"`
|
||||
CoverArtId string `json:"-"`
|
||||
Artist string `json:"artist" orm:"index"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
Year int `json:"year" orm:"index"`
|
||||
Compilation bool `json:"compilation"`
|
||||
SongCount int `json:"songCount"`
|
||||
Duration int `json:"duration"`
|
||||
Genre string `json:"genre" orm:"index"`
|
||||
CreatedAt time.Time `json:"createdAt" orm:"null"`
|
||||
UpdatedAt time.Time `json:"updatedAt" orm:"null"`
|
||||
}
|
||||
|
||||
type albumRepository struct {
|
||||
searchableRepository
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
func NewAlbumRepository(o orm.Ormer) model.AlbumRepository {
|
||||
func NewAlbumRepository(ctx context.Context, o orm.Ormer) model.AlbumRepository {
|
||||
r := &albumRepository{}
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
r.tableName = "album"
|
||||
r.sortMappings = map[string]string{
|
||||
"artist": "compilation asc, album_artist asc, name asc",
|
||||
"random": "RANDOM()",
|
||||
}
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"name": fullTextFilter,
|
||||
"compilation": booleanFilter,
|
||||
"artist_id": artistFilter,
|
||||
"year": yearFilter,
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func yearFilter(field string, value interface{}) Sqlizer {
|
||||
return Or{
|
||||
And{
|
||||
Gt{"min_year": 0},
|
||||
LtOrEq{"min_year": value},
|
||||
GtOrEq{"max_year": value},
|
||||
},
|
||||
Eq{"max_year": value},
|
||||
}
|
||||
}
|
||||
|
||||
func artistFilter(field string, value interface{}) Sqlizer {
|
||||
return Exists("media_file", And{
|
||||
ConcatExpr("album_id=album.id"),
|
||||
Or{
|
||||
Eq{"artist_id": value},
|
||||
Eq{"album_artist_id": value},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
return r.count(Select(), options...)
|
||||
}
|
||||
|
||||
func (r *albumRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(Select().Where(Eq{"id": id}))
|
||||
}
|
||||
|
||||
func (r *albumRepository) Put(a *model.Album) error {
|
||||
ta := album(*a)
|
||||
return r.put(a.ID, a.Name, &ta)
|
||||
a.FullText = r.getFullText(a.Name, a.Artist, a.AlbumArtist)
|
||||
_, err := r.put(a.ID, a)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder {
|
||||
return r.newSelectWithAnnotation("album.id", options...).Columns("*")
|
||||
}
|
||||
|
||||
func (r *albumRepository) Get(id string) (*model.Album, error) {
|
||||
ta := album{ID: id}
|
||||
err := r.ormer.Read(&ta)
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
sq := r.selectAlbum().Where(Eq{"id": id})
|
||||
var res model.Album
|
||||
err := r.queryOne(sq, &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := model.Album(ta)
|
||||
return &a, err
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
func (r *albumRepository) FindByArtist(artistId string) (model.Albums, error) {
|
||||
var albums []album
|
||||
_, err := r.newQuery().Filter("artist_id", artistId).OrderBy("year", "name").All(&albums)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.toAlbums(albums), nil
|
||||
sq := r.selectAlbum().Where(Eq{"album_artist_id": artistId}).OrderBy("max_year")
|
||||
res := model.Albums{}
|
||||
err := r.queryAll(sq, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, error) {
|
||||
var all []album
|
||||
_, err := r.newQuery(options...).All(&all)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.toAlbums(all), nil
|
||||
}
|
||||
|
||||
func (r *albumRepository) GetMap(ids []string) (map[string]model.Album, error) {
|
||||
var all []album
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
_, err := r.newQuery().Filter("id__in", ids).All(&all)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := make(map[string]model.Album)
|
||||
for _, a := range all {
|
||||
res[a.ID] = model.Album(a)
|
||||
}
|
||||
return res, nil
|
||||
sq := r.selectAlbum(options...)
|
||||
res := model.Albums{}
|
||||
err := r.queryAll(sq, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
// TODO Keep order when paginating
|
||||
func (r *albumRepository) GetRandom(options ...model.QueryOptions) (model.Albums, error) {
|
||||
sq := r.newRawQuery(options...)
|
||||
switch r.ormer.Driver().Type() {
|
||||
case orm.DRMySQL:
|
||||
sq = sq.OrderBy("RAND()")
|
||||
default:
|
||||
sq = sq.OrderBy("RANDOM()")
|
||||
}
|
||||
sql, args, err := sq.ToSql()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var results []album
|
||||
_, err = r.ormer.Raw(sql, args...).QueryRows(&results)
|
||||
return r.toAlbums(results), err
|
||||
}
|
||||
|
||||
func (r *albumRepository) toAlbums(all []album) model.Albums {
|
||||
result := make(model.Albums, len(all))
|
||||
for i, a := range all {
|
||||
result[i] = model.Album(a)
|
||||
}
|
||||
return result
|
||||
sq := r.selectAlbum(options...)
|
||||
sq = sq.OrderBy("RANDOM()")
|
||||
results := model.Albums{}
|
||||
err := r.queryAll(sq, &results)
|
||||
return results, err
|
||||
}
|
||||
|
||||
func (r *albumRepository) Refresh(ids ...string) error {
|
||||
type refreshAlbum struct {
|
||||
album
|
||||
model.Album
|
||||
CurrentId string
|
||||
HasCoverArt bool
|
||||
}
|
||||
var albums []refreshAlbum
|
||||
o := r.ormer
|
||||
sql := fmt.Sprintf(`
|
||||
select album_id as id, album as name, f.artist, f.album_artist, f.artist_id, f.compilation, f.genre,
|
||||
max(f.year) as year, sum(f.duration) as duration, max(f.updated_at) as updated_at,
|
||||
min(f.created_at) as created_at, 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
|
||||
from media_file f left outer join album a on f.album_id = a.id
|
||||
where f.album_id in ('%s')
|
||||
group by album_id order by f.id`, strings.Join(ids, "','"))
|
||||
_, err := o.Raw(sql).QueryRows(&albums)
|
||||
sel := Select(`album_id as id, album as name, f.artist, f.album_artist, f.artist_id, f.album_artist_id,
|
||||
f.compilation, f.genre, max(f.year) as max_year, min(f.year) as min_year, sum(f.duration) as duration,
|
||||
count(*) as song_count, a.id as current_id, f.id as cover_art_id, f.path as cover_art_path, f.has_cover_art`).
|
||||
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")
|
||||
err := r.queryAll(sel, &albums)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var toInsert []album
|
||||
var toUpdate []album
|
||||
toInsert := 0
|
||||
toUpdate := 0
|
||||
for _, al := range albums {
|
||||
if !al.HasCoverArt {
|
||||
al.CoverArtId = ""
|
||||
}
|
||||
if al.Compilation {
|
||||
al.AlbumArtist = "Various Artists"
|
||||
al.AlbumArtist = consts.VariousArtists
|
||||
al.AlbumArtistID = consts.VariousArtistsID
|
||||
}
|
||||
if al.AlbumArtist == "" {
|
||||
al.AlbumArtist = al.Artist
|
||||
al.AlbumArtistID = al.ArtistID
|
||||
}
|
||||
al.UpdatedAt = time.Now()
|
||||
if al.CurrentId != "" {
|
||||
toUpdate = append(toUpdate, al.album)
|
||||
toUpdate++
|
||||
} else {
|
||||
toInsert = append(toInsert, al.album)
|
||||
toInsert++
|
||||
al.CreatedAt = time.Now()
|
||||
}
|
||||
err := r.addToIndex(r.tableName, al.ID, al.Name)
|
||||
err := r.Put(&al.Album)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(toInsert) > 0 {
|
||||
n, err := o.InsertMulti(10, toInsert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Debug("Inserted new albums", "num", n)
|
||||
if toInsert > 0 {
|
||||
log.Debug(r.ctx, "Inserted new albums", "totalInserted", toInsert)
|
||||
}
|
||||
if len(toUpdate) > 0 {
|
||||
for _, al := range toUpdate {
|
||||
_, err := o.Update(&al, "name", "artist_id", "cover_art_path", "cover_art_id", "artist", "album_artist",
|
||||
"year", "compilation", "song_count", "duration", "updated_at", "created_at")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
log.Debug("Updated albums", "num", len(toUpdate))
|
||||
if toUpdate > 0 {
|
||||
log.Debug(r.ctx, "Updated albums", "totalUpdated", toUpdate)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *albumRepository) PurgeEmpty() error {
|
||||
_, err := r.ormer.Raw("delete from album where id not in (select distinct(album_id) from media_file)").Exec()
|
||||
del := Delete(r.tableName).Where("id not in (select distinct(album_id) from media_file)")
|
||||
c, err := r.executeSQL(del)
|
||||
if err == nil {
|
||||
if c > 0 {
|
||||
log.Debug(r.ctx, "Purged empty albums", "totalDeleted", c)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *albumRepository) GetStarred(userId string, options ...model.QueryOptions) (model.Albums, error) {
|
||||
var starred []album
|
||||
sq := r.newRawQuery(options...).Join("annotation").Where("annotation.item_id = " + r.tableName + ".id")
|
||||
sq = sq.Where(squirrel.And{
|
||||
squirrel.Eq{"annotation.user_id": userId},
|
||||
squirrel.Eq{"annotation.starred": true},
|
||||
})
|
||||
sql, args, err := sq.ToSql()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = r.ormer.Raw(sql, args...).QueryRows(&starred)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.toAlbums(starred), nil
|
||||
func (r *albumRepository) GetStarred(options ...model.QueryOptions) (model.Albums, error) {
|
||||
sq := r.selectAlbum(options...).Where("starred = true")
|
||||
starred := model.Albums{}
|
||||
err := r.queryAll(sq, &starred)
|
||||
return starred, err
|
||||
}
|
||||
|
||||
func (r *albumRepository) Search(q string, offset int, size int) (model.Albums, error) {
|
||||
if len(q) <= 2 {
|
||||
return nil, nil
|
||||
}
|
||||
results := model.Albums{}
|
||||
err := r.doSearch(q, offset, size, &results, "name")
|
||||
return results, err
|
||||
}
|
||||
|
||||
var results []album
|
||||
err := r.doSearch(r.tableName, q, offset, size, &results, "name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.toAlbums(results), nil
|
||||
func (r *albumRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(options...))
|
||||
}
|
||||
|
||||
func (r *albumRepository) Read(id string) (interface{}, error) {
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
func (r *albumRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(options...))
|
||||
}
|
||||
|
||||
func (r *albumRepository) EntityName() string {
|
||||
return "album"
|
||||
}
|
||||
|
||||
func (r *albumRepository) NewInstance() interface{} {
|
||||
return &model.Album{}
|
||||
}
|
||||
|
||||
var _ model.AlbumRepository = (*albumRepository)(nil)
|
||||
var _ = model.Album(album{})
|
||||
var _ model.ResourceRepository = (*albumRepository)(nil)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -11,7 +14,18 @@ var _ = Describe("AlbumRepository", func() {
|
||||
var repo model.AlbumRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = NewAlbumRepository(orm.NewOrm())
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
|
||||
repo = NewAlbumRepository(ctx, orm.NewOrm())
|
||||
})
|
||||
|
||||
Describe("Get", func() {
|
||||
It("returns an existent album", func() {
|
||||
Expect(repo.Get("3")).To(Equal(&albumRadioactivity))
|
||||
})
|
||||
It("returns ErrNotFound when the album does not exist", func() {
|
||||
_, err := repo.Get("666")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetAll", func() {
|
||||
@@ -20,7 +34,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
})
|
||||
|
||||
It("returns all records sorted", func() {
|
||||
Expect(repo.GetAll(model.QueryOptions{Sort: "Name"})).To(Equal(model.Albums{
|
||||
Expect(repo.GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{
|
||||
albumAbbeyRoad,
|
||||
albumRadioactivity,
|
||||
albumSgtPeppers,
|
||||
@@ -28,7 +42,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
})
|
||||
|
||||
It("returns all records sorted desc", func() {
|
||||
Expect(repo.GetAll(model.QueryOptions{Sort: "Name", Order: "desc"})).To(Equal(model.Albums{
|
||||
Expect(repo.GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{
|
||||
albumSgtPeppers,
|
||||
albumRadioactivity,
|
||||
albumAbbeyRoad,
|
||||
@@ -44,7 +58,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
|
||||
Describe("GetStarred", func() {
|
||||
It("returns all starred records", func() {
|
||||
Expect(repo.GetStarred("userid", model.QueryOptions{})).To(Equal(model.Albums{
|
||||
Expect(repo.GetStarred(model.QueryOptions{})).To(Equal(model.Albums{
|
||||
albumRadioactivity,
|
||||
}))
|
||||
})
|
||||
@@ -52,9 +66,9 @@ var _ = Describe("AlbumRepository", func() {
|
||||
|
||||
Describe("FindByArtist", func() {
|
||||
It("returns all records from a given ArtistID", func() {
|
||||
Expect(repo.FindByArtist("1")).To(Equal(model.Albums{
|
||||
albumAbbeyRoad,
|
||||
Expect(repo.FindByArtist("3")).To(Equal(model.Albums{
|
||||
albumSgtPeppers,
|
||||
albumAbbeyRoad,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type annotation struct {
|
||||
AnnotationID string `orm:"pk;column(ann_id)"`
|
||||
UserID string `orm:"column(user_id)"`
|
||||
ItemID string `orm:"column(item_id)"`
|
||||
ItemType string `orm:"column(item_type)"`
|
||||
PlayCount int `orm:"index;null"`
|
||||
PlayDate time.Time `orm:"index;null"`
|
||||
Rating int `orm:"index;null"`
|
||||
Starred bool `orm:"index"`
|
||||
StarredAt time.Time `orm:"null"`
|
||||
}
|
||||
|
||||
func (u *annotation) TableUnique() [][]string {
|
||||
return [][]string{
|
||||
[]string{"UserID", "ItemID", "ItemType"},
|
||||
}
|
||||
}
|
||||
|
||||
type annotationRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewAnnotationRepository(o orm.Ormer) model.AnnotationRepository {
|
||||
r := &annotationRepository{}
|
||||
r.ormer = o
|
||||
r.tableName = "annotation"
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *annotationRepository) Get(userID, itemType string, itemID string) (*model.Annotation, error) {
|
||||
if userID == "" {
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
q := r.newQuery().Filter("user_id", userID).Filter("item_type", itemType).Filter("item_id", itemID)
|
||||
var ann annotation
|
||||
err := q.One(&ann)
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := model.Annotation(ann)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (r *annotationRepository) GetMap(userID, itemType string, itemID []string) (model.AnnotationMap, error) {
|
||||
if userID == "" {
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
if len(itemID) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
q := r.newQuery().Filter("user_id", userID).Filter("item_type", itemType).Filter("item_id__in", itemID)
|
||||
var res []annotation
|
||||
_, err := q.All(&res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m := make(model.AnnotationMap)
|
||||
for _, a := range res {
|
||||
m[a.ItemID] = model.Annotation(a)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (r *annotationRepository) GetAll(userID, itemType string, options ...model.QueryOptions) ([]model.Annotation, error) {
|
||||
if userID == "" {
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
q := r.newQuery(options...).Filter("user_id", userID).Filter("item_type", itemType)
|
||||
var res []annotation
|
||||
_, err := q.All(&res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
all := make([]model.Annotation, len(res))
|
||||
for i, a := range res {
|
||||
all[i] = model.Annotation(a)
|
||||
}
|
||||
return all, err
|
||||
}
|
||||
|
||||
func (r *annotationRepository) new(userID, itemType string, itemID string) *annotation {
|
||||
id, _ := uuid.NewRandom()
|
||||
return &annotation{
|
||||
AnnotationID: id.String(),
|
||||
UserID: userID,
|
||||
ItemID: itemID,
|
||||
ItemType: itemType,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *annotationRepository) IncPlayCount(userID, itemType string, itemID string, ts time.Time) error {
|
||||
if userID == "" {
|
||||
return model.ErrInvalidAuth
|
||||
}
|
||||
q := r.newQuery().Filter("user_id", userID).Filter("item_type", itemType).Filter("item_id", itemID)
|
||||
c, err := q.Update(orm.Params{
|
||||
"play_count": orm.ColValue(orm.ColAdd, 1),
|
||||
"play_date": ts,
|
||||
})
|
||||
if c == 0 || err == orm.ErrNoRows {
|
||||
ann := r.new(userID, itemType, itemID)
|
||||
ann.PlayCount = 1
|
||||
ann.PlayDate = ts
|
||||
_, err = r.ormer.Insert(ann)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *annotationRepository) SetStar(starred bool, userID, itemType string, ids ...string) error {
|
||||
if userID == "" {
|
||||
return model.ErrInvalidAuth
|
||||
}
|
||||
q := r.newQuery().Filter("user_id", userID).Filter("item_type", itemType).Filter("item_id__in", ids)
|
||||
var starredAt time.Time
|
||||
if starred {
|
||||
starredAt = time.Now()
|
||||
}
|
||||
c, err := q.Update(orm.Params{
|
||||
"starred": starred,
|
||||
"starred_at": starredAt,
|
||||
})
|
||||
if c == 0 || err == orm.ErrNoRows {
|
||||
for _, id := range ids {
|
||||
ann := r.new(userID, itemType, id)
|
||||
ann.Starred = starred
|
||||
ann.StarredAt = starredAt
|
||||
_, err = r.ormer.Insert(ann)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *annotationRepository) SetRating(rating int, userID, itemType string, itemID string) error {
|
||||
if userID == "" {
|
||||
return model.ErrInvalidAuth
|
||||
}
|
||||
q := r.newQuery().Filter("user_id", userID).Filter("item_type", itemType).Filter("item_id", itemID)
|
||||
c, err := q.Update(orm.Params{
|
||||
"rating": rating,
|
||||
})
|
||||
if c == 0 || err == orm.ErrNoRows {
|
||||
ann := r.new(userID, itemType, itemID)
|
||||
ann.Rating = rating
|
||||
_, err = r.ormer.Insert(ann)
|
||||
}
|
||||
return err
|
||||
|
||||
}
|
||||
|
||||
func (r *annotationRepository) Delete(userID, itemType string, itemID ...string) error {
|
||||
q := r.newQuery().Filter("user_id", userID).Filter("item_type", itemType).Filter("item_id__in", itemID)
|
||||
_, err := q.Delete()
|
||||
return err
|
||||
}
|
||||
@@ -1,39 +1,50 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
"github.com/deluan/rest"
|
||||
)
|
||||
|
||||
type artist struct {
|
||||
ID string `json:"id" orm:"pk;column(id)"`
|
||||
Name string `json:"name" orm:"index"`
|
||||
AlbumCount int `json:"albumCount" orm:"column(album_count)"`
|
||||
}
|
||||
|
||||
type artistRepository struct {
|
||||
searchableRepository
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
indexGroups utils.IndexGroups
|
||||
}
|
||||
|
||||
func NewArtistRepository(o orm.Ormer) model.ArtistRepository {
|
||||
func NewArtistRepository(ctx context.Context, o orm.Ormer) model.ArtistRepository {
|
||||
r := &artistRepository{}
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups)
|
||||
r.tableName = "artist"
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"name": fullTextFilter,
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *artistRepository) getIndexKey(a *artist) string {
|
||||
func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder {
|
||||
return r.newSelectWithAnnotation("artist.id", options...).Columns("*")
|
||||
}
|
||||
|
||||
func (r *artistRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
return r.count(Select(), options...)
|
||||
}
|
||||
|
||||
func (r *artistRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(Select().Where(Eq{"id": id}))
|
||||
}
|
||||
|
||||
func (r *artistRepository) getIndexKey(a *model.Artist) string {
|
||||
name := strings.ToLower(utils.NoArticle(a.Name))
|
||||
for k, v := range r.indexGroups {
|
||||
key := strings.ToLower(k)
|
||||
@@ -45,28 +56,31 @@ func (r *artistRepository) getIndexKey(a *artist) string {
|
||||
}
|
||||
|
||||
func (r *artistRepository) Put(a *model.Artist) error {
|
||||
ta := artist(*a)
|
||||
return r.put(a.ID, a.Name, &ta)
|
||||
a.FullText = r.getFullText(a.Name)
|
||||
_, err := r.put(a.ID, a)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *artistRepository) Get(id string) (*model.Artist, error) {
|
||||
ta := artist{ID: id}
|
||||
err := r.ormer.Read(&ta)
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := model.Artist(ta)
|
||||
return &a, nil
|
||||
sel := r.selectArtist().Where(Eq{"id": id})
|
||||
var res model.Artist
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, error) {
|
||||
sel := r.selectArtist(options...)
|
||||
res := model.Artists{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
// TODO Cache the index (recalculate when there are changes to the DB)
|
||||
func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
|
||||
var all []artist
|
||||
sq := r.selectArtist().OrderBy("name")
|
||||
var all model.Artists
|
||||
// TODO Paginate
|
||||
_, err := r.newQuery().OrderBy("name").All(&all)
|
||||
err := r.queryAll(sq, &all)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -79,7 +93,7 @@ func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
|
||||
idx = &model.ArtistIndex{ID: ax}
|
||||
fullIdx[ax] = idx
|
||||
}
|
||||
idx.Artists = append(idx.Artists, model.Artist(a))
|
||||
idx.Artists = append(idx.Artists, a)
|
||||
}
|
||||
var result model.ArtistIndexes
|
||||
for _, idx := range fullIdx {
|
||||
@@ -93,126 +107,86 @@ func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
|
||||
|
||||
func (r *artistRepository) Refresh(ids ...string) error {
|
||||
type refreshArtist struct {
|
||||
artist
|
||||
CurrentId string
|
||||
AlbumArtist string
|
||||
Compilation bool
|
||||
model.Artist
|
||||
CurrentId string
|
||||
}
|
||||
var artists []refreshArtist
|
||||
o := r.ormer
|
||||
sql := fmt.Sprintf(`
|
||||
select f.artist_id as id,
|
||||
f.artist as name,
|
||||
f.album_artist,
|
||||
f.compilation,
|
||||
count(*) as album_count,
|
||||
a.id as current_id
|
||||
from album f
|
||||
left outer join artist a on f.artist_id = a.id
|
||||
where f.artist_id in ('%s') group by f.artist_id order by f.id`, strings.Join(ids, "','"))
|
||||
_, err := o.Raw(sql).QueryRows(&artists)
|
||||
sel := Select("f.album_artist_id as id", "f.album_artist as name", "count(*) as album_count", "a.id as current_id").
|
||||
From("album f").
|
||||
LeftJoin("artist a on f.album_artist_id = a.id").
|
||||
Where(Eq{"f.album_artist_id": ids}).
|
||||
GroupBy("f.album_artist_id").OrderBy("f.id")
|
||||
err := r.queryAll(sel, &artists)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var toInsert []artist
|
||||
var toUpdate []artist
|
||||
toInsert := 0
|
||||
toUpdate := 0
|
||||
for _, ar := range artists {
|
||||
if ar.Compilation {
|
||||
ar.AlbumArtist = "Various Artists"
|
||||
}
|
||||
if ar.AlbumArtist != "" {
|
||||
ar.Name = ar.AlbumArtist
|
||||
}
|
||||
if ar.CurrentId != "" {
|
||||
toUpdate = append(toUpdate, ar.artist)
|
||||
toUpdate++
|
||||
} else {
|
||||
toInsert = append(toInsert, ar.artist)
|
||||
toInsert++
|
||||
}
|
||||
err := r.addToIndex(r.tableName, ar.ID, ar.Name)
|
||||
err := r.Put(&ar.Artist)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(toInsert) > 0 {
|
||||
n, err := o.InsertMulti(10, toInsert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Debug("Inserted new artists", "num", n)
|
||||
if toInsert > 0 {
|
||||
log.Debug(r.ctx, "Inserted new artists", "totalInserted", toInsert)
|
||||
}
|
||||
if len(toUpdate) > 0 {
|
||||
for _, al := range toUpdate {
|
||||
// Don't update Starred
|
||||
_, err := o.Update(&al, "name", "album_count")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
log.Debug("Updated artists", "num", len(toUpdate))
|
||||
if toUpdate > 0 {
|
||||
log.Debug(r.ctx, "Updated artists", "totalUpdated", toUpdate)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *artistRepository) GetStarred(userId string, options ...model.QueryOptions) (model.Artists, error) {
|
||||
var starred []artist
|
||||
sq := r.newRawQuery(options...).Join("annotation").Where("annotation.item_id = " + r.tableName + ".id")
|
||||
sq = sq.Where(squirrel.And{
|
||||
squirrel.Eq{"annotation.user_id": userId},
|
||||
squirrel.Eq{"annotation.starred": true},
|
||||
})
|
||||
sql, args, err := sq.ToSql()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = r.ormer.Raw(sql, args...).QueryRows(&starred)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.toArtists(starred), nil
|
||||
}
|
||||
|
||||
func (r *artistRepository) SetStar(starred bool, ids ...string) error {
|
||||
if len(ids) == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
var starredAt time.Time
|
||||
if starred {
|
||||
starredAt = time.Now()
|
||||
}
|
||||
_, err := r.newQuery().Filter("id__in", ids).Update(orm.Params{
|
||||
"starred": starred,
|
||||
"starred_at": starredAt,
|
||||
})
|
||||
return err
|
||||
func (r *artistRepository) GetStarred(options ...model.QueryOptions) (model.Artists, error) {
|
||||
sq := r.selectArtist(options...).Where("starred = true")
|
||||
starred := model.Artists{}
|
||||
err := r.queryAll(sq, &starred)
|
||||
return starred, err
|
||||
}
|
||||
|
||||
func (r *artistRepository) PurgeEmpty() error {
|
||||
_, err := r.ormer.Raw("delete from artist where id not in (select distinct(artist_id) from album)").Exec()
|
||||
del := Delete(r.tableName).Where("id not in (select distinct(album_artist_id) from album)")
|
||||
c, err := r.executeSQL(del)
|
||||
if err == nil {
|
||||
if c > 0 {
|
||||
log.Debug(r.ctx, "Purged empty artists", "totalDeleted", c)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *artistRepository) Search(q string, offset int, size int) (model.Artists, error) {
|
||||
if len(q) <= 2 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var results []artist
|
||||
err := r.doSearch(r.tableName, q, offset, size, &results, "name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r.toArtists(results), nil
|
||||
results := model.Artists{}
|
||||
err := r.doSearch(q, offset, size, &results, "name")
|
||||
return results, err
|
||||
}
|
||||
|
||||
func (r *artistRepository) toArtists(all []artist) model.Artists {
|
||||
result := make(model.Artists, len(all))
|
||||
for i, a := range all {
|
||||
result[i] = model.Artist(a)
|
||||
}
|
||||
return result
|
||||
func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(options...))
|
||||
}
|
||||
|
||||
func (r *artistRepository) Read(id string) (interface{}, error) {
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(options...))
|
||||
}
|
||||
|
||||
func (r *artistRepository) EntityName() string {
|
||||
return "artist"
|
||||
}
|
||||
|
||||
func (r *artistRepository) NewInstance() interface{} {
|
||||
return &model.Artist{}
|
||||
}
|
||||
|
||||
var _ model.ArtistRepository = (*artistRepository)(nil)
|
||||
var _ = model.Artist(artist{})
|
||||
var _ model.ArtistRepository = (*artistRepository)(nil)
|
||||
var _ model.ResourceRepository = (*artistRepository)(nil)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -11,22 +14,36 @@ var _ = Describe("ArtistRepository", func() {
|
||||
var repo model.ArtistRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = NewArtistRepository(orm.NewOrm())
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
|
||||
repo = NewArtistRepository(ctx, orm.NewOrm())
|
||||
})
|
||||
|
||||
Describe("Put/Get", func() {
|
||||
Describe("Count", func() {
|
||||
It("returns the number of artists in the DB", func() {
|
||||
Expect(repo.CountAll()).To(Equal(int64(2)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Exists", func() {
|
||||
It("returns true for an artist that is in the DB", func() {
|
||||
Expect(repo.Exists("3")).To(BeTrue())
|
||||
})
|
||||
It("returns false for an artist that is in the DB", func() {
|
||||
Expect(repo.Exists("666")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Get", func() {
|
||||
It("saves and retrieves data", func() {
|
||||
Expect(repo.Get("1")).To(Equal(&artistSaaraSaara))
|
||||
Expect(repo.Get("2")).To(Equal(&artistKraftwerk))
|
||||
})
|
||||
})
|
||||
|
||||
It("overrides data if ID already exists", func() {
|
||||
Expect(repo.Put(&model.Artist{ID: "1", Name: "Saara Saara is The Best!", AlbumCount: 3})).To(BeNil())
|
||||
Expect(repo.Get("1")).To(Equal(&model.Artist{ID: "1", Name: "Saara Saara is The Best!", AlbumCount: 3}))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when the ID does not exist", func() {
|
||||
_, err := repo.Get("999")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Describe("GetStarred", func() {
|
||||
It("returns all starred records", func() {
|
||||
Expect(repo.GetStarred(model.QueryOptions{})).To(Equal(model.Artists{
|
||||
artistBeatles,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,12 +64,6 @@ var _ = Describe("ArtistRepository", func() {
|
||||
artistKraftwerk,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "S",
|
||||
Artists: model.Artists{
|
||||
{ID: "1", Name: "Saara Saara is The Best!", AlbumCount: 3},
|
||||
},
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,60 +1,29 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"context"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
type genreRepository struct {
|
||||
ormer orm.Ormer
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewGenreRepository(o orm.Ormer) model.GenreRepository {
|
||||
return &genreRepository{ormer: o}
|
||||
func NewGenreRepository(ctx context.Context, o orm.Ormer) model.GenreRepository {
|
||||
r := &genreRepository{}
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
r.tableName = "media_file"
|
||||
return r
|
||||
}
|
||||
|
||||
func (r genreRepository) GetAll() (model.Genres, error) {
|
||||
genres := make(map[string]model.Genre)
|
||||
|
||||
// Collect SongCount
|
||||
var res []orm.Params
|
||||
_, err := r.ormer.Raw("select genre, count(*) as c from media_file group by genre").Values(&res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range res {
|
||||
name := r["genre"].(string)
|
||||
count := r["c"].(string)
|
||||
g, ok := genres[name]
|
||||
if !ok {
|
||||
g = model.Genre{Name: name}
|
||||
}
|
||||
g.SongCount, _ = strconv.Atoi(count)
|
||||
genres[name] = g
|
||||
}
|
||||
|
||||
// Collect AlbumCount
|
||||
_, err = r.ormer.Raw("select genre, count(*) as c from album group by genre").Values(&res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range res {
|
||||
name := r["genre"].(string)
|
||||
count := r["c"].(string)
|
||||
g, ok := genres[name]
|
||||
if !ok {
|
||||
g = model.Genre{Name: name}
|
||||
}
|
||||
g.AlbumCount, _ = strconv.Atoi(count)
|
||||
genres[name] = g
|
||||
}
|
||||
|
||||
// Build response
|
||||
result := model.Genres{}
|
||||
for _, g := range genres {
|
||||
result = append(result, g)
|
||||
}
|
||||
return result, err
|
||||
sq := Select("genre as name", "count(distinct album_id) as album_count", "count(distinct id) as song_count").
|
||||
From("media_file").GroupBy("genre")
|
||||
res := model.Genres{}
|
||||
err := r.queryAll(sq, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package persistence
|
||||
package persistence_test
|
||||
|
||||
import (
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -11,7 +13,7 @@ var _ = Describe("GenreRepository", func() {
|
||||
var repo model.GenreRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = NewGenreRepository(orm.NewOrm())
|
||||
repo = persistence.NewGenreRepository(log.NewContext(nil), orm.NewOrm())
|
||||
})
|
||||
|
||||
It("returns all records", func() {
|
||||
|
||||
55
persistence/helpers.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
func toSqlArgs(rec interface{}) (map[string]interface{}, error) {
|
||||
// Convert to JSON...
|
||||
b, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// ... then convert to map
|
||||
var m map[string]interface{}
|
||||
err = json.Unmarshal(b, &m)
|
||||
r := make(map[string]interface{}, len(m))
|
||||
for f, v := range m {
|
||||
if !utils.StringInSlice(f, model.AnnotationFields) {
|
||||
r[toSnakeCase(f)] = v
|
||||
}
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)")
|
||||
var matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])")
|
||||
|
||||
func toSnakeCase(str string) string {
|
||||
snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}")
|
||||
snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}")
|
||||
return strings.ToLower(snake)
|
||||
}
|
||||
|
||||
func Exists(subTable string, cond squirrel.Sqlizer) exists {
|
||||
return exists{subTable: subTable, cond: cond}
|
||||
}
|
||||
|
||||
type exists struct {
|
||||
subTable string
|
||||
cond squirrel.Sqlizer
|
||||
}
|
||||
|
||||
func (e exists) ToSql() (string, []interface{}, error) {
|
||||
sql, args, err := e.cond.ToSql()
|
||||
sql = fmt.Sprintf("exists (select 1 from %s where %s)", e.subTable, sql)
|
||||
return sql, args, err
|
||||
}
|
||||
19
persistence/helpers_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"github.com/Masterminds/squirrel"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Helpers", func() {
|
||||
Describe("Exists", func() {
|
||||
It("constructs the correct EXISTS query", func() {
|
||||
e := Exists("album", squirrel.Eq{"id": 1})
|
||||
sql, args, err := e.ToSql()
|
||||
Expect(sql).To(Equal("exists (select 1 from album where id = ?)"))
|
||||
Expect(args).To(Equal([]interface{}{1}))
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,176 +1,160 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/rest"
|
||||
)
|
||||
|
||||
type mediaFile struct {
|
||||
ID string `json:"id" orm:"pk;column(id)"`
|
||||
Path string `json:"path" orm:"index"`
|
||||
Title string `json:"title" orm:"index"`
|
||||
Album string `json:"album"`
|
||||
Artist string `json:"artist"`
|
||||
ArtistID string `json:"artistId" orm:"column(artist_id)"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
AlbumID string `json:"albumId" orm:"column(album_id);index"`
|
||||
HasCoverArt bool `json:"-"`
|
||||
TrackNumber int `json:"trackNumber"`
|
||||
DiscNumber int `json:"discNumber"`
|
||||
Year int `json:"year"`
|
||||
Size int `json:"size"`
|
||||
Suffix string `json:"suffix"`
|
||||
Duration int `json:"duration"`
|
||||
BitRate int `json:"bitRate"`
|
||||
Genre string `json:"genre" orm:"index"`
|
||||
Compilation bool `json:"compilation"`
|
||||
CreatedAt time.Time `json:"createdAt" orm:"null"`
|
||||
UpdatedAt time.Time `json:"updatedAt" orm:"null"`
|
||||
}
|
||||
|
||||
type mediaFileRepository struct {
|
||||
searchableRepository
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
func NewMediaFileRepository(o orm.Ormer) model.MediaFileRepository {
|
||||
func NewMediaFileRepository(ctx context.Context, o orm.Ormer) *mediaFileRepository {
|
||||
r := &mediaFileRepository{}
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
r.tableName = "media_file"
|
||||
r.sortMappings = map[string]string{
|
||||
"artist": "artist asc, album asc, disc_number asc, track_number asc",
|
||||
"album": "album asc, disc_number asc, track_number asc",
|
||||
}
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"title": fullTextFilter,
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Put(m *model.MediaFile) error {
|
||||
tm := mediaFile(*m)
|
||||
// Don't update media annotation fields (playcount, starred, etc..)
|
||||
// TODO Validate if this is still necessary, now that we don't have annotations in the mediafile model
|
||||
return r.put(m.ID, m.Title, &tm, "path", "title", "album", "artist", "artist_id", "album_artist",
|
||||
"album_id", "has_cover_art", "track_number", "disc_number", "year", "size", "suffix", "duration",
|
||||
"bit_rate", "genre", "compilation", "updated_at")
|
||||
func (r mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
return r.count(Select(), options...)
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Get(id string) (*model.MediaFile, error) {
|
||||
tm := mediaFile{ID: id}
|
||||
err := r.ormer.Read(&tm)
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
func (r mediaFileRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(Select().Where(Eq{"id": id}))
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) Put(m *model.MediaFile) error {
|
||||
m.FullText = r.getFullText(m.Title, m.Album, m.Artist, m.AlbumArtist)
|
||||
_, err := r.put(m.ID, m)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder {
|
||||
return r.newSelectWithAnnotation("media_file.id", options...).Columns("media_file.*")
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) Get(id string) (*model.MediaFile, error) {
|
||||
sel := r.selectMediaFile().Where(Eq{"id": id})
|
||||
var res model.MediaFile
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
sq := r.selectMediaFile(options...)
|
||||
res := model.MediaFiles{}
|
||||
err := r.queryAll(sq, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) FindByAlbum(albumId string) (model.MediaFiles, error) {
|
||||
sel := r.selectMediaFile().Where(Eq{"album_id": albumId}).OrderBy("disc_number", "track_number")
|
||||
res := model.MediaFiles{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) FindByPath(path string) (model.MediaFiles, error) {
|
||||
sel := r.selectMediaFile().Where(Like{"path": path + "%"})
|
||||
res := model.MediaFiles{}
|
||||
err := r.queryAll(sel, &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := model.MediaFile(tm)
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) toMediaFiles(all []mediaFile) model.MediaFiles {
|
||||
result := make(model.MediaFiles, len(all))
|
||||
for i, m := range all {
|
||||
result[i] = model.MediaFile(m)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) FindByAlbum(albumId string) (model.MediaFiles, error) {
|
||||
var mfs []mediaFile
|
||||
_, err := r.newQuery().Filter("album_id", albumId).OrderBy("disc_number", "track_number").All(&mfs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.toMediaFiles(mfs), nil
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) FindByPath(path string) (model.MediaFiles, error) {
|
||||
var mfs []mediaFile
|
||||
_, err := r.newQuery().Filter("path__istartswith", path).OrderBy("disc_number", "track_number").All(&mfs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var filtered []mediaFile
|
||||
// Only return mediafiles that are direct child of requested path
|
||||
filtered := model.MediaFiles{}
|
||||
path = strings.ToLower(path) + string(os.PathSeparator)
|
||||
for _, mf := range mfs {
|
||||
for _, mf := range res {
|
||||
filename := strings.TrimPrefix(strings.ToLower(mf.Path), path)
|
||||
if len(strings.Split(filename, string(os.PathSeparator))) > 1 {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, mf)
|
||||
}
|
||||
return r.toMediaFiles(filtered), nil
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) DeleteByPath(path string) error {
|
||||
var mfs []mediaFile
|
||||
// TODO Paginate this (and all other situations similar)
|
||||
_, err := r.newQuery().Filter("path__istartswith", path).OrderBy("disc_number", "track_number").All(&mfs)
|
||||
func (r mediaFileRepository) GetStarred(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
sq := r.selectMediaFile(options...).Where("starred = true")
|
||||
starred := model.MediaFiles{}
|
||||
err := r.queryAll(sq, &starred)
|
||||
return starred, err
|
||||
}
|
||||
|
||||
// TODO Keep order when paginating
|
||||
func (r mediaFileRepository) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
sq := r.selectMediaFile(options...)
|
||||
sq = sq.OrderBy("RANDOM()")
|
||||
results := model.MediaFiles{}
|
||||
err := r.queryAll(sq, &results)
|
||||
return results, err
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) Delete(id string) error {
|
||||
return r.delete(Eq{"id": id})
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) DeleteByPath(path string) error {
|
||||
filtered, err := r.FindByPath(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var filtered []string
|
||||
path = strings.ToLower(path) + string(os.PathSeparator)
|
||||
for _, mf := range mfs {
|
||||
filename := strings.TrimPrefix(strings.ToLower(mf.Path), path)
|
||||
if len(strings.Split(filename, string(os.PathSeparator))) > 1 {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, mf.ID)
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err = r.newQuery().Filter("id__in", filtered).Delete()
|
||||
ids := make([]string, len(filtered))
|
||||
for i, mf := range filtered {
|
||||
ids[i] = mf.ID
|
||||
}
|
||||
log.Debug(r.ctx, "Deleting mediafiles by path", "path", path, "totalDeleted", len(ids))
|
||||
del := Delete(r.tableName).Where(Eq{"id": ids})
|
||||
_, err = r.executeSQL(del)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
sq := r.newRawQuery(options...)
|
||||
switch r.ormer.Driver().Type() {
|
||||
case orm.DRMySQL:
|
||||
sq = sq.OrderBy("RAND()")
|
||||
default:
|
||||
sq = sq.OrderBy("RANDOM()")
|
||||
}
|
||||
sql, args, err := sq.ToSql()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var results []mediaFile
|
||||
_, err = r.ormer.Raw(sql, args...).QueryRows(&results)
|
||||
return r.toMediaFiles(results), err
|
||||
func (r mediaFileRepository) Search(q string, offset int, size int) (model.MediaFiles, error) {
|
||||
results := model.MediaFiles{}
|
||||
err := r.doSearch(q, offset, size, &results, "title")
|
||||
return results, err
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) GetStarred(userId string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
var starred []mediaFile
|
||||
sq := r.newRawQuery(options...).Join("annotation").Where("annotation.item_id = " + r.tableName + ".id")
|
||||
sq = sq.Where(squirrel.And{
|
||||
squirrel.Eq{"annotation.user_id": userId},
|
||||
squirrel.Eq{"annotation.starred": true},
|
||||
})
|
||||
sql, args, err := sq.ToSql()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = r.ormer.Raw(sql, args...).QueryRows(&starred)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.toMediaFiles(starred), nil
|
||||
func (r mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(options...))
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Search(q string, offset int, size int) (model.MediaFiles, error) {
|
||||
if len(q) <= 2 {
|
||||
return nil, nil
|
||||
}
|
||||
func (r mediaFileRepository) Read(id string) (interface{}, error) {
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
var results []mediaFile
|
||||
err := r.doSearch(r.tableName, q, offset, size, &results, "title")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.toMediaFiles(results), nil
|
||||
func (r mediaFileRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(options...))
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) EntityName() string {
|
||||
return "mediafile"
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) NewInstance() interface{} {
|
||||
return model.MediaFile{}
|
||||
}
|
||||
|
||||
var _ model.MediaFileRepository = (*mediaFileRepository)(nil)
|
||||
var _ = model.MediaFile(mediaFile{})
|
||||
var _ model.ResourceRepository = (*mediaFileRepository)(nil)
|
||||
|
||||
@@ -1,29 +1,119 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/google/uuid"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("MediaFileRepository", func() {
|
||||
var repo model.MediaFileRepository
|
||||
var _ = Describe("MediaRepository", func() {
|
||||
var mr model.MediaFileRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = NewMediaFileRepository(orm.NewOrm())
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
|
||||
mr = NewMediaFileRepository(ctx, orm.NewOrm())
|
||||
})
|
||||
|
||||
Describe("FindByPath", func() {
|
||||
It("returns all records from a given ArtistID", func() {
|
||||
path := string(os.PathSeparator) + filepath.Join("beatles", "1")
|
||||
Expect(repo.FindByPath(path)).To(Equal(model.MediaFiles{
|
||||
songComeTogether,
|
||||
}))
|
||||
It("gets mediafile from the DB", func() {
|
||||
Expect(mr.Get("4")).To(Equal(&songAntenna))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound", func() {
|
||||
_, err := mr.Get("56")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("counts the number of mediafiles in the DB", func() {
|
||||
Expect(mr.CountAll()).To(Equal(int64(4)))
|
||||
})
|
||||
|
||||
It("checks existence of mediafiles in the DB", func() {
|
||||
Expect(mr.Exists(songAntenna.ID)).To(BeTrue())
|
||||
Expect(mr.Exists("666")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("find mediafiles by album", func() {
|
||||
Expect(mr.FindByAlbum("3")).To(Equal(model.MediaFiles{
|
||||
songRadioactivity,
|
||||
songAntenna,
|
||||
}))
|
||||
})
|
||||
|
||||
It("returns empty array when no tracks are found", 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("returns starred tracks", func() {
|
||||
Expect(mr.GetStarred()).To(Equal(model.MediaFiles{
|
||||
songComeTogether,
|
||||
}))
|
||||
})
|
||||
|
||||
It("delete tracks by id", func() {
|
||||
random, _ := uuid.NewRandom()
|
||||
id := random.String()
|
||||
Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil())
|
||||
|
||||
Expect(mr.Delete(id)).To(BeNil())
|
||||
|
||||
_, err := mr.Get(id)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("delete tracks by path", func() {
|
||||
id1 := "1111"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id1, Path: P("/abc/123/" + id1 + ".mp3")})).To(BeNil())
|
||||
id2 := "2222"
|
||||
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())
|
||||
|
||||
Expect(mr.DeleteByPath(P("/abc"))).To(BeNil())
|
||||
|
||||
Expect(mr.Get(id1)).ToNot(BeNil())
|
||||
Expect(mr.Get(id2)).ToNot(BeNil())
|
||||
_, err := mr.Get(id3)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
Context("Annotations", func() {
|
||||
It("increments play count when the tracks does not have annotations", func() {
|
||||
id := "incplay.firsttime"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil())
|
||||
playDate := time.Now()
|
||||
Expect(mr.IncPlayCount(id, playDate)).To(BeNil())
|
||||
|
||||
mf, err := mr.Get(id)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix()))
|
||||
Expect(mf.PlayCount).To(Equal(1))
|
||||
})
|
||||
|
||||
It("increments play count on newly starred items", func() {
|
||||
id := "star.incplay"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil())
|
||||
Expect(mr.SetStar(true, id)).To(BeNil())
|
||||
playDate := time.Now()
|
||||
Expect(mr.IncPlayCount(id, playDate)).To(BeNil())
|
||||
|
||||
mf, err := mr.Get(id)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix()))
|
||||
Expect(mf.PlayCount).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
type mediaFolderRepository struct {
|
||||
model.MediaFolderRepository
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func NewMediaFolderRepository(o orm.Ormer) model.MediaFolderRepository {
|
||||
return &mediaFolderRepository{}
|
||||
func NewMediaFolderRepository(ctx context.Context, o orm.Ormer) model.MediaFolderRepository {
|
||||
return &mediaFolderRepository{ctx}
|
||||
}
|
||||
|
||||
func (r *mediaFolderRepository) Get(id string) (*model.MediaFolder, error) {
|
||||
mediaFolder := hardCoded()
|
||||
return &mediaFolder, nil
|
||||
}
|
||||
|
||||
func (*mediaFolderRepository) GetAll() (model.MediaFolders, error) {
|
||||
mediaFolder := model.MediaFolder{ID: "0", Path: conf.Server.MusicFolder}
|
||||
mediaFolder.Name = "Music Library"
|
||||
mediaFolder := hardCoded()
|
||||
result := make(model.MediaFolders, 1)
|
||||
result[0] = mediaFolder
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func hardCoded() model.MediaFolder {
|
||||
mediaFolder := model.MediaFolder{ID: "0", Path: conf.Server.MusicFolder}
|
||||
mediaFolder.Name = "Music Library"
|
||||
return mediaFolder
|
||||
}
|
||||
|
||||
var _ model.MediaFolderRepository = (*mediaFolderRepository)(nil)
|
||||
|
||||
@@ -68,7 +68,7 @@ func (m *MockAlbum) FindByArtist(artistId string) (model.Albums, error) {
|
||||
var res = make(model.Albums, len(m.data))
|
||||
i := 0
|
||||
for _, a := range m.data {
|
||||
if a.ArtistID == artistId {
|
||||
if a.AlbumArtistID == artistId {
|
||||
res[i] = *a
|
||||
i++
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ import (
|
||||
)
|
||||
|
||||
type MockDataStore struct {
|
||||
MockedGenre model.GenreRepository
|
||||
MockedAlbum model.AlbumRepository
|
||||
MockedArtist model.ArtistRepository
|
||||
MockedMediaFile model.MediaFileRepository
|
||||
MockedUser model.UserRepository
|
||||
MockedGenre model.GenreRepository
|
||||
MockedAlbum model.AlbumRepository
|
||||
MockedArtist model.ArtistRepository
|
||||
MockedMediaFile model.MediaFileRepository
|
||||
MockedUser model.UserRepository
|
||||
MockedPlayer model.PlayerRepository
|
||||
MockedTranscoding model.TranscodingRepository
|
||||
}
|
||||
|
||||
func (db *MockDataStore) Album(context.Context) model.AlbumRepository {
|
||||
@@ -61,8 +63,18 @@ func (db *MockDataStore) User(context.Context) model.UserRepository {
|
||||
return db.MockedUser
|
||||
}
|
||||
|
||||
func (db *MockDataStore) Annotation(context.Context) model.AnnotationRepository {
|
||||
return struct{ model.AnnotationRepository }{}
|
||||
func (db *MockDataStore) Transcoding(context.Context) model.TranscodingRepository {
|
||||
if db.MockedTranscoding != nil {
|
||||
return db.MockedTranscoding
|
||||
}
|
||||
return struct{ model.TranscodingRepository }{}
|
||||
}
|
||||
|
||||
func (db *MockDataStore) Player(context.Context) model.PlayerRepository {
|
||||
if db.MockedPlayer != nil {
|
||||
return db.MockedPlayer
|
||||
}
|
||||
return struct{ model.PlayerRepository }{}
|
||||
}
|
||||
|
||||
func (db *MockDataStore) WithTx(block func(db model.DataStore) error) error {
|
||||
@@ -73,6 +85,10 @@ func (db *MockDataStore) Resource(ctx context.Context, m interface{}) model.Reso
|
||||
return struct{ model.ResourceRepository }{}
|
||||
}
|
||||
|
||||
func (db *MockDataStore) GC(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockedUserRepo struct {
|
||||
model.UserRepository
|
||||
}
|
||||
|
||||
@@ -3,23 +3,11 @@ package persistence
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/db"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
_ "github.com/lib/pq"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const batchSize = 100
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
driver = "sqlite3"
|
||||
mappedModels map[interface{}]interface{}
|
||||
)
|
||||
|
||||
type SQLStore struct {
|
||||
@@ -27,64 +15,74 @@ type SQLStore struct {
|
||||
}
|
||||
|
||||
func New() model.DataStore {
|
||||
once.Do(func() {
|
||||
dbPath := conf.Server.DbPath
|
||||
if dbPath == ":memory:" {
|
||||
dbPath = "file::memory:?cache=shared"
|
||||
}
|
||||
log.Debug("Opening DataBase", "dbPath", dbPath, "driver", driver)
|
||||
|
||||
err := initORM(dbPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
return &SQLStore{}
|
||||
}
|
||||
|
||||
func (db *SQLStore) Album(context.Context) model.AlbumRepository {
|
||||
return NewAlbumRepository(db.getOrmer())
|
||||
func (s *SQLStore) Album(ctx context.Context) model.AlbumRepository {
|
||||
return NewAlbumRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) Artist(context.Context) model.ArtistRepository {
|
||||
return NewArtistRepository(db.getOrmer())
|
||||
func (s *SQLStore) Artist(ctx context.Context) model.ArtistRepository {
|
||||
return NewArtistRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) MediaFile(context.Context) model.MediaFileRepository {
|
||||
return NewMediaFileRepository(db.getOrmer())
|
||||
func (s *SQLStore) MediaFile(ctx context.Context) model.MediaFileRepository {
|
||||
return NewMediaFileRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) MediaFolder(context.Context) model.MediaFolderRepository {
|
||||
return NewMediaFolderRepository(db.getOrmer())
|
||||
func (s *SQLStore) MediaFolder(ctx context.Context) model.MediaFolderRepository {
|
||||
return NewMediaFolderRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) Genre(context.Context) model.GenreRepository {
|
||||
return NewGenreRepository(db.getOrmer())
|
||||
func (s *SQLStore) Genre(ctx context.Context) model.GenreRepository {
|
||||
return NewGenreRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) Playlist(context.Context) model.PlaylistRepository {
|
||||
return NewPlaylistRepository(db.getOrmer())
|
||||
func (s *SQLStore) Playlist(ctx context.Context) model.PlaylistRepository {
|
||||
return NewPlaylistRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) Property(context.Context) model.PropertyRepository {
|
||||
return NewPropertyRepository(db.getOrmer())
|
||||
func (s *SQLStore) Property(ctx context.Context) model.PropertyRepository {
|
||||
return NewPropertyRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) User(context.Context) model.UserRepository {
|
||||
return NewUserRepository(db.getOrmer())
|
||||
func (s *SQLStore) User(ctx context.Context) model.UserRepository {
|
||||
return NewUserRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) Annotation(context.Context) model.AnnotationRepository {
|
||||
return NewAnnotationRepository(db.getOrmer())
|
||||
func (s *SQLStore) Transcoding(ctx context.Context) model.TranscodingRepository {
|
||||
return NewTranscodingRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) Resource(ctx context.Context, model interface{}) model.ResourceRepository {
|
||||
return NewResource(db.getOrmer(), model, getMappedModel(model))
|
||||
func (s *SQLStore) Player(ctx context.Context) model.PlayerRepository {
|
||||
return NewPlayerRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) WithTx(block func(tx model.DataStore) error) error {
|
||||
o := orm.NewOrm()
|
||||
err := o.Begin()
|
||||
func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
|
||||
switch m.(type) {
|
||||
case model.User:
|
||||
return s.User(ctx).(model.ResourceRepository)
|
||||
case model.Transcoding:
|
||||
return s.Transcoding(ctx).(model.ResourceRepository)
|
||||
case model.Player:
|
||||
return s.Player(ctx).(model.ResourceRepository)
|
||||
case model.Artist:
|
||||
return s.Artist(ctx).(model.ResourceRepository)
|
||||
case model.Album:
|
||||
return s.Album(ctx).(model.ResourceRepository)
|
||||
case model.MediaFile:
|
||||
return s.MediaFile(ctx).(model.ResourceRepository)
|
||||
}
|
||||
log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) WithTx(block func(tx model.DataStore) error) error {
|
||||
o, err := orm.NewOrmWithDB(db.Driver, "default", db.Db())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = o.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -107,64 +105,33 @@ func (db *SQLStore) WithTx(block func(tx model.DataStore) error) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *SQLStore) getOrmer() orm.Ormer {
|
||||
if db.orm == nil {
|
||||
return orm.NewOrm()
|
||||
}
|
||||
return db.orm
|
||||
}
|
||||
|
||||
func initORM(dbPath string) error {
|
||||
verbose := conf.Server.LogLevel == "trace"
|
||||
orm.Debug = verbose
|
||||
if strings.Contains(dbPath, "postgres") {
|
||||
driver = "postgres"
|
||||
}
|
||||
err := orm.RegisterDataBase("default", driver, dbPath)
|
||||
func (s *SQLStore) GC(ctx context.Context) error {
|
||||
err := s.Album(ctx).PurgeEmpty()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return err
|
||||
}
|
||||
return orm.RunSyncdb("default", false, verbose)
|
||||
}
|
||||
|
||||
func collectField(collection interface{}, getValue func(item interface{}) string) []string {
|
||||
s := reflect.ValueOf(collection)
|
||||
result := make([]string, s.Len())
|
||||
|
||||
for i := 0; i < s.Len(); i++ {
|
||||
result[i] = getValue(s.Index(i).Interface())
|
||||
err = s.Artist(ctx).PurgeEmpty()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func getType(myvar interface{}) string {
|
||||
if t := reflect.TypeOf(myvar); t.Kind() == reflect.Ptr {
|
||||
return t.Elem().Name()
|
||||
} else {
|
||||
return t.Name()
|
||||
err = s.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.Album(ctx).(*albumRepository).cleanAnnotations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Artist(ctx).(*artistRepository).cleanAnnotations()
|
||||
}
|
||||
|
||||
func registerModel(model interface{}, mappedModel interface{}) {
|
||||
mappedModels[getType(model)] = mappedModel
|
||||
orm.RegisterModel(mappedModel)
|
||||
}
|
||||
|
||||
func getMappedModel(model interface{}) interface{} {
|
||||
return mappedModels[getType(model)]
|
||||
}
|
||||
|
||||
func init() {
|
||||
mappedModels = map[interface{}]interface{}{}
|
||||
|
||||
registerModel(model.Artist{}, new(artist))
|
||||
registerModel(model.Album{}, new(album))
|
||||
registerModel(model.MediaFile{}, new(mediaFile))
|
||||
registerModel(model.Property{}, new(property))
|
||||
registerModel(model.Playlist{}, new(playlist))
|
||||
registerModel(model.User{}, new(user))
|
||||
registerModel(model.Annotation{}, new(annotation))
|
||||
|
||||
orm.RegisterModel(new(search))
|
||||
func (s *SQLStore) getOrmer() orm.Ormer {
|
||||
if s.orm == nil {
|
||||
o, err := orm.NewOrmWithDB(db.Driver, "default", db.Db())
|
||||
if err != nil {
|
||||
log.Error("Error obtaining new orm instance", err)
|
||||
}
|
||||
return o
|
||||
}
|
||||
return s.orm
|
||||
}
|
||||
|
||||
@@ -1,97 +1,148 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/db"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/tests"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestPersistence(t *testing.T) {
|
||||
tests.Init(t, true)
|
||||
|
||||
//os.Remove("./test-123.db")
|
||||
//conf.Server.Path = "./test-123.db"
|
||||
conf.Server.DbPath = "file::memory:?cache=shared"
|
||||
orm.RegisterDataBase("default", db.Driver, conf.Server.DbPath)
|
||||
New()
|
||||
db.EnsureLatestVersion()
|
||||
log.SetLevel(log.LevelCritical)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Persistence Suite")
|
||||
}
|
||||
|
||||
var artistSaaraSaara = model.Artist{ID: "1", Name: "Saara Saara", AlbumCount: 2}
|
||||
var artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk"}
|
||||
var artistBeatles = model.Artist{ID: "3", Name: "The Beatles"}
|
||||
var testArtists = model.Artists{
|
||||
artistSaaraSaara,
|
||||
artistKraftwerk,
|
||||
artistBeatles,
|
||||
}
|
||||
var (
|
||||
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1, FullText: "kraftwerk"}
|
||||
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2, FullText: "the beatles"}
|
||||
testArtists = model.Artists{
|
||||
artistKraftwerk,
|
||||
artistBeatles,
|
||||
}
|
||||
)
|
||||
|
||||
var albumSgtPeppers = model.Album{ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", ArtistID: "1", Genre: "Rock"}
|
||||
var albumAbbeyRoad = model.Album{ID: "2", Name: "Abbey Road", Artist: "The Beatles", ArtistID: "1", Genre: "Rock"}
|
||||
var albumRadioactivity = model.Album{ID: "3", Name: "Radioactivity", Artist: "Kraftwerk", ArtistID: "2", Genre: "Electronic"}
|
||||
var testAlbums = model.Albums{
|
||||
albumSgtPeppers,
|
||||
albumAbbeyRoad,
|
||||
albumRadioactivity,
|
||||
}
|
||||
var (
|
||||
albumSgtPeppers = model.Album{ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", AlbumArtistID: "3", Genre: "Rock", CoverArtId: "1", CoverArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967, FullText: "sgt peppers the beatles"}
|
||||
albumAbbeyRoad = model.Album{ID: "2", Name: "Abbey Road", Artist: "The Beatles", AlbumArtistID: "3", Genre: "Rock", CoverArtId: "2", CoverArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969, FullText: "abbey road the beatles"}
|
||||
albumRadioactivity = model.Album{ID: "3", Name: "Radioactivity", Artist: "Kraftwerk", AlbumArtistID: "2", Genre: "Electronic", CoverArtId: "3", CoverArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2, FullText: "radioactivity kraftwerk"}
|
||||
testAlbums = model.Albums{
|
||||
albumSgtPeppers,
|
||||
albumAbbeyRoad,
|
||||
albumRadioactivity,
|
||||
}
|
||||
)
|
||||
|
||||
var annRadioactivity = model.Annotation{AnnotationID: "1", UserID: "userid", ItemType: model.AlbumItemType, ItemID: "3", Starred: true}
|
||||
var testAnnotations = []model.Annotation{
|
||||
annRadioactivity,
|
||||
}
|
||||
var (
|
||||
songDayInALife = model.MediaFile{ID: "1", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "1", Album: "Sgt Peppers", Genre: "Rock", Path: P("/beatles/1/sgt/a day.mp3"), FullText: "a day in a life sgt peppers the beatles"}
|
||||
songComeTogether = model.MediaFile{ID: "2", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "2", Album: "Abbey Road", Genre: "Rock", Path: P("/beatles/1/come together.mp3"), FullText: "come together abbey road the beatles"}
|
||||
songRadioactivity = model.MediaFile{ID: "3", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Album: "Radioactivity", Genre: "Electronic", Path: P("/kraft/radio/radio.mp3"), FullText: "radioactivity radioactivity kraftwerk"}
|
||||
songAntenna = model.MediaFile{ID: "4", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Genre: "Electronic", Path: P("/kraft/radio/antenna.mp3"), FullText: "antenna kraftwerk"}
|
||||
testSongs = model.MediaFiles{
|
||||
songDayInALife,
|
||||
songComeTogether,
|
||||
songRadioactivity,
|
||||
songAntenna,
|
||||
}
|
||||
)
|
||||
|
||||
var songDayInALife = model.MediaFile{ID: "1", Title: "A Day In A Life", ArtistID: "3", AlbumID: "1", Genre: "Rock", Path: P("/beatles/1/sgt/a day.mp3")}
|
||||
var songComeTogether = model.MediaFile{ID: "2", Title: "Come Together", ArtistID: "3", AlbumID: "2", Genre: "Rock", Path: P("/beatles/1/come together.mp3")}
|
||||
var songRadioactivity = model.MediaFile{ID: "3", Title: "Radioactivity", ArtistID: "2", AlbumID: "3", Genre: "Electronic", Path: P("/kraft/radio/radio.mp3")}
|
||||
var songAntenna = model.MediaFile{ID: "4", Title: "Antenna", ArtistID: "2", AlbumID: "3", Genre: "Electronic", Path: P("/kraft/radio/antenna.mp3")}
|
||||
var testSongs = model.MediaFiles{
|
||||
songDayInALife,
|
||||
songComeTogether,
|
||||
songRadioactivity,
|
||||
songAntenna,
|
||||
}
|
||||
var (
|
||||
plsBest = model.Playlist{
|
||||
ID: "10",
|
||||
Name: "Best",
|
||||
Comment: "No Comments",
|
||||
Duration: 10,
|
||||
Owner: "userid",
|
||||
Public: true,
|
||||
Tracks: model.MediaFiles{{ID: "1"}, {ID: "3"}},
|
||||
}
|
||||
plsCool = model.Playlist{ID: "11", Name: "Cool", Tracks: model.MediaFiles{{ID: "4"}}}
|
||||
testPlaylists = model.Playlists{plsBest, plsCool}
|
||||
)
|
||||
|
||||
func P(path string) string {
|
||||
return strings.ReplaceAll(path, "/", string(os.PathSeparator))
|
||||
return filepath.FromSlash(path)
|
||||
}
|
||||
|
||||
var _ = Describe("Initialize test DB", func() {
|
||||
|
||||
// TODO Load this data setup from file(s)
|
||||
BeforeSuite(func() {
|
||||
conf.Server.DbPath = ":memory:"
|
||||
ds := New()
|
||||
artistRepo := ds.Artist(nil)
|
||||
for _, a := range testArtists {
|
||||
err := artistRepo.Put(&a)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
albumRepository := ds.Album(nil)
|
||||
for _, a := range testAlbums {
|
||||
err := albumRepository.Put(&a)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
mediaFileRepository := ds.MediaFile(nil)
|
||||
o := orm.NewOrm()
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
|
||||
mr := NewMediaFileRepository(ctx, o)
|
||||
for _, s := range testSongs {
|
||||
err := mediaFileRepository.Put(&s)
|
||||
err := mr.Put(&s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
o := orm.NewOrm()
|
||||
for _, a := range testAnnotations {
|
||||
ann := annotation(a)
|
||||
_, err := o.Insert(&ann)
|
||||
alr := NewAlbumRepository(ctx, o)
|
||||
for _, a := range testAlbums {
|
||||
err := alr.Put(&a)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
arr := NewArtistRepository(ctx, o)
|
||||
for _, a := range testArtists {
|
||||
err := arr.Put(&a)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
pr := NewPlaylistRepository(ctx, o)
|
||||
for _, pls := range testPlaylists {
|
||||
err := pr.Put(&pls)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare annotations
|
||||
if err := arr.SetStar(true, artistBeatles.ID); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ar, _ := arr.Get(artistBeatles.ID)
|
||||
artistBeatles.Starred = true
|
||||
artistBeatles.StarredAt = ar.StarredAt
|
||||
testArtists[1] = artistBeatles
|
||||
|
||||
if err := alr.SetStar(true, albumRadioactivity.ID); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
al, _ := alr.Get(albumRadioactivity.ID)
|
||||
albumRadioactivity.Starred = true
|
||||
albumRadioactivity.StarredAt = al.StarredAt
|
||||
testAlbums[2] = albumRadioactivity
|
||||
|
||||
if err := mr.SetStar(true, songComeTogether.ID); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
mf, _ := mr.Get(songComeTogether.ID)
|
||||
songComeTogether.Starred = true
|
||||
songComeTogether.StarredAt = mf.StarredAt
|
||||
testSongs[1] = songComeTogether
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
118
persistence/player_repository.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/rest"
|
||||
)
|
||||
|
||||
type playerRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
func NewPlayerRepository(ctx context.Context, o orm.Ormer) model.PlayerRepository {
|
||||
r := &playerRepository{}
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
r.tableName = "player"
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *playerRepository) Put(p *model.Player) error {
|
||||
_, err := r.put(p.ID, p)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *playerRepository) Get(id string) (*model.Player, error) {
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"id": id})
|
||||
var res model.Player
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *playerRepository) FindByName(client, userName string) (*model.Player, error) {
|
||||
sel := r.newSelect().Columns("*").Where(And{Eq{"client": client}, Eq{"user_name": userName}})
|
||||
var res model.Player
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBuilder {
|
||||
s := r.newSelect(options...)
|
||||
u := loggedUser(r.ctx)
|
||||
if u.IsAdmin {
|
||||
return s
|
||||
}
|
||||
return s.Where(Eq{"user_name": u.UserName})
|
||||
}
|
||||
|
||||
func (r *playerRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.count(r.newRestSelect(), r.parseRestOptions(options...))
|
||||
}
|
||||
|
||||
func (r *playerRepository) Read(id string) (interface{}, error) {
|
||||
sel := r.newRestSelect().Columns("*").Where(Eq{"id": id})
|
||||
var res model.Player
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *playerRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
sel := r.newRestSelect(r.parseRestOptions(options...)).Columns("*")
|
||||
res := model.Players{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *playerRepository) EntityName() string {
|
||||
return "player"
|
||||
}
|
||||
|
||||
func (r *playerRepository) NewInstance() interface{} {
|
||||
return &model.Player{}
|
||||
}
|
||||
|
||||
func (r *playerRepository) isPermitted(p *model.Player) bool {
|
||||
u := loggedUser(r.ctx)
|
||||
return u.IsAdmin || p.UserName == u.UserName
|
||||
}
|
||||
|
||||
func (r *playerRepository) Save(entity interface{}) (string, error) {
|
||||
t := entity.(*model.Player)
|
||||
if !r.isPermitted(t) {
|
||||
return "", rest.ErrPermissionDenied
|
||||
}
|
||||
id, err := r.put(t.ID, t)
|
||||
if err == model.ErrNotFound {
|
||||
return "", rest.ErrNotFound
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (r *playerRepository) Update(entity interface{}, cols ...string) error {
|
||||
t := entity.(*model.Player)
|
||||
if !r.isPermitted(t) {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
_, err := r.put(t.ID, t)
|
||||
if err == model.ErrNotFound {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *playerRepository) Delete(id string) error {
|
||||
err := r.delete(And{Eq{"id": id}, Eq{"user_name": loggedUser(r.ctx).UserName}})
|
||||
if err == model.ErrNotFound {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var _ model.PlayerRepository = (*playerRepository)(nil)
|
||||
var _ rest.Repository = (*playerRepository)(nil)
|
||||
var _ rest.Persistable = (*playerRepository)(nil)
|
||||
@@ -1,57 +1,59 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type playlist struct {
|
||||
ID string `orm:"pk;column(id)"`
|
||||
Name string `orm:"index"`
|
||||
ID string `orm:"column(id)"`
|
||||
Name string
|
||||
Comment string
|
||||
Duration int
|
||||
Duration float32
|
||||
Owner string
|
||||
Public bool
|
||||
Tracks string `orm:"type(text)"`
|
||||
Tracks string
|
||||
}
|
||||
|
||||
type playlistRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewPlaylistRepository(o orm.Ormer) model.PlaylistRepository {
|
||||
func NewPlaylistRepository(ctx context.Context, o orm.Ormer) model.PlaylistRepository {
|
||||
r := &playlistRepository{}
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
r.tableName = "playlist"
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *playlistRepository) CountAll() (int64, error) {
|
||||
return r.count(Select())
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(Select().Where(Eq{"id": id}))
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Delete(id string) error {
|
||||
return r.delete(Eq{"id": id})
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Put(p *model.Playlist) error {
|
||||
if p.ID == "" {
|
||||
id, _ := uuid.NewRandom()
|
||||
p.ID = id.String()
|
||||
}
|
||||
tp := r.fromModel(p)
|
||||
err := r.put(p.ID, &tp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pls := r.fromModel(p)
|
||||
_, err := r.put(pls.ID, pls)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Get(id string) (*model.Playlist, error) {
|
||||
tp := &playlist{ID: id}
|
||||
err := r.ormer.Read(tp)
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pls := r.toModel(tp)
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"id": id})
|
||||
var res playlist
|
||||
err := r.queryOne(sel, &res)
|
||||
pls := r.toModel(&res)
|
||||
return &pls, err
|
||||
}
|
||||
|
||||
@@ -60,35 +62,34 @@ func (r *playlistRepository) GetWithTracks(id string) (*model.Playlist, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
qs := r.ormer.QueryTable(&mediaFile{})
|
||||
mfRepo := NewMediaFileRepository(r.ctx, r.ormer)
|
||||
pls.Duration = 0
|
||||
var newTracks model.MediaFiles
|
||||
newTracks := model.MediaFiles{}
|
||||
for _, t := range pls.Tracks {
|
||||
mf := &mediaFile{}
|
||||
if err := qs.Filter("id", t.ID).One(mf); err == nil {
|
||||
pls.Duration += mf.Duration
|
||||
newTracks = append(newTracks, model.MediaFile(*mf))
|
||||
mf, err := mfRepo.Get(t.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
pls.Duration += mf.Duration
|
||||
newTracks = append(newTracks, *mf)
|
||||
}
|
||||
pls.Tracks = newTracks
|
||||
return pls, err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playlists, error) {
|
||||
var all []playlist
|
||||
_, err := r.newQuery(options...).All(&all)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.toModels(all)
|
||||
sel := r.newSelect(options...).Columns("*")
|
||||
var res []playlist
|
||||
err := r.queryAll(sel, &res)
|
||||
return r.toModels(res), err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) toModels(all []playlist) (model.Playlists, error) {
|
||||
func (r *playlistRepository) toModels(all []playlist) model.Playlists {
|
||||
result := make(model.Playlists, len(all))
|
||||
for i, p := range all {
|
||||
result[i] = r.toModel(&p)
|
||||
}
|
||||
return result, nil
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *playlistRepository) toModel(p *playlist) model.Playlist {
|
||||
|
||||