mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-04 12:58:11 -05:00
Compare commits
212 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09985453aa | ||
|
|
159a6e1cad | ||
|
|
b429949dd9 | ||
|
|
b9f601dfb4 | ||
|
|
5b488b72b1 | ||
|
|
03044bcb68 | ||
|
|
7bc3dace4c | ||
|
|
c2ec142ce3 | ||
|
|
2d39a6df8d | ||
|
|
5265d0234f | ||
|
|
4fc88f23e9 | ||
|
|
5412bb2dc8 | ||
|
|
b661d52477 | ||
|
|
43ce81af67 | ||
|
|
b8d1185f7f | ||
|
|
0fa8290ed3 | ||
|
|
519e3f014d | ||
|
|
d38f8544d5 | ||
|
|
089a92157f | ||
|
|
db246900a6 | ||
|
|
a0f389fc3e | ||
|
|
d0188db4f9 | ||
|
|
f537984bbf | ||
|
|
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 |
@@ -3,6 +3,7 @@ ui/node_modules
|
||||
ui/build
|
||||
Jamstash-master
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
data
|
||||
*.db
|
||||
testDB
|
||||
|
||||
6
.github/workflows/build.yml
vendored
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
|
||||
|
||||
9
.github/workflows/release.yml
vendored
9
.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,7 @@ jobs:
|
||||
- name: Fetch tags
|
||||
run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
|
||||
- name: Run GoReleaser
|
||||
uses: docker://bepsays/ci-goreleaser:latest
|
||||
uses: docker://bepsays/ci-goreleaser:1.14-1
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -10,7 +10,7 @@ Navidrome and Subsonic:
|
||||
* Right now, Navidrome only works with a single Music Library (Music Folder)
|
||||
* Navidrome does not mark songs as played by calls to `stream`, only when
|
||||
`scrobble` is called with `submission=true`
|
||||
* Next features to be implemented: Playlists (WIP), MultiUser (WIP), Jukebox, Sharing, Podcasts, Bookmarks, Internet Radio.
|
||||
* Next features to be implemented: Last.FM integration, Jukebox, Sharing, Bookmarks, Podcasts, Internet Radio.
|
||||
|
||||
Navidrome is actively being tested with:
|
||||
[DSub](http://www.subsonic.org/pages/apps.jsp#dsub),
|
||||
@@ -54,7 +54,7 @@ Navidrome is actively being tested with:
|
||||
| `deletePlaylist` | |
|
||||
| ||
|
||||
| _MEDIA RETRIEVAL_ ||
|
||||
| `stream` | No Transcoding/Downsampling support (for now)|
|
||||
| `stream` | |
|
||||
| `download` | |
|
||||
| `getCoverArt` | Only gets embedded artwork |
|
||||
| `getAvatar` | Always returns the same image |
|
||||
@@ -62,7 +62,7 @@ Navidrome is actively being tested with:
|
||||
| _MEDIA ANNOTATION_ ||
|
||||
| `star` | |
|
||||
| `unstar` | |
|
||||
| `setRating` | Doesn't work with artists |
|
||||
| `setRating` | |
|
||||
| `scrobble` | No Last.FM support yet. It is used to update play count and last played |
|
||||
| ||
|
||||
| _USER MANAGEMENT_ ||
|
||||
|
||||
11
Dockerfile
11
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})" && \
|
||||
@@ -63,10 +63,13 @@ 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 ["/tini", "--"]
|
||||
|
||||
57
Makefile
57
Makefile
@@ -1,74 +1,90 @@
|
||||
GO_VERSION=1.13
|
||||
NODE_VERSION=v13.7.0
|
||||
GO_VERSION=$(shell grep -e "^go " go.mod | cut -f 2 -d ' ')
|
||||
NODE_VERSION=$(shell cat .nvmrc)
|
||||
|
||||
GIT_SHA=$(shell git rev-parse --short HEAD)
|
||||
|
||||
.PHONY: dev
|
||||
## Default target just build the Go project.
|
||||
default:
|
||||
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=master"
|
||||
.PHONY: default
|
||||
|
||||
dev: check_env
|
||||
@goreman -f Procfile.dev -b 4533 start
|
||||
.PHONY: dev
|
||||
|
||||
.PHONY: server
|
||||
server: check_go_env
|
||||
@reflex -d none -c reflex.conf
|
||||
.PHONY: server
|
||||
|
||||
wire: check_go_env
|
||||
wire ./...
|
||||
.PHONY: wire
|
||||
|
||||
.PHONY: watch
|
||||
watch: check_go_env
|
||||
ginkgo watch -notify ./...
|
||||
.PHONY: watch
|
||||
|
||||
.PHONY: test
|
||||
test: check_go_env
|
||||
go test ./... -v
|
||||
# @(cd ./ui && npm test -- --watchAll=false)
|
||||
.PHONY: test
|
||||
|
||||
.PHONY: testall
|
||||
testall: check_go_env test
|
||||
@(cd ./ui && npm test -- --watchAll=false)
|
||||
.PHONY: testall
|
||||
|
||||
.PHONY: setup
|
||||
setup: Jamstash-master
|
||||
@which goconvey || (echo "Installing GoConvey" && GO111MODULE=off go get -u github.com/smartystreets/goconvey)
|
||||
@which wire || (echo "Installing Wire" && GO111MODULE=off go get -u github.com/google/wire/cmd/wire)
|
||||
@which go-bindata || (echo "Installing BinData" && GO111MODULE=off go get -u github.com/go-bindata/go-bindata/...)
|
||||
@which reflex || (echo "Installing Reflex" && GO111MODULE=off go get -u github.com/cespare/reflex)
|
||||
@which goreman || (echo "Installing Goreman" && GO111MODULE=off go get -u github.com/mattn/goreman)
|
||||
@which ginkgo || (echo "Installing Ginkgo" && GO111MODULE=off go get -u github.com/onsi/ginkgo/ginkgo)
|
||||
@which goose || (echo "Installing Goose" && GO111MODULE=off go get -u github.com/pressly/goose/cmd/goose)
|
||||
@which lefthook || (echo "Installing Lefthook" && GO111MODULE=off go get -u github.com/Arkweid/lefthook)
|
||||
@lefthook install
|
||||
go mod download
|
||||
@(cd ./ui && npm ci)
|
||||
.PHONY: setup
|
||||
|
||||
.PHONY: static
|
||||
static:
|
||||
cd static && go-bindata -fs -prefix "static" -nocompress -ignore="\\\*.go" -pkg static .
|
||||
.PHONY: static
|
||||
|
||||
Jamstash-master:
|
||||
wget -N https://github.com/tsquillario/Jamstash/archive/master.zip
|
||||
unzip -o master.zip
|
||||
rm master.zip
|
||||
(cd Jamstash-master && npm ci && npx bower install && npx grunt build)
|
||||
rm -rf Jamstash-master/node_modules Jamstash-master/bower_components
|
||||
|
||||
.PHONE: check_env
|
||||
check_env: check_go_env check_node_env
|
||||
.PHONE: check_env
|
||||
|
||||
check_hooks:
|
||||
@lefthook add pre-commit
|
||||
@lefthook add pre-push
|
||||
.PHONE: check_hooks
|
||||
|
||||
.PHONY: check_go_env
|
||||
check_go_env:
|
||||
@(hash go) || (echo "\nERROR: GO environment not setup properly!\n"; exit 1)
|
||||
@go version | grep -q $(GO_VERSION) || (echo "\nERROR: Please upgrade your GO version\n"; exit 1)
|
||||
@go version | grep -q $(GO_VERSION) || (echo "\nERROR: Please upgrade your GO version\nThis project requires version $(GO_VERSION)"; exit 1)
|
||||
.PHONY: check_go_env
|
||||
|
||||
.PHONY: check_node_env
|
||||
check_node_env:
|
||||
@(hash node) || (echo "\nERROR: Node environment not setup properly!\n"; exit 1)
|
||||
@node --version | grep -q $(NODE_VERSION) || (echo "\nERROR: Please check your Node version. Should be $(NODE_VERSION)\n"; exit 1)
|
||||
.PHONY: check_node_env
|
||||
|
||||
.PHONY: build
|
||||
build: check_go_env
|
||||
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=master"
|
||||
.PHONY: build
|
||||
|
||||
.PHONY: buildall
|
||||
buildall: check_env
|
||||
@(cd ./ui && npm run build)
|
||||
go-bindata -fs -prefix "ui/build" -tags embed -nocompress -pkg assets -o assets/embedded_gen.go ui/build/...
|
||||
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=master" -tags=embed
|
||||
.PHONY: buildall
|
||||
|
||||
.PHONY: release
|
||||
release:
|
||||
@if [[ ! "${V}" =~ ^[0-9]+\.[0-9]+\.[0-9]+.*$$ ]]; then echo "Usage: make release V=X.X.X"; exit 1; fi
|
||||
go mod tidy
|
||||
@@ -76,7 +92,8 @@ release:
|
||||
make test
|
||||
git tag v${V}
|
||||
git push origin v${V}
|
||||
.PHONY: release
|
||||
|
||||
.PHONY: dist
|
||||
dist:
|
||||
docker run -it -v $(PWD):/github/workspace -w /github/workspace bepsays/ci-goreleaser:1.13-4 goreleaser release --rm-dist --skip-publish --snapshot
|
||||
snapshot:
|
||||
docker run -it -v $(PWD):/workspace -w /workspace bepsays/ci-goreleaser:1.14-1 goreleaser release --rm-dist --skip-publish --snapshot
|
||||
.PHONY: snapshot
|
||||
|
||||
66
README.md
66
README.md
@@ -1,15 +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://discord.gg/xh7j7yF)
|
||||
[](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. It's like your personal Spotify!
|
||||
|
||||
__Any feedback is welcome!__ If you need/want a new feature, find a bug or think of any way to improve Navidrome,
|
||||
please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join the chat in our [Discord server](https://discord.gg/xh7j7yF)
|
||||
please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join the discussion in our
|
||||
[Subreddit](https://www.reddit.com/r/navidrome/). If you want to contribute to the project in any other way
|
||||
(ui/backend dev, translations, [themes](ui/src/themes/README.md)), please join the chat in our
|
||||
[Discord server](https://discord.gg/xh7j7yF).
|
||||
|
||||
|
||||
## Features
|
||||
@@ -20,21 +24,36 @@ please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join
|
||||
- Multi-user, each user has their own play counts, playlists, favourites, etc..
|
||||
- Very low resource usage: Ex: with a library of 300GB (~29000 songs), it uses less than 50MB of RAM
|
||||
- Multi-platform, runs on macOS, Linux and Windows. Docker images are also provided
|
||||
- Ready to use Raspberry Pi binaries available
|
||||
- Automatically monitors your library for changes, importing new files and reloading new metadata
|
||||
- Modern and responsive Web interface based on Material UI, to manage users and browse your library
|
||||
- Compatible with the huge selection of clients for [Subsonic](http://www.subsonic.org),
|
||||
[Airsonic](https://airsonic.github.io/) and [Madsonic](https://www.madsonic.org/).
|
||||
See the [complete list of available mobile and web apps](https://airsonic.github.io/docs/apps/)
|
||||
- Transcoding/Downsampling on-the-fly (WIP. Experimental support is available)
|
||||
- [Themeable](ui/src/themes/README.md), modern and responsive Web interface based on Material UI, to manage users and
|
||||
browse your library
|
||||
- Compatible with all Subsonic/Madsonic/Airsonic clients. See bellow for a list of tested clients
|
||||
- Transcoding/Downsampling on-the-fly. Can be set per user/player. Opus encoding is supported
|
||||
- Integrated music player (WIP)
|
||||
|
||||
Navidrome should be compatible with all Subsonic clients. The following clients are tested and confirmed to work properly:
|
||||
- Android: [DSub](https://play.google.com/store/apps/details?id=github.daneren2005.dsub),
|
||||
[Ultrasonic](https://play.google.com/store/apps/details?id=org.moire.ultrasonic) and
|
||||
[Music Stash](https://play.google.com/store/apps/details?id=com.ghenry22.mymusicstash)
|
||||
- iOS: [play:Sub](http://michaelsapps.dk/playsubapp/)
|
||||
- Web: [Jamstash](http://jamstash.com),
|
||||
[Aurial](http://shrimpza.github.io/aurial/),
|
||||
[Subfire](http://p.subfireplayer.net/) and
|
||||
[Subplayer](https://github.com/peguerosdc/subplayer)
|
||||
|
||||
For more options, look at the [list of clients](https://airsonic.github.io/docs/apps/) maintained by
|
||||
the Airsonic project. Please open an [issue](https://github.com/deluan/navidrome/issues) if you have any
|
||||
trouble with the client of your choice.
|
||||
|
||||
|
||||
## Road map
|
||||
|
||||
This project is being actively worked on. Expect a more polished experience and new features/releases
|
||||
on a frequent basis. Some upcoming features planned:
|
||||
|
||||
- Complete WebUI, to browse and listen to your library
|
||||
- Last.FM integration
|
||||
- Pre-build binaries for Raspberry Pi
|
||||
- Smart/dynamic playlists (similar to iTunes)
|
||||
- Support for audiobooks (bookmarking)
|
||||
- Jukebox mode
|
||||
@@ -49,17 +68,19 @@ Various options are available:
|
||||
### Pre-built executables
|
||||
|
||||
Just head to the [releases page](https://github.com/deluan/navidrome/releases) and download the latest version for you
|
||||
platform. There are builds available for Linux, macOS and Windows (32 and 64 bits).
|
||||
platform. There are builds available for Linux (amd64 and arm), macOS and Windows (32 and 64 bits).
|
||||
For Raspberry Pi (tested with Raspbian Buster on Pi 4), use the Linux arm builds.
|
||||
|
||||
Remember to install [ffmpeg](https://ffmpeg.org/download.html) in your system, a requirement for Navidrome to work properly.
|
||||
You may find the latest static build for your platform here: https://johnvansickle.com/ffmpeg/
|
||||
Remember to install [ffmpeg](https://ffmpeg.org/download.html) in your system, a requirement for Navidrome to work
|
||||
properly. You may find the latest static build for your platform here: https://johnvansickle.com/ffmpeg/
|
||||
|
||||
If you have any issues with these binaries, or need a binary for a different platform, please
|
||||
[open an issue](https://github.com/deluan/navidrome/issues)
|
||||
|
||||
### Docker
|
||||
|
||||
[Docker images](https://hub.docker.com/r/deluan/navidrome) are available. They include everything needed to run Navidrome. Example of usage:
|
||||
[Docker images](https://hub.docker.com/r/deluan/navidrome) are available. They include everything needed
|
||||
to run Navidrome. Example of usage:
|
||||
|
||||
```yaml
|
||||
# This is just an example. Customize it to your needs.
|
||||
@@ -77,16 +98,19 @@ services:
|
||||
ND_SCANINTERVAL: 1m
|
||||
ND_LOGLEVEL: info
|
||||
ND_PORT: 4533
|
||||
ND_TRANSCODINGCACHESIZE: 100MB
|
||||
ND_SESSIONTIMEOUT: 30m
|
||||
ND_BASEURL: ""
|
||||
volumes:
|
||||
- "./data:/data"
|
||||
- "/path/to/your/music/folder:/music"
|
||||
- "/path/to/your/music/folder:/music:ro"
|
||||
```
|
||||
|
||||
To get the cutting-edge, latest version from master, use the image `deluan/navidrome:develop`
|
||||
|
||||
### Build from source
|
||||
|
||||
You will need to install [Go 1.13](https://golang.org/dl/) and [Node 13.7.0](http://nodejs.org).
|
||||
You will need to install [Go 1.14](https://golang.org/dl/) and [Node 13.12.0](http://nodejs.org).
|
||||
You'll also need [ffmpeg](https://ffmpeg.org) installed in your system. The setup is very strict, and
|
||||
the steps bellow only work with these specific versions (enforced in the Makefile)
|
||||
|
||||
@@ -114,6 +138,12 @@ user.
|
||||
|
||||
For more options, run `navidrome --help`
|
||||
|
||||
### Running as a service
|
||||
|
||||
Check the [contrib](https://github.com/deluan/navidrome/tree/master/contrib)
|
||||
folder for startup files for your init system.
|
||||
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
@@ -121,7 +151,7 @@ For more options, run `navidrome --help`
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-login.png">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-player.png">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-album-view.png">
|
||||
<img width="900"src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-desktop-player.png">
|
||||
<img width="900" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-desktop-player.png">
|
||||
</p>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -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:""`
|
||||
|
||||
UILoginBackgroundURL string `default:"https://source.unsplash.com/random/1600x900?music"`
|
||||
|
||||
IgnoredArticles string `default:"The El La Los Las Le Les Os As O A"`
|
||||
IndexGroups string `default:"A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)"`
|
||||
|
||||
EnableDownsampling bool `default:"false"`
|
||||
MaxBitRate int `default:"0"`
|
||||
DownsampleCommand string `default:"ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -"`
|
||||
ProbeCommand string `default:"ffmpeg -i %s -f ffmetadata"`
|
||||
ScanInterval string `default:"1m"`
|
||||
TranscodingCacheSize string `default:"100MB"` // in MB
|
||||
ImageCacheSize string `default:"100MB"` // in MB
|
||||
ProbeCommand string `default:"ffmpeg -i %s -f ffmetadata"`
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
DevDisableBanner bool `default:"false"`
|
||||
DevLogSourceLine bool `default:"false"`
|
||||
DevAutoCreateAdminPassword string `default:""`
|
||||
}
|
||||
@@ -82,7 +84,7 @@ func LoadFromFile(confFile string, skipFlags ...bool) {
|
||||
os.Exit(2)
|
||||
}
|
||||
if Server.DbPath == "" {
|
||||
Server.DbPath = filepath.Join(Server.DataFolder, "navidrome.db")
|
||||
Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath)
|
||||
}
|
||||
if os.Getenv("PORT") != "" {
|
||||
Server.Port = os.Getenv("PORT")
|
||||
|
||||
@@ -3,17 +3,18 @@ package consts
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/deluan/navidrome/static"
|
||||
)
|
||||
|
||||
func getBanner() string {
|
||||
data, _ := static.Asset("banner.txt")
|
||||
return strings.TrimSuffix(string(data), "\n")
|
||||
return strings.TrimRightFunc(string(data), unicode.IsSpace)
|
||||
}
|
||||
|
||||
func Banner() string {
|
||||
version := "Version: " + Version()
|
||||
padding := strings.Repeat(" ", 52-len(version))
|
||||
return fmt.Sprintf("%s%s%s\n", getBanner(), padding, version)
|
||||
return fmt.Sprintf("%s\n%s%s\n", getBanner(), padding, version)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,65 @@
|
||||
package consts
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
AppName = "navidrome"
|
||||
|
||||
LocalConfigFile = "./navidrome.toml"
|
||||
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL"
|
||||
InitialSetupFlagKey = "InitialSetup"
|
||||
|
||||
JWTSecretKey = "JWTSecret"
|
||||
JWTIssuer = "ND"
|
||||
JWTTokenExpiration = 30 * time.Minute
|
||||
UIAuthorizationHeader = "X-ND-Authorization"
|
||||
JWTSecretKey = "JWTSecret"
|
||||
JWTIssuer = "ND"
|
||||
DefaultSessionTimeout = 30 * time.Minute
|
||||
|
||||
UIAssetsLocalPath = "ui/build"
|
||||
|
||||
DevInitialUserName = "admin"
|
||||
DevInitialName = "Dev Admin"
|
||||
|
||||
URLPathUI = "/app"
|
||||
URLPathSubsonicAPI = "/rest"
|
||||
)
|
||||
|
||||
// Cache options
|
||||
const (
|
||||
TranscodingCacheDir = "cache/transcoding"
|
||||
DefaultTranscodingCacheMaxItems = 0 // Unlimited
|
||||
|
||||
ImageCacheDir = "cache/images"
|
||||
DefaultImageCacheMaxItems = 0 // Unlimited
|
||||
|
||||
DefaultCacheSize = 100 * 1024 * 1024 // 100MB
|
||||
DefaultCacheCleanUpInterval = 10 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultTranscodings = []map[string]interface{}{
|
||||
{
|
||||
"name": "mp3 audio",
|
||||
"targetFormat": "mp3",
|
||||
"defaultBitRate": 192,
|
||||
"command": "ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -",
|
||||
},
|
||||
{
|
||||
"name": "opus audio",
|
||||
"targetFormat": "oga",
|
||||
"defaultBitRate": 128,
|
||||
"command": "ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -c:a libopus -f opus -",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
VariousArtists = "Various Artists"
|
||||
VariousArtistsID = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(VariousArtists))))
|
||||
UnknownArtist = "[Unknown Artist]"
|
||||
UnknownArtistID = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(UnknownArtist))))
|
||||
)
|
||||
|
||||
@@ -8,7 +8,6 @@ func init() {
|
||||
".ogg": "audio/ogg",
|
||||
".oga": "audio/ogg",
|
||||
".opus": "audio/ogg",
|
||||
".ogx": "application/ogg",
|
||||
".aac": "audio/mp4",
|
||||
".m4a": "audio/mp4",
|
||||
".m4b": "audio/mp4",
|
||||
@@ -18,20 +17,8 @@ func init() {
|
||||
".ape": "audio/x-monkeys-audio",
|
||||
".mpc": "audio/x-musepack",
|
||||
".shn": "audio/x-shn",
|
||||
".flv": "video/x-flv",
|
||||
".avi": "video/avi",
|
||||
".mpg": "video/mpeg",
|
||||
".mpeg": "video/mpeg",
|
||||
".mp4": "video/mp4",
|
||||
".m4v": "video/x-m4v",
|
||||
".mkv": "video/x-matroska",
|
||||
".mov": "video/quicktime",
|
||||
".wmv": "video/x-ms-wmv",
|
||||
".ogv": "video/ogg",
|
||||
".divx": "video/divx",
|
||||
".m2ts": "video/MP2T",
|
||||
".ts": "video/MP2T",
|
||||
".webm": "video/webm",
|
||||
".aif": "audio/x-aiff",
|
||||
".aiff": "audio/x-aiff",
|
||||
".gif": "image/gif",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
|
||||
15
contrib/navidrome
Normal file
15
contrib/navidrome
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/sbin/openrc-run
|
||||
|
||||
name=$RC_SVCNAME
|
||||
command="/opt/navidrome/${RC_SVCNAME}"
|
||||
command_args="-datafolder /opt/navidrome"
|
||||
command_user="${RC_SVCNAME}"
|
||||
pidfile="/var/run/${RC_SVCNAME}.pid"
|
||||
output_log="/opt/navidrome/${RC_SVCNAME}.log"
|
||||
error_log="/opt/navidrome/${RC_SVCNAME}.err"
|
||||
command_background="yes"
|
||||
|
||||
depend() {
|
||||
need net
|
||||
}
|
||||
|
||||
35
contrib/navidrome.service
Normal file
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
|
||||
24
db/db.go
24
db/db.go
@@ -13,32 +13,36 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
Driver = "sqlite3"
|
||||
Path string
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var (
|
||||
once sync.Once
|
||||
db *sql.DB
|
||||
)
|
||||
|
||||
func Db() *sql.DB {
|
||||
once.Do(func() {
|
||||
var err error
|
||||
Path = conf.Server.DbPath
|
||||
if Path == ":memory:" {
|
||||
Path = "file::memory:?cache=shared"
|
||||
conf.Server.DbPath = Path
|
||||
}
|
||||
log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver)
|
||||
db, err = sql.Open(Driver, Path)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
return db
|
||||
}
|
||||
|
||||
func EnsureLatestVersion() {
|
||||
Init()
|
||||
db, err := sql.Open(Driver, Path)
|
||||
defer db.Close()
|
||||
if err != nil {
|
||||
log.Error("Failed to open DB", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
db := Db()
|
||||
|
||||
err = goose.SetDialect(Driver)
|
||||
err := goose.SetDialect(Driver)
|
||||
if err != nil {
|
||||
log.Error("Invalid DB driver", "driver", Driver, err)
|
||||
os.Exit(1)
|
||||
|
||||
129
db/migration/20200220143731_change_duration_to_float.go
Normal file
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
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
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
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
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
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
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
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200409002249, Down20200409002249)
|
||||
}
|
||||
|
||||
func Up20200409002249(tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to enable search by individual Artist in an Album!")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200409002249(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
55
db/migration/migration.go
Normal file
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)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ services:
|
||||
ND_SCANINTERVAL: 1m
|
||||
ND_LOGLEVEL: info
|
||||
ND_PORT: 4533
|
||||
ND_TRANSCODINGCACHESIZE: 100MB
|
||||
ND_SESSIONTIMEOUT: 30m
|
||||
ND_BASEURL: ""
|
||||
volumes:
|
||||
- "./data:/data"
|
||||
- "./music:/music"
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
@@ -13,9 +14,10 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
JwtSecret []byte
|
||||
TokenAuth *jwtauth.JWTAuth
|
||||
once sync.Once
|
||||
JwtSecret []byte
|
||||
TokenAuth *jwtauth.JWTAuth
|
||||
sessionTimeOut time.Duration
|
||||
)
|
||||
|
||||
func InitTokenAuth(ds model.DataStore) {
|
||||
@@ -39,8 +41,21 @@ func CreateToken(u *model.User) (string, error) {
|
||||
return TouchToken(token)
|
||||
}
|
||||
|
||||
func getSessionTimeOut() time.Duration {
|
||||
if sessionTimeOut == 0 {
|
||||
if to, err := time.ParseDuration(conf.Server.SessionTimeout); err != nil {
|
||||
sessionTimeOut = consts.DefaultSessionTimeout
|
||||
} else {
|
||||
sessionTimeOut = to
|
||||
}
|
||||
log.Info("Setting Session Timeout", "value", sessionTimeOut)
|
||||
}
|
||||
return sessionTimeOut
|
||||
}
|
||||
|
||||
func TouchToken(token *jwt.Token) (string, error) {
|
||||
expireIn := time.Now().Add(consts.JWTTokenExpiration).Unix()
|
||||
timeout := getSessionTimeOut()
|
||||
expireIn := time.Now().Add(timeout).Unix()
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims["exp"] = expireIn
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ func (b *browser) MediaFolders(ctx context.Context) (model.MediaFolders, error)
|
||||
|
||||
func (b *browser) Indexes(ctx context.Context, mediaFolderId string, ifModifiedSince time.Time) (model.ArtistIndexes, time.Time, error) {
|
||||
// TODO Proper handling of mediaFolderId param
|
||||
folder, err := b.ds.MediaFolder(ctx).Get(mediaFolderId)
|
||||
folder, _ := b.ds.MediaFolder(ctx).Get(mediaFolderId)
|
||||
|
||||
l, err := b.ds.Property(ctx).DefaultGet(model.PropLastScan+"-"+folder.Path, "-1")
|
||||
ms, _ := strconv.ParseInt(l, 10, 64)
|
||||
@@ -155,20 +155,23 @@ func (b *browser) buildAlbumDir(al *model.Album, tracks model.MediaFiles) *Direc
|
||||
dir := &DirectoryInfo{
|
||||
Id: al.ID,
|
||||
Name: al.Name,
|
||||
Parent: al.ArtistID,
|
||||
Artist: al.Artist,
|
||||
ArtistId: al.ArtistID,
|
||||
Parent: al.AlbumArtistID,
|
||||
Artist: al.AlbumArtist,
|
||||
ArtistId: al.AlbumArtistID,
|
||||
SongCount: al.SongCount,
|
||||
Duration: al.Duration,
|
||||
Duration: int(al.Duration),
|
||||
Created: al.CreatedAt,
|
||||
Year: al.Year,
|
||||
Year: al.MaxYear,
|
||||
Genre: al.Genre,
|
||||
CoverArt: al.CoverArtId,
|
||||
PlayCount: int32(al.PlayCount),
|
||||
Starred: al.StarredAt,
|
||||
UserRating: al.Rating,
|
||||
}
|
||||
|
||||
if al.Starred {
|
||||
dir.Starred = al.StarredAt
|
||||
}
|
||||
|
||||
dir.Entries = FromMediaFiles(tracks)
|
||||
return dir
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
@@ -51,7 +53,9 @@ func FromArtist(ar *model.Artist) Entry {
|
||||
e.Title = ar.Name
|
||||
e.AlbumCount = ar.AlbumCount
|
||||
e.IsDir = true
|
||||
e.Starred = ar.StarredAt
|
||||
if ar.Starred {
|
||||
e.Starred = ar.StarredAt
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
@@ -60,18 +64,20 @@ func FromAlbum(al *model.Album) Entry {
|
||||
e.Id = al.ID
|
||||
e.Title = al.Name
|
||||
e.IsDir = true
|
||||
e.Parent = al.ArtistID
|
||||
e.Parent = al.AlbumArtistID
|
||||
e.Album = al.Name
|
||||
e.Year = al.Year
|
||||
e.Year = al.MaxYear
|
||||
e.Artist = al.AlbumArtist
|
||||
e.Genre = al.Genre
|
||||
e.CoverArt = al.CoverArtId
|
||||
e.Created = al.CreatedAt
|
||||
e.AlbumId = al.ID
|
||||
e.ArtistId = al.ArtistID
|
||||
e.Duration = al.Duration
|
||||
e.ArtistId = al.AlbumArtistID
|
||||
e.Duration = int(al.Duration)
|
||||
e.SongCount = al.SongCount
|
||||
e.Starred = al.StarredAt
|
||||
if al.Starred {
|
||||
e.Starred = al.StarredAt
|
||||
}
|
||||
e.PlayCount = int32(al.PlayCount)
|
||||
e.UserRating = al.Rating
|
||||
return e
|
||||
@@ -88,7 +94,7 @@ func FromMediaFile(mf *model.MediaFile) Entry {
|
||||
e.Artist = mf.Artist
|
||||
e.Genre = mf.Genre
|
||||
e.Track = mf.TrackNumber
|
||||
e.Duration = mf.Duration
|
||||
e.Duration = int(mf.Duration)
|
||||
e.Size = mf.Size
|
||||
e.Suffix = mf.Suffix
|
||||
e.BitRate = mf.BitRate
|
||||
@@ -105,9 +111,11 @@ func FromMediaFile(mf *model.MediaFile) Entry {
|
||||
e.Created = mf.CreatedAt
|
||||
e.AlbumId = mf.AlbumID
|
||||
e.ArtistId = mf.ArtistID
|
||||
e.Type = "music" // TODO Hardcoded for now
|
||||
e.Type = "music"
|
||||
e.PlayCount = int32(mf.PlayCount)
|
||||
e.Starred = mf.StarredAt
|
||||
if mf.Starred {
|
||||
e.Starred = mf.StarredAt
|
||||
}
|
||||
e.UserRating = mf.Rating
|
||||
return e
|
||||
}
|
||||
@@ -115,7 +123,7 @@ func FromMediaFile(mf *model.MediaFile) Entry {
|
||||
func realArtistName(mf *model.MediaFile) string {
|
||||
switch {
|
||||
case mf.Compilation:
|
||||
return "Various Artists"
|
||||
return consts.VariousArtists
|
||||
case mf.AlbumArtist != "":
|
||||
return mf.AlbumArtist
|
||||
}
|
||||
@@ -146,3 +154,12 @@ func FromArtists(ars model.Artists) Entries {
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func userName(ctx context.Context) string {
|
||||
user := ctx.Value("user")
|
||||
if user == nil {
|
||||
return "UNKNOWN"
|
||||
}
|
||||
usr := user.(model.User)
|
||||
return usr.UserName
|
||||
}
|
||||
|
||||
164
engine/cover.go
164
engine/cover.go
@@ -4,95 +4,141 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/static"
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/nfnt/resize"
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/djherbis/fscache"
|
||||
)
|
||||
|
||||
type Cover interface {
|
||||
Get(ctx context.Context, id string, size int, out io.Writer) error
|
||||
}
|
||||
|
||||
type ImageCache fscache.Cache
|
||||
|
||||
func NewCover(ds model.DataStore, cache ImageCache) Cover {
|
||||
return &cover{ds: ds, cache: cache}
|
||||
}
|
||||
|
||||
type cover struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func NewCover(ds model.DataStore) Cover {
|
||||
return &cover{ds}
|
||||
}
|
||||
|
||||
func (c *cover) getCoverPath(ctx context.Context, id string) (string, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(id, "al-"):
|
||||
id = id[3:]
|
||||
al, err := c.ds.Album(ctx).Get(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return al.CoverArtPath, nil
|
||||
default:
|
||||
mf, err := c.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if mf.HasCoverArt {
|
||||
return mf.Path, nil
|
||||
}
|
||||
}
|
||||
return "", model.ErrNotFound
|
||||
ds model.DataStore
|
||||
cache fscache.Cache
|
||||
}
|
||||
|
||||
func (c *cover) Get(ctx context.Context, id string, size int, out io.Writer) error {
|
||||
path, err := c.getCoverPath(ctx, id)
|
||||
id = strings.TrimPrefix(id, "al-")
|
||||
path, lastUpdate, err := c.getCoverPath(ctx, id)
|
||||
if err != nil && err != model.ErrNotFound {
|
||||
return err
|
||||
}
|
||||
|
||||
var reader io.Reader
|
||||
|
||||
if err != model.ErrNotFound {
|
||||
reader, err = readFromTag(path)
|
||||
} else {
|
||||
var f http.File
|
||||
f, err = static.AssetFile().Open("default_cover.jpg")
|
||||
if err == nil {
|
||||
defer f.Close()
|
||||
reader = f
|
||||
}
|
||||
}
|
||||
|
||||
cacheKey := imageCacheKey(path, size, lastUpdate)
|
||||
r, w, err := c.cache.Get(cacheKey)
|
||||
if err != nil {
|
||||
return model.ErrNotFound
|
||||
log.Error(ctx, "Error reading from image cache", "path", path, "size", size, err)
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
if w != nil {
|
||||
log.Trace(ctx, "Image cache miss", "path", path, "size", size, "lastUpdate", lastUpdate)
|
||||
go func() {
|
||||
defer w.Close()
|
||||
reader, err := c.getCover(ctx, path, size)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading cover art", "path", path, "size", size, err)
|
||||
return
|
||||
}
|
||||
io.Copy(w, reader)
|
||||
}()
|
||||
} else {
|
||||
log.Trace(ctx, "Loading image from cache", "path", path, "size", size, "lastUpdate", lastUpdate)
|
||||
}
|
||||
|
||||
if size > 0 {
|
||||
return resizeImage(reader, size, out)
|
||||
}
|
||||
_, err = io.Copy(out, reader)
|
||||
_, err = io.Copy(out, r)
|
||||
return err
|
||||
}
|
||||
|
||||
func resizeImage(reader io.Reader, size int, out io.Writer) error {
|
||||
img, _, err := image.Decode(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
func (c *cover) getCoverPath(ctx context.Context, id string) (path string, lastUpdated time.Time, err error) {
|
||||
var found bool
|
||||
if found, err = c.ds.Album(ctx).Exists(id); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
m := resize.Resize(uint(size), 0, img, resize.NearestNeighbor)
|
||||
return jpeg.Encode(out, m, &jpeg.Options{Quality: 75})
|
||||
if found {
|
||||
var al *model.Album
|
||||
al, err = c.ds.Album(ctx).Get(id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if al.CoverArtId == "" {
|
||||
err = model.ErrNotFound
|
||||
return
|
||||
}
|
||||
id = al.CoverArtId
|
||||
}
|
||||
var mf *model.MediaFile
|
||||
mf, err = c.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if mf.HasCoverArt {
|
||||
return mf.Path, mf.UpdatedAt, nil
|
||||
}
|
||||
return "", time.Time{}, model.ErrNotFound
|
||||
}
|
||||
|
||||
func readFromTag(path string) (io.Reader, error) {
|
||||
func imageCacheKey(path string, size int, lastUpdate time.Time) string {
|
||||
return fmt.Sprintf("%s.%d.%s", path, size, lastUpdate.Format(time.RFC3339Nano))
|
||||
}
|
||||
|
||||
func (c *cover) getCover(ctx context.Context, path string, size int) (reader io.Reader, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error extracting image", "path", path, "size", size, err)
|
||||
reader, err = static.AssetFile().Open("navidrome-310x310.png")
|
||||
}
|
||||
}()
|
||||
var data []byte
|
||||
data, err = readFromTag(path)
|
||||
|
||||
if err == nil && size > 0 {
|
||||
data, err = resizeImage(bytes.NewReader(data), size)
|
||||
}
|
||||
|
||||
// Confirm the image is valid. Costly, but necessary
|
||||
_, _, err = image.Decode(bytes.NewReader(data))
|
||||
if err == nil {
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func resizeImage(reader io.Reader, size int) ([]byte, error) {
|
||||
img, _, err := image.Decode(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := imaging.Resize(img, size, size, imaging.Lanczos)
|
||||
buf := new(bytes.Buffer)
|
||||
err = jpeg.Encode(buf, m, &jpeg.Options{Quality: 75})
|
||||
return buf.Bytes(), err
|
||||
}
|
||||
|
||||
func readFromTag(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -106,7 +152,11 @@ func readFromTag(path string) (io.Reader, error) {
|
||||
|
||||
picture := m.Picture()
|
||||
if picture == nil {
|
||||
return nil, errors.New("error extracting art from file " + path)
|
||||
return nil, errors.New("file does not contain embedded art")
|
||||
}
|
||||
return bytes.NewReader(picture.Data), nil
|
||||
return picture.Data, nil
|
||||
}
|
||||
|
||||
func NewImageCache() (ImageCache, error) {
|
||||
return newFileCache("image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems)
|
||||
}
|
||||
|
||||
105
engine/cover_test.go
Normal file
105
engine/cover_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Cover", func() {
|
||||
var cover Cover
|
||||
var ds model.DataStore
|
||||
ctx := log.NewContext(nil)
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &persistence.MockDataStore{MockedTranscoding: &mockTranscodingRepository{}}
|
||||
ds.Album(ctx).(*persistence.MockAlbum).SetData(`[{"id": "222", "coverArtId": "123"}, {"id": "333", "coverArtId": ""}]`)
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "hasCoverArt": true, "updatedAt":"2020-04-02T21:29:31.6377Z"}]`)
|
||||
cover = NewCover(ds, testCache)
|
||||
})
|
||||
|
||||
It("retrieves the original cover art from an album", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "222", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
|
||||
It("accepts albumIds with 'al-' prefix", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "al-222", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
|
||||
It("returns the default cover if album does not have cover", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "333", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("png"))
|
||||
})
|
||||
|
||||
It("returns the default cover if album is not found", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "444", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("png"))
|
||||
})
|
||||
|
||||
It("retrieves the original cover art from a media_file", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 0, buf)).To(BeNil())
|
||||
|
||||
img, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(600))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(600))
|
||||
})
|
||||
|
||||
It("resized cover art as requested", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 200, buf)).To(BeNil())
|
||||
|
||||
img, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
|
||||
Context("Errors", func() {
|
||||
It("returns err if gets error from album table", func() {
|
||||
ds.Album(ctx).(*persistence.MockAlbum).SetError(true)
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "222", 0, buf)).To(MatchError("Error!"))
|
||||
})
|
||||
|
||||
It("returns err if gets error from media_file table", func() {
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetError(true)
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 0, buf)).To(MatchError("Error!"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,13 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/tests"
|
||||
"github.com/djherbis/fscache"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -15,3 +18,18 @@ func TestEngine(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Engine Suite")
|
||||
}
|
||||
|
||||
var testCache fscache.Cache
|
||||
var testCacheDir string
|
||||
|
||||
var _ = Describe("Engine Suite Setup", func() {
|
||||
BeforeSuite(func() {
|
||||
testCacheDir, _ = ioutil.TempDir("", "engine_test_cache")
|
||||
fs, _ := fscache.NewFs(testCacheDir, 0755)
|
||||
testCache, _ = fscache.NewCache(fs, nil)
|
||||
})
|
||||
|
||||
AfterSuite(func() {
|
||||
os.RemoveAll(testCacheDir)
|
||||
})
|
||||
})
|
||||
|
||||
28
engine/file_caches.go
Normal file
28
engine/file_caches.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/djherbis/fscache"
|
||||
"github.com/dustin/go-humanize"
|
||||
)
|
||||
|
||||
func newFileCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cache, error) {
|
||||
size, err := humanize.ParseBytes(cacheSize)
|
||||
if err != nil {
|
||||
size = consts.DefaultCacheSize
|
||||
}
|
||||
lru := fscache.NewLRUHaunter(maxItems, int64(size), consts.DefaultCacheCleanUpInterval)
|
||||
h := fscache.NewLRUHaunterStrategy(lru)
|
||||
cacheFolder = filepath.Join(conf.Server.DataFolder, cacheFolder)
|
||||
log.Info(fmt.Sprintf("Creating %s cache", name), "path", cacheFolder, "maxSize", humanize.Bytes(size))
|
||||
fs, err := fscache.NewFs(cacheFolder, 0755)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fscache.NewCacheWithHaunter(fs, h)
|
||||
}
|
||||
33
engine/file_caches_test.go
Normal file
33
engine/file_caches_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("File Caches", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DataFolder, _ = ioutil.TempDir("", "file_caches")
|
||||
})
|
||||
AfterEach(func() {
|
||||
os.RemoveAll(conf.Server.DataFolder)
|
||||
})
|
||||
|
||||
Describe("newFileCache", func() {
|
||||
It("creates the cache folder", func() {
|
||||
Expect(newFileCache("test", "1k", "test", 10)).ToNot(BeNil())
|
||||
|
||||
_, err := os.Stat(filepath.Join(conf.Server.DataFolder, "test"))
|
||||
Expect(os.IsNotExist(err)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("creates the cache folder with invalid size", func() {
|
||||
Expect(newFileCache("test", "abc", "test", 10)).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,232 +2,216 @@ package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"mime"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/engine/transcoder"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
"github.com/djherbis/fscache"
|
||||
)
|
||||
|
||||
type MediaStreamer interface {
|
||||
NewStream(ctx context.Context, id string, maxBitRate int, format string) (mediaStream, error)
|
||||
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error)
|
||||
}
|
||||
|
||||
func NewMediaStreamer(ds model.DataStore) MediaStreamer {
|
||||
return &mediaStreamer{ds: ds}
|
||||
}
|
||||
type TranscodingCache fscache.Cache
|
||||
|
||||
type mediaStream interface {
|
||||
io.ReadSeeker
|
||||
ContentType() string
|
||||
Name() string
|
||||
ModTime() time.Time
|
||||
Close() error
|
||||
Duration() int
|
||||
func NewMediaStreamer(ds model.DataStore, ffm transcoder.Transcoder, cache TranscodingCache) MediaStreamer {
|
||||
return &mediaStreamer{ds: ds, ffm: ffm, cache: cache}
|
||||
}
|
||||
|
||||
type mediaStreamer struct {
|
||||
ds model.DataStore
|
||||
ds model.DataStore
|
||||
ffm transcoder.Transcoder
|
||||
cache fscache.Cache
|
||||
}
|
||||
|
||||
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, maxBitRate int, format string) (mediaStream, error) {
|
||||
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error) {
|
||||
mf, err := ms.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var format string
|
||||
var bitRate int
|
||||
defer func() {
|
||||
log.Info("Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format,
|
||||
"bitRate", bitRate, "user", userName(ctx), "transcoding", format != "raw", "originalFormat", mf.Suffix)
|
||||
}()
|
||||
|
||||
if format == "raw" || !conf.Server.EnableDownsampling {
|
||||
bitRate = mf.BitRate
|
||||
format = mf.Suffix
|
||||
} else {
|
||||
if maxBitRate == 0 {
|
||||
bitRate = mf.BitRate
|
||||
} else {
|
||||
bitRate = utils.MinInt(mf.BitRate, maxBitRate)
|
||||
}
|
||||
format = mf.Suffix
|
||||
}
|
||||
if conf.Server.MaxBitRate != 0 {
|
||||
bitRate = utils.MinInt(bitRate, conf.Server.MaxBitRate)
|
||||
}
|
||||
format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate)
|
||||
log.Trace(ctx, "Selected transcoding options",
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
||||
"selectedBitrate", bitRate, "selectedFormat", format,
|
||||
)
|
||||
s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
|
||||
|
||||
var stream mediaStream
|
||||
|
||||
if bitRate == mf.BitRate && mime.TypeByExtension("."+format) == mf.ContentType() {
|
||||
if format == "raw" {
|
||||
log.Debug(ctx, "Streaming raw file", "id", mf.ID, "path", mf.Path,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
|
||||
|
||||
f, err := os.Open(mf.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stream = &rawMediaStream{ctx: ctx, mf: mf, file: f}
|
||||
return stream, nil
|
||||
s.Reader = f
|
||||
s.Closer = f
|
||||
s.Seeker = f
|
||||
s.format = mf.Suffix
|
||||
return s, nil
|
||||
}
|
||||
|
||||
key := cacheKey(id, bitRate, format)
|
||||
r, w, err := ms.cache.Get(key)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error creating stream caching buffer", "id", mf.ID, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If this is a brand new transcoding request, not in the cache, start transcoding
|
||||
if w != nil {
|
||||
log.Trace(ctx, "Cache miss. Starting new transcoding session", "id", mf.ID)
|
||||
t, err := ms.ds.Transcoding(ctx).FindByFormat(format)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading transcoding command", "format", format, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
out, err := ms.ffm.Start(ctx, t.Command, mf.Path, bitRate, format)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error starting transcoder", "id", mf.ID, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
go copyAndClose(ctx, w, out)
|
||||
}
|
||||
|
||||
// If it is in the cache, check if the stream is done being written. If so, return a ReaderSeeker
|
||||
if w == nil {
|
||||
size := getFinalCachedSize(r)
|
||||
if size > 0 {
|
||||
log.Debug(ctx, "Streaming cached file", "id", mf.ID, "path", mf.Path,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix, "size", size)
|
||||
sr := io.NewSectionReader(r, 0, size)
|
||||
s.Reader = sr
|
||||
s.Closer = r
|
||||
s.Seeker = sr
|
||||
s.format = format
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Streaming transcoded file", "id", mf.ID, "path", mf.Path,
|
||||
"requestBitrate", bitRate, "requestFormat", format,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
|
||||
|
||||
f := &transcodedMediaStream{ctx: ctx, mf: mf, bitRate: bitRate, format: format}
|
||||
return f, err
|
||||
// All other cases, just return a ReadCloser, without Seek capabilities
|
||||
s.Reader = r
|
||||
s.Closer = r
|
||||
s.format = format
|
||||
return s, nil
|
||||
}
|
||||
|
||||
type rawMediaStream struct {
|
||||
file *os.File
|
||||
ctx context.Context
|
||||
mf *model.MediaFile
|
||||
func copyAndClose(ctx context.Context, w io.WriteCloser, r io.ReadCloser) {
|
||||
_, err := io.Copy(w, r)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error copying data to cache", err)
|
||||
}
|
||||
err = r.Close()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error closing transcode output", err)
|
||||
}
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error closing cache", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *rawMediaStream) Read(p []byte) (n int, err error) {
|
||||
return m.file.Read(p)
|
||||
}
|
||||
|
||||
func (m *rawMediaStream) Seek(offset int64, whence int) (int64, error) {
|
||||
return m.file.Seek(offset, whence)
|
||||
}
|
||||
|
||||
func (m *rawMediaStream) ContentType() string {
|
||||
return m.mf.ContentType()
|
||||
}
|
||||
|
||||
func (m *rawMediaStream) Name() string {
|
||||
return m.mf.Path
|
||||
}
|
||||
|
||||
func (m *rawMediaStream) ModTime() time.Time {
|
||||
return m.mf.UpdatedAt
|
||||
}
|
||||
|
||||
func (m *rawMediaStream) Duration() int {
|
||||
return m.mf.Duration
|
||||
}
|
||||
|
||||
func (m *rawMediaStream) Close() error {
|
||||
log.Trace(m.ctx, "Closing file", "id", m.mf.ID, "path", m.mf.Path)
|
||||
return m.file.Close()
|
||||
}
|
||||
|
||||
type transcodedMediaStream struct {
|
||||
type Stream struct {
|
||||
ctx context.Context
|
||||
mf *model.MediaFile
|
||||
pipe io.ReadCloser
|
||||
bitRate int
|
||||
format string
|
||||
skip int64
|
||||
pos int64
|
||||
io.Reader
|
||||
io.Closer
|
||||
io.Seeker
|
||||
}
|
||||
|
||||
func (m *transcodedMediaStream) Read(p []byte) (n int, err error) {
|
||||
// Open the pipe and optionally skip a initial chunk of the stream (to simulate a Seek)
|
||||
if m.pipe == nil {
|
||||
m.pipe, err = newTranscode(m.ctx, m.mf.Path, m.bitRate, m.format)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if m.skip > 0 {
|
||||
_, err := io.CopyN(ioutil.Discard, m.pipe, m.skip)
|
||||
m.pos = m.skip
|
||||
if err != nil {
|
||||
return 0, err
|
||||
func (s *Stream) Seekable() bool { return s.Seeker != nil }
|
||||
func (s *Stream) Duration() float32 { return s.mf.Duration }
|
||||
func (s *Stream) ContentType() string { return mime.TypeByExtension("." + s.format) }
|
||||
func (s *Stream) Name() string { return s.mf.Title + "." + s.format }
|
||||
func (s *Stream) ModTime() time.Time { return s.mf.UpdatedAt }
|
||||
func (s *Stream) EstimatedContentLength() int {
|
||||
return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
|
||||
}
|
||||
|
||||
// TODO This function deserves some love (refactoring)
|
||||
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) {
|
||||
format = "raw"
|
||||
if reqFormat == "raw" {
|
||||
return
|
||||
}
|
||||
if reqFormat == mf.Suffix && reqBitRate == 0 {
|
||||
bitRate = mf.BitRate
|
||||
return
|
||||
}
|
||||
trc, hasDefault := ctx.Value("transcoding").(model.Transcoding)
|
||||
var cFormat string
|
||||
var cBitRate int
|
||||
if reqFormat != "" {
|
||||
cFormat = reqFormat
|
||||
} else {
|
||||
if hasDefault {
|
||||
cFormat = trc.TargetFormat
|
||||
cBitRate = trc.DefaultBitRate
|
||||
if p, ok := ctx.Value("player").(model.Player); ok {
|
||||
cBitRate = p.MaxBitRate
|
||||
}
|
||||
}
|
||||
}
|
||||
n, err = m.pipe.Read(p)
|
||||
m.pos += int64(n)
|
||||
if err == io.EOF {
|
||||
m.Close()
|
||||
if reqBitRate > 0 {
|
||||
cBitRate = reqBitRate
|
||||
}
|
||||
if cBitRate == 0 && cFormat == "" {
|
||||
return
|
||||
}
|
||||
t, err := ds.Transcoding(ctx).FindByFormat(cFormat)
|
||||
if err == nil {
|
||||
format = t.TargetFormat
|
||||
if cBitRate != 0 {
|
||||
bitRate = cBitRate
|
||||
} else {
|
||||
bitRate = t.DefaultBitRate
|
||||
}
|
||||
}
|
||||
if format == mf.Suffix && bitRate >= mf.BitRate {
|
||||
format = "raw"
|
||||
bitRate = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// This is an attempt to make a pipe seekable. It is very wasteful, restarting the stream every time
|
||||
// a Seek happens. This is ok-ish for audio, but would kill the server for video.
|
||||
func (m *transcodedMediaStream) Seek(offset int64, whence int) (int64, error) {
|
||||
size := int64((m.mf.Duration)*m.bitRate*1000) / 8
|
||||
log.Trace(m.ctx, "Seeking transcoded stream", "path", m.mf.Path, "offset", offset, "whence", whence, "size", size)
|
||||
func cacheKey(id string, bitRate int, format string) string {
|
||||
return fmt.Sprintf("%s.%d.%s", id, bitRate, format)
|
||||
}
|
||||
|
||||
switch whence {
|
||||
case io.SeekEnd:
|
||||
m.skip = size - offset
|
||||
offset = size
|
||||
case io.SeekStart:
|
||||
m.skip = offset
|
||||
case io.SeekCurrent:
|
||||
io.CopyN(ioutil.Discard, m.pipe, offset)
|
||||
m.pos += offset
|
||||
offset = m.pos
|
||||
}
|
||||
|
||||
// If need to Seek to a previous position, close the pipe (will be restarted on next Read)
|
||||
var err error
|
||||
if whence != io.SeekCurrent {
|
||||
if m.pipe != nil {
|
||||
err = m.Close()
|
||||
func getFinalCachedSize(r fscache.ReadAtCloser) int64 {
|
||||
cr, ok := r.(*fscache.CacheReader)
|
||||
if ok {
|
||||
size, final, err := cr.Size()
|
||||
if final && err == nil {
|
||||
return size
|
||||
}
|
||||
}
|
||||
return offset, err
|
||||
return -1
|
||||
}
|
||||
|
||||
func (m *transcodedMediaStream) ContentType() string {
|
||||
return mime.TypeByExtension(".mp3")
|
||||
}
|
||||
|
||||
func (m *transcodedMediaStream) Name() string {
|
||||
return m.mf.Path
|
||||
}
|
||||
|
||||
func (m *transcodedMediaStream) ModTime() time.Time {
|
||||
return m.mf.UpdatedAt
|
||||
}
|
||||
|
||||
func (m *transcodedMediaStream) Duration() int {
|
||||
return m.mf.Duration
|
||||
}
|
||||
|
||||
func (m *transcodedMediaStream) Close() error {
|
||||
log.Trace(m.ctx, "Closing stream", "id", m.mf.ID, "path", m.mf.Path)
|
||||
err := m.pipe.Close()
|
||||
m.pipe = nil
|
||||
m.pos = 0
|
||||
return err
|
||||
}
|
||||
|
||||
func newTranscode(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
|
||||
cmdLine, args := createTranscodeCommand(path, maxBitRate, format)
|
||||
|
||||
log.Trace(ctx, "Executing ffmpeg command", "arg0", cmdLine, "args", args)
|
||||
cmd := exec.Command(cmdLine, args...)
|
||||
cmd.Stderr = os.Stderr
|
||||
if f, err = cmd.StdoutPipe(); err != nil {
|
||||
return f, err
|
||||
}
|
||||
if err = cmd.Start(); err != nil {
|
||||
return f, err
|
||||
}
|
||||
go cmd.Wait() // prevent zombies
|
||||
return f, err
|
||||
}
|
||||
|
||||
func createTranscodeCommand(path string, maxBitRate int, format string) (string, []string) {
|
||||
cmd := conf.Server.DownsampleCommand
|
||||
|
||||
split := strings.Split(cmd, " ")
|
||||
for i, s := range split {
|
||||
s = strings.Replace(s, "%s", path, -1)
|
||||
s = strings.Replace(s, "%b", strconv.Itoa(maxBitRate), -1)
|
||||
split[i] = s
|
||||
}
|
||||
|
||||
return split[0], split[1:]
|
||||
func NewTranscodingCache() (TranscodingCache, error) {
|
||||
return newFileCache("transcoding", conf.Server.TranscodingCacheSize, consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"time"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
@@ -12,64 +13,185 @@ import (
|
||||
)
|
||||
|
||||
var _ = Describe("MediaStreamer", func() {
|
||||
|
||||
var streamer MediaStreamer
|
||||
var ds model.DataStore
|
||||
ffmpeg := &fakeFFmpeg{Data: "fake data"}
|
||||
ctx := log.NewContext(nil)
|
||||
|
||||
BeforeEach(func() {
|
||||
conf.Server.EnableDownsampling = true
|
||||
ds = &persistence.MockDataStore{}
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "bitRate": 128}]`, 1)
|
||||
streamer = NewMediaStreamer(ds)
|
||||
ds = &persistence.MockDataStore{MockedTranscoding: &mockTranscodingRepository{}}
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "suffix": "mp3", "bitRate": 128, "duration": 257.0}]`)
|
||||
streamer = NewMediaStreamer(ds, ffmpeg, testCache)
|
||||
})
|
||||
|
||||
Context("NewStream", func() {
|
||||
It("returns a rawMediaStream if format is 'raw'", func() {
|
||||
Expect(streamer.NewStream(ctx, "123", 0, "raw")).To(BeAssignableToTypeOf(&rawMediaStream{}))
|
||||
It("returns a seekable stream if format is 'raw'", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "raw", 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a rawMediaStream if maxBitRate is 0", func() {
|
||||
Expect(streamer.NewStream(ctx, "123", 0, "mp3")).To(BeAssignableToTypeOf(&rawMediaStream{}))
|
||||
It("returns a seekable stream if maxBitRate is 0", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a rawMediaStream if maxBitRate is higher than file bitRate", func() {
|
||||
Expect(streamer.NewStream(ctx, "123", 256, "mp3")).To(BeAssignableToTypeOf(&rawMediaStream{}))
|
||||
It("returns a seekable stream if maxBitRate is higher than file bitRate", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 320)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a transcodedMediaStream if maxBitRate is lower than file bitRate", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", 64, "mp3")
|
||||
It("returns a NON seekable stream if transcode is required", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 64)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s).To(BeAssignableToTypeOf(&transcodedMediaStream{}))
|
||||
Expect(s.(*transcodedMediaStream).bitRate).To(Equal(64))
|
||||
Expect(s.Seekable()).To(BeFalse())
|
||||
Expect(s.Duration()).To(Equal(float32(257.0)))
|
||||
})
|
||||
It("returns a seekable stream if the file is complete in the cache", func() {
|
||||
Eventually(func() bool { return ffmpeg.closed }).Should(BeTrue())
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 64)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("rawMediaStream", func() {
|
||||
var rawStream mediaStream
|
||||
var modTime time.Time
|
||||
|
||||
BeforeEach(func() {
|
||||
modTime = time.Now()
|
||||
mf := &model.MediaFile{ID: "123", Path: "test.mp3", UpdatedAt: modTime, Suffix: "mp3"}
|
||||
rawStream = &rawMediaStream{mf: mf, ctx: ctx}
|
||||
Context("selectTranscodingOptions", func() {
|
||||
mf := &model.MediaFile{}
|
||||
Context("player is not configured", func() {
|
||||
It("returns raw if raw is requested", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns raw if a transcoder does not exists", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns the requested format if a transcoder exists", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
||||
})
|
||||
It("returns raw if requested format is the same as the original and it is not necessary to downsample", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 112
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns the requested format if requested BitRate is lower than original", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 320
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(192))
|
||||
})
|
||||
It("returns raw if requested format is the same as the original, but requested BitRate is 0", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 320
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
Expect(bitRate).To(Equal(320))
|
||||
})
|
||||
})
|
||||
|
||||
It("returns the ContentType", func() {
|
||||
Expect(rawStream.ContentType()).To(Equal("audio/mpeg"))
|
||||
Context("player has format configured", func() {
|
||||
BeforeEach(func() {
|
||||
t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
|
||||
ctx = context.WithValue(ctx, "transcoding", t)
|
||||
})
|
||||
It("returns raw if raw is requested", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns configured format/bitrate as default", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(96))
|
||||
})
|
||||
It("returns requested format", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
||||
})
|
||||
It("returns requested bitrate", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(80))
|
||||
})
|
||||
It("returns raw if selected bitrate and format is the same as original", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 192
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
|
||||
Expect(format).To(Equal("raw"))
|
||||
Expect(bitRate).To(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
It("returns the ModTime", func() {
|
||||
Expect(rawStream.ModTime()).To(Equal(modTime))
|
||||
Context("player has maxBitRate configured", func() {
|
||||
BeforeEach(func() {
|
||||
t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
|
||||
p := model.Player{ID: "player1", TranscodingId: t.ID, MaxBitRate: 80}
|
||||
ctx = context.WithValue(ctx, "transcoding", t)
|
||||
ctx = context.WithValue(ctx, "player", p)
|
||||
})
|
||||
It("returns raw if raw is requested", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns configured format/bitrate as default", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(80))
|
||||
})
|
||||
It("returns requested format", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
||||
})
|
||||
It("returns requested bitrate", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(80))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("createTranscodeCommand", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DownsampleCommand = "ffmpeg -i %s -b:a %bk mp3 -"
|
||||
})
|
||||
It("creates a valid command line", func() {
|
||||
cmd, args := createTranscodeCommand("/music library/file.mp3", 123, "")
|
||||
Expect(cmd).To(Equal("ffmpeg"))
|
||||
Expect(args).To(Equal([]string{"-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
type fakeFFmpeg struct {
|
||||
Data string
|
||||
r io.Reader
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (ff *fakeFFmpeg) Start(ctx context.Context, cmd, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
|
||||
ff.r = strings.NewReader(ff.Data)
|
||||
return ff, nil
|
||||
}
|
||||
|
||||
func (ff *fakeFFmpeg) Read(p []byte) (n int, err error) {
|
||||
return ff.r.Read(p)
|
||||
}
|
||||
|
||||
func (ff *fakeFFmpeg) Close() error {
|
||||
ff.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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
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
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
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 {
|
||||
@@ -127,7 +131,7 @@ func (p *playlists) Get(ctx context.Context, id string) (*PlaylistInfo, error) {
|
||||
Id: pl.ID,
|
||||
Name: pl.Name,
|
||||
SongCount: len(pl.Tracks),
|
||||
Duration: pl.Duration,
|
||||
Duration: int(pl.Duration),
|
||||
Public: pl.Public,
|
||||
Owner: pl.Owner,
|
||||
Comment: pl.Comment,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
@@ -42,6 +43,9 @@ func (s *scrobbler) Register(ctx context.Context, playerId int, trackId string,
|
||||
err = s.ds.Artist(ctx).IncPlayCount(mf.ArtistID, playTime)
|
||||
return err
|
||||
})
|
||||
|
||||
log.Info("Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", userName(ctx))
|
||||
|
||||
return mf, err
|
||||
}
|
||||
|
||||
@@ -56,6 +60,8 @@ func (s *scrobbler) NowPlaying(ctx context.Context, playerId int, playerName, tr
|
||||
return nil, errors.New(fmt.Sprintf(`ID "%s" not found`, trackId))
|
||||
}
|
||||
|
||||
log.Info("Now Playing", "title", mf.Title, "artist", mf.Artist, "user", userName(ctx))
|
||||
|
||||
info := &NowPlayingInfo{TrackID: trackId, Username: username, Start: time.Now(), PlayerId: playerId, PlayerName: playerName}
|
||||
return mf, s.npRepo.Enqueue(info)
|
||||
}
|
||||
|
||||
50
engine/transcoder/ffmpeg.go
Normal file
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
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", "-"}))
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,9 @@
|
||||
package engine
|
||||
|
||||
import "github.com/google/wire"
|
||||
import (
|
||||
"github.com/deluan/navidrome/engine/transcoder"
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
var Set = wire.NewSet(
|
||||
NewBrowser,
|
||||
@@ -13,4 +16,8 @@ var Set = wire.NewSet(
|
||||
NewNowPlayingRepository,
|
||||
NewUsers,
|
||||
NewMediaStreamer,
|
||||
transcoder.New,
|
||||
NewTranscodingCache,
|
||||
NewImageCache,
|
||||
NewPlayers,
|
||||
)
|
||||
|
||||
18
go.mod
18
go.mod
@@ -1,19 +1,22 @@
|
||||
module github.com/deluan/navidrome
|
||||
|
||||
go 1.13
|
||||
go 1.14
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v0.3.1 // indirect
|
||||
github.com/Masterminds/squirrel v1.2.0
|
||||
github.com/astaxie/beego v1.12.1
|
||||
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible
|
||||
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4
|
||||
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/djherbis/fscache v0.10.0
|
||||
github.com/dustin/go-humanize v1.0.0
|
||||
github.com/fatih/camelcase v0.0.0-20160318181535-f6a740d52f96 // indirect
|
||||
github.com/fatih/structs v1.0.0 // indirect
|
||||
github.com/go-chi/chi v4.0.3+incompatible
|
||||
github.com/go-chi/cors v1.0.0
|
||||
github.com/go-chi/chi v4.1.0+incompatible
|
||||
github.com/go-chi/cors v1.0.1
|
||||
github.com/go-chi/jwtauth v4.0.4+incompatible
|
||||
github.com/go-sql-driver/mysql v1.5.0 // indirect
|
||||
github.com/golang/protobuf v1.3.1 // indirect
|
||||
@@ -24,19 +27,18 @@ require (
|
||||
github.com/kr/pretty v0.1.0 // indirect
|
||||
github.com/lib/pq v1.3.0 // indirect
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible
|
||||
github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5
|
||||
github.com/onsi/ginkgo v1.12.0
|
||||
github.com/onsi/gomega v1.9.0
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pressly/goose v2.6.0+incompatible
|
||||
github.com/sirupsen/logrus v1.4.2
|
||||
github.com/smartystreets/assertions v1.0.1 // indirect
|
||||
github.com/smartystreets/goconvey v1.6.4
|
||||
github.com/sirupsen/logrus v1.5.0
|
||||
github.com/stretchr/testify v1.4.0 // indirect
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2 // indirect
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 // indirect
|
||||
golang.org/x/text v0.3.2 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
|
||||
gopkg.in/djherbis/atime.v1 v1.0.0 // indirect
|
||||
gopkg.in/djherbis/stream.v1 v1.2.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.2.8 // indirect
|
||||
)
|
||||
|
||||
31
go.sum
31
go.sum
@@ -22,12 +22,18 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4 h1:VSoAcWJvj656TSyWbJ5KuGsi/J8dO5+iO9+5/7I8wao=
|
||||
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
||||
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0 h1:qHX6TTBIsrpg0JkYNkoePIpstV37lhRVLj23bHUDNwk=
|
||||
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8 h1:nmFnmD8VZXkjDHIb1Gnfz50cgzUvGN72zLjPRXBW/hU=
|
||||
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8/go.mod h1:SniNVYuaD1jmdEEvi+7ywb1QFR7agjeTdGKyFb0p7Rw=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/djherbis/fscache v0.10.0 h1:+O3s3LwKL1Jfz7txRHpgKOz8/krh9w+NzfzxtFdbsQg=
|
||||
github.com/djherbis/fscache v0.10.0/go.mod h1:yyPYtkNnnPXsW+81lAcQS6yab3G2CRfnPLotBvtbf0c=
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8=
|
||||
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
|
||||
github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
|
||||
@@ -37,10 +43,10 @@ github.com/fatih/structs v1.0.0 h1:BrX964Rv5uQ3wwS+KRUAJCBBw5PQmgJfJ6v4yly5QwU=
|
||||
github.com/fatih/structs v1.0.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/go-chi/chi v4.0.3+incompatible h1:gakN3pDJnzZN5jqFV2TEdF66rTfKeITyR8qu6ekICEY=
|
||||
github.com/go-chi/chi v4.0.3+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||
github.com/go-chi/cors v1.0.0 h1:e6x8k7uWbUwYs+aXDoiUzeQFT6l0cygBYyNhD7/1Tg0=
|
||||
github.com/go-chi/cors v1.0.0/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
|
||||
github.com/go-chi/chi v4.1.0+incompatible h1:ETj3cggsVIY2Xao5ExCu6YhEh5MD6JTfcBzS37R260w=
|
||||
github.com/go-chi/chi v4.1.0+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||
github.com/go-chi/cors v1.0.1 h1:56TT/uWGoLWZpnMI/AwAmCneikXr5eLsiIq27wrKecw=
|
||||
github.com/go-chi/cors v1.0.1/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
|
||||
github.com/go-chi/jwtauth v4.0.4+incompatible h1:LGIxg6YfvSBzxU2BljXbrzVc1fMlgqSKBQgKOGAVtPY=
|
||||
github.com/go-chi/jwtauth v4.0.4+incompatible/go.mod h1:Q5EIArY/QnD6BdS+IyDw7B2m6iNbnPxtfd6/BcmtWbs=
|
||||
github.com/go-redis/redis v6.14.2+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
|
||||
@@ -89,8 +95,6 @@ github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5 h1:BvoENQQU+fZ9uukda/RzCAL/191HHwJA5b13R6diVlY=
|
||||
github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.0 h1:Iw5WCbBcaAAd0fpRb1c9r5YCylv4XDoCSigm1zLevwU=
|
||||
github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
|
||||
@@ -115,10 +119,10 @@ github.com/siddontang/rdb v0.0.0-20150307021120-fc89ed2e418d h1:NVwnfyR3rENtlz62
|
||||
github.com/siddontang/rdb v0.0.0-20150307021120-fc89ed2e418d/go.mod h1:AMEsy7v5z92TR1JKMkLLoaOQk++LVnOKL3ScbJ8GNGA=
|
||||
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sirupsen/logrus v1.5.0 h1:1N5EYkVAPEywqZRJd7cwnRtCb6xJx7NH3T3WUTF980Q=
|
||||
github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w=
|
||||
github.com/smartystreets/assertions v1.0.1/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM=
|
||||
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/ssdb/gossdb v0.0.0-20180723034631-88f6b59b84ec/go.mod h1:QBvMkMya+gXctz3kmljlUCu/yB3GZ6oee+dUozsezQE=
|
||||
@@ -133,6 +137,8 @@ github.com/wendal/errors v0.0.0-20130201093226-f66c77a7882b/go.mod h1:Q12BUT7DqI
|
||||
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-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628=
|
||||
@@ -162,6 +168,7 @@ 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=
|
||||
@@ -172,6 +179,10 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/djherbis/atime.v1 v1.0.0 h1:eMRqB/JrLKocla2PBPKgQYg/p5UG4L6AUAs92aP7F60=
|
||||
gopkg.in/djherbis/atime.v1 v1.0.0/go.mod h1:hQIUStKmJfvf7xdh/wtK84qe+DsTV5LnA9lzxxtPpJ8=
|
||||
gopkg.in/djherbis/stream.v1 v1.2.0 h1:3tZuXO+RK8opjw8/BJr780h+eAPwOFfLHCKRKyYxk3s=
|
||||
gopkg.in/djherbis/stream.v1 v1.2.0/go.mod h1:aEV8CBVRmSpLamVJfM903Npic1IKmb2qS30VAZ+sssg=
|
||||
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
|
||||
13
lefthook.yml
Normal file
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}; git add {staged_files}
|
||||
162
log/log_test.go
162
log/log_test.go
@@ -6,105 +6,175 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestLog(t *testing.T) {
|
||||
SetLevel(LevelInfo)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Log Suite")
|
||||
}
|
||||
|
||||
Convey("Test Logger", t, func() {
|
||||
l, hook := test.NewNullLogger()
|
||||
var _ = Describe("Logger", func() {
|
||||
var l *logrus.Logger
|
||||
var hook *test.Hook
|
||||
|
||||
BeforeEach(func() {
|
||||
l, hook = test.NewNullLogger()
|
||||
SetLevel(LevelInfo)
|
||||
SetDefaultLogger(l)
|
||||
})
|
||||
|
||||
Convey("Plain message", func() {
|
||||
Context("Logging", func() {
|
||||
It("logs a simple message", func() {
|
||||
Error("Simple Message")
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Simple Message")
|
||||
So(hook.LastEntry().Data, ShouldBeEmpty)
|
||||
Expect(hook.LastEntry().Message).To(Equal("Simple Message"))
|
||||
Expect(hook.LastEntry().Data).To(BeEmpty())
|
||||
})
|
||||
|
||||
Convey("Passing nil as context", func() {
|
||||
It("logs a message when context is nil", func() {
|
||||
Error(nil, "Simple Message")
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Simple Message")
|
||||
So(hook.LastEntry().Data, ShouldBeEmpty)
|
||||
Expect(hook.LastEntry().Message).To(Equal("Simple Message"))
|
||||
Expect(hook.LastEntry().Data).To(BeEmpty())
|
||||
})
|
||||
|
||||
SkipConvey("Empty context", func() {
|
||||
XIt("Empty context", func() {
|
||||
Error(context.Background(), "Simple Message")
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Simple Message")
|
||||
So(hook.LastEntry().Data, ShouldBeEmpty)
|
||||
Expect(hook.LastEntry().Message).To(Equal("Simple Message"))
|
||||
Expect(hook.LastEntry().Data).To(BeEmpty())
|
||||
})
|
||||
|
||||
Convey("Message with two kv pairs", func() {
|
||||
It("logs messages with two kv pairs", func() {
|
||||
Error("Simple Message", "key1", "value1", "key2", "value2")
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Simple Message")
|
||||
So(hook.LastEntry().Data["key1"], ShouldEqual, "value1")
|
||||
So(hook.LastEntry().Data["key2"], ShouldEqual, "value2")
|
||||
So(hook.LastEntry().Data, ShouldHaveLength, 2)
|
||||
Expect(hook.LastEntry().Message).To(Equal("Simple Message"))
|
||||
Expect(hook.LastEntry().Data["key1"]).To(Equal("value1"))
|
||||
Expect(hook.LastEntry().Data["key2"]).To(Equal("value2"))
|
||||
Expect(hook.LastEntry().Data).To(HaveLen(2))
|
||||
})
|
||||
|
||||
Convey("Only error", func() {
|
||||
It("logs error objects as simple messages", func() {
|
||||
Error(errors.New("error test"))
|
||||
So(hook.LastEntry().Message, ShouldEqual, "error test")
|
||||
So(hook.LastEntry().Data, ShouldBeEmpty)
|
||||
Expect(hook.LastEntry().Message).To(Equal("error test"))
|
||||
Expect(hook.LastEntry().Data).To(BeEmpty())
|
||||
})
|
||||
|
||||
Convey("Error as last argument", func() {
|
||||
It("logs errors passed as last argument", func() {
|
||||
Error("Error scrobbling track", "id", 1, errors.New("some issue"))
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Error scrobbling track")
|
||||
So(hook.LastEntry().Data["id"], ShouldEqual, 1)
|
||||
So(hook.LastEntry().Data["error"], ShouldEqual, "some issue")
|
||||
So(hook.LastEntry().Data, ShouldHaveLength, 2)
|
||||
Expect(hook.LastEntry().Message).To(Equal("Error scrobbling track"))
|
||||
Expect(hook.LastEntry().Data["id"]).To(Equal(1))
|
||||
Expect(hook.LastEntry().Data["error"]).To(Equal("some issue"))
|
||||
Expect(hook.LastEntry().Data).To(HaveLen(2))
|
||||
})
|
||||
|
||||
Convey("Passing a request", func() {
|
||||
It("can get data from the request's context", func() {
|
||||
ctx := NewContext(nil, "foo", "bar")
|
||||
req := httptest.NewRequest("get", "/", nil).WithContext(ctx)
|
||||
|
||||
Error(req, "Simple Message", "key1", "value1")
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Simple Message")
|
||||
So(hook.LastEntry().Data["foo"], ShouldEqual, "bar")
|
||||
So(hook.LastEntry().Data["key1"], ShouldEqual, "value1")
|
||||
So(hook.LastEntry().Data, ShouldHaveLength, 2)
|
||||
|
||||
Expect(hook.LastEntry().Message).To(Equal("Simple Message"))
|
||||
Expect(hook.LastEntry().Data["foo"]).To(Equal("bar"))
|
||||
Expect(hook.LastEntry().Data["key1"]).To(Equal("value1"))
|
||||
Expect(hook.LastEntry().Data).To(HaveLen(2))
|
||||
})
|
||||
|
||||
Convey("Skip if level is lower", func() {
|
||||
It("does not log anything if level is lower", func() {
|
||||
SetLevel(LevelError)
|
||||
Info("Simple Message")
|
||||
So(hook.LastEntry(), ShouldBeNil)
|
||||
Expect(hook.LastEntry()).To(BeNil())
|
||||
})
|
||||
|
||||
It("logs source file and line number, if requested", func() {
|
||||
SetLogSourceLine(true)
|
||||
Error("A crash happened")
|
||||
Expect(hook.LastEntry().Message).To(Equal("A crash happened"))
|
||||
// NOTE: This assertions breaks if the line number changes
|
||||
Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:92"))
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Test extractLogger", t, func() {
|
||||
Convey("It returns an error if the context is nil", func() {
|
||||
Context("Levels", func() {
|
||||
BeforeEach(func() {
|
||||
SetLevel(LevelTrace)
|
||||
})
|
||||
It("logs error messages", func() {
|
||||
Error("msg")
|
||||
Expect(hook.LastEntry().Level).To(Equal(logrus.ErrorLevel))
|
||||
})
|
||||
It("logs warn messages", func() {
|
||||
Warn("msg")
|
||||
Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel))
|
||||
})
|
||||
It("logs info messages", func() {
|
||||
Info("msg")
|
||||
Expect(hook.LastEntry().Level).To(Equal(logrus.InfoLevel))
|
||||
})
|
||||
It("logs debug messages", func() {
|
||||
Debug("msg")
|
||||
Expect(hook.LastEntry().Level).To(Equal(logrus.DebugLevel))
|
||||
})
|
||||
It("logs info messages", func() {
|
||||
Trace("msg")
|
||||
Expect(hook.LastEntry().Level).To(Equal(logrus.TraceLevel))
|
||||
})
|
||||
})
|
||||
|
||||
Context("extractLogger", func() {
|
||||
It("returns an error if the context is nil", func() {
|
||||
_, err := extractLogger(nil)
|
||||
So(err, ShouldNotBeNil)
|
||||
Expect(err).ToNot(BeNil())
|
||||
})
|
||||
|
||||
Convey("It returns an error if the context is a string", func() {
|
||||
It("returns an error if the context is a string", func() {
|
||||
_, err := extractLogger("any msg")
|
||||
So(err, ShouldNotBeNil)
|
||||
Expect(err).ToNot(BeNil())
|
||||
})
|
||||
|
||||
Convey("It returns the logger from context if it has one", func() {
|
||||
It("returns the logger from context if it has one", func() {
|
||||
logger := logrus.NewEntry(logrus.New())
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, "logger", logger)
|
||||
|
||||
l, err := extractLogger(ctx)
|
||||
So(err, ShouldBeNil)
|
||||
So(l, ShouldEqual, logger)
|
||||
Expect(extractLogger(ctx)).To(Equal(logger))
|
||||
})
|
||||
|
||||
Convey("It returns the logger from request's context if it has one", func() {
|
||||
It("returns the logger from request's context if it has one", func() {
|
||||
logger := logrus.NewEntry(logrus.New())
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, "logger", logger)
|
||||
req := httptest.NewRequest("get", "/", nil).WithContext(ctx)
|
||||
l, err := extractLogger(req)
|
||||
So(err, ShouldBeNil)
|
||||
So(l, ShouldEqual, logger)
|
||||
|
||||
Expect(extractLogger(req)).To(Equal(logger))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Context("SetLevelString", func() {
|
||||
It("converts Critical level", func() {
|
||||
SetLevelString("Critical")
|
||||
Expect(CurrentLevel()).To(Equal(LevelCritical))
|
||||
})
|
||||
It("converts Error level", func() {
|
||||
SetLevelString("ERROR")
|
||||
Expect(CurrentLevel()).To(Equal(LevelError))
|
||||
})
|
||||
It("converts Warn level", func() {
|
||||
SetLevelString("warn")
|
||||
Expect(CurrentLevel()).To(Equal(LevelWarn))
|
||||
})
|
||||
It("converts Info level", func() {
|
||||
SetLevelString("info")
|
||||
Expect(CurrentLevel()).To(Equal(LevelInfo))
|
||||
})
|
||||
It("converts Debug level", func() {
|
||||
SetLevelString("debug")
|
||||
Expect(CurrentLevel()).To(Equal(LevelDebug))
|
||||
})
|
||||
It("converts Trace level", func() {
|
||||
SetLevelString("trace")
|
||||
Expect(CurrentLevel()).To(Equal(LevelTrace))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
14
main.go
14
main.go
@@ -1,21 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/db"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if !conf.Server.DevDisableBanner {
|
||||
println(consts.Banner())
|
||||
}
|
||||
println(consts.Banner())
|
||||
|
||||
conf.Load()
|
||||
db.EnsureLatestVersion()
|
||||
|
||||
subsonic, err := CreateSubsonicAPIRouter()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Could not create the Subsonic API router. Aborting! err=%v", err))
|
||||
}
|
||||
a := CreateServer(conf.Server.MusicFolder)
|
||||
a.MountRouter("/rest", CreateSubsonicAPIRouter())
|
||||
a.MountRouter("/app", CreateAppRouter("/app"))
|
||||
a.MountRouter(consts.URLPathSubsonicAPI, subsonic)
|
||||
a.MountRouter(consts.URLPathUI, CreateAppRouter())
|
||||
a.Run(":" + conf.Server.Port)
|
||||
}
|
||||
|
||||
@@ -3,27 +3,30 @@ package model
|
||||
import "time"
|
||||
|
||||
type Album struct {
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
ArtistID string `json:"artistId" orm:"pk;column(artist_id)"`
|
||||
CoverArtPath string `json:"coverArtPath"`
|
||||
CoverArtId string `json:"coverArtId"`
|
||||
Artist string `json:"artist"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
Year int `json:"year"`
|
||||
Compilation bool `json:"compilation"`
|
||||
SongCount int `json:"songCount"`
|
||||
Duration int `json:"duration"`
|
||||
Genre string `json:"genre"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
CoverArtPath string `json:"coverArtPath"`
|
||||
CoverArtId string `json:"coverArtId"`
|
||||
ArtistID string `json:"artistId" orm:"pk;column(artist_id)"`
|
||||
Artist string `json:"artist"`
|
||||
AlbumArtistID string `json:"albumArtistId" orm:"pk;column(album_artist_id)"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
MaxYear int `json:"maxYear"`
|
||||
MinYear int `json:"minYear"`
|
||||
Compilation bool `json:"compilation"`
|
||||
SongCount int `json:"songCount"`
|
||||
Duration float32 `json:"duration"`
|
||||
Genre string `json:"genre"`
|
||||
FullText string `json:"fullText"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
// Annotations
|
||||
PlayCount int `json:"-" orm:"-"`
|
||||
PlayDate time.Time `json:"-" orm:"-"`
|
||||
Rating int `json:"-" orm:"-"`
|
||||
Starred bool `json:"-" orm:"-"`
|
||||
StarredAt time.Time `json:"-" orm:"-"`
|
||||
PlayCount int `json:"playCount" orm:"-"`
|
||||
PlayDate time.Time `json:"playDate" orm:"-"`
|
||||
Rating int `json:"rating" orm:"-"`
|
||||
Starred bool `json:"starred" orm:"-"`
|
||||
StarredAt time.Time `json:"starredAt" orm:"-"`
|
||||
}
|
||||
|
||||
type Albums []Album
|
||||
@@ -31,9 +34,8 @@ type Albums []Album
|
||||
type AlbumRepository interface {
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
Exists(id string) (bool, error)
|
||||
Put(m *Album) error
|
||||
Get(id string) (*Album, error)
|
||||
FindByArtist(artistId string) (Albums, error)
|
||||
FindByArtist(albumArtistId string) (Albums, error)
|
||||
GetAll(...QueryOptions) (Albums, error)
|
||||
GetRandom(...QueryOptions) (Albums, error)
|
||||
GetStarred(options ...QueryOptions) (Albums, error)
|
||||
|
||||
@@ -7,3 +7,7 @@ type AnnotatedRepository interface {
|
||||
SetStar(starred bool, itemIDs ...string) error
|
||||
SetRating(rating int, itemID string) error
|
||||
}
|
||||
|
||||
// While I can't find a better way to make these fields optional in the models, I keep this list here
|
||||
// to be used in other packages
|
||||
var AnnotationFields = []string{"playCount", "playDate", "rating", "starred", "starredAt"}
|
||||
|
||||
@@ -6,13 +6,14 @@ type Artist struct {
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
AlbumCount int `json:"albumCount" orm:"column(album_count)"`
|
||||
FullText string `json:"fullText"`
|
||||
|
||||
// Annotations
|
||||
PlayCount int `json:"-" orm:"-"`
|
||||
PlayDate time.Time `json:"-" orm:"-"`
|
||||
Rating int `json:"-" orm:"-"`
|
||||
Starred bool `json:"-" orm:"-"`
|
||||
StarredAt time.Time `json:"-" orm:"-"`
|
||||
PlayCount int `json:"playCount" orm:"-"`
|
||||
PlayDate time.Time `json:"playDate" orm:"-"`
|
||||
Rating int `json:"rating" orm:"-"`
|
||||
Starred bool `json:"starred" orm:"-"`
|
||||
StarredAt time.Time `json:"starredAt" orm:"-"`
|
||||
}
|
||||
|
||||
type Artists []Artist
|
||||
|
||||
@@ -28,6 +28,8 @@ type DataStore interface {
|
||||
Playlist(ctx context.Context) PlaylistRepository
|
||||
Property(ctx context.Context) PropertyRepository
|
||||
User(ctx context.Context) UserRepository
|
||||
Transcoding(ctx context.Context) TranscodingRepository
|
||||
Player(ctx context.Context) PlayerRepository
|
||||
|
||||
Resource(ctx context.Context, model interface{}) ResourceRepository
|
||||
|
||||
|
||||
@@ -6,33 +6,35 @@ import (
|
||||
)
|
||||
|
||||
type MediaFile struct {
|
||||
ID string `json:"id" orm:"pk;column(id)"`
|
||||
Path string `json:"path"`
|
||||
Title string `json:"title"`
|
||||
Album string `json:"album"`
|
||||
Artist string `json:"artist"`
|
||||
ArtistID string `json:"artistId" orm:"pk;column(artist_id)"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
AlbumID string `json:"albumId" orm:"pk;column(album_id)"`
|
||||
HasCoverArt bool `json:"hasCoverArt"`
|
||||
TrackNumber int `json:"trackNumber"`
|
||||
DiscNumber int `json:"discNumber"`
|
||||
Year int `json:"year"`
|
||||
Size int `json:"size"`
|
||||
Suffix string `json:"suffix"`
|
||||
Duration int `json:"duration"`
|
||||
BitRate int `json:"bitRate"`
|
||||
Genre string `json:"genre"`
|
||||
Compilation bool `json:"compilation"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id" orm:"pk;column(id)"`
|
||||
Path string `json:"path"`
|
||||
Title string `json:"title"`
|
||||
Album string `json:"album"`
|
||||
ArtistID string `json:"artistId" orm:"pk;column(artist_id)"`
|
||||
Artist string `json:"artist"`
|
||||
AlbumArtistID string `json:"albumArtistId"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
AlbumID string `json:"albumId" orm:"pk;column(album_id)"`
|
||||
HasCoverArt bool `json:"hasCoverArt"`
|
||||
TrackNumber int `json:"trackNumber"`
|
||||
DiscNumber int `json:"discNumber"`
|
||||
Year int `json:"year"`
|
||||
Size int `json:"size"`
|
||||
Suffix string `json:"suffix"`
|
||||
Duration float32 `json:"duration"`
|
||||
BitRate int `json:"bitRate"`
|
||||
Genre string `json:"genre"`
|
||||
FullText string `json:"fullText"`
|
||||
Compilation bool `json:"compilation"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
// Annotations
|
||||
PlayCount int `json:"-" orm:"-"`
|
||||
PlayDate time.Time `json:"-" orm:"-"`
|
||||
Rating int `json:"-" orm:"-"`
|
||||
Starred bool `json:"-" orm:"-"`
|
||||
StarredAt time.Time `json:"-" orm:"-"`
|
||||
PlayCount int `json:"playCount" orm:"-"`
|
||||
PlayDate time.Time `json:"playDate" orm:"-"`
|
||||
Rating int `json:"rating" orm:"-"`
|
||||
Starred bool `json:"starred" orm:"-"`
|
||||
StarredAt time.Time `json:"starredAt" orm:"-"`
|
||||
}
|
||||
|
||||
func (mf *MediaFile) ContentType() string {
|
||||
|
||||
25
model/player.go
Normal file
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
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)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ type UserRepository interface {
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
Get(id string) (*User, error)
|
||||
Put(*User) error
|
||||
// FindByUsername must be case-insensitive
|
||||
FindByUsername(username string) (*User, error)
|
||||
UpdateLastLoginAt(id string) error
|
||||
UpdateLastAccessAt(id string) error
|
||||
|
||||
@@ -6,14 +6,15 @@ import (
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/dhowden/tag/mbz"
|
||||
)
|
||||
|
||||
type albumRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
func NewAlbumRepository(ctx context.Context, o orm.Ormer) model.AlbumRepository {
|
||||
@@ -21,9 +22,41 @@ func NewAlbumRepository(ctx context.Context, o orm.Ormer) model.AlbumRepository
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
r.tableName = "album"
|
||||
r.sortMappings = map[string]string{
|
||||
"artist": "compilation asc, album_artist asc, name asc",
|
||||
"random": "RANDOM()",
|
||||
}
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"name": fullTextFilter,
|
||||
"compilation": booleanFilter,
|
||||
"artist_id": artistFilter,
|
||||
"year": yearFilter,
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func yearFilter(field string, value interface{}) Sqlizer {
|
||||
return Or{
|
||||
And{
|
||||
Gt{"min_year": 0},
|
||||
LtOrEq{"min_year": value},
|
||||
GtOrEq{"max_year": value},
|
||||
},
|
||||
Eq{"max_year": value},
|
||||
}
|
||||
}
|
||||
|
||||
func artistFilter(field string, value interface{}) Sqlizer {
|
||||
return Exists("media_file", And{
|
||||
ConcatExpr("album_id=album.id"),
|
||||
Or{
|
||||
Eq{"artist_id": value},
|
||||
Eq{"album_artist_id": value},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
return r.count(Select(), options...)
|
||||
}
|
||||
@@ -32,16 +65,8 @@ func (r *albumRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(Select().Where(Eq{"id": id}))
|
||||
}
|
||||
|
||||
func (r *albumRepository) Put(a *model.Album) error {
|
||||
_, err := r.put(a.ID, a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.index(a.ID, a.Name, a.Artist, mbz.AlbumArtist)
|
||||
}
|
||||
|
||||
func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder {
|
||||
return r.newSelectWithAnnotation("id", options...).Columns("*")
|
||||
return r.newSelectWithAnnotation("album.id", options...).Columns("*")
|
||||
}
|
||||
|
||||
func (r *albumRepository) Get(id string) (*model.Album, error) {
|
||||
@@ -55,7 +80,7 @@ func (r *albumRepository) Get(id string) (*model.Album, error) {
|
||||
}
|
||||
|
||||
func (r *albumRepository) FindByArtist(artistId string) (model.Albums, error) {
|
||||
sq := r.selectAlbum().Where(Eq{"artist_id": artistId}).OrderBy("year")
|
||||
sq := r.selectAlbum().Where(Eq{"album_artist_id": artistId}).OrderBy("max_year")
|
||||
res := model.Albums{}
|
||||
err := r.queryAll(sq, &res)
|
||||
return res, err
|
||||
@@ -71,12 +96,7 @@ func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, e
|
||||
// TODO Keep order when paginating
|
||||
func (r *albumRepository) GetRandom(options ...model.QueryOptions) (model.Albums, error) {
|
||||
sq := r.selectAlbum(options...)
|
||||
switch r.ormer.Driver().Type() {
|
||||
case orm.DRMySQL:
|
||||
sq = sq.OrderBy("RAND()")
|
||||
default:
|
||||
sq = sq.OrderBy("RANDOM()")
|
||||
}
|
||||
sq = sq.OrderBy("RANDOM()")
|
||||
results := model.Albums{}
|
||||
err := r.queryAll(sq, &results)
|
||||
return results, err
|
||||
@@ -87,11 +107,13 @@ func (r *albumRepository) Refresh(ids ...string) error {
|
||||
model.Album
|
||||
CurrentId string
|
||||
HasCoverArt bool
|
||||
SongArtists string
|
||||
}
|
||||
var albums []refreshAlbum
|
||||
sel := Select(`album_id as id, album as name, f.artist, f.album_artist, f.artist_id, f.compilation, f.genre,
|
||||
max(f.year) as year, sum(f.duration) as duration, count(*) as song_count, a.id as current_id,
|
||||
f.id as cover_art_id, f.path as cover_art_path, f.has_cover_art`).
|
||||
sel := Select(`album_id as id, album as name, f.artist, f.album_artist, f.artist_id, f.album_artist_id,
|
||||
f.compilation, f.genre, max(f.year) as max_year, min(f.year) as min_year, sum(f.duration) as duration,
|
||||
count(*) as song_count, a.id as current_id, f.id as cover_art_id, f.path as cover_art_path,
|
||||
group_concat(f.artist, ' ') as song_artists, f.has_cover_art`).
|
||||
From("media_file f").
|
||||
LeftJoin("album a on f.album_id = a.id").
|
||||
Where(Eq{"f.album_id": ids}).GroupBy("album_id").OrderBy("f.id")
|
||||
@@ -107,10 +129,12 @@ func (r *albumRepository) Refresh(ids ...string) error {
|
||||
al.CoverArtId = ""
|
||||
}
|
||||
if al.Compilation {
|
||||
al.AlbumArtist = "Various Artists"
|
||||
al.AlbumArtist = consts.VariousArtists
|
||||
al.AlbumArtistID = consts.VariousArtistsID
|
||||
}
|
||||
if al.AlbumArtist == "" {
|
||||
al.AlbumArtist = al.Artist
|
||||
al.AlbumArtistID = al.ArtistID
|
||||
}
|
||||
al.UpdatedAt = time.Now()
|
||||
if al.CurrentId != "" {
|
||||
@@ -119,7 +143,8 @@ func (r *albumRepository) Refresh(ids ...string) error {
|
||||
toInsert++
|
||||
al.CreatedAt = time.Now()
|
||||
}
|
||||
err := r.Put(&al.Album)
|
||||
al.FullText = r.getFullText(al.Name, al.Artist, al.AlbumArtist, al.SongArtists)
|
||||
_, err := r.put(al.ID, al.Album)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
var repo model.AlbumRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"})
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
|
||||
repo = NewAlbumRepository(ctx, orm.NewOrm())
|
||||
})
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
type artistRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
indexGroups utils.IndexGroups
|
||||
}
|
||||
|
||||
@@ -25,11 +26,14 @@ func NewArtistRepository(ctx context.Context, o orm.Ormer) model.ArtistRepositor
|
||||
r.ormer = o
|
||||
r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups)
|
||||
r.tableName = "artist"
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"name": fullTextFilter,
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder {
|
||||
return r.newSelectWithAnnotation("id", options...).Columns("*")
|
||||
return r.newSelectWithAnnotation("artist.id", options...).Columns("*")
|
||||
}
|
||||
|
||||
func (r *artistRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
@@ -52,11 +56,9 @@ func (r *artistRepository) getIndexKey(a *model.Artist) string {
|
||||
}
|
||||
|
||||
func (r *artistRepository) Put(a *model.Artist) error {
|
||||
a.FullText = r.getFullText(a.Name)
|
||||
_, err := r.put(a.ID, a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.index(a.ID, a.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *artistRepository) Get(id string) (*model.Artist, error) {
|
||||
@@ -106,17 +108,14 @@ func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
|
||||
func (r *artistRepository) Refresh(ids ...string) error {
|
||||
type refreshArtist struct {
|
||||
model.Artist
|
||||
CurrentId string
|
||||
AlbumArtist string
|
||||
Compilation bool
|
||||
CurrentId string
|
||||
}
|
||||
var artists []refreshArtist
|
||||
sel := Select("f.artist_id as id", "f.artist as name", "f.album_artist", "f.compilation",
|
||||
"count(*) as album_count", "a.id as current_id").
|
||||
sel := Select("f.album_artist_id as id", "f.album_artist as name", "count(*) as album_count", "a.id as current_id").
|
||||
From("album f").
|
||||
LeftJoin("artist a on f.artist_id = a.id").
|
||||
Where(Eq{"f.artist_id": ids}).
|
||||
GroupBy("f.artist_id").OrderBy("f.id")
|
||||
LeftJoin("artist a on f.album_artist_id = a.id").
|
||||
Where(Eq{"f.album_artist_id": ids}).
|
||||
GroupBy("f.album_artist_id").OrderBy("f.id")
|
||||
err := r.queryAll(sel, &artists)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -125,12 +124,6 @@ func (r *artistRepository) Refresh(ids ...string) error {
|
||||
toInsert := 0
|
||||
toUpdate := 0
|
||||
for _, ar := range artists {
|
||||
if ar.Compilation {
|
||||
ar.AlbumArtist = "Various Artists"
|
||||
}
|
||||
if ar.AlbumArtist != "" {
|
||||
ar.Name = ar.AlbumArtist
|
||||
}
|
||||
if ar.CurrentId != "" {
|
||||
toUpdate++
|
||||
} else {
|
||||
@@ -158,7 +151,7 @@ func (r *artistRepository) GetStarred(options ...model.QueryOptions) (model.Arti
|
||||
}
|
||||
|
||||
func (r *artistRepository) PurgeEmpty() error {
|
||||
del := Delete(r.tableName).Where("id not in (select distinct(artist_id) from album)")
|
||||
del := Delete(r.tableName).Where("id not in (select distinct(album_artist_id) from album)")
|
||||
c, err := r.executeSQL(del)
|
||||
if err == nil {
|
||||
if c > 0 {
|
||||
|
||||
@@ -14,7 +14,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
var repo model.ArtistRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"})
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
|
||||
repo = NewArtistRepository(ctx, orm.NewOrm())
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Exist", func() {
|
||||
Describe("Exists", func() {
|
||||
It("returns true for an artist that is in the DB", func() {
|
||||
Expect(repo.Exists("3")).To(BeTrue())
|
||||
})
|
||||
|
||||
@@ -5,6 +5,10 @@ import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
func toSqlArgs(rec interface{}) (map[string]interface{}, error) {
|
||||
@@ -19,7 +23,9 @@ func toSqlArgs(rec interface{}) (map[string]interface{}, error) {
|
||||
err = json.Unmarshal(b, &m)
|
||||
r := make(map[string]interface{}, len(m))
|
||||
for f, v := range m {
|
||||
r[toSnakeCase(f)] = v
|
||||
if !utils.StringInSlice(f, model.AnnotationFields) {
|
||||
r[toSnakeCase(f)] = v
|
||||
}
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
@@ -33,30 +39,17 @@ func toSnakeCase(str string) string {
|
||||
return strings.ToLower(snake)
|
||||
}
|
||||
|
||||
func ToStruct(m map[string]interface{}, rec interface{}, fieldNames []string) error {
|
||||
var r = make(map[string]interface{}, len(m))
|
||||
for _, f := range fieldNames {
|
||||
v, ok := m[f]
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid field '%s'", f)
|
||||
}
|
||||
r[toCamelCase(f)] = v
|
||||
}
|
||||
// Convert to JSON...
|
||||
b, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ... then convert to struct
|
||||
err = json.Unmarshal(b, &rec)
|
||||
return err
|
||||
func Exists(subTable string, cond squirrel.Sqlizer) exists {
|
||||
return exists{subTable: subTable, cond: cond}
|
||||
}
|
||||
|
||||
var matchUnderscore = regexp.MustCompile("_([A-Za-z])")
|
||||
|
||||
func toCamelCase(str string) string {
|
||||
return matchUnderscore.ReplaceAllStringFunc(str, func(s string) string {
|
||||
return strings.ToUpper(strings.Replace(s, "_", "", -1))
|
||||
})
|
||||
type exists struct {
|
||||
subTable string
|
||||
cond squirrel.Sqlizer
|
||||
}
|
||||
|
||||
func (e exists) ToSql() (string, []interface{}, error) {
|
||||
sql, args, err := e.cond.ToSql()
|
||||
sql = fmt.Sprintf("exists (select 1 from %s where %s)", e.subTable, sql)
|
||||
return sql, args, err
|
||||
}
|
||||
|
||||
19
persistence/helpers_test.go
Normal file
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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
type mediaFileRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
func NewMediaFileRepository(ctx context.Context, o orm.Ormer) *mediaFileRepository {
|
||||
@@ -25,6 +26,9 @@ func NewMediaFileRepository(ctx context.Context, o orm.Ormer) *mediaFileReposito
|
||||
"artist": "artist asc, album asc, disc_number asc, track_number asc",
|
||||
"album": "album asc, disc_number asc, track_number asc",
|
||||
}
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"title": fullTextFilter,
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -37,11 +41,9 @@ func (r mediaFileRepository) Exists(id string) (bool, error) {
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) Put(m *model.MediaFile) error {
|
||||
m.FullText = r.getFullText(m.Title, m.Album, m.Artist, m.AlbumArtist)
|
||||
_, err := r.put(m.ID, m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.index(m.ID, m.Title, m.Album, m.Artist, m.AlbumArtist)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder {
|
||||
@@ -100,12 +102,7 @@ func (r mediaFileRepository) GetStarred(options ...model.QueryOptions) (model.Me
|
||||
// TODO Keep order when paginating
|
||||
func (r mediaFileRepository) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
sq := r.selectMediaFile(options...)
|
||||
switch r.ormer.Driver().Type() {
|
||||
case orm.DRMySQL:
|
||||
sq = sq.OrderBy("RAND()")
|
||||
default:
|
||||
sq = sq.OrderBy("RANDOM()")
|
||||
}
|
||||
sq = sq.OrderBy("RANDOM()")
|
||||
results := model.MediaFiles{}
|
||||
err := r.queryAll(sq, &results)
|
||||
return results, err
|
||||
|
||||
@@ -16,7 +16,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
var mr model.MediaFileRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"})
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
|
||||
mr = NewMediaFileRepository(ctx, orm.NewOrm())
|
||||
})
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ func CreateMockAlbumRepo() *MockAlbum {
|
||||
|
||||
type MockAlbum struct {
|
||||
model.AlbumRepository
|
||||
data map[string]*model.Album
|
||||
data map[string]model.Album
|
||||
all model.Albums
|
||||
err bool
|
||||
Options model.QueryOptions
|
||||
@@ -24,19 +24,22 @@ func (m *MockAlbum) SetError(err bool) {
|
||||
m.err = err
|
||||
}
|
||||
|
||||
func (m *MockAlbum) SetData(j string, size int) {
|
||||
m.data = make(map[string]*model.Album)
|
||||
m.all = make(model.Albums, size)
|
||||
func (m *MockAlbum) SetData(j string) {
|
||||
m.data = make(map[string]model.Album)
|
||||
m.all = model.Albums{}
|
||||
err := json.Unmarshal([]byte(j), &m.all)
|
||||
if err != nil {
|
||||
fmt.Println("ERROR: ", err)
|
||||
}
|
||||
for _, a := range m.all {
|
||||
m.data[a.ID] = &a
|
||||
m.data[a.ID] = a
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockAlbum) Exists(id string) (bool, error) {
|
||||
if m.err {
|
||||
return false, errors.New("Error!")
|
||||
}
|
||||
_, found := m.data[id]
|
||||
return found, nil
|
||||
}
|
||||
@@ -46,7 +49,7 @@ func (m *MockAlbum) Get(id string) (*model.Album, error) {
|
||||
return nil, errors.New("Error!")
|
||||
}
|
||||
if d, ok := m.data[id]; ok {
|
||||
return d, nil
|
||||
return &d, nil
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
@@ -68,8 +71,8 @@ func (m *MockAlbum) FindByArtist(artistId string) (model.Albums, error) {
|
||||
var res = make(model.Albums, len(m.data))
|
||||
i := 0
|
||||
for _, a := range m.data {
|
||||
if a.ArtistID == artistId {
|
||||
res[i] = *a
|
||||
if a.AlbumArtistID == artistId {
|
||||
res[i] = a
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ func CreateMockArtistRepo() *MockArtist {
|
||||
|
||||
type MockArtist struct {
|
||||
model.ArtistRepository
|
||||
data map[string]*model.Artist
|
||||
data map[string]model.Artist
|
||||
err bool
|
||||
}
|
||||
|
||||
@@ -22,19 +22,22 @@ func (m *MockArtist) SetError(err bool) {
|
||||
m.err = err
|
||||
}
|
||||
|
||||
func (m *MockArtist) SetData(j string, size int) {
|
||||
m.data = make(map[string]*model.Artist)
|
||||
var l = make([]model.Artist, size)
|
||||
func (m *MockArtist) SetData(j string) {
|
||||
m.data = make(map[string]model.Artist)
|
||||
var l = model.Artists{}
|
||||
err := json.Unmarshal([]byte(j), &l)
|
||||
if err != nil {
|
||||
fmt.Println("ERROR: ", err)
|
||||
}
|
||||
for _, a := range l {
|
||||
m.data[a.ID] = &a
|
||||
m.data[a.ID] = a
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockArtist) Exists(id string) (bool, error) {
|
||||
if m.err {
|
||||
return false, errors.New("Error!")
|
||||
}
|
||||
_, found := m.data[id]
|
||||
return found, nil
|
||||
}
|
||||
@@ -44,7 +47,7 @@ func (m *MockArtist) Get(id string) (*model.Artist, error) {
|
||||
return nil, errors.New("Error!")
|
||||
}
|
||||
if d, ok := m.data[id]; ok {
|
||||
return d, nil
|
||||
return &d, nil
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
@@ -22,9 +22,9 @@ func (m *MockMediaFile) SetError(err bool) {
|
||||
m.err = err
|
||||
}
|
||||
|
||||
func (m *MockMediaFile) SetData(j string, size int) {
|
||||
func (m *MockMediaFile) SetData(j string) {
|
||||
m.data = make(map[string]model.MediaFile)
|
||||
var l = make(model.MediaFiles, size)
|
||||
var l = model.MediaFiles{}
|
||||
err := json.Unmarshal([]byte(j), &l)
|
||||
if err != nil {
|
||||
fmt.Println("ERROR: ", err)
|
||||
|
||||
@@ -7,11 +7,13 @@ import (
|
||||
)
|
||||
|
||||
type MockDataStore struct {
|
||||
MockedGenre model.GenreRepository
|
||||
MockedAlbum model.AlbumRepository
|
||||
MockedArtist model.ArtistRepository
|
||||
MockedMediaFile model.MediaFileRepository
|
||||
MockedUser model.UserRepository
|
||||
MockedGenre model.GenreRepository
|
||||
MockedAlbum model.AlbumRepository
|
||||
MockedArtist model.ArtistRepository
|
||||
MockedMediaFile model.MediaFileRepository
|
||||
MockedUser model.UserRepository
|
||||
MockedPlayer model.PlayerRepository
|
||||
MockedTranscoding model.TranscodingRepository
|
||||
}
|
||||
|
||||
func (db *MockDataStore) Album(context.Context) model.AlbumRepository {
|
||||
@@ -61,6 +63,20 @@ func (db *MockDataStore) User(context.Context) model.UserRepository {
|
||||
return db.MockedUser
|
||||
}
|
||||
|
||||
func (db *MockDataStore) Transcoding(context.Context) model.TranscodingRepository {
|
||||
if db.MockedTranscoding != nil {
|
||||
return db.MockedTranscoding
|
||||
}
|
||||
return struct{ model.TranscodingRepository }{}
|
||||
}
|
||||
|
||||
func (db *MockDataStore) Player(context.Context) model.PlayerRepository {
|
||||
if db.MockedPlayer != nil {
|
||||
return db.MockedPlayer
|
||||
}
|
||||
return struct{ model.PlayerRepository }{}
|
||||
}
|
||||
|
||||
func (db *MockDataStore) WithTx(block func(db model.DataStore) error) error {
|
||||
return block(db)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package persistence
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"sync"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/db"
|
||||
@@ -11,74 +10,79 @@ import (
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
type SQLStore struct {
|
||||
orm orm.Ormer
|
||||
}
|
||||
|
||||
func New() model.DataStore {
|
||||
once.Do(func() {
|
||||
err := orm.RegisterDataBase("default", db.Driver, db.Path)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
return &SQLStore{}
|
||||
}
|
||||
|
||||
func (db *SQLStore) Album(ctx context.Context) model.AlbumRepository {
|
||||
return NewAlbumRepository(ctx, db.getOrmer())
|
||||
func (s *SQLStore) Album(ctx context.Context) model.AlbumRepository {
|
||||
return NewAlbumRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) Artist(ctx context.Context) model.ArtistRepository {
|
||||
return NewArtistRepository(ctx, db.getOrmer())
|
||||
func (s *SQLStore) Artist(ctx context.Context) model.ArtistRepository {
|
||||
return NewArtistRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) MediaFile(ctx context.Context) model.MediaFileRepository {
|
||||
return NewMediaFileRepository(ctx, db.getOrmer())
|
||||
func (s *SQLStore) MediaFile(ctx context.Context) model.MediaFileRepository {
|
||||
return NewMediaFileRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) MediaFolder(ctx context.Context) model.MediaFolderRepository {
|
||||
return NewMediaFolderRepository(ctx, db.getOrmer())
|
||||
func (s *SQLStore) MediaFolder(ctx context.Context) model.MediaFolderRepository {
|
||||
return NewMediaFolderRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) Genre(ctx context.Context) model.GenreRepository {
|
||||
return NewGenreRepository(ctx, db.getOrmer())
|
||||
func (s *SQLStore) Genre(ctx context.Context) model.GenreRepository {
|
||||
return NewGenreRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) Playlist(ctx context.Context) model.PlaylistRepository {
|
||||
return NewPlaylistRepository(ctx, db.getOrmer())
|
||||
func (s *SQLStore) Playlist(ctx context.Context) model.PlaylistRepository {
|
||||
return NewPlaylistRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) Property(ctx context.Context) model.PropertyRepository {
|
||||
return NewPropertyRepository(ctx, db.getOrmer())
|
||||
func (s *SQLStore) Property(ctx context.Context) model.PropertyRepository {
|
||||
return NewPropertyRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) User(ctx context.Context) model.UserRepository {
|
||||
return NewUserRepository(ctx, db.getOrmer())
|
||||
func (s *SQLStore) User(ctx context.Context) model.UserRepository {
|
||||
return NewUserRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
|
||||
func (s *SQLStore) Transcoding(ctx context.Context) model.TranscodingRepository {
|
||||
return NewTranscodingRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (s *SQLStore) Player(ctx context.Context) model.PlayerRepository {
|
||||
return NewPlayerRepository(ctx, s.getOrmer())
|
||||
}
|
||||
|
||||
func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
|
||||
switch m.(type) {
|
||||
case model.User:
|
||||
return db.User(ctx).(model.ResourceRepository)
|
||||
return s.User(ctx).(model.ResourceRepository)
|
||||
case model.Transcoding:
|
||||
return s.Transcoding(ctx).(model.ResourceRepository)
|
||||
case model.Player:
|
||||
return s.Player(ctx).(model.ResourceRepository)
|
||||
case model.Artist:
|
||||
return db.Artist(ctx).(model.ResourceRepository)
|
||||
return s.Artist(ctx).(model.ResourceRepository)
|
||||
case model.Album:
|
||||
return db.Album(ctx).(model.ResourceRepository)
|
||||
return s.Album(ctx).(model.ResourceRepository)
|
||||
case model.MediaFile:
|
||||
return db.MediaFile(ctx).(model.ResourceRepository)
|
||||
return s.MediaFile(ctx).(model.ResourceRepository)
|
||||
}
|
||||
log.Error("Resource no implemented", "model", reflect.TypeOf(m).Name())
|
||||
log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *SQLStore) WithTx(block func(tx model.DataStore) error) error {
|
||||
o := orm.NewOrm()
|
||||
err := o.Begin()
|
||||
func (s *SQLStore) WithTx(block func(tx model.DataStore) error) error {
|
||||
o, err := orm.NewOrmWithDB(db.Driver, "default", db.Db())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = o.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -101,41 +105,33 @@ func (db *SQLStore) WithTx(block func(tx model.DataStore) error) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *SQLStore) GC(ctx context.Context) error {
|
||||
err := db.Album(ctx).PurgeEmpty()
|
||||
func (s *SQLStore) GC(ctx context.Context) error {
|
||||
err := s.Album(ctx).PurgeEmpty()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = db.Artist(ctx).PurgeEmpty()
|
||||
err = s.Artist(ctx).PurgeEmpty()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = db.MediaFile(ctx).(*mediaFileRepository).cleanSearchIndex()
|
||||
err = s.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = db.Album(ctx).(*albumRepository).cleanSearchIndex()
|
||||
err = s.Album(ctx).(*albumRepository).cleanAnnotations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = db.Artist(ctx).(*artistRepository).cleanSearchIndex()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = db.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = db.Album(ctx).(*albumRepository).cleanAnnotations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Artist(ctx).(*artistRepository).cleanAnnotations()
|
||||
return s.Artist(ctx).(*artistRepository).cleanAnnotations()
|
||||
}
|
||||
|
||||
func (db *SQLStore) getOrmer() orm.Ormer {
|
||||
if db.orm == nil {
|
||||
return orm.NewOrm()
|
||||
func (s *SQLStore) getOrmer() orm.Ormer {
|
||||
if s.orm == nil {
|
||||
o, err := orm.NewOrmWithDB(db.Driver, "default", db.Db())
|
||||
if err != nil {
|
||||
log.Error("Error obtaining new orm instance", err)
|
||||
}
|
||||
return o
|
||||
}
|
||||
return db.orm
|
||||
return s.orm
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ func TestPersistence(t *testing.T) {
|
||||
|
||||
//os.Remove("./test-123.db")
|
||||
//conf.Server.Path = "./test-123.db"
|
||||
conf.Server.DbPath = ":memory:"
|
||||
db.Init()
|
||||
conf.Server.DbPath = "file::memory:?cache=shared"
|
||||
orm.RegisterDataBase("default", db.Driver, conf.Server.DbPath)
|
||||
New()
|
||||
db.EnsureLatestVersion()
|
||||
log.SetLevel(log.LevelCritical)
|
||||
@@ -31,8 +31,8 @@ func TestPersistence(t *testing.T) {
|
||||
}
|
||||
|
||||
var (
|
||||
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1}
|
||||
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2}
|
||||
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1, FullText: "kraftwerk"}
|
||||
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2, FullText: "beatles the"}
|
||||
testArtists = model.Artists{
|
||||
artistKraftwerk,
|
||||
artistBeatles,
|
||||
@@ -40,9 +40,9 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
albumSgtPeppers = model.Album{ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", ArtistID: "3", Genre: "Rock", CoverArtId: "1", CoverArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, Year: 1967}
|
||||
albumAbbeyRoad = model.Album{ID: "2", Name: "Abbey Road", Artist: "The Beatles", ArtistID: "3", Genre: "Rock", CoverArtId: "2", CoverArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, Year: 1969}
|
||||
albumRadioactivity = model.Album{ID: "3", Name: "Radioactivity", Artist: "Kraftwerk", ArtistID: "2", Genre: "Electronic", CoverArtId: "3", CoverArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2}
|
||||
albumSgtPeppers = model.Album{ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", AlbumArtistID: "3", Genre: "Rock", CoverArtId: "1", CoverArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967, FullText: "beatles peppers sgt the"}
|
||||
albumAbbeyRoad = model.Album{ID: "2", Name: "Abbey Road", Artist: "The Beatles", AlbumArtistID: "3", Genre: "Rock", CoverArtId: "2", CoverArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969, FullText: "abbey beatles road the"}
|
||||
albumRadioactivity = model.Album{ID: "3", Name: "Radioactivity", Artist: "Kraftwerk", AlbumArtistID: "2", Genre: "Electronic", CoverArtId: "3", CoverArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2, FullText: "kraftwerk radioactivity"}
|
||||
testAlbums = model.Albums{
|
||||
albumSgtPeppers,
|
||||
albumAbbeyRoad,
|
||||
@@ -51,10 +51,10 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
songDayInALife = model.MediaFile{ID: "1", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "1", Album: "Sgt Peppers", Genre: "Rock", Path: P("/beatles/1/sgt/a day.mp3")}
|
||||
songComeTogether = model.MediaFile{ID: "2", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "2", Album: "Abbey Road", Genre: "Rock", Path: P("/beatles/1/come together.mp3")}
|
||||
songRadioactivity = model.MediaFile{ID: "3", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Album: "Radioactivity", Genre: "Electronic", Path: P("/kraft/radio/radio.mp3")}
|
||||
songAntenna = model.MediaFile{ID: "4", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Genre: "Electronic", Path: P("/kraft/radio/antenna.mp3")}
|
||||
songDayInALife = model.MediaFile{ID: "1", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "1", Album: "Sgt Peppers", Genre: "Rock", Path: P("/beatles/1/sgt/a day.mp3"), FullText: "a beatles day in life peppers sgt the"}
|
||||
songComeTogether = model.MediaFile{ID: "2", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "2", Album: "Abbey Road", Genre: "Rock", Path: P("/beatles/1/come together.mp3"), FullText: "abbey beatles come road the together"}
|
||||
songRadioactivity = model.MediaFile{ID: "3", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Album: "Radioactivity", Genre: "Electronic", Path: P("/kraft/radio/radio.mp3"), FullText: "kraftwerk radioactivity"}
|
||||
songAntenna = model.MediaFile{ID: "4", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Genre: "Electronic", Path: P("/kraft/radio/antenna.mp3"), FullText: "antenna kraftwerk"}
|
||||
testSongs = model.MediaFiles{
|
||||
songDayInALife,
|
||||
songComeTogether,
|
||||
@@ -86,7 +86,7 @@ var _ = Describe("Initialize test DB", func() {
|
||||
// TODO Load this data setup from file(s)
|
||||
BeforeSuite(func() {
|
||||
o := orm.NewOrm()
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"})
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
|
||||
mr := NewMediaFileRepository(ctx, o)
|
||||
for _, s := range testSongs {
|
||||
err := mr.Put(&s)
|
||||
@@ -95,9 +95,9 @@ var _ = Describe("Initialize test DB", func() {
|
||||
}
|
||||
}
|
||||
|
||||
alr := NewAlbumRepository(ctx, o)
|
||||
alr := NewAlbumRepository(ctx, o).(*albumRepository)
|
||||
for _, a := range testAlbums {
|
||||
err := alr.Put(&a)
|
||||
_, err := alr.put(a.ID, &a)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
118
persistence/player_repository.go
Normal file
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)
|
||||
@@ -13,7 +13,7 @@ type playlist struct {
|
||||
ID string `orm:"column(id)"`
|
||||
Name string
|
||||
Comment string
|
||||
Duration int
|
||||
Duration float32
|
||||
Owner string
|
||||
Public bool
|
||||
Tracks string
|
||||
@@ -71,7 +71,7 @@ func (r *playlistRepository) GetWithTracks(id string) (*model.Playlist, error) {
|
||||
continue
|
||||
}
|
||||
pls.Duration += mf.Duration
|
||||
newTracks = append(newTracks, model.MediaFile(*mf))
|
||||
newTracks = append(newTracks, *mf)
|
||||
}
|
||||
pls.Tracks = newTracks
|
||||
return pls, err
|
||||
|
||||
@@ -21,7 +21,7 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Exist", func() {
|
||||
Describe("Exists", func() {
|
||||
It("returns true for an existing playlist", func() {
|
||||
Expect(repo.Exists("11")).To(BeTrue())
|
||||
})
|
||||
|
||||
@@ -8,11 +8,6 @@ import (
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
type property struct {
|
||||
ID string `orm:"pk;column(id)"`
|
||||
Value string
|
||||
}
|
||||
|
||||
type propertyRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
@@ -10,18 +10,6 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type annotation struct {
|
||||
AnnID string `json:"annID" orm:"pk;column(ann_id)"`
|
||||
UserID string `json:"userID" orm:"pk;column(user_id)"`
|
||||
ItemID string `json:"itemID" orm:"pk;column(item_id)"`
|
||||
ItemType string `json:"itemType"`
|
||||
PlayCount int `json:"playCount"`
|
||||
PlayDate time.Time `json:"playDate"`
|
||||
Rating int `json:"rating"`
|
||||
Starred bool `json:"starred"`
|
||||
StarredAt time.Time `json:"starredAt"`
|
||||
}
|
||||
|
||||
const annotationTable = "annotation"
|
||||
|
||||
func (r sqlRepository) newSelectWithAnnotation(idField string, options ...model.QueryOptions) SelectBuilder {
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -29,7 +28,7 @@ func userId(ctx context.Context) string {
|
||||
if user == nil {
|
||||
return invalidUserId
|
||||
}
|
||||
usr := user.(*model.User)
|
||||
usr := user.(model.User)
|
||||
return usr.ID
|
||||
}
|
||||
|
||||
@@ -38,7 +37,8 @@ func loggedUser(ctx context.Context) *model.User {
|
||||
if user == nil {
|
||||
return &model.User{}
|
||||
}
|
||||
return user.(*model.User)
|
||||
u := user.(model.User)
|
||||
return &u
|
||||
}
|
||||
|
||||
func (r sqlRepository) newSelect(options ...model.QueryOptions) SelectBuilder {
|
||||
@@ -99,8 +99,11 @@ func (r sqlRepository) executeSQL(sq Sqlizer) (int64, error) {
|
||||
return 0, err
|
||||
}
|
||||
start := time.Now()
|
||||
var c int64
|
||||
res, err := r.ormer.Raw(query, args...).Exec()
|
||||
c, _ := res.RowsAffected()
|
||||
if res != nil {
|
||||
c, _ = res.RowsAffected()
|
||||
}
|
||||
r.logSQL(query, args, err, c, start)
|
||||
if err != nil {
|
||||
if err.Error() != "LastInsertId is not supported by this driver" {
|
||||
@@ -157,6 +160,8 @@ func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOpt
|
||||
|
||||
func (r sqlRepository) put(id string, m interface{}) (newId string, err error) {
|
||||
values, _ := toSqlArgs(m)
|
||||
createdAt := values["created_at"]
|
||||
delete(values, "created_at")
|
||||
if id != "" {
|
||||
update := Update(r.tableName).Where(Eq{"id": id}).SetMap(values)
|
||||
count, err := r.executeSQL(update)
|
||||
@@ -173,6 +178,9 @@ func (r sqlRepository) put(id string, m interface{}) (newId string, err error) {
|
||||
id = rand.String()
|
||||
values["id"] = id
|
||||
}
|
||||
if createdAt != nil {
|
||||
values["created_at"] = createdAt
|
||||
}
|
||||
insert := Insert(r.tableName).SetMap(values)
|
||||
_, err = r.executeSQL(insert)
|
||||
return id, err
|
||||
@@ -188,7 +196,7 @@ func (r sqlRepository) delete(cond Sqlizer) error {
|
||||
}
|
||||
|
||||
func (r sqlRepository) logSQL(sql string, args []interface{}, err error, rowsAffected int64, start time.Time) {
|
||||
lapsed := time.Since(start)
|
||||
elapsed := time.Since(start)
|
||||
var fmtArgs []string
|
||||
for i := range args {
|
||||
var f string
|
||||
@@ -201,26 +209,8 @@ func (r sqlRepository) logSQL(sql string, args []interface{}, err error, rowsAff
|
||||
fmtArgs = append(fmtArgs, f)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, "lapsedTime", lapsed, err)
|
||||
log.Error(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, "elapsedTime", elapsed, err)
|
||||
} else {
|
||||
log.Trace(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, "lapsedTime", lapsed)
|
||||
log.Trace(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, "elapsedTime", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func (r sqlRepository) parseRestOptions(options ...rest.QueryOptions) model.QueryOptions {
|
||||
qo := model.QueryOptions{}
|
||||
if len(options) > 0 {
|
||||
qo.Sort = options[0].Sort
|
||||
qo.Order = strings.ToLower(options[0].Order)
|
||||
qo.Max = options[0].Max
|
||||
qo.Offset = options[0].Offset
|
||||
if len(options[0].Filters) > 0 {
|
||||
filters := And{}
|
||||
for f, v := range options[0].Filters {
|
||||
filters = append(filters, Like{f: fmt.Sprintf("%s%%", v)})
|
||||
}
|
||||
qo.Filters = filters
|
||||
}
|
||||
}
|
||||
return qo
|
||||
}
|
||||
|
||||
73
persistence/sql_restful.go
Normal file
73
persistence/sql_restful.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/kennygrant/sanitize"
|
||||
)
|
||||
|
||||
type filterFunc = func(field string, value interface{}) Sqlizer
|
||||
|
||||
type sqlRestful struct {
|
||||
filterMappings map[string]filterFunc
|
||||
}
|
||||
|
||||
func (r sqlRestful) parseRestFilters(options rest.QueryOptions) Sqlizer {
|
||||
if len(options.Filters) == 0 {
|
||||
return nil
|
||||
}
|
||||
filters := And{}
|
||||
for f, v := range options.Filters {
|
||||
if ff, ok := r.filterMappings[f]; ok {
|
||||
filters = append(filters, ff(f, v))
|
||||
} else if f == "id" {
|
||||
filters = append(filters, eqFilter(f, v))
|
||||
} else {
|
||||
filters = append(filters, startsWithFilter(f, v))
|
||||
}
|
||||
}
|
||||
return filters
|
||||
}
|
||||
|
||||
func (r sqlRestful) parseRestOptions(options ...rest.QueryOptions) model.QueryOptions {
|
||||
qo := model.QueryOptions{}
|
||||
if len(options) > 0 {
|
||||
qo.Sort = options[0].Sort
|
||||
qo.Order = strings.ToLower(options[0].Order)
|
||||
qo.Max = options[0].Max
|
||||
qo.Offset = options[0].Offset
|
||||
qo.Filters = r.parseRestFilters(options[0])
|
||||
}
|
||||
return qo
|
||||
}
|
||||
|
||||
func eqFilter(field string, value interface{}) Sqlizer {
|
||||
return Eq{field: value}
|
||||
}
|
||||
|
||||
func startsWithFilter(field string, value interface{}) Sqlizer {
|
||||
return Like{field: fmt.Sprintf("%s%%", value)}
|
||||
}
|
||||
|
||||
func booleanFilter(field string, value interface{}) Sqlizer {
|
||||
v := strings.ToLower(value.(string))
|
||||
return Eq{field: strings.ToLower(v) == "true"}
|
||||
}
|
||||
|
||||
func fullTextFilter(field string, value interface{}) Sqlizer {
|
||||
q := value.(string)
|
||||
q = strings.TrimSpace(sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*"))))
|
||||
parts := strings.Split(q, " ")
|
||||
filters := And{}
|
||||
for _, part := range parts {
|
||||
filters = append(filters, Or{
|
||||
Like{"full_text": part + "%"},
|
||||
Like{"full_text": "%" + part + "%"},
|
||||
})
|
||||
}
|
||||
return filters
|
||||
}
|
||||
49
persistence/sql_restful_test.go
Normal file
49
persistence/sql_restful_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("sqlRestful", func() {
|
||||
Describe("parseRestFilters", func() {
|
||||
var r sqlRestful
|
||||
var options rest.QueryOptions
|
||||
|
||||
BeforeEach(func() {
|
||||
r = sqlRestful{}
|
||||
})
|
||||
|
||||
It("returns nil if filters is empty", func() {
|
||||
options.Filters = nil
|
||||
Expect(r.parseRestFilters(options)).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns a '=' condition for 'id' filter", func() {
|
||||
options.Filters = map[string]interface{}{"id": "123"}
|
||||
Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Eq{"id": "123"}}))
|
||||
})
|
||||
|
||||
It("returns a 'in' condition for multiples 'id' filters", func() {
|
||||
options.Filters = map[string]interface{}{"id": []string{"123", "456"}}
|
||||
Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Eq{"id": []string{"123", "456"}}}))
|
||||
})
|
||||
|
||||
It("returns a 'like' condition for other filters", func() {
|
||||
options.Filters = map[string]interface{}{"name": "joe"}
|
||||
Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Like{"name": "joe%"}}))
|
||||
})
|
||||
|
||||
It("uses the custom filter", func() {
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"test": func(field string, value interface{}) squirrel.Sqlizer {
|
||||
return squirrel.Gt{field: value}
|
||||
},
|
||||
}
|
||||
options.Filters = map[string]interface{}{"test": 100}
|
||||
Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Gt{"test": 100}}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,37 +1,28 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/kennygrant/sanitize"
|
||||
)
|
||||
|
||||
const searchTable = "search"
|
||||
|
||||
func (r sqlRepository) index(id string, text ...string) error {
|
||||
func (r sqlRepository) getFullText(text ...string) string {
|
||||
sanitizedText := strings.Builder{}
|
||||
for _, txt := range text {
|
||||
sanitizedText.WriteString(strings.TrimSpace(sanitize.Accents(strings.ToLower(txt))) + " ")
|
||||
}
|
||||
|
||||
values := map[string]interface{}{
|
||||
"id": id,
|
||||
"item_type": r.tableName,
|
||||
"full_text": strings.TrimSpace(sanitizedText.String()),
|
||||
words := make(map[string]struct{})
|
||||
for _, w := range strings.Fields(sanitizedText.String()) {
|
||||
words[w] = struct{}{}
|
||||
}
|
||||
update := Update(searchTable).Where(Eq{"id": id}).SetMap(values)
|
||||
count, err := r.executeSQL(update)
|
||||
if err != nil {
|
||||
return err
|
||||
var fullText []string
|
||||
for w := range words {
|
||||
fullText = append(fullText, w)
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
insert := Insert(searchTable).SetMap(values)
|
||||
_, err = r.executeSQL(insert)
|
||||
return err
|
||||
sort.Strings(fullText)
|
||||
return strings.Join(fullText, " ")
|
||||
}
|
||||
|
||||
func (r sqlRepository) doSearch(q string, offset, size int, results interface{}, orderBys ...string) error {
|
||||
@@ -39,12 +30,11 @@ func (r sqlRepository) doSearch(q string, offset, size int, results interface{},
|
||||
if len(q) < 2 {
|
||||
return nil
|
||||
}
|
||||
sq := Select("*").From(r.tableName)
|
||||
sq := r.newSelectWithAnnotation(r.tableName + ".id").Columns("*")
|
||||
sq = sq.Limit(uint64(size)).Offset(uint64(offset))
|
||||
if len(orderBys) > 0 {
|
||||
sq = sq.OrderBy(orderBys...)
|
||||
}
|
||||
sq = sq.Join("search").Where("search.id = " + r.tableName + ".id")
|
||||
parts := strings.Split(q, " ")
|
||||
for _, part := range parts {
|
||||
sq = sq.Where(Or{
|
||||
@@ -55,15 +45,3 @@ func (r sqlRepository) doSearch(q string, offset, size int, results interface{},
|
||||
err := r.queryAll(sq, results)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r sqlRepository) cleanSearchIndex() error {
|
||||
del := Delete(searchTable).Where(Eq{"item_type": r.tableName}).Where("id not in (select id from " + r.tableName + ")")
|
||||
c, err := r.executeSQL(del)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c > 0 {
|
||||
log.Debug(r.ctx, "Clean-up search index", "table", r.tableName, "totalDeleted", c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
28
persistence/sql_search_test.go
Normal file
28
persistence/sql_search_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("sqlRepository", func() {
|
||||
var sqlRepository = &sqlRepository{}
|
||||
|
||||
Describe("getFullText", func() {
|
||||
It("returns all lowercase chars", func() {
|
||||
Expect(sqlRepository.getFullText("Some Text")).To(Equal("some text"))
|
||||
})
|
||||
|
||||
It("removes accents", func() {
|
||||
Expect(sqlRepository.getFullText("Quintão")).To(Equal("quintao"))
|
||||
})
|
||||
|
||||
It("remove extra spaces", func() {
|
||||
Expect(sqlRepository.getFullText(" some text ")).To(Equal("some text"))
|
||||
})
|
||||
|
||||
It("remove duplicated words", func() {
|
||||
Expect(sqlRepository.getFullText("legião urbana urbana legiÃo")).To(Equal("legiao urbana"))
|
||||
})
|
||||
})
|
||||
})
|
||||
99
persistence/transcoding_repository.go
Normal file
99
persistence/transcoding_repository.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/rest"
|
||||
)
|
||||
|
||||
type transcodingRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
func NewTranscodingRepository(ctx context.Context, o orm.Ormer) model.TranscodingRepository {
|
||||
r := &transcodingRepository{}
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
r.tableName = "transcoding"
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Get(id string) (*model.Transcoding, error) {
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"id": id})
|
||||
var res model.Transcoding
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) CountAll(qo ...model.QueryOptions) (int64, error) {
|
||||
return r.count(Select(), qo...)
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) FindByFormat(format string) (*model.Transcoding, error) {
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"target_format": format})
|
||||
var res model.Transcoding
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Put(t *model.Transcoding) error {
|
||||
_, err := r.put(t.ID, t)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.count(Select(), r.parseRestOptions(options...))
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Read(id string) (interface{}, error) {
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
sel := r.newSelect(r.parseRestOptions(options...)).Columns("*")
|
||||
res := model.Transcodings{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) EntityName() string {
|
||||
return "transcoding"
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) NewInstance() interface{} {
|
||||
return &model.Transcoding{}
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Save(entity interface{}) (string, error) {
|
||||
t := entity.(*model.Transcoding)
|
||||
id, err := r.put(t.ID, t)
|
||||
if err == model.ErrNotFound {
|
||||
return "", rest.ErrNotFound
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Update(entity interface{}, cols ...string) error {
|
||||
t := entity.(*model.Transcoding)
|
||||
_, err := r.put(t.ID, t)
|
||||
if err == model.ErrNotFound {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Delete(id string) error {
|
||||
err := r.delete(Eq{"id": id})
|
||||
if err == model.ErrNotFound {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var _ model.TranscodingRepository = (*transcodingRepository)(nil)
|
||||
var _ rest.Repository = (*transcodingRepository)(nil)
|
||||
var _ rest.Persistable = (*transcodingRepository)(nil)
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
type userRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
func NewUserRepository(ctx context.Context, o orm.Ormer) model.UserRepository {
|
||||
@@ -65,6 +66,7 @@ func (r *userRepository) Put(u *model.User) error {
|
||||
}
|
||||
|
||||
func (r *userRepository) FindByUsername(username string) (*model.User, error) {
|
||||
username = strings.ToLower(username)
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"user_name": username})
|
||||
var usr model.User
|
||||
err := r.queryOne(sel, &usr)
|
||||
|
||||
41
persistence/user_repository_test.go
Normal file
41
persistence/user_repository_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("UserRepository", func() {
|
||||
var repo model.UserRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = NewUserRepository(log.NewContext(nil), orm.NewOrm())
|
||||
})
|
||||
|
||||
Describe("Put/Get/FindByUsername", func() {
|
||||
usr := model.User{
|
||||
ID: "123",
|
||||
UserName: "AdMiN",
|
||||
Name: "Admin",
|
||||
Email: "admin@admin.com",
|
||||
Password: "wordpass",
|
||||
IsAdmin: true,
|
||||
}
|
||||
It("saves the user to the DB", func() {
|
||||
Expect(repo.Put(&usr)).To(BeNil())
|
||||
})
|
||||
It("returns the newly created user", func() {
|
||||
actual, err := repo.Get("123")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(actual.Name).To(Equal("Admin"))
|
||||
})
|
||||
It("find the user by case-insensitive username", func() {
|
||||
actual, err := repo.FindByUsername("aDmIn")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(actual.Name).To(Equal("Admin"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1 +1 @@
|
||||
-s -r "(\.go$$|navidrome.toml)" -R "(Jamstash-master|^ui)" -- go run .
|
||||
-s -r "(\.go$$|navidrome.toml)" -R "(Jamstash-master|^ui|^data)" -- go run .
|
||||
|
||||
@@ -46,10 +46,10 @@ func (s *ChangeDetector) Scan(lastModifiedSince time.Time) (changed []string, de
|
||||
|
||||
func (s *ChangeDetector) loadDir(dirPath string) (children []string, lastUpdated time.Time, err error) {
|
||||
dir, err := os.Open(dirPath)
|
||||
defer dir.Close()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer dir.Close()
|
||||
dirInfo, err := os.Stat(dirPath)
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -24,19 +24,19 @@ type Metadata struct {
|
||||
tags map[string]string
|
||||
}
|
||||
|
||||
func (m *Metadata) Title() string { return m.tags["title"] }
|
||||
func (m *Metadata) Album() string { return m.tags["album"] }
|
||||
func (m *Metadata) Artist() string { return m.tags["artist"] }
|
||||
func (m *Metadata) AlbumArtist() string { return m.tags["album_artist"] }
|
||||
func (m *Metadata) Composer() string { return m.tags["composer"] }
|
||||
func (m *Metadata) Genre() string { return m.tags["genre"] }
|
||||
func (m *Metadata) Year() int { return m.parseYear("year") }
|
||||
func (m *Metadata) TrackNumber() (int, int) { return m.parseTuple("trackNum", "trackTotal") }
|
||||
func (m *Metadata) DiscNumber() (int, int) { return m.parseTuple("discNum", "discTotal") }
|
||||
func (m *Metadata) HasPicture() bool { return m.tags["hasPicture"] == "Video" }
|
||||
func (m *Metadata) Comment() string { return m.tags["comment"] }
|
||||
func (m *Metadata) Title() string { return m.getTag("title", "sort_name") }
|
||||
func (m *Metadata) Album() string { return m.getTag("album", "sort_album") }
|
||||
func (m *Metadata) Artist() string { return m.getTag("artist", "sort_artist") }
|
||||
func (m *Metadata) AlbumArtist() string { return m.getTag("album_artist") }
|
||||
func (m *Metadata) Composer() string { return m.getTag("composer", "tcm", "sort_composer") }
|
||||
func (m *Metadata) Genre() string { return m.getTag("genre") }
|
||||
func (m *Metadata) Year() int { return m.parseYear("date") }
|
||||
func (m *Metadata) TrackNumber() (int, int) { return m.parseTuple("track") }
|
||||
func (m *Metadata) DiscNumber() (int, int) { return m.parseTuple("tpa", "disc") }
|
||||
func (m *Metadata) HasPicture() bool { return m.getTag("has_picture", "metadata_block_picture") != "" }
|
||||
func (m *Metadata) Comment() string { return m.getTag("comment") }
|
||||
func (m *Metadata) Compilation() bool { return m.parseBool("compilation") }
|
||||
func (m *Metadata) Duration() int { return m.parseDuration("duration") }
|
||||
func (m *Metadata) Duration() float32 { return m.parseDuration("duration") }
|
||||
func (m *Metadata) BitRate() int { return m.parseInt("bitrate") }
|
||||
func (m *Metadata) ModificationTime() time.Time { return m.fileInfo.ModTime() }
|
||||
func (m *Metadata) FilePath() string { return m.filePath }
|
||||
@@ -94,7 +94,22 @@ func ExtractAllMetadata(inputs []string) (map[string]*Metadata, error) {
|
||||
return mds, nil
|
||||
}
|
||||
|
||||
var inputRegex = regexp.MustCompile(`(?m)^Input #\d+,.*,\sfrom\s'(.*)'`)
|
||||
var (
|
||||
// Input #0, mp3, from 'groovin.mp3':
|
||||
inputRegex = regexp.MustCompile(`(?m)^Input #\d+,.*,\sfrom\s'(.*)'`)
|
||||
|
||||
// TITLE : Back In Black
|
||||
tagsRx = regexp.MustCompile(`(?i)^\s{4,6}(\w+)\s*:(.*)`)
|
||||
|
||||
// Duration: 00:04:16.00, start: 0.000000, bitrate: 995 kb/s`
|
||||
durationRx = regexp.MustCompile(`^\s\sDuration: ([\d.:]+).*bitrate: (\d+)`)
|
||||
|
||||
// Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s
|
||||
bitRateRx = regexp.MustCompile(`^\s{4}Stream #\d+:\d+: (Audio):.*, (\d+) kb/s`)
|
||||
|
||||
// Stream #0:1: Video: mjpeg, yuvj444p(pc, bt470bg/unknown/unknown), 600x600 [SAR 1:1 DAR 1:1], 90k tbr, 90k tbn, 90k tbc`
|
||||
coverRx = regexp.MustCompile(`^\s{4}Stream #\d+:\d+: (Video):.*`)
|
||||
)
|
||||
|
||||
func parseOutput(output string) map[string]string {
|
||||
split := map[string]string{}
|
||||
@@ -141,47 +156,44 @@ func isAudioFile(extension string) bool {
|
||||
return strings.HasPrefix(typ, "audio/")
|
||||
}
|
||||
|
||||
var (
|
||||
tagsRx = map[*regexp.Regexp]string{
|
||||
regexp.MustCompile(`(?i)^\s{4}compilation\s+:(.*)`): "compilation",
|
||||
regexp.MustCompile(`(?i)^\s{4}genre\s+:\s(.*)`): "genre",
|
||||
regexp.MustCompile(`(?i)^\s{4}title\s+:\s(.*)`): "title",
|
||||
regexp.MustCompile(`(?i)^\s{4}comment\s+:\s(.*)`): "comment",
|
||||
regexp.MustCompile(`(?i)^\s{4}artist\s+:\s(.*)`): "artist",
|
||||
regexp.MustCompile(`(?i)^\s{4}album_artist\s+:\s(.*)`): "album_artist",
|
||||
regexp.MustCompile(`(?i)^\s{4}TCM\s+:\s(.*)`): "composer",
|
||||
regexp.MustCompile(`(?i)^\s{4}album\s+:\s(.*)`): "album",
|
||||
regexp.MustCompile(`(?i)^\s{4}track\s+:\s(.*)`): "trackNum",
|
||||
regexp.MustCompile(`(?i)^\s{4}tracktotal\s+:\s(.*)`): "trackTotal",
|
||||
regexp.MustCompile(`(?i)^\s{4}disc\s+:\s(.*)`): "discNum",
|
||||
regexp.MustCompile(`(?i)^\s{4}disctotal\s+:\s(.*)`): "discTotal",
|
||||
regexp.MustCompile(`(?i)^\s{4}TPA\s+:\s(.*)`): "discNum",
|
||||
regexp.MustCompile(`(?i)^\s{4}date\s+:\s(.*)`): "year",
|
||||
regexp.MustCompile(`^\s{4}Stream #\d+:\d+: (.+):\s`): "hasPicture",
|
||||
}
|
||||
|
||||
durationRx = regexp.MustCompile(`^\s\sDuration: ([\d.:]+).*bitrate: (\d+)`)
|
||||
)
|
||||
|
||||
func (m *Metadata) parseInfo(info string) {
|
||||
reader := strings.NewReader(info)
|
||||
scanner := bufio.NewScanner(reader)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
for rx, tag := range tagsRx {
|
||||
match := rx.FindStringSubmatch(line)
|
||||
if len(match) > 0 {
|
||||
m.tags[tag] = match[1]
|
||||
break
|
||||
}
|
||||
match = durationRx.FindStringSubmatch(line)
|
||||
if len(match) == 0 {
|
||||
continue
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
match := tagsRx.FindStringSubmatch(line)
|
||||
if len(match) > 0 {
|
||||
tagName := strings.ToLower(match[1])
|
||||
tagValue := strings.TrimSpace(match[2])
|
||||
|
||||
// Skip when the tag was previously found
|
||||
if _, ok := m.tags[tagName]; !ok {
|
||||
m.tags[tagName] = tagValue
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
match = coverRx.FindStringSubmatch(line)
|
||||
if len(match) > 0 {
|
||||
m.tags["has_picture"] = "true"
|
||||
continue
|
||||
}
|
||||
|
||||
match = durationRx.FindStringSubmatch(line)
|
||||
if len(match) > 0 {
|
||||
m.tags["duration"] = match[1]
|
||||
if len(match) > 1 {
|
||||
m.tags["bitrate"] = match[2]
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
match = bitRateRx.FindStringSubmatch(line)
|
||||
if len(match) > 0 {
|
||||
m.tags["bitrate"] = match[2]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,15 +206,6 @@ func (m *Metadata) parseInt(tagName string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
var tagYearFormats = []string{
|
||||
"2006",
|
||||
"2006.01",
|
||||
"2006.01.02",
|
||||
"2006-01",
|
||||
"2006-01-02",
|
||||
time.RFC3339,
|
||||
}
|
||||
|
||||
var dateRegex = regexp.MustCompile(`^([12]\d\d\d)`)
|
||||
|
||||
func (m *Metadata) parseYear(tagName string) int {
|
||||
@@ -218,17 +221,28 @@ func (m *Metadata) parseYear(tagName string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m *Metadata) parseTuple(numTag string, totalTag string) (int, int) {
|
||||
if v, ok := m.tags[numTag]; ok {
|
||||
tuple := strings.Split(v, "/")
|
||||
t1, t2 := 0, 0
|
||||
t1, _ = strconv.Atoi(tuple[0])
|
||||
if len(tuple) > 1 {
|
||||
t2, _ = strconv.Atoi(tuple[1])
|
||||
} else {
|
||||
t2, _ = strconv.Atoi(m.tags[totalTag])
|
||||
func (m *Metadata) getTag(tags ...string) string {
|
||||
for _, t := range tags {
|
||||
if v, ok := m.tags[t]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *Metadata) parseTuple(tags ...string) (int, int) {
|
||||
for _, tagName := range tags {
|
||||
if v, ok := m.tags[tagName]; ok {
|
||||
tuple := strings.Split(v, "/")
|
||||
t1, t2 := 0, 0
|
||||
t1, _ = strconv.Atoi(tuple[0])
|
||||
if len(tuple) > 1 {
|
||||
t2, _ = strconv.Atoi(tuple[1])
|
||||
} else {
|
||||
t2, _ = strconv.Atoi(m.tags[tagName+"total"])
|
||||
}
|
||||
return t1, t2
|
||||
}
|
||||
return t1, t2
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
@@ -243,17 +257,18 @@ func (m *Metadata) parseBool(tagName string) bool {
|
||||
|
||||
var zeroTime = time.Date(0000, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func (m *Metadata) parseDuration(tagName string) int {
|
||||
func (m *Metadata) parseDuration(tagName string) float32 {
|
||||
if v, ok := m.tags[tagName]; ok {
|
||||
d, err := time.Parse("15:04:05", v)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int(d.Sub(zeroTime).Seconds())
|
||||
return float32(d.Sub(zeroTime).Seconds())
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Inputs will always be absolute paths
|
||||
func createProbeCommand(inputs []string) (string, []string) {
|
||||
cmd := conf.Server.ProbeCommand
|
||||
|
||||
|
||||
@@ -67,31 +67,85 @@ var _ = Describe("Metadata", func() {
|
||||
})
|
||||
|
||||
Context("extractMetadata", func() {
|
||||
It("detects embedded cover art correctly", func() {
|
||||
const output = `
|
||||
Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3':
|
||||
Metadata:
|
||||
compilation : 1
|
||||
Duration: 00:00:01.02, start: 0.000000, bitrate: 477 kb/s
|
||||
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s
|
||||
Stream #0:1: Video: mjpeg, yuvj444p(pc, bt470bg/unknown/unknown), 600x600 [SAR 1:1 DAR 1:1], 90k tbr, 90k tbn, 90k tbc`
|
||||
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
|
||||
Expect(md.HasPicture()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("detects embedded cover art in ogg containers", func() {
|
||||
const output = `
|
||||
Input #0, ogg, from '/Users/deluan/Music/iTunes/iTunes Media/Music/_Testes/Jamaican In New York/01-02 Jamaican In New York (Album Version).opus':
|
||||
Duration: 00:04:28.69, start: 0.007500, bitrate: 139 kb/s
|
||||
Stream #0:0(eng): Audio: opus, 48000 Hz, stereo, fltp
|
||||
Metadata:
|
||||
ALBUM : Jamaican In New York
|
||||
metadata_block_picture: AAAAAwAAAAppbWFnZS9qcGVnAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4Id/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQ
|
||||
TITLE : Jamaican In New York (Album Version)`
|
||||
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
|
||||
Expect(md.HasPicture()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("gets bitrate from the stream, if available", func() {
|
||||
const output = `
|
||||
Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3':
|
||||
Duration: 00:00:01.02, start: 0.000000, bitrate: 477 kb/s
|
||||
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s`
|
||||
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
|
||||
Expect(md.BitRate()).To(Equal(192))
|
||||
})
|
||||
|
||||
It("parses correctly the compilation tag", func() {
|
||||
const outputWithOverlappingTitleTag = `
|
||||
const output = `
|
||||
Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3':
|
||||
Metadata:
|
||||
compilation : 1
|
||||
Duration: 00:05:02.63, start: 0.000000, bitrate: 140 kb/s`
|
||||
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithOverlappingTitleTag)
|
||||
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
|
||||
Expect(md.Compilation()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("parses correct the title without overlapping with the stream tag", func() {
|
||||
const outputWithOverlappingTitleTag = `
|
||||
It("parses duration with milliseconds", func() {
|
||||
const output = `
|
||||
Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3':
|
||||
Duration: 00:05:02.63, start: 0.000000, bitrate: 140 kb/s`
|
||||
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
|
||||
Expect(md.Duration()).To(BeNumerically("~", 302.63, 0.001))
|
||||
})
|
||||
|
||||
It("parses stream level tags", func() {
|
||||
const output = `
|
||||
Input #0, ogg, from './01-02 Drive (Teku).opus':
|
||||
Metadata:
|
||||
ALBUM : Hot Wheels Acceleracers Soundtrack
|
||||
Duration: 00:03:37.37, start: 0.007500, bitrate: 135 kb/s
|
||||
Stream #0:0(eng): Audio: opus, 48000 Hz, stereo, fltp
|
||||
Metadata:
|
||||
TITLE : Drive (Teku)`
|
||||
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
|
||||
Expect(md.Title()).To(Equal("Drive (Teku)"))
|
||||
})
|
||||
|
||||
It("does not overlap top level tags with the stream level tags", func() {
|
||||
const output = `
|
||||
Input #0, mp3, from 'groovin.mp3':
|
||||
Metadata:
|
||||
title : Groovin' (feat. Daniel Sneijers, Susanne Alt)
|
||||
Duration: 00:03:34.28, start: 0.025056, bitrate: 323 kb/s
|
||||
Metadata:
|
||||
title : cover
|
||||
At least one output file must be specified`
|
||||
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithOverlappingTitleTag)
|
||||
title : garbage`
|
||||
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
|
||||
Expect(md.Title()).To(Equal("Groovin' (feat. Daniel Sneijers, Susanne Alt)"))
|
||||
})
|
||||
|
||||
It("ignores case in the tag name", func() {
|
||||
const outputWithOverlappingTitleTag = `
|
||||
const output = `
|
||||
Input #0, flac, from '/Users/deluan/Downloads/06. Back In Black.flac':
|
||||
Metadata:
|
||||
ALBUM : Back In Black
|
||||
@@ -103,7 +157,7 @@ Input #0, flac, from '/Users/deluan/Downloads/06. Back In Black.flac':
|
||||
TRACKTOTAL : 10
|
||||
track : 6
|
||||
Duration: 00:04:16.00, start: 0.000000, bitrate: 995 kb/s`
|
||||
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithOverlappingTitleTag)
|
||||
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
|
||||
Expect(md.Title()).To(Equal("Back In Black"))
|
||||
Expect(md.Album()).To(Equal("Back In Black"))
|
||||
Expect(md.Genre()).To(Equal("Hard Rock"))
|
||||
@@ -164,13 +218,13 @@ Tracklist:
|
||||
"May 12, 2016": 0,
|
||||
}
|
||||
for tag, expected := range examples {
|
||||
md := &Metadata{tags: map[string]string{"year": tag}}
|
||||
md := &Metadata{tags: map[string]string{"date": tag}}
|
||||
Expect(md.Year()).To(Equal(expected))
|
||||
}
|
||||
})
|
||||
|
||||
It("returns 0 if year is invalid", func() {
|
||||
md := &Metadata{tags: map[string]string{"year": "invalid"}}
|
||||
md := &Metadata{tags: map[string]string{"date": "invalid"}}
|
||||
Expect(md.Year()).To(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
@@ -168,33 +169,39 @@ func (s *TagScanner) processChangedDir(ctx context.Context, dir string, updatedA
|
||||
delete(currentTracks, filePath)
|
||||
}
|
||||
|
||||
// Load tracks Metadata from the folder
|
||||
newTracks, err := s.loadTracks(filesToUpdate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If track from folder is newer than the one in DB, update/insert in DB and delete from the current tracks
|
||||
log.Trace("Updating mediaFiles in DB", "dir", dir, "files", filesToUpdate, "numFiles", len(filesToUpdate))
|
||||
numUpdatedTracks := 0
|
||||
numPurgedTracks := 0
|
||||
for _, n := range newTracks {
|
||||
err := s.ds.MediaFile(ctx).Put(&n)
|
||||
updatedArtists[n.ArtistID] = true
|
||||
updatedAlbums[n.AlbumID] = true
|
||||
numUpdatedTracks++
|
||||
|
||||
if len(filesToUpdate) > 0 {
|
||||
// Load tracks Metadata from the folder
|
||||
newTracks, err := s.loadTracks(filesToUpdate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If track from folder is newer than the one in DB, update/insert in DB and delete from the current tracks
|
||||
log.Trace("Updating mediaFiles in DB", "dir", dir, "files", filesToUpdate, "numFiles", len(filesToUpdate))
|
||||
for _, n := range newTracks {
|
||||
err := s.ds.MediaFile(ctx).Put(&n)
|
||||
updatedArtists[n.AlbumArtistID] = true
|
||||
updatedAlbums[n.AlbumID] = true
|
||||
numUpdatedTracks++
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remaining tracks from DB that are not in the folder are deleted
|
||||
for _, ct := range currentTracks {
|
||||
numPurgedTracks++
|
||||
updatedArtists[ct.ArtistID] = true
|
||||
updatedAlbums[ct.AlbumID] = true
|
||||
if err := s.ds.MediaFile(ctx).Delete(ct.ID); err != nil {
|
||||
return err
|
||||
if len(currentTracks) > 0 {
|
||||
log.Trace("Deleting dangling tracks from DB", "dir", dir, "numTracks", len(currentTracks))
|
||||
// Remaining tracks from DB that are not in the folder are deleted
|
||||
for _, ct := range currentTracks {
|
||||
numPurgedTracks++
|
||||
updatedArtists[ct.AlbumArtistID] = true
|
||||
updatedAlbums[ct.AlbumID] = true
|
||||
if err := s.ds.MediaFile(ctx).Delete(ct.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,11 +218,11 @@ func (s *TagScanner) processDeletedDir(ctx context.Context, dir string, updatedA
|
||||
return err
|
||||
}
|
||||
for _, t := range ct {
|
||||
updatedArtists[t.ArtistID] = true
|
||||
updatedArtists[t.AlbumArtistID] = true
|
||||
updatedAlbums[t.AlbumID] = true
|
||||
}
|
||||
|
||||
log.Info("Finished processing deleted folder", "dir", dir, "deleted", len(ct), "elapsed", time.Since(start))
|
||||
log.Info("Finished processing deleted folder", "dir", dir, "purged", len(ct), "elapsed", time.Since(start))
|
||||
return s.ds.MediaFile(ctx).DeleteByPath(dir)
|
||||
}
|
||||
|
||||
@@ -240,13 +247,10 @@ func (s *TagScanner) toMediaFile(md *Metadata) model.MediaFile {
|
||||
mf.Album = md.Album()
|
||||
mf.AlbumID = s.albumID(md)
|
||||
mf.Album = s.mapAlbumName(md)
|
||||
if md.Artist() == "" {
|
||||
mf.Artist = "[Unknown Artist]"
|
||||
} else {
|
||||
mf.Artist = md.Artist()
|
||||
}
|
||||
mf.ArtistID = s.artistID(md)
|
||||
mf.AlbumArtist = md.AlbumArtist()
|
||||
mf.Artist = s.mapArtistName(md)
|
||||
mf.AlbumArtistID = s.albumArtistID(md)
|
||||
mf.AlbumArtist = s.mapAlbumArtistName(md)
|
||||
mf.Genre = md.Genre()
|
||||
mf.Compilation = md.Compilation()
|
||||
mf.Year = md.Year()
|
||||
@@ -275,19 +279,26 @@ func (s *TagScanner) mapTrackTitle(md *Metadata) string {
|
||||
return md.Title()
|
||||
}
|
||||
|
||||
func (s *TagScanner) mapArtistName(md *Metadata) string {
|
||||
func (s *TagScanner) mapAlbumArtistName(md *Metadata) string {
|
||||
switch {
|
||||
case md.Compilation():
|
||||
return "Various Artists"
|
||||
return consts.VariousArtists
|
||||
case md.AlbumArtist() != "":
|
||||
return md.AlbumArtist()
|
||||
case md.Artist() != "":
|
||||
return md.Artist()
|
||||
default:
|
||||
return "[Unknown Artist]"
|
||||
return consts.UnknownArtist
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TagScanner) mapArtistName(md *Metadata) string {
|
||||
if md.Artist() != "" {
|
||||
return md.Artist()
|
||||
}
|
||||
return consts.UnknownArtist
|
||||
}
|
||||
|
||||
func (s *TagScanner) mapAlbumName(md *Metadata) string {
|
||||
name := md.Album()
|
||||
if name == "" {
|
||||
@@ -301,10 +312,14 @@ func (s *TagScanner) trackID(md *Metadata) string {
|
||||
}
|
||||
|
||||
func (s *TagScanner) albumID(md *Metadata) string {
|
||||
albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", s.mapArtistName(md), s.mapAlbumName(md)))
|
||||
albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", s.mapAlbumArtistName(md), s.mapAlbumName(md)))
|
||||
return fmt.Sprintf("%x", md5.Sum([]byte(albumPath)))
|
||||
}
|
||||
|
||||
func (s *TagScanner) artistID(md *Metadata) string {
|
||||
return fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(s.mapArtistName(md)))))
|
||||
}
|
||||
|
||||
func (s *TagScanner) albumArtistID(md *Metadata) string {
|
||||
return fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(s.mapAlbumArtistName(md)))))
|
||||
}
|
||||
|
||||
@@ -15,42 +15,46 @@ import (
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
ds model.DataStore
|
||||
mux http.Handler
|
||||
path string
|
||||
ds model.DataStore
|
||||
mux http.Handler
|
||||
}
|
||||
|
||||
func New(ds model.DataStore, path string) *Router {
|
||||
r := &Router{ds: ds, path: path}
|
||||
r.mux = r.routes()
|
||||
return r
|
||||
func New(ds model.DataStore) *Router {
|
||||
return &Router{ds: ds}
|
||||
}
|
||||
|
||||
func (app *Router) Setup(path string) {
|
||||
app.mux = app.routes(path)
|
||||
}
|
||||
|
||||
func (app *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
app.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (app *Router) routes() http.Handler {
|
||||
func (app *Router) routes(path string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Post("/login", Login(app.ds))
|
||||
r.Post("/createAdmin", CreateAdmin(app.ds))
|
||||
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Use(mapAuthHeader())
|
||||
r.Use(jwtauth.Verifier(auth.TokenAuth))
|
||||
r.Use(Authenticator(app.ds))
|
||||
r.Use(authenticator(app.ds))
|
||||
app.R(r, "/user", model.User{})
|
||||
app.R(r, "/song", model.MediaFile{})
|
||||
app.R(r, "/album", model.Album{})
|
||||
app.R(r, "/artist", model.Artist{})
|
||||
app.R(r, "/transcoding", model.Transcoding{})
|
||||
app.R(r, "/player", model.Player{})
|
||||
|
||||
// Keepalive endpoint to be used to keep the session valid (ex: while playing songs)
|
||||
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"response":"ok"}`)) })
|
||||
})
|
||||
|
||||
// Serve UI app assets
|
||||
r.Handle("/", ServeIndex(app.ds))
|
||||
r.Handle("/*", http.StripPrefix(app.path, http.FileServer(assets.AssetFile())))
|
||||
r.Handle("/", ServeIndex(app.ds, assets.AssetFile()))
|
||||
r.Handle("/*", http.StripPrefix(path, http.FileServer(assets.AssetFile())))
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
17
server/app/app_suite_test.go
Normal file
17
server/app/app_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestApp(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelCritical)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "RESTful API Suite")
|
||||
}
|
||||
@@ -63,7 +63,6 @@ func handleLogin(ds model.DataStore, username string, password string, w http.Re
|
||||
"name": user.Name,
|
||||
"username": username,
|
||||
"isAdmin": user.IsAdmin,
|
||||
"version": consts.Version(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -149,7 +148,7 @@ func validateLogin(userRepo model.UserRepository, userName, password string) (*m
|
||||
func contextWithUser(ctx context.Context, ds model.DataStore, claims jwt.MapClaims) context.Context {
|
||||
userName := claims["sub"].(string)
|
||||
user, _ := ds.User(ctx).FindByUsername(userName)
|
||||
return context.WithValue(ctx, "user", user)
|
||||
return context.WithValue(ctx, "user", *user)
|
||||
}
|
||||
|
||||
func getToken(ds model.DataStore, ctx context.Context) (*jwt.Token, error) {
|
||||
@@ -169,7 +168,18 @@ func getToken(ds model.DataStore, ctx context.Context) (*jwt.Token, error) {
|
||||
return nil, errors.New("invalid authentication")
|
||||
}
|
||||
|
||||
func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
// This method maps the custom authorization header to the default 'Authorization', used by the jwtauth library
|
||||
func mapAuthHeader() func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
bearer := r.Header.Get(consts.UIAuthorizationHeader)
|
||||
r.Header.Set("Authorization", bearer)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
auth.InitTokenAuth(ds)
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
@@ -194,7 +204,7 @@ func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Authorization", newTokenString)
|
||||
w.Header().Set(consts.UIAuthorizationHeader, newTokenString)
|
||||
next.ServeHTTP(w, r.WithContext(newCtx))
|
||||
})
|
||||
}
|
||||
|
||||
27
server/app/auth_test.go
Normal file
27
server/app/auth_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Auth", func() {
|
||||
Describe("mapAuthHeader", func() {
|
||||
It("maps the custom header to Authorization header", func() {
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
r.Header.Set(consts.UIAuthorizationHeader, "test authorization bearer")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
mapAuthHeader()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Expect(r.Header.Get("Authorization")).To(Equal("test authorization bearer"))
|
||||
w.WriteHeader(200)
|
||||
})).ServeHTTP(w, r)
|
||||
|
||||
Expect(w.Code).To(Equal(200))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,35 +5,33 @@ import (
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/assets"
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
// Injects the `firstTime` config in the `index.html` template
|
||||
func ServeIndex(ds model.DataStore) http.HandlerFunc {
|
||||
func ServeIndex(ds model.DataStore, fs http.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := ds.User(r.Context()).CountAll()
|
||||
firstTime := c == 0 && err == nil
|
||||
|
||||
t := template.New("initial state")
|
||||
fs := assets.AssetFile()
|
||||
indexHtml, err := fs.Open("index.html")
|
||||
if err != nil {
|
||||
log.Error(r, "Could not find `index.html` template", err)
|
||||
}
|
||||
indexStr, err := ioutil.ReadAll(indexHtml)
|
||||
if err != nil {
|
||||
log.Error(r, "Could not read from `index.html`", err)
|
||||
}
|
||||
t, _ = t.Parse(string(indexStr))
|
||||
t := getIndexTemplate(r, fs)
|
||||
|
||||
appConfig := map[string]interface{}{
|
||||
"firstTime": firstTime,
|
||||
"version": consts.Version(),
|
||||
"firstTime": firstTime,
|
||||
"baseURL": strings.TrimSuffix(conf.Server.BaseURL, "/"),
|
||||
"loginBackgroundURL": conf.Server.UILoginBackgroundURL,
|
||||
}
|
||||
j, _ := json.Marshal(appConfig)
|
||||
|
||||
data := map[string]interface{}{
|
||||
"AppConfig": string(j),
|
||||
"Version": consts.Version(),
|
||||
}
|
||||
err = t.Execute(w, data)
|
||||
if err != nil {
|
||||
@@ -41,3 +39,20 @@ func ServeIndex(ds model.DataStore) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getIndexTemplate(r *http.Request, fs http.FileSystem) *template.Template {
|
||||
t := template.New("initial state")
|
||||
indexHtml, err := fs.Open("index.html")
|
||||
if err != nil {
|
||||
log.Error(r, "Could not find `index.html` template", err)
|
||||
}
|
||||
indexStr, err := ioutil.ReadAll(indexHtml)
|
||||
if err != nil {
|
||||
log.Error(r, "Could not read from `index.html`", err)
|
||||
}
|
||||
t, err = t.Parse(string(indexStr))
|
||||
if err != nil {
|
||||
log.Error(r, "Error parsing `index.html`", err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user