Compare commits
109 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
400fa65326 | ||
|
|
ab10719d27 | ||
|
|
029290f304 | ||
|
|
2c146ea1fe | ||
|
|
10ead1f5f2 | ||
|
|
730722cfe3 | ||
|
|
dc352834b9 | ||
|
|
313a3342a0 | ||
|
|
0f13bbdbd0 | ||
|
|
4310f2c94f | ||
|
|
6ce4811460 | ||
|
|
52cd17963f | ||
|
|
8f0c07d29f | ||
|
|
a50735a94c | ||
|
|
f0e7f3ef25 | ||
|
|
2ca98d8e81 | ||
|
|
81e1a7088f | ||
|
|
d37351610a | ||
|
|
99361c0d9f | ||
|
|
8673533cd4 | ||
|
|
d9dd9fe587 | ||
|
|
abb99a8501 | ||
|
|
690f92a671 | ||
|
|
c57007db52 | ||
|
|
cc229dcee6 | ||
|
|
7aab82c246 | ||
|
|
989deb1200 | ||
|
|
6aaee4342e | ||
|
|
b5dadf55f4 | ||
|
|
18c7397709 | ||
|
|
4a82a6cb02 | ||
|
|
220ffd5324 | ||
|
|
e33d2305a1 | ||
|
|
7815b57920 | ||
|
|
18cbb153f3 | ||
|
|
9f086b5f7b | ||
|
|
c8d6f2d506 | ||
|
|
6619b0986a | ||
|
|
2dbd645292 | ||
|
|
6978790e96 | ||
|
|
e0308acef3 | ||
|
|
5fbde33b97 | ||
|
|
19fb29e520 | ||
|
|
e5e35516d7 | ||
|
|
28bad95e66 | ||
|
|
9260957271 | ||
|
|
79b0f1f57b | ||
|
|
4dffcb7b46 | ||
|
|
d1f8d39866 | ||
|
|
0996272943 | ||
|
|
d093191659 | ||
|
|
998323b364 | ||
|
|
6dfe56c1c4 | ||
|
|
fd5548f890 | ||
|
|
6e2454f6cc | ||
|
|
8372dee000 | ||
|
|
41fd5862b8 | ||
|
|
a6b5be7b0a | ||
|
|
4d06d250e6 | ||
|
|
694b5d1d39 | ||
|
|
5329ac7b72 | ||
|
|
464880dd31 | ||
|
|
0e01f9a0f9 | ||
|
|
d9eb3e58cd | ||
|
|
0d64fb05c7 | ||
|
|
0849d6b901 | ||
|
|
40ad6a7bef | ||
|
|
ddae5588d4 | ||
|
|
67c20f36b1 | ||
|
|
ff8c18e0f4 | ||
|
|
203754726b | ||
|
|
e97d805444 | ||
|
|
d4365b9f64 | ||
|
|
b62b78edfe | ||
|
|
7c4511e33a | ||
|
|
7e65bb8f20 | ||
|
|
76ca8afc84 | ||
|
|
a6b8f40ac3 | ||
|
|
0d0787e656 | ||
|
|
88e01d05f6 | ||
|
|
de1fea64bc | ||
|
|
5d1df19291 | ||
|
|
0b91d8a30e | ||
|
|
cdbbb2f596 | ||
|
|
44671c59c0 | ||
|
|
d9f61a278c | ||
|
|
a260e65307 | ||
|
|
5a4c763510 | ||
|
|
d755609d13 | ||
|
|
4f4af34595 | ||
|
|
f5071d1614 | ||
|
|
d389d40db1 | ||
|
|
72d9ddf532 | ||
|
|
67ed830a68 | ||
|
|
71c1844bca | ||
|
|
b26a5ef2d0 | ||
|
|
b286034977 | ||
|
|
c9f5625abf | ||
|
|
22d57a7c26 | ||
|
|
0c5bf18d80 | ||
|
|
9b7d1757e7 | ||
|
|
c34a5dcb07 | ||
|
|
90a1e6d213 | ||
|
|
482350c076 | ||
|
|
64388b2d4a | ||
|
|
3007ca68d5 | ||
|
|
d4edff3aaa | ||
|
|
99b1dc1421 | ||
|
|
37dfe4c092 |
@@ -6,7 +6,6 @@ Dockerfile
|
||||
data
|
||||
*.db
|
||||
testDB
|
||||
*_test.go
|
||||
navidrome
|
||||
navidrome.db
|
||||
navidrome.toml
|
||||
|
||||
BIN
.github/screenshots/screenshot-desktop.png
vendored
|
Before Width: | Height: | Size: 264 KiB |
BIN
.github/screenshots/ss-desktop-player.png
vendored
Normal file
|
After Width: | Height: | Size: 364 KiB |
BIN
.github/screenshots/ss-mobile-album-view.png
vendored
Normal file
|
After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 709 KiB After Width: | Height: | Size: 709 KiB |
BIN
.github/screenshots/ss-mobile-player.png
vendored
Normal file
|
After Width: | Height: | Size: 411 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
3
.github/workflows/release.yml
vendored
@@ -24,7 +24,8 @@ jobs:
|
||||
- name: Fetch tags
|
||||
run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
|
||||
- name: Run GoReleaser
|
||||
uses: docker://bepsays/ci-goreleaser:1.13-4
|
||||
uses: docker://bepsays/ci-goreleaser:latest
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
|
||||
2
.gitignore
vendored
@@ -18,3 +18,5 @@ navidrome.db
|
||||
*.swp
|
||||
*_gen.go
|
||||
dist
|
||||
music
|
||||
docker-compose.override.yml
|
||||
|
||||
@@ -19,7 +19,7 @@ builds:
|
||||
flags:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- -X main.gitSha={{.ShortCommit}} -X main.gitTag={{.Tag}}
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
|
||||
|
||||
- id: navidrome_linux
|
||||
env:
|
||||
@@ -32,7 +32,7 @@ builds:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -X main.gitSha={{.ShortCommit}} -X main.gitTag={{.Tag}}
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
|
||||
|
||||
- id: navidrome_windows_i686
|
||||
env:
|
||||
@@ -47,7 +47,7 @@ builds:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -X main.gitSha={{.ShortCommit}} -X main.gitTag={{.Tag}}
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
|
||||
|
||||
- id: navidrome_windows_x64
|
||||
env:
|
||||
@@ -62,7 +62,7 @@ builds:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -X main.gitSha={{.ShortCommit}} -X main.gitTag={{.Tag}}
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
|
||||
|
||||
archives:
|
||||
-
|
||||
@@ -83,5 +83,3 @@ changelog:
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- '^test:'
|
||||
- '^ci:'
|
||||
|
||||
@@ -25,7 +25,7 @@ Navidrome is actively being tested with:
|
||||
| `getLicense` | Always valid ;) |
|
||||
| ||
|
||||
| _BROWSING_ ||
|
||||
| `getMusicFolders` | Hardcoded to just one, configured in `app.conf` |
|
||||
| `getMusicFolders` | Hardcoded to just one, set with ND_MUSICFOLDER configuration |
|
||||
| `getIndexes` | Doesn't support shortcuts, nor direct children |
|
||||
| `getMusicDirectory` | |
|
||||
| `getSong` | |
|
||||
@@ -40,7 +40,7 @@ Navidrome is actively being tested with:
|
||||
| `getStarred` | |
|
||||
| `getStarred2` | |
|
||||
| `getNowPlaying` | |
|
||||
| `getRandomSongs` | Ignores `year` parameter |
|
||||
| `getRandomSongs` | Ignores `fromYear` and `toYear` parameters |
|
||||
| ||
|
||||
| _SEARCHING_ ||
|
||||
| `search2` | Doesn't support Lucene queries, only simple auto complete queries |
|
||||
@@ -54,7 +54,7 @@ Navidrome is actively being tested with:
|
||||
| `deletePlaylist` | |
|
||||
| ||
|
||||
| _MEDIA RETRIEVAL_ ||
|
||||
| `stream` | Returns wrong content-length when downsampling |
|
||||
| `stream` | No Transcoding/Downsampling support (for now)|
|
||||
| `download` | |
|
||||
| `getCoverArt` | Only gets embedded artwork |
|
||||
| `getAvatar` | Always returns the same image |
|
||||
|
||||
28
Dockerfile
@@ -18,8 +18,7 @@ RUN apk add -U --no-cache build-base git
|
||||
RUN go get -u github.com/go-bindata/go-bindata/...
|
||||
|
||||
# Download and unpack static ffmpeg
|
||||
ARG FFMPEG_VERSION=4.1.4
|
||||
ARG FFMPEG_URL=https://www.johnvansickle.com/ffmpeg/old-releases/ffmpeg-${FFMPEG_VERSION}-amd64-static.tar.xz
|
||||
ARG FFMPEG_URL=https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
|
||||
RUN wget -O /tmp/ffmpeg.tar.xz ${FFMPEG_URL}
|
||||
RUN cd /tmp && tar xJf ffmpeg.tar.xz && rm ffmpeg.tar.xz
|
||||
|
||||
@@ -28,30 +27,41 @@ WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source and UI bundle, build executable
|
||||
# Copy source, test it
|
||||
COPY . .
|
||||
RUN go test ./...
|
||||
|
||||
# Copy UI bundle, build executable
|
||||
COPY --from=jsbuilder /src/build/* /src/ui/build/
|
||||
COPY --from=jsbuilder /src/build/static/css/* /src/ui/build/static/css/
|
||||
COPY --from=jsbuilder /src/build/static/js/* /src/ui/build/static/js/
|
||||
RUN rm -rf /src/build/css /src/build/js
|
||||
RUN GIT_SHA=$(git rev-parse --short HEAD) && \
|
||||
GIT_TAG=$(git describe --tags --abbrev=0 2> /dev/null) && \
|
||||
RUN GIT_TAG=$(git name-rev --name-only HEAD) && \
|
||||
GIT_TAG=${GIT_TAG#"tags/"} && \
|
||||
GIT_SHA=$(git rev-parse --short HEAD) && \
|
||||
echo "Building version: ${GIT_TAG} (${GIT_SHA})" && \
|
||||
go-bindata -fs -prefix ui/build -tags embed -nocompress -pkg assets -o assets/embedded_gen.go ui/build/... && \
|
||||
go build -ldflags="-X main.gitSha=${GIT_SHA} -X main.gitTag=${GIT_TAG}" -tags=embed
|
||||
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=${GIT_SHA} -X github.com/deluan/navidrome/consts.gitTag=${GIT_TAG}" -tags=embed
|
||||
|
||||
#####################################################
|
||||
### Build Final Image
|
||||
FROM alpine
|
||||
FROM alpine as release
|
||||
MAINTAINER Deluan Quintao <navidrome@deluan.com>
|
||||
|
||||
COPY --from=gobuilder /src/navidrome /app/
|
||||
COPY --from=gobuilder /tmp/ffmpeg*/ffmpeg /usr/bin/
|
||||
|
||||
# Check if ffmpeg runs properly
|
||||
RUN ffmpeg -buildconf
|
||||
|
||||
VOLUME ["/data", "/music"]
|
||||
ENV ND_MUSICFOLDER /music
|
||||
ENV ND_DATAFOLDER /data
|
||||
ENV ND_SCANINTERVAL 1m
|
||||
ENV ND_LOGLEVEL info
|
||||
ENV ND_PORT 4533
|
||||
EXPOSE 4533
|
||||
|
||||
EXPOSE 4533
|
||||
WORKDIR /app
|
||||
CMD "/app/navidrome"
|
||||
|
||||
ENTRYPOINT "/app/navidrome"
|
||||
|
||||
40
Makefile
@@ -4,11 +4,11 @@ NODE_VERSION=v13.7.0
|
||||
GIT_SHA=$(shell git rev-parse --short HEAD)
|
||||
|
||||
.PHONY: dev
|
||||
dev: check_env data
|
||||
dev: check_env
|
||||
@goreman -f Procfile.dev -b 4533 start
|
||||
|
||||
.PHONY: server
|
||||
server: check_go_env data
|
||||
server: check_go_env
|
||||
@reflex -d none -c reflex.conf
|
||||
|
||||
.PHONY: watch
|
||||
@@ -26,12 +26,13 @@ testall: check_go_env test
|
||||
|
||||
.PHONY: setup
|
||||
setup: Jamstash-master
|
||||
@which reflex || (echo "Installing Reflex" && GO111MODULE=off go get -u github.com/cespare/reflex)
|
||||
@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 go-bindata || (echo "Installing BinData" && GO111MODULE=off go get -u github.com/go-bindata/go-bindata/...)
|
||||
@which goose || (echo "Installing Goose" && GO111MODULE=off go get -u github.com/pressly/goose/cmd/goose)
|
||||
go mod download
|
||||
@(cd ./ui && npm ci)
|
||||
|
||||
@@ -57,20 +58,25 @@ 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)
|
||||
|
||||
data:
|
||||
mkdir data
|
||||
|
||||
UI_SRC = $(shell find ui/src ui/public -name "*.js")
|
||||
ui/build: $(UI_SRC) $(UI_PUBLIC) ui/package-lock.json
|
||||
@(cd ./ui && npm run build)
|
||||
|
||||
assets/embedded_gen.go: ui/build
|
||||
go-bindata -fs -prefix "ui/build" -tags embed -nocompress -pkg assets -o assets/embedded_gen.go ui/build/...
|
||||
|
||||
.PHONY: build
|
||||
build: check_go_env
|
||||
go build -ldflags="-X main.gitSha=$(GIT_SHA) -X main.gitTag=master"
|
||||
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=master"
|
||||
|
||||
.PHONY: buildall
|
||||
buildall: check_go_env assets/embedded_gen.go
|
||||
go build -ldflags="-X main.gitSha=$(GIT_SHA) -X main.gitTag=master" -tags=embed
|
||||
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: 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
|
||||
@if [ -n "`git status -s`" ]; then echo "\n\nThere are pending changes. Please commit or stash first"; exit 1; fi
|
||||
make test
|
||||
git tag v${V}
|
||||
git push origin v${V}
|
||||
|
||||
.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
|
||||
|
||||
45
README.md
@@ -3,11 +3,10 @@
|
||||
[](https://github.com/deluan/navidrome/actions)
|
||||
[](https://github.com/deluan/navidrome/releases)
|
||||
[](https://hub.docker.com/r/deluan/navidrome)
|
||||
[](https://discord.gg/xh7j7yF)
|
||||
|
||||
Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your
|
||||
music collection from any browser or mobile device.
|
||||
|
||||
This is a fully functional _alpha quality_ software. Expect some changes in the feature set and the way it works.
|
||||
music collection from any browser or mobile device. It's like your personal Spotify!
|
||||
|
||||
__Any feedback is welcome!__ If you need/want a new feature, find a bug or think of any way to improve Navidrome,
|
||||
please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join the chat in our [Discord server](https://discord.gg/xh7j7yF)
|
||||
@@ -26,31 +25,34 @@ please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join
|
||||
- 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)
|
||||
- Integrated music player (WIP)
|
||||
|
||||
## Road map
|
||||
|
||||
This project is being actively worked on. Expect a more polished experience and new features/releases
|
||||
on a frequent basis. Some upcoming features planned:
|
||||
|
||||
- Transcoding/Downsampling on-the-fly
|
||||
- Last.FM integration
|
||||
- Integrated music player
|
||||
- Pre-build binaries for Raspberry Pi
|
||||
- Smart/dynamic playlists (similar to iTunes)
|
||||
- Support for audiobooks (bookmarking)
|
||||
- Jukebox mode
|
||||
- Sharing links to albums/songs/playlists
|
||||
- Podcasts
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
Various options are available:
|
||||
|
||||
### Pre-build executables
|
||||
### Pre-built executables
|
||||
|
||||
Just head to the [releases page](https://github.com/deluan/navidrome/releases) and download the latest version for you
|
||||
platform. There are builds available for Linux, macOS and Windows (32 and 64 bits).
|
||||
|
||||
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)
|
||||
@@ -77,22 +79,27 @@ services:
|
||||
ND_PORT: 4533
|
||||
volumes:
|
||||
- "./data:/data"
|
||||
- "/Users/deluan/Music/iTunes/iTunes Media/Music:/music"
|
||||
- "/path/to/your/music/folder:/music"
|
||||
```
|
||||
|
||||
### Build it yourself
|
||||
To get the cutting-edge, latest version from master, use the image `deluan/navidrome:develop`
|
||||
|
||||
You will need to install [Go 1.13](https://golang.org/dl/) and [Node 13.7](http://nodejs.org).
|
||||
You'll also need [ffmpeg](https://ffmpeg.org) installed in your system
|
||||
### Build from source
|
||||
|
||||
After the prerequisites above are installed, build the application with:
|
||||
You will need to install [Go 1.13](https://golang.org/dl/) and [Node 13.7.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)
|
||||
|
||||
```
|
||||
$ make setup
|
||||
$ make buildall
|
||||
After the prerequisites above are installed, clone this repository and build the application with:
|
||||
|
||||
```shell script
|
||||
$ git clone https://github.com/deluan/navidrome
|
||||
$ cd navidrome
|
||||
$ make setup # Install tools required for Navidrome's development
|
||||
$ make buildall # Build UI and server, generates a single executable
|
||||
```
|
||||
|
||||
This will generate the `navidrome` binary executable in the project's root folder.
|
||||
This will generate the `navidrome` executable binary in the project's root folder.
|
||||
|
||||
### Running for the first time
|
||||
|
||||
@@ -111,10 +118,10 @@ For more options, run `navidrome --help`
|
||||
|
||||
<p align="center">
|
||||
<p float="left">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/screenshot-login-mobile.png">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/screenshot-mobile.png">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/screenshot-users-mobile.png">
|
||||
<img width="900"src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/screenshot-desktop.png">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-login.png">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-player.png">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-album-view.png">
|
||||
<img width="900"src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-desktop-player.png">
|
||||
</p>
|
||||
</p>
|
||||
|
||||
|
||||
36
banner.go
@@ -1,36 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/static"
|
||||
)
|
||||
|
||||
var (
|
||||
// This will be set in build time. If not, version will be set to "dev"
|
||||
gitTag string
|
||||
gitSha string
|
||||
)
|
||||
|
||||
// Formats:
|
||||
// dev
|
||||
// v0.2.0 (5b84188)
|
||||
// master (9ed35cb)
|
||||
func getVersion() string {
|
||||
if gitSha == "" {
|
||||
return "dev"
|
||||
}
|
||||
return fmt.Sprintf("%s (%s)", gitTag, gitSha)
|
||||
}
|
||||
|
||||
func getBanner() string {
|
||||
data, _ := static.Asset("banner.txt")
|
||||
return strings.TrimSuffix(string(data), "\n")
|
||||
}
|
||||
|
||||
func ShowBanner() {
|
||||
version := "Version: " + getVersion()
|
||||
padding := strings.Repeat(" ", 52-len(version))
|
||||
fmt.Printf("%s%s%s\n\n", getBanner(), padding, version)
|
||||
}
|
||||
@@ -22,14 +22,16 @@ type nd struct {
|
||||
IgnoredArticles string `default:"The El La Los Las Le Les Os As O A"`
|
||||
IndexGroups string `default:"A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)"`
|
||||
|
||||
DisableDownsampling bool `default:"false"`
|
||||
DownsampleCommand string `default:"ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -"`
|
||||
ProbeCommand string `default:"ffmpeg %s -f ffmetadata"`
|
||||
ScanInterval string `default:"1m"`
|
||||
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"`
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
DevDisableAuthentication bool `default:"false"`
|
||||
DevDisableBanner bool `default:"false"`
|
||||
DevDisableBanner bool `default:"false"`
|
||||
DevLogSourceLine bool `default:"false"`
|
||||
DevAutoCreateAdminPassword string `default:""`
|
||||
}
|
||||
|
||||
var Server = &nd{}
|
||||
@@ -52,8 +54,6 @@ func newWithPath(path string, skipFlags ...bool) *multiconfig.DefaultLoader {
|
||||
if strings.HasSuffix(path, "yml") || strings.HasSuffix(path, "yaml") {
|
||||
loaders = append(loaders, &multiconfig.YAMLLoader{Path: path})
|
||||
}
|
||||
} else {
|
||||
println("Skipping config file not found: ", path)
|
||||
}
|
||||
|
||||
e := &multiconfig.EnvironmentLoader{}
|
||||
@@ -87,7 +87,8 @@ func LoadFromFile(confFile string, skipFlags ...bool) {
|
||||
if os.Getenv("PORT") != "" {
|
||||
Server.Port = os.Getenv("PORT")
|
||||
}
|
||||
log.SerLevelString(Server.LogLevel)
|
||||
log.SetLevelString(Server.LogLevel)
|
||||
log.SetLogSourceLine(Server.DevLogSourceLine)
|
||||
log.Trace("Loaded configuration", "file", confFile, "config", fmt.Sprintf("%#v", Server))
|
||||
}
|
||||
|
||||
|
||||
19
consts/banner.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package consts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/static"
|
||||
)
|
||||
|
||||
func getBanner() string {
|
||||
data, _ := static.Asset("banner.txt")
|
||||
return strings.TrimSuffix(string(data), "\n")
|
||||
}
|
||||
|
||||
func Banner() string {
|
||||
version := "Version: " + Version()
|
||||
padding := strings.Repeat(" ", 52-len(version))
|
||||
return fmt.Sprintf("%s%s%s\n", getBanner(), padding, version)
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package consts
|
||||
import "time"
|
||||
|
||||
const (
|
||||
AppName = "navidrome"
|
||||
|
||||
LocalConfigFile = "./navidrome.toml"
|
||||
InitialSetupFlagKey = "InitialSetup"
|
||||
|
||||
@@ -10,7 +12,8 @@ const (
|
||||
JWTIssuer = "ND"
|
||||
JWTTokenExpiration = 30 * time.Minute
|
||||
|
||||
InitialUserName = "admin"
|
||||
|
||||
UIAssetsLocalPath = "ui/build"
|
||||
|
||||
DevInitialUserName = "admin"
|
||||
DevInitialName = "Dev Admin"
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package server
|
||||
package consts
|
||||
|
||||
import "mime"
|
||||
|
||||
func initMimeTypes() {
|
||||
func init() {
|
||||
mt := map[string]string{
|
||||
".mp3": "audio/mpeg",
|
||||
".ogg": "audio/ogg",
|
||||
@@ -11,6 +11,7 @@ func initMimeTypes() {
|
||||
".ogx": "application/ogg",
|
||||
".aac": "audio/mp4",
|
||||
".m4a": "audio/mp4",
|
||||
".m4b": "audio/mp4",
|
||||
".flac": "audio/flac",
|
||||
".wav": "audio/x-wav",
|
||||
".wma": "audio/x-ms-wma",
|
||||
20
consts/version.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package consts
|
||||
|
||||
import "fmt"
|
||||
|
||||
var (
|
||||
// This will be set in build time. If not, version will be set to "dev"
|
||||
gitTag string
|
||||
gitSha string
|
||||
)
|
||||
|
||||
// Formats:
|
||||
// dev
|
||||
// v0.2.0 (5b84188)
|
||||
// master (9ed35cb)
|
||||
func Version() string {
|
||||
if gitSha == "" {
|
||||
return "dev"
|
||||
}
|
||||
return fmt.Sprintf("%s (%s)", gitTag, gitSha)
|
||||
}
|
||||
51
db/db.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
_ "github.com/deluan/navidrome/db/migration"
|
||||
"github.com/deluan/navidrome/log"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
Driver = "sqlite3"
|
||||
Path string
|
||||
)
|
||||
|
||||
func Init() {
|
||||
once.Do(func() {
|
||||
Path = conf.Server.DbPath
|
||||
if Path == ":memory:" {
|
||||
Path = "file::memory:?cache=shared"
|
||||
conf.Server.DbPath = Path
|
||||
}
|
||||
log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver)
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
err = goose.SetDialect(Driver)
|
||||
if err != nil {
|
||||
log.Error("Invalid DB driver", "driver", Driver, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
err = goose.Run("up", db, "./")
|
||||
if err != nil {
|
||||
log.Error("Failed to apply new migrations", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
183
db/migration/20200130083147_create_schema.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200130083147, Down20200130083147)
|
||||
}
|
||||
|
||||
func Up20200130083147(tx *sql.Tx) error {
|
||||
log.Info("Creating DB Schema")
|
||||
_, err := tx.Exec(`
|
||||
create table if not exists album
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar(255) default '' not null,
|
||||
artist_id varchar(255) default '' not null,
|
||||
cover_art_path varchar(255) default '' not null,
|
||||
cover_art_id varchar(255) default '' not null,
|
||||
artist varchar(255) default '' not null,
|
||||
album_artist varchar(255) default '' not null,
|
||||
year integer default 0 not null,
|
||||
compilation bool default FALSE not null,
|
||||
song_count integer default 0 not null,
|
||||
duration integer default 0 not null,
|
||||
genre varchar(255) default '' not null,
|
||||
created_at datetime,
|
||||
updated_at datetime
|
||||
);
|
||||
|
||||
create index if not exists album_artist
|
||||
on album (artist);
|
||||
|
||||
create index if not exists album_artist_id
|
||||
on album (artist_id);
|
||||
|
||||
create index if not exists album_genre
|
||||
on album (genre);
|
||||
|
||||
create index if not exists album_name
|
||||
on album (name);
|
||||
|
||||
create index if not exists album_year
|
||||
on album (year);
|
||||
|
||||
create table if not exists annotation
|
||||
(
|
||||
ann_id varchar(255) not null
|
||||
primary key,
|
||||
user_id varchar(255) default '' not null,
|
||||
item_id varchar(255) default '' not null,
|
||||
item_type varchar(255) default '' not null,
|
||||
play_count integer,
|
||||
play_date datetime,
|
||||
rating integer,
|
||||
starred bool default FALSE not null,
|
||||
starred_at datetime,
|
||||
unique (user_id, item_id, item_type)
|
||||
);
|
||||
|
||||
create index if not exists annotation_play_count
|
||||
on annotation (play_count);
|
||||
|
||||
create index if not exists annotation_play_date
|
||||
on annotation (play_date);
|
||||
|
||||
create index if not exists annotation_rating
|
||||
on annotation (rating);
|
||||
|
||||
create index if not exists annotation_starred
|
||||
on annotation (starred);
|
||||
|
||||
create table if not exists artist
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar(255) default '' not null,
|
||||
album_count integer default 0 not null
|
||||
);
|
||||
|
||||
create index if not exists artist_name
|
||||
on artist (name);
|
||||
|
||||
create table if not exists media_file
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
path varchar(255) default '' not null,
|
||||
title varchar(255) default '' not null,
|
||||
album varchar(255) default '' not null,
|
||||
artist varchar(255) default '' not null,
|
||||
artist_id varchar(255) default '' not null,
|
||||
album_artist varchar(255) default '' not null,
|
||||
album_id varchar(255) default '' not null,
|
||||
has_cover_art bool default FALSE not null,
|
||||
track_number integer default 0 not null,
|
||||
disc_number integer default 0 not null,
|
||||
year integer default 0 not null,
|
||||
size integer default 0 not null,
|
||||
suffix varchar(255) default '' not null,
|
||||
duration integer default 0 not null,
|
||||
bit_rate integer default 0 not null,
|
||||
genre varchar(255) default '' not null,
|
||||
compilation bool default FALSE not null,
|
||||
created_at datetime,
|
||||
updated_at datetime
|
||||
);
|
||||
|
||||
create index if not exists media_file_album_id
|
||||
on media_file (album_id);
|
||||
|
||||
create index if not exists media_file_genre
|
||||
on media_file (genre);
|
||||
|
||||
create index if not exists media_file_path
|
||||
on media_file (path);
|
||||
|
||||
create index if not exists media_file_title
|
||||
on media_file (title);
|
||||
|
||||
create table if not exists playlist
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar(255) default '' not null,
|
||||
comment varchar(255) default '' not null,
|
||||
duration integer default 0 not null,
|
||||
owner varchar(255) default '' not null,
|
||||
public bool default FALSE not null,
|
||||
tracks text not null
|
||||
);
|
||||
|
||||
create index if not exists playlist_name
|
||||
on playlist (name);
|
||||
|
||||
create table if not exists property
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
value varchar(255) default '' not null
|
||||
);
|
||||
|
||||
create table if not exists search
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
"table" varchar(255) default '' not null,
|
||||
full_text varchar(255) default '' not null
|
||||
);
|
||||
|
||||
create index if not exists search_full_text
|
||||
on search (full_text);
|
||||
|
||||
create index if not exists search_table
|
||||
on search ("table");
|
||||
|
||||
create table if not exists user
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
user_name varchar(255) default '' not null
|
||||
unique,
|
||||
name varchar(255) default '' not null,
|
||||
email varchar(255) default '' not null
|
||||
unique,
|
||||
password varchar(255) default '' not null,
|
||||
is_admin bool default FALSE not null,
|
||||
last_login_at datetime,
|
||||
last_access_at datetime,
|
||||
created_at datetime not null,
|
||||
updated_at datetime not null
|
||||
);`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200130083147(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
63
db/migration/20200131183653_standardize_item_type.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200131183653, Down20200131183653)
|
||||
}
|
||||
|
||||
func Up20200131183653(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table search_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
item_type varchar(255) default '' not null,
|
||||
full_text varchar(255) default '' not null
|
||||
);
|
||||
|
||||
insert into search_dg_tmp(id, item_type, full_text) select id, "table", full_text from search;
|
||||
|
||||
drop table search;
|
||||
|
||||
alter table search_dg_tmp rename to search;
|
||||
|
||||
create index search_full_text
|
||||
on search (full_text);
|
||||
create index search_table
|
||||
on search (item_type);
|
||||
|
||||
update annotation set item_type = 'media_file' where item_type = 'mediaFile';
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200131183653(tx *sql.Tx) error {
|
||||
tx.Exec(`
|
||||
create table search_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
"table" varchar(255) default '' not null,
|
||||
full_text varchar(255) default '' not null
|
||||
);
|
||||
|
||||
insert into search_dg_tmp(id, "table", full_text) select id, item_type, full_text from search;
|
||||
|
||||
drop table search;
|
||||
|
||||
alter table search_dg_tmp rename to search;
|
||||
|
||||
create index search_full_text
|
||||
on search (full_text);
|
||||
create index search_table
|
||||
on search ("table");
|
||||
|
||||
update annotation set item_type = 'mediaFile' where item_type = 'media_file';
|
||||
`)
|
||||
return nil
|
||||
}
|
||||
56
db/migration/20200208222418_add_defaults_to_annotations.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200208222418, Down20200208222418)
|
||||
}
|
||||
|
||||
func Up20200208222418(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
update annotation set play_count = 0 where play_count is null;
|
||||
update annotation set rating = 0 where rating is null;
|
||||
create table annotation_dg_tmp
|
||||
(
|
||||
ann_id varchar(255) not null
|
||||
primary key,
|
||||
user_id varchar(255) default '' not null,
|
||||
item_id varchar(255) default '' not null,
|
||||
item_type varchar(255) default '' not null,
|
||||
play_count integer default 0,
|
||||
play_date datetime,
|
||||
rating integer default 0,
|
||||
starred bool default FALSE not null,
|
||||
starred_at datetime,
|
||||
unique (user_id, item_id, item_type)
|
||||
);
|
||||
|
||||
insert into annotation_dg_tmp(ann_id, user_id, item_id, item_type, play_count, play_date, rating, starred, starred_at) select ann_id, user_id, item_id, item_type, play_count, play_date, rating, starred, starred_at from annotation;
|
||||
|
||||
drop table annotation;
|
||||
|
||||
alter table annotation_dg_tmp rename to annotation;
|
||||
|
||||
create index annotation_play_count
|
||||
on annotation (play_count);
|
||||
|
||||
create index annotation_play_date
|
||||
on annotation (play_date);
|
||||
|
||||
create index annotation_rating
|
||||
on annotation (rating);
|
||||
|
||||
create index annotation_starred
|
||||
on annotation (starred);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200208222418(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
@@ -3,14 +3,16 @@
|
||||
version: "3"
|
||||
services:
|
||||
navidrome:
|
||||
build: .
|
||||
image: deluan/navidrome:latest
|
||||
ports:
|
||||
- "4533:4533"
|
||||
environment:
|
||||
# See all options and defaults in conf/configuration.go
|
||||
# All options with their default values:
|
||||
ND_MUSICFOLDER: /music
|
||||
ND_DATAFOLDER: /data
|
||||
ND_SCANINTERVAL: 1m
|
||||
ND_LOGLEVEL: info
|
||||
ND_PORT: 4533
|
||||
ND_SCANINTERVAL: 5s
|
||||
ND_LOGLEVEL: debug
|
||||
volumes:
|
||||
- "./data:/data"
|
||||
- "/Users/deluan/Music/iTunes/iTunes Media/Music:/music"
|
||||
- "./music:/music"
|
||||
|
||||
64
engine/auth/auth.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/go-chi/jwtauth"
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
JwtSecret []byte
|
||||
TokenAuth *jwtauth.JWTAuth
|
||||
)
|
||||
|
||||
func InitTokenAuth(ds model.DataStore) {
|
||||
once.Do(func() {
|
||||
secret, err := ds.Property(nil).DefaultGet(consts.JWTSecretKey, "not so secret")
|
||||
if err != nil {
|
||||
log.Error("No JWT secret found in DB. Setting a temp one, but please report this error", err)
|
||||
}
|
||||
JwtSecret = []byte(secret)
|
||||
TokenAuth = jwtauth.New("HS256", JwtSecret, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func CreateToken(u *model.User) (string, error) {
|
||||
token := jwt.New(jwt.SigningMethodHS256)
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims["iss"] = consts.JWTIssuer
|
||||
claims["sub"] = u.UserName
|
||||
claims["adm"] = u.IsAdmin
|
||||
|
||||
return TouchToken(token)
|
||||
}
|
||||
|
||||
func TouchToken(token *jwt.Token) (string, error) {
|
||||
expireIn := time.Now().Add(consts.JWTTokenExpiration).Unix()
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims["exp"] = expireIn
|
||||
|
||||
return token.SignedString(JwtSecret)
|
||||
}
|
||||
|
||||
func Validate(tokenStr string) (jwt.MapClaims, error) {
|
||||
token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) {
|
||||
// Don't forget to validate the alg is what you expect:
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
// hmacSampleSecret is a []byte containing your secret, e.g. []byte("my_secret_key")
|
||||
return JwtSecret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return token.Claims.(jwt.MapClaims), err
|
||||
}
|
||||
55
engine/auth/auth_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/engine/auth"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestAuth(t *testing.T) {
|
||||
log.SetLevel(log.LevelCritical)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Auth Test Suite")
|
||||
}
|
||||
|
||||
const testJWTSecret = "not so secret"
|
||||
|
||||
var _ = Describe("Auth", func() {
|
||||
BeforeEach(func() {
|
||||
auth.JwtSecret = []byte(testJWTSecret)
|
||||
})
|
||||
Context("Validate", func() {
|
||||
It("returns error with an invalid JWT token", func() {
|
||||
_, err := auth.Validate("invalid.token")
|
||||
Expect(err).To(Not(BeNil()))
|
||||
})
|
||||
|
||||
It("returns the claims from a valid JWT token", func() {
|
||||
token := jwt.New(jwt.SigningMethodHS256)
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims["iss"] = "issuer"
|
||||
claims["exp"] = time.Now().Add(1 * time.Minute).Unix()
|
||||
tokenStr, _ := token.SignedString(auth.JwtSecret)
|
||||
|
||||
decodedClaims, err := auth.Validate(tokenStr)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(decodedClaims["iss"]).To(Equal("issuer"))
|
||||
})
|
||||
|
||||
It("returns ErrExpired if the `exp` field is in the past", func() {
|
||||
token := jwt.New(jwt.SigningMethodHS256)
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims["iss"] = "issuer"
|
||||
claims["exp"] = time.Now().Add(-1 * time.Minute).Unix()
|
||||
tokenStr, _ := token.SignedString(auth.JwtSecret)
|
||||
|
||||
_, err := auth.Validate(tokenStr)
|
||||
Expect(err).To(MatchError("Token is expired"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
type Browser interface {
|
||||
MediaFolders(ctx context.Context) (model.MediaFolders, error)
|
||||
Indexes(ctx context.Context, ifModifiedSince time.Time) (model.ArtistIndexes, time.Time, error)
|
||||
Indexes(ctx context.Context, mediaFolderId string, ifModifiedSince time.Time) (model.ArtistIndexes, time.Time, error)
|
||||
Directory(ctx context.Context, id string) (*DirectoryInfo, error)
|
||||
Artist(ctx context.Context, id string) (*DirectoryInfo, error)
|
||||
Album(ctx context.Context, id string) (*DirectoryInfo, error)
|
||||
@@ -35,8 +35,11 @@ func (b *browser) MediaFolders(ctx context.Context) (model.MediaFolders, error)
|
||||
return b.ds.MediaFolder(ctx).GetAll()
|
||||
}
|
||||
|
||||
func (b *browser) Indexes(ctx context.Context, ifModifiedSince time.Time) (model.ArtistIndexes, time.Time, error) {
|
||||
l, err := b.ds.Property(ctx).DefaultGet(model.PropLastScan, "-1")
|
||||
func (b *browser) Indexes(ctx context.Context, mediaFolderId string, ifModifiedSince time.Time) (model.ArtistIndexes, time.Time, error) {
|
||||
// TODO Proper handling of mediaFolderId param
|
||||
folder, err := b.ds.MediaFolder(ctx).Get(mediaFolderId)
|
||||
|
||||
l, err := b.ds.Property(ctx).DefaultGet(model.PropLastScan+"-"+folder.Path, "-1")
|
||||
ms, _ := strconv.ParseInt(l, 10, 64)
|
||||
lastModified := utils.ToTime(ms)
|
||||
|
||||
@@ -81,8 +84,7 @@ func (b *browser) Artist(ctx context.Context, id string) (*DirectoryInfo, error)
|
||||
for _, al := range albums {
|
||||
albumIds = append(albumIds, al.ID)
|
||||
}
|
||||
annMap, err := b.ds.Annotation(ctx).GetMap(getUserID(ctx), model.AlbumItemType, albumIds)
|
||||
return b.buildArtistDir(a, albums, annMap), nil
|
||||
return b.buildArtistDir(a, albums), nil
|
||||
}
|
||||
|
||||
func (b *browser) Album(ctx context.Context, id string) (*DirectoryInfo, error) {
|
||||
@@ -96,16 +98,7 @@ func (b *browser) Album(ctx context.Context, id string) (*DirectoryInfo, error)
|
||||
mfIds = append(mfIds, mf.ID)
|
||||
}
|
||||
|
||||
userID := getUserID(ctx)
|
||||
trackAnnMap, err := b.ds.Annotation(ctx).GetMap(userID, model.MediaItemType, mfIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ann, err := b.ds.Annotation(ctx).Get(userID, model.AlbumItemType, al.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.buildAlbumDir(al, ann, tracks, trackAnnMap), nil
|
||||
return b.buildAlbumDir(al, tracks), nil
|
||||
}
|
||||
|
||||
func (b *browser) Directory(ctx context.Context, id string) (*DirectoryInfo, error) {
|
||||
@@ -126,13 +119,7 @@ func (b *browser) GetSong(ctx context.Context, id string) (*Entry, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userId := getUserID(ctx)
|
||||
ann, err := b.ds.Annotation(ctx).Get(userId, model.MediaItemType, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entry := FromMediaFile(mf, ann)
|
||||
entry := FromMediaFile(mf)
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
@@ -149,7 +136,7 @@ func (b *browser) GetGenres(ctx context.Context) (model.Genres, error) {
|
||||
return genres, err
|
||||
}
|
||||
|
||||
func (b *browser) buildArtistDir(a *model.Artist, albums model.Albums, albumAnnMap model.AnnotationMap) *DirectoryInfo {
|
||||
func (b *browser) buildArtistDir(a *model.Artist, albums model.Albums) *DirectoryInfo {
|
||||
dir := &DirectoryInfo{
|
||||
Id: a.ID,
|
||||
Name: a.Name,
|
||||
@@ -158,39 +145,31 @@ func (b *browser) buildArtistDir(a *model.Artist, albums model.Albums, albumAnnM
|
||||
|
||||
dir.Entries = make(Entries, len(albums))
|
||||
for i, al := range albums {
|
||||
ann := albumAnnMap[al.ID]
|
||||
dir.Entries[i] = FromAlbum(&al, &ann)
|
||||
dir.PlayCount += int32(ann.PlayCount)
|
||||
dir.Entries[i] = FromAlbum(&al)
|
||||
dir.PlayCount += int32(al.PlayCount)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func (b *browser) buildAlbumDir(al *model.Album, albumAnn *model.Annotation, tracks model.MediaFiles, trackAnnMap model.AnnotationMap) *DirectoryInfo {
|
||||
func (b *browser) buildAlbumDir(al *model.Album, tracks model.MediaFiles) *DirectoryInfo {
|
||||
dir := &DirectoryInfo{
|
||||
Id: al.ID,
|
||||
Name: al.Name,
|
||||
Parent: al.ArtistID,
|
||||
Artist: al.Artist,
|
||||
ArtistId: al.ArtistID,
|
||||
SongCount: al.SongCount,
|
||||
Duration: al.Duration,
|
||||
Created: al.CreatedAt,
|
||||
Year: al.Year,
|
||||
Genre: al.Genre,
|
||||
CoverArt: al.CoverArtId,
|
||||
}
|
||||
if albumAnn != nil {
|
||||
dir.PlayCount = int32(albumAnn.PlayCount)
|
||||
dir.Starred = albumAnn.StarredAt
|
||||
dir.UserRating = albumAnn.Rating
|
||||
Id: al.ID,
|
||||
Name: al.Name,
|
||||
Parent: al.ArtistID,
|
||||
Artist: al.Artist,
|
||||
ArtistId: al.ArtistID,
|
||||
SongCount: al.SongCount,
|
||||
Duration: al.Duration,
|
||||
Created: al.CreatedAt,
|
||||
Year: al.Year,
|
||||
Genre: al.Genre,
|
||||
CoverArt: al.CoverArtId,
|
||||
PlayCount: int32(al.PlayCount),
|
||||
Starred: al.StarredAt,
|
||||
UserRating: al.Rating,
|
||||
}
|
||||
|
||||
dir.Entries = make(Entries, len(tracks))
|
||||
for i, mf := range tracks {
|
||||
mfId := mf.ID
|
||||
ann := trackAnnMap[mfId]
|
||||
dir.Entries[i] = FromMediaFile(&mf, &ann)
|
||||
}
|
||||
dir.Entries = FromMediaFiles(tracks)
|
||||
return dir
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -46,19 +45,17 @@ type Entry struct {
|
||||
|
||||
type Entries []Entry
|
||||
|
||||
func FromArtist(ar *model.Artist, ann *model.Annotation) Entry {
|
||||
func FromArtist(ar *model.Artist) Entry {
|
||||
e := Entry{}
|
||||
e.Id = ar.ID
|
||||
e.Title = ar.Name
|
||||
e.AlbumCount = ar.AlbumCount
|
||||
e.IsDir = true
|
||||
if ann != nil {
|
||||
e.Starred = ann.StarredAt
|
||||
}
|
||||
e.Starred = ar.StarredAt
|
||||
return e
|
||||
}
|
||||
|
||||
func FromAlbum(al *model.Album, ann *model.Annotation) Entry {
|
||||
func FromAlbum(al *model.Album) Entry {
|
||||
e := Entry{}
|
||||
e.Id = al.ID
|
||||
e.Title = al.Name
|
||||
@@ -74,15 +71,13 @@ func FromAlbum(al *model.Album, ann *model.Annotation) Entry {
|
||||
e.ArtistId = al.ArtistID
|
||||
e.Duration = al.Duration
|
||||
e.SongCount = al.SongCount
|
||||
if ann != nil {
|
||||
e.Starred = ann.StarredAt
|
||||
e.PlayCount = int32(ann.PlayCount)
|
||||
e.UserRating = ann.Rating
|
||||
}
|
||||
e.Starred = al.StarredAt
|
||||
e.PlayCount = int32(al.PlayCount)
|
||||
e.UserRating = al.Rating
|
||||
return e
|
||||
}
|
||||
|
||||
func FromMediaFile(mf *model.MediaFile, ann *model.Annotation) Entry {
|
||||
func FromMediaFile(mf *model.MediaFile) Entry {
|
||||
e := Entry{}
|
||||
e.Id = mf.ID
|
||||
e.Title = mf.Title
|
||||
@@ -111,11 +106,9 @@ func FromMediaFile(mf *model.MediaFile, ann *model.Annotation) Entry {
|
||||
e.AlbumId = mf.AlbumID
|
||||
e.ArtistId = mf.ArtistID
|
||||
e.Type = "music" // TODO Hardcoded for now
|
||||
if ann != nil {
|
||||
e.PlayCount = int32(ann.PlayCount)
|
||||
e.Starred = ann.StarredAt
|
||||
e.UserRating = ann.Rating
|
||||
}
|
||||
e.PlayCount = int32(mf.PlayCount)
|
||||
e.Starred = mf.StarredAt
|
||||
e.UserRating = mf.Rating
|
||||
return e
|
||||
}
|
||||
|
||||
@@ -130,37 +123,26 @@ func realArtistName(mf *model.MediaFile) string {
|
||||
return mf.Artist
|
||||
}
|
||||
|
||||
func FromAlbums(albums model.Albums, annMap model.AnnotationMap) Entries {
|
||||
func FromAlbums(albums model.Albums) Entries {
|
||||
entries := make(Entries, len(albums))
|
||||
for i, al := range albums {
|
||||
ann := annMap[al.ID]
|
||||
entries[i] = FromAlbum(&al, &ann)
|
||||
entries[i] = FromAlbum(&al)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func FromMediaFiles(mfs model.MediaFiles, annMap model.AnnotationMap) Entries {
|
||||
func FromMediaFiles(mfs model.MediaFiles) Entries {
|
||||
entries := make(Entries, len(mfs))
|
||||
for i, mf := range mfs {
|
||||
ann := annMap[mf.ID]
|
||||
entries[i] = FromMediaFile(&mf, &ann)
|
||||
entries[i] = FromMediaFile(&mf)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func FromArtists(ars model.Artists, annMap model.AnnotationMap) Entries {
|
||||
func FromArtists(ars model.Artists) Entries {
|
||||
entries := make(Entries, len(ars))
|
||||
for i, ar := range ars {
|
||||
ann := annMap[ar.ID]
|
||||
entries[i] = FromArtist(&ar, &ann)
|
||||
entries[i] = FromArtist(&ar)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func getUserID(ctx context.Context) string {
|
||||
user, ok := ctx.Value("user").(*model.User)
|
||||
if ok {
|
||||
return user.ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
package engine_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"github.com/deluan/navidrome/engine"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
. "github.com/deluan/navidrome/tests"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestCover(t *testing.T) {
|
||||
Init(t, false)
|
||||
|
||||
ds := &persistence.MockDataStore{}
|
||||
mockMediaFileRepo := ds.MediaFile(nil).(*persistence.MockMediaFile)
|
||||
mockAlbumRepo := ds.Album(nil).(*persistence.MockAlbum)
|
||||
|
||||
cover := engine.NewCover(ds)
|
||||
out := new(bytes.Buffer)
|
||||
|
||||
Convey("Subject: GetCoverArt Endpoint", t, func() {
|
||||
Convey("When id is not found", func() {
|
||||
mockMediaFileRepo.SetData(`[]`, 1)
|
||||
err := cover.Get(context.TODO(), "1", 0, out)
|
||||
|
||||
Convey("Then return default cover", func() {
|
||||
So(err, ShouldBeNil)
|
||||
So(out.Bytes(), ShouldMatchMD5, "963552b04e87a5a55e993f98a0fbdf82")
|
||||
})
|
||||
})
|
||||
Convey("When id is found", func() {
|
||||
mockMediaFileRepo.SetData(`[{"ID":"2","HasCoverArt":true,"Path":"tests/fixtures/01 Invisible (RED) Edit Version.mp3"}]`, 1)
|
||||
err := cover.Get(context.TODO(), "2", 0, out)
|
||||
|
||||
Convey("Then it should return the cover from the file", func() {
|
||||
So(err, ShouldBeNil)
|
||||
So(out.Bytes(), ShouldMatchMD5, "e859a71cd1b1aaeb1ad437d85b306668")
|
||||
})
|
||||
})
|
||||
Convey("When there is an error accessing the database", func() {
|
||||
mockMediaFileRepo.SetData(`[{"ID":"2","HasCoverArt":true,"Path":"tests/fixtures/01 Invisible (RED) Edit Version.mp3"}]`, 1)
|
||||
mockMediaFileRepo.SetError(true)
|
||||
err := cover.Get(context.TODO(), "2", 0, out)
|
||||
|
||||
Convey("Then error should not be nil", func() {
|
||||
So(err, ShouldNotBeNil)
|
||||
})
|
||||
})
|
||||
Convey("When id is found but file is not present", func() {
|
||||
mockMediaFileRepo.SetData(`[{"ID":"2","HasCoverArt":true,"Path":"tests/fixtures/NOT_FOUND.mp3"}]`, 1)
|
||||
err := cover.Get(context.TODO(), "2", 0, out)
|
||||
|
||||
Convey("Then it should return DatNotFound error", func() {
|
||||
So(err, ShouldEqual, model.ErrNotFound)
|
||||
})
|
||||
})
|
||||
Convey("When specifying a size", func() {
|
||||
mockMediaFileRepo.SetData(`[{"ID":"2","HasCoverArt":true,"Path":"tests/fixtures/01 Invisible (RED) Edit Version.mp3"}]`, 1)
|
||||
err := cover.Get(context.TODO(), "2", 100, out)
|
||||
|
||||
Convey("Then image returned should be 100x100", func() {
|
||||
So(err, ShouldBeNil)
|
||||
So(out.Bytes(), ShouldMatchMD5, "04378f523ca3e8ead33bf7140d39799e")
|
||||
img, _, err := image.Decode(bytes.NewReader(out.Bytes()))
|
||||
So(err, ShouldBeNil)
|
||||
So(img.Bounds().Max.X, ShouldEqual, 100)
|
||||
So(img.Bounds().Max.Y, ShouldEqual, 100)
|
||||
})
|
||||
})
|
||||
Convey("When id is for an album", func() {
|
||||
mockAlbumRepo.SetData(`[{"ID":"1","CoverArtPath":"tests/fixtures/01 Invisible (RED) Edit Version.mp3"}]`, 1)
|
||||
err := cover.Get(context.TODO(), "al-1", 0, out)
|
||||
|
||||
Convey("Then it should return the cover for the album", func() {
|
||||
So(err, ShouldBeNil)
|
||||
So(out.Bytes(), ShouldMatchMD5, "e859a71cd1b1aaeb1ad437d85b306668")
|
||||
})
|
||||
})
|
||||
|
||||
Reset(func() {
|
||||
mockMediaFileRepo.SetData("[]", 0)
|
||||
mockMediaFileRepo.SetError(false)
|
||||
out = new(bytes.Buffer)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestEngine(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelCritical)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Engine Suite")
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
@@ -39,34 +40,7 @@ func (g *listGenerator) query(ctx context.Context, qo model.QueryOptions) (Entri
|
||||
for i, al := range albums {
|
||||
albumIds[i] = al.ID
|
||||
}
|
||||
annMap, err := g.ds.Annotation(ctx).GetMap(getUserID(ctx), model.AlbumItemType, albumIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return FromAlbums(albums, annMap), err
|
||||
}
|
||||
|
||||
func (g *listGenerator) queryByAnnotation(ctx context.Context, qo model.QueryOptions) (Entries, error) {
|
||||
annotations, err := g.ds.Annotation(ctx).GetAll(getUserID(ctx), model.AlbumItemType, qo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
albumIds := make([]string, len(annotations))
|
||||
for i, ann := range annotations {
|
||||
albumIds[i] = ann.ItemID
|
||||
}
|
||||
|
||||
albumMap, err := g.ds.Album(ctx).GetMap(albumIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var albums Entries
|
||||
for _, ann := range annotations {
|
||||
album := albumMap[ann.ItemID]
|
||||
albums = append(albums, FromAlbum(&album, &ann))
|
||||
}
|
||||
return albums, nil
|
||||
return FromAlbums(albums), err
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetNewest(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
@@ -76,20 +50,20 @@ func (g *listGenerator) GetNewest(ctx context.Context, offset int, size int) (En
|
||||
|
||||
func (g *listGenerator) GetRecent(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
qo := model.QueryOptions{Sort: "PlayDate", Order: "desc", Offset: offset, Max: size,
|
||||
Filters: map[string]interface{}{"play_date__gt": time.Time{}}}
|
||||
return g.queryByAnnotation(ctx, qo)
|
||||
Filters: squirrel.Gt{"play_date": time.Time{}}}
|
||||
return g.query(ctx, qo)
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetFrequent(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
qo := model.QueryOptions{Sort: "PlayCount", Order: "desc", Offset: offset, Max: size,
|
||||
Filters: map[string]interface{}{"play_count__gt": 0}}
|
||||
return g.queryByAnnotation(ctx, qo)
|
||||
Filters: squirrel.Gt{"play_count": 0}}
|
||||
return g.query(ctx, qo)
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetHighest(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
qo := model.QueryOptions{Sort: "Rating", Order: "desc", Offset: offset, Max: size,
|
||||
Filters: map[string]interface{}{"rating__gt": 0}}
|
||||
return g.queryByAnnotation(ctx, qo)
|
||||
Filters: squirrel.Gt{"rating": 0}}
|
||||
return g.query(ctx, qo)
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetByName(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
@@ -108,70 +82,46 @@ func (g *listGenerator) GetRandom(ctx context.Context, offset int, size int) (En
|
||||
return nil, err
|
||||
}
|
||||
|
||||
annMap, err := g.getAnnotationsForAlbums(ctx, albums)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return FromAlbums(albums, annMap), nil
|
||||
}
|
||||
|
||||
func (g *listGenerator) getAnnotationsForAlbums(ctx context.Context, albums model.Albums) (model.AnnotationMap, error) {
|
||||
albumIds := make([]string, len(albums))
|
||||
for i, al := range albums {
|
||||
albumIds[i] = al.ID
|
||||
}
|
||||
return g.ds.Annotation(ctx).GetMap(getUserID(ctx), model.AlbumItemType, albumIds)
|
||||
return FromAlbums(albums), nil
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetRandomSongs(ctx context.Context, size int, genre string) (Entries, error) {
|
||||
options := model.QueryOptions{Max: size}
|
||||
if genre != "" {
|
||||
options.Filters = map[string]interface{}{"genre": genre}
|
||||
options.Filters = squirrel.Eq{"genre": genre}
|
||||
}
|
||||
mediaFiles, err := g.ds.MediaFile(ctx).GetRandom(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r := make(Entries, len(mediaFiles))
|
||||
for i, mf := range mediaFiles {
|
||||
ann, err := g.ds.Annotation(ctx).Get(getUserID(ctx), model.MediaItemType, mf.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r[i] = FromMediaFile(&mf, ann)
|
||||
}
|
||||
return r, nil
|
||||
return FromMediaFiles(mediaFiles), nil
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetStarred(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
qo := model.QueryOptions{Offset: offset, Max: size, Sort: "starred_at", Order: "desc"}
|
||||
albums, err := g.ds.Album(ctx).GetStarred(getUserID(ctx), qo)
|
||||
albums, err := g.ds.Album(ctx).GetStarred(qo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
annMap, err := g.getAnnotationsForAlbums(ctx, albums)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return FromAlbums(albums, annMap), nil
|
||||
return FromAlbums(albums), nil
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetAllStarred(ctx context.Context) (artists Entries, albums Entries, mediaFiles Entries, err error) {
|
||||
options := model.QueryOptions{Sort: "starred_at", Order: "desc"}
|
||||
|
||||
ars, err := g.ds.Artist(ctx).GetStarred(getUserID(ctx), options)
|
||||
ars, err := g.ds.Artist(ctx).GetStarred(options)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
als, err := g.ds.Album(ctx).GetStarred(getUserID(ctx), options)
|
||||
als, err := g.ds.Album(ctx).GetStarred(options)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
mfs, err := g.ds.MediaFile(ctx).GetStarred(getUserID(ctx), options)
|
||||
mfs, err := g.ds.MediaFile(ctx).GetStarred(options)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
@@ -180,28 +130,15 @@ func (g *listGenerator) GetAllStarred(ctx context.Context) (artists Entries, alb
|
||||
for _, mf := range mfs {
|
||||
mfIds = append(mfIds, mf.ID)
|
||||
}
|
||||
trackAnnMap, err := g.ds.Annotation(ctx).GetMap(getUserID(ctx), model.MediaItemType, mfIds)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
albumAnnMap, err := g.getAnnotationsForAlbums(ctx, als)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
var artistIds []string
|
||||
for _, ar := range ars {
|
||||
artistIds = append(artistIds, ar.ID)
|
||||
}
|
||||
artistAnnMap, err := g.ds.Annotation(ctx).GetMap(getUserID(ctx), model.MediaItemType, artistIds)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
artists = FromArtists(ars, artistAnnMap)
|
||||
albums = FromAlbums(als, albumAnnMap)
|
||||
mediaFiles = FromMediaFiles(mfs, trackAnnMap)
|
||||
artists = FromArtists(ars)
|
||||
albums = FromAlbums(als)
|
||||
mediaFiles = FromMediaFiles(mfs)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -217,8 +154,7 @@ func (g *listGenerator) GetNowPlaying(ctx context.Context) (Entries, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ann, err := g.ds.Annotation(ctx).Get(getUserID(ctx), model.MediaItemType, mf.ID)
|
||||
entries[i] = FromMediaFile(mf, ann)
|
||||
entries[i] = FromMediaFile(mf)
|
||||
entries[i].UserName = np.Username
|
||||
entries[i].MinutesAgo = int(time.Now().Sub(np.Start).Minutes())
|
||||
entries[i].PlayerId = np.PlayerId
|
||||
|
||||
220
engine/media_streamer.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"mime"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
type MediaStreamer interface {
|
||||
NewStream(ctx context.Context, id string, maxBitRate int, format string) (mediaStream, error)
|
||||
}
|
||||
|
||||
func NewMediaStreamer(ds model.DataStore) MediaStreamer {
|
||||
return &mediaStreamer{ds: ds}
|
||||
}
|
||||
|
||||
type mediaStream interface {
|
||||
io.ReadSeeker
|
||||
ContentType() string
|
||||
Name() string
|
||||
ModTime() time.Time
|
||||
Close() error
|
||||
}
|
||||
|
||||
type mediaStreamer struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, maxBitRate int, format string) (mediaStream, error) {
|
||||
mf, err := ms.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var bitRate int
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
var stream mediaStream
|
||||
|
||||
if bitRate == mf.BitRate && mime.TypeByExtension("."+format) == mf.ContentType() {
|
||||
log.Debug(ctx, "Streaming raw file", "id", mf.ID, "path", mf.Path,
|
||||
"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
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Streaming transcoded file", "id", mf.ID, "path", mf.Path,
|
||||
"requestBitrate", bitRate, "requestFormat", format,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
|
||||
|
||||
f := &transcodedMediaStream{ctx: ctx, mf: mf, bitRate: bitRate, format: format}
|
||||
return f, err
|
||||
}
|
||||
|
||||
type rawMediaStream struct {
|
||||
file *os.File
|
||||
ctx context.Context
|
||||
mf *model.MediaFile
|
||||
}
|
||||
|
||||
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) Close() error {
|
||||
log.Trace(m.ctx, "Closing file", "id", m.mf.ID, "path", m.mf.Path)
|
||||
return m.file.Close()
|
||||
}
|
||||
|
||||
type transcodedMediaStream struct {
|
||||
ctx context.Context
|
||||
mf *model.MediaFile
|
||||
pipe io.ReadCloser
|
||||
bitRate int
|
||||
format string
|
||||
skip int64
|
||||
pos int64
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
n, err = m.pipe.Read(p)
|
||||
m.pos += int64(n)
|
||||
if err == io.EOF {
|
||||
m.Close()
|
||||
}
|
||||
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)
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
return offset, err
|
||||
}
|
||||
|
||||
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) 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
|
||||
}
|
||||
return f, cmd.Start()
|
||||
}
|
||||
|
||||
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:]
|
||||
}
|
||||
75
engine/media_streamer_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("MediaStreamer", func() {
|
||||
|
||||
var streamer MediaStreamer
|
||||
var ds model.DataStore
|
||||
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)
|
||||
})
|
||||
|
||||
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 rawMediaStream if maxBitRate is 0", func() {
|
||||
Expect(streamer.NewStream(ctx, "123", 0, "mp3")).To(BeAssignableToTypeOf(&rawMediaStream{}))
|
||||
})
|
||||
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 transcodedMediaStream if maxBitRate is lower than file bitRate", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", 64, "mp3")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s).To(BeAssignableToTypeOf(&transcodedMediaStream{}))
|
||||
Expect(s.(*transcodedMediaStream).bitRate).To(Equal(64))
|
||||
})
|
||||
})
|
||||
|
||||
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}
|
||||
})
|
||||
|
||||
It("returns the ContentType", func() {
|
||||
Expect(rawStream.ContentType()).To(Equal("audio/mpeg"))
|
||||
})
|
||||
|
||||
It("returns the ModTime", func() {
|
||||
Expect(rawStream.ModTime()).To(Equal(modTime))
|
||||
})
|
||||
})
|
||||
|
||||
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", "-"}))
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,6 @@ package engine
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
@@ -52,12 +51,11 @@ func (p *playlists) Create(ctx context.Context, playlistId, name string, ids []s
|
||||
}
|
||||
|
||||
func (p *playlists) getUser(ctx context.Context) string {
|
||||
owner := consts.InitialUserName
|
||||
user, ok := ctx.Value("user").(*model.User)
|
||||
if ok {
|
||||
owner = user.UserName
|
||||
return user.UserName
|
||||
}
|
||||
return owner
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *playlists) Delete(ctx context.Context, playlistId string) error {
|
||||
@@ -125,7 +123,7 @@ func (p *playlists) Get(ctx context.Context, id string) (*PlaylistInfo, error) {
|
||||
}
|
||||
|
||||
// TODO Use model.Playlist when got rid of Entries
|
||||
pinfo := &PlaylistInfo{
|
||||
plsInfo := &PlaylistInfo{
|
||||
Id: pl.ID,
|
||||
Name: pl.Name,
|
||||
SongCount: len(pl.Tracks),
|
||||
@@ -134,19 +132,7 @@ func (p *playlists) Get(ctx context.Context, id string) (*PlaylistInfo, error) {
|
||||
Owner: pl.Owner,
|
||||
Comment: pl.Comment,
|
||||
}
|
||||
pinfo.Entries = make(Entries, len(pl.Tracks))
|
||||
|
||||
var mfIds []string
|
||||
for _, mf := range pl.Tracks {
|
||||
mfIds = append(mfIds, mf.ID)
|
||||
}
|
||||
|
||||
annMap, err := p.ds.Annotation(ctx).GetMap(getUserID(ctx), model.MediaItemType, mfIds)
|
||||
|
||||
for i, mf := range pl.Tracks {
|
||||
ann := annMap[mf.ID]
|
||||
pinfo.Entries[i] = FromMediaFile(&mf, &ann)
|
||||
}
|
||||
|
||||
return pinfo, nil
|
||||
plsInfo.Entries = FromMediaFiles(pl.Tracks)
|
||||
return plsInfo, nil
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ func (r ratings) SetRating(ctx context.Context, id string, rating int) error {
|
||||
return err
|
||||
}
|
||||
if exist {
|
||||
return r.ds.Annotation(ctx).SetRating(rating, getUserID(ctx), model.AlbumItemType, id)
|
||||
return r.ds.Album(ctx).SetRating(rating, id)
|
||||
}
|
||||
return r.ds.Annotation(ctx).SetRating(rating, getUserID(ctx), model.MediaItemType, id)
|
||||
return r.ds.MediaFile(ctx).SetRating(rating, id)
|
||||
}
|
||||
|
||||
func (r ratings) SetStar(ctx context.Context, star bool, ids ...string) error {
|
||||
@@ -36,7 +36,6 @@ func (r ratings) SetStar(ctx context.Context, star bool, ids ...string) error {
|
||||
log.Warn(ctx, "Cannot star/unstar an empty list of ids")
|
||||
return nil
|
||||
}
|
||||
userId := getUserID(ctx)
|
||||
|
||||
return r.ds.WithTx(func(tx model.DataStore) error {
|
||||
for _, id := range ids {
|
||||
@@ -45,7 +44,7 @@ func (r ratings) SetStar(ctx context.Context, star bool, ids ...string) error {
|
||||
return err
|
||||
}
|
||||
if exist {
|
||||
err = tx.Annotation(ctx).SetStar(star, userId, model.AlbumItemType, ids...)
|
||||
err = tx.Album(ctx).SetStar(star, ids...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -56,13 +55,13 @@ func (r ratings) SetStar(ctx context.Context, star bool, ids ...string) error {
|
||||
return err
|
||||
}
|
||||
if exist {
|
||||
err = tx.Annotation(ctx).SetStar(star, userId, model.ArtistItemType, ids...)
|
||||
err = tx.Artist(ctx).SetStar(star, ids...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
err = tx.Annotation(ctx).SetStar(star, userId, model.MediaItemType, ids...)
|
||||
err = tx.MediaFile(ctx).SetStar(star, ids...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -24,8 +24,6 @@ type scrobbler struct {
|
||||
}
|
||||
|
||||
func (s *scrobbler) Register(ctx context.Context, playerId int, trackId string, playTime time.Time) (*model.MediaFile, error) {
|
||||
userId := getUserID(ctx)
|
||||
|
||||
var mf *model.MediaFile
|
||||
var err error
|
||||
err = s.ds.WithTx(func(tx model.DataStore) error {
|
||||
@@ -33,11 +31,15 @@ func (s *scrobbler) Register(ctx context.Context, playerId int, trackId string,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.ds.Annotation(ctx).IncPlayCount(userId, model.MediaItemType, trackId, playTime)
|
||||
err = s.ds.MediaFile(ctx).IncPlayCount(trackId, playTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.ds.Annotation(ctx).IncPlayCount(userId, model.AlbumItemType, mf.AlbumID, playTime)
|
||||
err = s.ds.Album(ctx).IncPlayCount(mf.AlbumID, playTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.ds.Artist(ctx).IncPlayCount(mf.ArtistID, playTime)
|
||||
return err
|
||||
})
|
||||
return mf, err
|
||||
|
||||
@@ -34,12 +34,7 @@ func (s *search) SearchArtist(ctx context.Context, q string, offset int, size in
|
||||
for i, al := range artists {
|
||||
artistIds[i] = al.ID
|
||||
}
|
||||
annMap, err := s.ds.Annotation(ctx).GetMap(getUserID(ctx), model.ArtistItemType, artistIds)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return FromArtists(artists, annMap), nil
|
||||
return FromArtists(artists), nil
|
||||
}
|
||||
|
||||
func (s *search) SearchAlbum(ctx context.Context, q string, offset int, size int) (Entries, error) {
|
||||
@@ -53,12 +48,8 @@ func (s *search) SearchAlbum(ctx context.Context, q string, offset int, size int
|
||||
for i, al := range albums {
|
||||
albumIds[i] = al.ID
|
||||
}
|
||||
annMap, err := s.ds.Annotation(ctx).GetMap(getUserID(ctx), model.AlbumItemType, albumIds)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return FromAlbums(albums, annMap), nil
|
||||
return FromAlbums(albums), nil
|
||||
}
|
||||
|
||||
func (s *search) SearchSong(ctx context.Context, q string, offset int, size int) (Entries, error) {
|
||||
@@ -72,10 +63,6 @@ func (s *search) SearchSong(ctx context.Context, q string, offset int, size int)
|
||||
for i, mf := range mediaFiles {
|
||||
trackIds[i] = mf.ID
|
||||
}
|
||||
annMap, err := s.ds.Annotation(ctx).GetMap(getUserID(ctx), model.MediaItemType, trackIds)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return FromMediaFiles(mediaFiles, annMap), nil
|
||||
return FromMediaFiles(mediaFiles), nil
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/log"
|
||||
)
|
||||
|
||||
// TODO Encapsulate as a io.Reader
|
||||
func Stream(ctx context.Context, path string, bitRate int, maxBitRate int, w io.Writer) error {
|
||||
var f io.Reader
|
||||
var err error
|
||||
enabled := !conf.Server.DisableDownsampling
|
||||
if enabled && maxBitRate > 0 && bitRate > maxBitRate {
|
||||
f, err = downsample(ctx, path, maxBitRate)
|
||||
} else {
|
||||
f, err = os.Open(path)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error opening file", "path", path, err)
|
||||
return err
|
||||
}
|
||||
if _, err = io.Copy(w, f); err != nil {
|
||||
log.Error(ctx, "Error copying file", "path", path, err)
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func downsample(ctx context.Context, path string, maxBitRate int) (f io.Reader, err error) {
|
||||
cmdLine, args := createDownsamplingCommand(path, maxBitRate)
|
||||
|
||||
log.Debug(ctx, "Executing command", "cmdLine", cmdLine, "args", args)
|
||||
cmd := exec.Command(cmdLine, args...)
|
||||
cmd.Stderr = os.Stderr
|
||||
if f, err = cmd.StdoutPipe(); err != nil {
|
||||
return f, err
|
||||
}
|
||||
return f, cmd.Start()
|
||||
}
|
||||
|
||||
func createDownsamplingCommand(path string, maxBitRate int) (string, []string) {
|
||||
cmd := conf.Server.DownsampleCommand
|
||||
|
||||
split := strings.Split(cmd, " ")
|
||||
for i, s := range split {
|
||||
s = strings.Replace(s, "%s", path, -1)
|
||||
s = strings.Replace(s, "%b", strconv.Itoa(maxBitRate), -1)
|
||||
split[i] = s
|
||||
}
|
||||
|
||||
return split[0], split[1:]
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/deluan/navidrome/tests"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestDownsampling(t *testing.T) {
|
||||
|
||||
Init(t, false)
|
||||
|
||||
Convey("Subject: createDownsamplingCommand", t, func() {
|
||||
|
||||
Convey("It should create a valid command line", func() {
|
||||
cmd, args := createDownsamplingCommand("/music library/file.mp3", 128)
|
||||
|
||||
So(cmd, ShouldEqual, "ffmpeg")
|
||||
So(args[0], ShouldEqual, "-i")
|
||||
So(args[1], ShouldEqual, "/music library/file.mp3")
|
||||
So(args[2], ShouldEqual, "-b:a")
|
||||
So(args[3], ShouldEqual, "128k")
|
||||
So(args[4], ShouldEqual, "mp3")
|
||||
So(args[5], ShouldEqual, "-")
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
@@ -7,12 +7,12 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/engine/auth"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
type Users interface {
|
||||
Authenticate(ctx context.Context, username, password, token, salt string) (*model.User, error)
|
||||
Authenticate(ctx context.Context, username, password, token, salt, jwt string) (*model.User, error)
|
||||
}
|
||||
|
||||
func NewUsers(ds model.DataStore) Users {
|
||||
@@ -23,7 +23,7 @@ type users struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func (u *users) Authenticate(ctx context.Context, username, pass, token, salt string) (*model.User, error) {
|
||||
func (u *users) Authenticate(ctx context.Context, username, pass, token, salt, jwt string) (*model.User, error) {
|
||||
user, err := u.ds.User(ctx).FindByUsername(username)
|
||||
if err == model.ErrNotFound {
|
||||
return nil, model.ErrInvalidAuth
|
||||
@@ -34,6 +34,9 @@ func (u *users) Authenticate(ctx context.Context, username, pass, token, salt st
|
||||
valid := false
|
||||
|
||||
switch {
|
||||
case jwt != "":
|
||||
claims, err := auth.Validate(jwt)
|
||||
valid = err == nil && claims["sub"] == username
|
||||
case pass != "":
|
||||
if strings.HasPrefix(pass, "enc:") {
|
||||
if dec, err := hex.DecodeString(pass[4:]); err == nil {
|
||||
@@ -49,11 +52,12 @@ func (u *users) Authenticate(ctx context.Context, username, pass, token, salt st
|
||||
if !valid {
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
go func() {
|
||||
err := u.ds.User(ctx).UpdateLastAccessAt(user.ID)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Could not update user's lastAccessAt", "user", user.UserName)
|
||||
}
|
||||
}()
|
||||
// TODO: Find a way to update LastAccessAt without causing too much retention in the DB
|
||||
//go func() {
|
||||
// err := u.ds.User(ctx).UpdateLastAccessAt(user.ID)
|
||||
// if err != nil {
|
||||
// log.Error(ctx, "Could not update user's lastAccessAt", "user", user.UserName)
|
||||
// }
|
||||
//}()
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package engine
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/navidrome/engine/auth"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo"
|
||||
@@ -19,20 +20,20 @@ var _ = Describe("Users", func() {
|
||||
|
||||
Context("Plaintext password", func() {
|
||||
It("authenticates with plaintext password ", func() {
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "wordpass", "", "")
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "wordpass", "", "", "")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
|
||||
})
|
||||
|
||||
It("fails authentication with wrong password", func() {
|
||||
_, err := users.Authenticate(context.TODO(), "admin", "INVALID", "", "")
|
||||
_, err := users.Authenticate(context.TODO(), "admin", "INVALID", "", "", "")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Encoded password", func() {
|
||||
It("authenticates with simple encoded password ", func() {
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "enc:776f726470617373", "", "")
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "enc:776f726470617373", "", "", "")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
|
||||
})
|
||||
@@ -40,13 +41,41 @@ var _ = Describe("Users", func() {
|
||||
|
||||
Context("Token based authentication", func() {
|
||||
It("authenticates with token based authentication", func() {
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "", "23b342970e25c7928831c3317edd0b67", "retnlmjetrymazgkt")
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "", "23b342970e25c7928831c3317edd0b67", "retnlmjetrymazgkt", "")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
|
||||
})
|
||||
|
||||
It("fails if salt is missing", func() {
|
||||
_, err := users.Authenticate(context.TODO(), "admin", "", "23b342970e25c7928831c3317edd0b67", "")
|
||||
_, err := users.Authenticate(context.TODO(), "admin", "", "23b342970e25c7928831c3317edd0b67", "", "")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
})
|
||||
|
||||
Context("JWT based authentication", func() {
|
||||
var validToken string
|
||||
BeforeEach(func() {
|
||||
u := &model.User{UserName: "admin"}
|
||||
var err error
|
||||
validToken, err = auth.CreateToken(u)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
It("authenticates with JWT token based authentication", func() {
|
||||
usr, err := users.Authenticate(context.TODO(), "admin", "", "", "", validToken)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
|
||||
})
|
||||
|
||||
It("fails if JWT token is invalid", func() {
|
||||
_, err := users.Authenticate(context.TODO(), "admin", "", "", "", "invalid.token")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
|
||||
It("fails if JWT token sub is different than username", func() {
|
||||
_, err := users.Authenticate(context.TODO(), "not_admin", "", "", "", validToken)
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,4 +12,5 @@ var Set = wire.NewSet(
|
||||
NewSearch,
|
||||
NewNowPlayingRepository,
|
||||
NewUsers,
|
||||
NewMediaStreamer,
|
||||
)
|
||||
|
||||
23
go.mod
@@ -4,7 +4,7 @@ go 1.13
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v0.3.1 // indirect
|
||||
github.com/Masterminds/squirrel v1.1.0
|
||||
github.com/Masterminds/squirrel v1.2.0
|
||||
github.com/astaxie/beego v1.12.0
|
||||
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible
|
||||
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4
|
||||
@@ -14,22 +14,29 @@ require (
|
||||
github.com/fatih/structs v1.0.0 // indirect
|
||||
github.com/go-chi/chi v4.0.3+incompatible
|
||||
github.com/go-chi/cors v1.0.0
|
||||
github.com/go-chi/jwtauth v4.0.3+incompatible
|
||||
github.com/go-chi/jwtauth v4.0.4+incompatible
|
||||
github.com/go-sql-driver/mysql v1.5.0 // indirect
|
||||
github.com/golang/protobuf v1.3.1 // indirect
|
||||
github.com/google/uuid v1.1.1
|
||||
github.com/google/wire v0.4.0
|
||||
github.com/kennygrant/sanitize v0.0.0-20170120101633-6a0bfdde8629
|
||||
github.com/koding/multiconfig v0.0.0-20170327155832-26b6dfd3a84a
|
||||
github.com/kr/pretty v0.1.0 // indirect
|
||||
github.com/lib/pq v1.3.0
|
||||
github.com/mattn/go-sqlite3 v2.0.2+incompatible
|
||||
github.com/lib/pq v1.3.0 // indirect
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible
|
||||
github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5
|
||||
github.com/onsi/ginkgo v1.11.0
|
||||
github.com/onsi/gomega v1.8.1
|
||||
github.com/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/stretchr/testify v1.4.0 // indirect
|
||||
golang.org/x/sys v0.0.0-20200107162124-548cf772de50 // indirect
|
||||
google.golang.org/appengine v1.6.5 // indirect
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2 // indirect
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 // indirect
|
||||
golang.org/x/text v0.3.2 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
|
||||
gopkg.in/yaml.v2 v2.2.8 // indirect
|
||||
)
|
||||
|
||||
43
go.sum
@@ -1,8 +1,8 @@
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/Knetic/govaluate v3.0.0+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
|
||||
github.com/Masterminds/squirrel v1.1.0 h1:baP1qLdoQCeTw3ifCdOq2dkYc6vGcmRdaociKLbEJXs=
|
||||
github.com/Masterminds/squirrel v1.1.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
|
||||
github.com/Masterminds/squirrel v1.2.0 h1:K1NhbTO21BWG47IVR0OnIZuE0LZcXAYqywrC3Ko53KI=
|
||||
github.com/Masterminds/squirrel v1.2.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
|
||||
github.com/OwnLocal/goes v1.0.0/go.mod h1:8rIFjBGTue3lCU0wplczcUgt9Gxgrkkrw7etMIcn8TM=
|
||||
github.com/astaxie/beego v1.12.0 h1:MRhVoeeye5N+Flul5PoVfD9CslfdoH+xqC/xvSQ5u2Y=
|
||||
github.com/astaxie/beego v1.12.0/go.mod h1:fysx+LZNZKnvh4GED/xND7jWtjCR6HzydR2Hh2Im57o=
|
||||
@@ -41,11 +41,13 @@ github.com/go-chi/chi v4.0.3+incompatible h1:gakN3pDJnzZN5jqFV2TEdF66rTfKeITyR8q
|
||||
github.com/go-chi/chi v4.0.3+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||
github.com/go-chi/cors v1.0.0 h1:e6x8k7uWbUwYs+aXDoiUzeQFT6l0cygBYyNhD7/1Tg0=
|
||||
github.com/go-chi/cors v1.0.0/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
|
||||
github.com/go-chi/jwtauth v4.0.3+incompatible h1:hPhobLUgh7fMpA1qUDdId14u2Z93M22fCNPMVLNWeHU=
|
||||
github.com/go-chi/jwtauth v4.0.3+incompatible/go.mod h1:Q5EIArY/QnD6BdS+IyDw7B2m6iNbnPxtfd6/BcmtWbs=
|
||||
github.com/go-chi/jwtauth v4.0.4+incompatible h1:LGIxg6YfvSBzxU2BljXbrzVc1fMlgqSKBQgKOGAVtPY=
|
||||
github.com/go-chi/jwtauth v4.0.4+incompatible/go.mod h1:Q5EIArY/QnD6BdS+IyDw7B2m6iNbnPxtfd6/BcmtWbs=
|
||||
github.com/go-redis/redis v6.14.2+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
|
||||
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
|
||||
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
@@ -85,20 +87,26 @@ github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
|
||||
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/mattn/go-sqlite3 v2.0.2+incompatible h1:qzw9c2GNT8UFrgWNDhCTqRqYUSmu/Dav/9Z58LGpk7U=
|
||||
github.com/mattn/go-sqlite3 v2.0.2+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5 h1:BvoENQQU+fZ9uukda/RzCAL/191HHwJA5b13R6diVlY=
|
||||
github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.11.0 h1:JAKSXpt1YjtLA7YpPiqO9ss6sNXEsPfSGdwN0UHqzrw=
|
||||
github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.8.1 h1:C5Dqfs/LeauYDX0jJXIe2SWmwCbGzx9yF8C8xy3Lh34=
|
||||
github.com/onsi/gomega v1.8.1/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
|
||||
github.com/onsi/ginkgo v1.12.0 h1:Iw5WCbBcaAAd0fpRb1c9r5YCylv4XDoCSigm1zLevwU=
|
||||
github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.9.0 h1:R1uwffexN6Pr340GtYRIdZmAiN4J+iw6WG4wog1DUXg=
|
||||
github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
|
||||
github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose v2.6.0+incompatible h1:3f8zIQ8rfgP9tyI0Hmcs2YNAqUCL1c+diLe3iU8Qd/k=
|
||||
github.com/pressly/goose v2.6.0+incompatible/go.mod h1:m+QHWCqxR3k8D9l7qfzuC/djtlfzxr34mozWDYEu1z8=
|
||||
github.com/siddontang/go v0.0.0-20180604090527-bdc77568d726 h1:xT+JlYxNGqyT+XcU8iUrN18JYed2TvG9yN5ULG2jATM=
|
||||
github.com/siddontang/go v0.0.0-20180604090527-bdc77568d726/go.mod h1:3yhqj7WBBfRhbBlzyOC3gUxftwsU0u8gqevxwIHQpMw=
|
||||
github.com/siddontang/ledisdb v0.0.0-20181029004158-becf5f38d373 h1:p6IxqQMjab30l4lb9mmkIkkcE1yv6o0SKbPhW5pxqHI=
|
||||
@@ -128,8 +136,8 @@ golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73r
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65 h1:+rhAzEzT3f4JtomfC371qB+0Ola2caSKcY69NUBZrRQ=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2 h1:CCH4IOTTfewWjGOlSp+zGcjutRKlBEZQ6wTn8ozI/nI=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -137,8 +145,9 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200107162124-548cf772de50 h1:YvQ10rzcqWXLlJZ3XCUoO25savxmscf4+SC+ZqiCHhA=
|
||||
golang.org/x/sys v0.0.0-20200107162124-548cf772de50/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 h1:LfCXLvNmTYH9kEmVgqbnsWfruoXZIrh4YBgqVHtDvw0=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||
@@ -150,8 +159,8 @@ golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b h1:NVD8gBK33xpdqCaZVVtd6OF
|
||||
golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
@@ -165,3 +174,5 @@ gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
|
||||
@@ -27,6 +27,7 @@ const (
|
||||
var (
|
||||
currentLevel Level
|
||||
defaultLogger = logrus.New()
|
||||
logSourceLine = false
|
||||
)
|
||||
|
||||
// SetLevel sets the global log level used by the simple logger.
|
||||
@@ -35,7 +36,7 @@ func SetLevel(l Level) {
|
||||
logrus.SetLevel(logrus.Level(l))
|
||||
}
|
||||
|
||||
func SerLevelString(l string) {
|
||||
func SetLevelString(l string) {
|
||||
envLevel := strings.ToLower(l)
|
||||
var level Level
|
||||
switch envLevel {
|
||||
@@ -55,6 +56,10 @@ func SerLevelString(l string) {
|
||||
SetLevel(level)
|
||||
}
|
||||
|
||||
func SetLogSourceLine(enabled bool) {
|
||||
logSourceLine = enabled
|
||||
}
|
||||
|
||||
func NewContext(ctx context.Context, keyValuePairs ...interface{}) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
@@ -132,7 +137,7 @@ func parseArgs(args []interface{}) (*logrus.Entry, string) {
|
||||
kvPairs := args[1:]
|
||||
l = addFields(l, kvPairs)
|
||||
}
|
||||
if currentLevel >= LevelTrace {
|
||||
if logSourceLine {
|
||||
_, file, line, ok := runtime.Caller(2)
|
||||
if !ok {
|
||||
file = "???"
|
||||
|
||||
@@ -30,6 +30,12 @@ func TestLog(t *testing.T) {
|
||||
So(hook.LastEntry().Data, ShouldBeEmpty)
|
||||
})
|
||||
|
||||
SkipConvey("Empty context", func() {
|
||||
Error(context.Background(), "Simple Message")
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Simple Message")
|
||||
So(hook.LastEntry().Data, ShouldBeEmpty)
|
||||
})
|
||||
|
||||
Convey("Message with two kv pairs", func() {
|
||||
Error("Simple Message", "key1", "value1", "key2", "value2")
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Simple Message")
|
||||
|
||||
5
main.go
@@ -2,14 +2,17 @@ package main
|
||||
|
||||
import (
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/db"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if !conf.Server.DevDisableBanner {
|
||||
ShowBanner()
|
||||
println(consts.Banner())
|
||||
}
|
||||
|
||||
conf.Load()
|
||||
db.EnsureLatestVersion()
|
||||
|
||||
a := CreateServer(conf.Server.MusicFolder)
|
||||
a.MountRouter("/rest", CreateSubsonicAPIRouter())
|
||||
|
||||
@@ -3,35 +3,42 @@ package model
|
||||
import "time"
|
||||
|
||||
type Album struct {
|
||||
ID string
|
||||
Name string
|
||||
ArtistID string
|
||||
CoverArtPath string
|
||||
CoverArtId string
|
||||
Artist string
|
||||
AlbumArtist string
|
||||
Year int
|
||||
Compilation bool
|
||||
SongCount int
|
||||
Duration int
|
||||
Genre string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
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"`
|
||||
|
||||
// Annotations
|
||||
PlayCount int `json:"-" orm:"-"`
|
||||
PlayDate time.Time `json:"-" orm:"-"`
|
||||
Rating int `json:"-" orm:"-"`
|
||||
Starred bool `json:"-" orm:"-"`
|
||||
StarredAt time.Time `json:"-" orm:"-"`
|
||||
}
|
||||
|
||||
type Albums []Album
|
||||
|
||||
type AlbumRepository interface {
|
||||
CountAll() (int64, error)
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
Exists(id string) (bool, error)
|
||||
Put(m *Album) error
|
||||
Get(id string) (*Album, error)
|
||||
FindByArtist(artistId string) (Albums, error)
|
||||
GetAll(...QueryOptions) (Albums, error)
|
||||
GetMap(ids []string) (map[string]Album, error)
|
||||
GetRandom(...QueryOptions) (Albums, error)
|
||||
GetStarred(userId string, options ...QueryOptions) (Albums, error)
|
||||
GetStarred(options ...QueryOptions) (Albums, error)
|
||||
Search(q string, offset int, size int) (Albums, error)
|
||||
Refresh(ids ...string) error
|
||||
PurgeEmpty() error
|
||||
AnnotatedRepository
|
||||
}
|
||||
|
||||
@@ -2,32 +2,8 @@ package model
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
ArtistItemType = "artist"
|
||||
AlbumItemType = "album"
|
||||
MediaItemType = "mediaFile"
|
||||
)
|
||||
|
||||
type Annotation struct {
|
||||
AnnotationID string
|
||||
UserID string
|
||||
ItemID string
|
||||
ItemType string
|
||||
PlayCount int
|
||||
PlayDate time.Time
|
||||
Rating int
|
||||
Starred bool
|
||||
StarredAt time.Time
|
||||
}
|
||||
|
||||
type AnnotationMap map[string]Annotation
|
||||
|
||||
type AnnotationRepository interface {
|
||||
Get(userID, itemType string, itemID string) (*Annotation, error)
|
||||
GetAll(userID, itemType string, options ...QueryOptions) ([]Annotation, error)
|
||||
GetMap(userID, itemType string, itemID []string) (AnnotationMap, error)
|
||||
Delete(userID, itemType string, itemID ...string) error
|
||||
IncPlayCount(userID, itemType string, itemID string, ts time.Time) error
|
||||
SetStar(starred bool, userID, itemType string, ids ...string) error
|
||||
SetRating(rating int, userID, itemType string, itemID string) error
|
||||
type AnnotatedRepository interface {
|
||||
IncPlayCount(itemID string, ts time.Time) error
|
||||
SetStar(starred bool, itemIDs ...string) error
|
||||
SetRating(rating int, itemID string) error
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Artist struct {
|
||||
ID string
|
||||
Name string
|
||||
AlbumCount int
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
AlbumCount int `json:"albumCount" orm:"column(album_count)"`
|
||||
|
||||
// Annotations
|
||||
PlayCount int `json:"-" orm:"-"`
|
||||
PlayDate time.Time `json:"-" orm:"-"`
|
||||
Rating int `json:"-" orm:"-"`
|
||||
Starred bool `json:"-" orm:"-"`
|
||||
StarredAt time.Time `json:"-" orm:"-"`
|
||||
}
|
||||
|
||||
type Artists []Artist
|
||||
|
||||
type ArtistIndex struct {
|
||||
@@ -14,14 +24,14 @@ type ArtistIndex struct {
|
||||
type ArtistIndexes []ArtistIndex
|
||||
|
||||
type ArtistRepository interface {
|
||||
CountAll() (int64, error)
|
||||
CountAll(options ...QueryOptions) (int64, error)
|
||||
Exists(id string) (bool, error)
|
||||
Put(m *Artist) error
|
||||
Get(id string) (*Artist, error)
|
||||
GetStarred(userId string, options ...QueryOptions) (Artists, error)
|
||||
SetStar(star bool, ids ...string) error
|
||||
GetStarred(options ...QueryOptions) (Artists, error)
|
||||
Search(q string, offset int, size int) (Artists, error)
|
||||
Refresh(ids ...string) error
|
||||
GetIndex() (ArtistIndexes, error)
|
||||
PurgeEmpty() error
|
||||
AnnotatedRepository
|
||||
}
|
||||
|
||||
@@ -3,24 +3,20 @@ package model
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
)
|
||||
|
||||
// Filters use the same operators as Beego ORM: See https://beego.me/docs/mvc/model/query.md#operators
|
||||
// Ex: var q = QueryOptions{Filters: Filters{"name__istartswith": "Deluan","age__gt": 25}}
|
||||
// All conditions will be ANDed together
|
||||
// TODO Implement filter in repositories' methods
|
||||
type QueryOptions struct {
|
||||
Sort string
|
||||
Order string
|
||||
Max int
|
||||
Offset int
|
||||
Filters map[string]interface{}
|
||||
Filters squirrel.Sqlizer
|
||||
}
|
||||
|
||||
type ResourceRepository interface {
|
||||
rest.Repository
|
||||
rest.Persistable
|
||||
}
|
||||
|
||||
type DataStore interface {
|
||||
@@ -32,9 +28,9 @@ type DataStore interface {
|
||||
Playlist(ctx context.Context) PlaylistRepository
|
||||
Property(ctx context.Context) PropertyRepository
|
||||
User(ctx context.Context) UserRepository
|
||||
Annotation(ctx context.Context) AnnotationRepository
|
||||
|
||||
Resource(ctx context.Context, model interface{}) ResourceRepository
|
||||
|
||||
WithTx(func(tx DataStore) error) error
|
||||
GC(ctx context.Context) error
|
||||
}
|
||||
|
||||
@@ -6,26 +6,33 @@ import (
|
||||
)
|
||||
|
||||
type MediaFile struct {
|
||||
ID string
|
||||
Path string
|
||||
Title string
|
||||
Album string
|
||||
Artist string
|
||||
ArtistID string
|
||||
AlbumArtist string
|
||||
AlbumID string
|
||||
HasCoverArt bool
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
Year int
|
||||
Size int
|
||||
Suffix string
|
||||
Duration int
|
||||
BitRate int
|
||||
Genre string
|
||||
Compilation bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID string `json:"id" orm:"pk;column(id)"`
|
||||
Path string `json:"path"`
|
||||
Title string `json:"title"`
|
||||
Album string `json:"album"`
|
||||
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"`
|
||||
|
||||
// Annotations
|
||||
PlayCount int `json:"-" orm:"-"`
|
||||
PlayDate time.Time `json:"-" orm:"-"`
|
||||
Rating int `json:"-" orm:"-"`
|
||||
Starred bool `json:"-" orm:"-"`
|
||||
StarredAt time.Time `json:"-" orm:"-"`
|
||||
}
|
||||
|
||||
func (mf *MediaFile) ContentType() string {
|
||||
@@ -35,15 +42,17 @@ func (mf *MediaFile) ContentType() string {
|
||||
type MediaFiles []MediaFile
|
||||
|
||||
type MediaFileRepository interface {
|
||||
CountAll() (int64, error)
|
||||
CountAll(options ...QueryOptions) (int64, error)
|
||||
Exists(id string) (bool, error)
|
||||
Put(m *MediaFile) error
|
||||
Get(id string) (*MediaFile, error)
|
||||
FindByAlbum(albumId string) (MediaFiles, error)
|
||||
FindByPath(path string) (MediaFiles, error)
|
||||
GetStarred(userId string, options ...QueryOptions) (MediaFiles, error)
|
||||
GetStarred(options ...QueryOptions) (MediaFiles, error)
|
||||
GetRandom(options ...QueryOptions) (MediaFiles, error)
|
||||
Search(q string, offset int, size int) (MediaFiles, error)
|
||||
Delete(id string) error
|
||||
DeleteByPath(path string) error
|
||||
|
||||
AnnotatedRepository
|
||||
}
|
||||
|
||||
@@ -9,5 +9,6 @@ type MediaFolder struct {
|
||||
type MediaFolders []MediaFolder
|
||||
|
||||
type MediaFolderRepository interface {
|
||||
Get(id string) (*MediaFolder, error)
|
||||
GetAll() (MediaFolders, error)
|
||||
}
|
||||
|
||||
@@ -3,18 +3,21 @@ package model
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
UserName string
|
||||
Name string
|
||||
Email string
|
||||
Password string
|
||||
IsAdmin bool
|
||||
LastLoginAt *time.Time
|
||||
LastAccessAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
UserName string `json:"userName"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
LastLoginAt *time.Time `json:"lastLoginAt"`
|
||||
LastAccessAt *time.Time `json:"lastAccessAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
// TODO ChangePassword string `json:"password"`
|
||||
}
|
||||
|
||||
type Users []User
|
||||
|
||||
type UserRepository interface {
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
Get(id string) (*User, error)
|
||||
|
||||
@@ -1,145 +1,106 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/rest"
|
||||
)
|
||||
|
||||
type album struct {
|
||||
ID string `json:"id" orm:"pk;column(id)"`
|
||||
Name string `json:"name" orm:"index"`
|
||||
ArtistID string `json:"artistId" orm:"column(artist_id);index"`
|
||||
CoverArtPath string `json:"-"`
|
||||
CoverArtId string `json:"-"`
|
||||
Artist string `json:"artist" orm:"index"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
Year int `json:"year" orm:"index"`
|
||||
Compilation bool `json:"compilation"`
|
||||
SongCount int `json:"songCount"`
|
||||
Duration int `json:"duration"`
|
||||
Genre string `json:"genre" orm:"index"`
|
||||
CreatedAt time.Time `json:"createdAt" orm:"null"`
|
||||
UpdatedAt time.Time `json:"updatedAt" orm:"null"`
|
||||
}
|
||||
|
||||
type albumRepository struct {
|
||||
searchableRepository
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewAlbumRepository(o orm.Ormer) model.AlbumRepository {
|
||||
func NewAlbumRepository(ctx context.Context, o orm.Ormer) model.AlbumRepository {
|
||||
r := &albumRepository{}
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
r.tableName = "album"
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
return r.count(Select(), options...)
|
||||
}
|
||||
|
||||
func (r *albumRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(Select().Where(Eq{"id": id}))
|
||||
}
|
||||
|
||||
func (r *albumRepository) Put(a *model.Album) error {
|
||||
ta := album(*a)
|
||||
return r.put(a.ID, a.Name, &ta)
|
||||
_, err := r.put(a.ID, a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.index(a.ID, a.Name)
|
||||
}
|
||||
|
||||
func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder {
|
||||
return r.newSelectWithAnnotation("id", options...).Columns("*")
|
||||
}
|
||||
|
||||
func (r *albumRepository) Get(id string) (*model.Album, error) {
|
||||
ta := album{ID: id}
|
||||
err := r.ormer.Read(&ta)
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
sq := r.selectAlbum().Where(Eq{"id": id})
|
||||
var res model.Album
|
||||
err := r.queryOne(sq, &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := model.Album(ta)
|
||||
return &a, err
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
func (r *albumRepository) FindByArtist(artistId string) (model.Albums, error) {
|
||||
var albums []album
|
||||
_, err := r.newQuery().Filter("artist_id", artistId).OrderBy("year", "name").All(&albums)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.toAlbums(albums), nil
|
||||
sq := r.selectAlbum().Where(Eq{"artist_id": artistId}).OrderBy("year")
|
||||
res := model.Albums{}
|
||||
err := r.queryAll(sq, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, error) {
|
||||
var all []album
|
||||
_, err := r.newQuery(options...).All(&all)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.toAlbums(all), nil
|
||||
}
|
||||
|
||||
func (r *albumRepository) GetMap(ids []string) (map[string]model.Album, error) {
|
||||
var all []album
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
_, err := r.newQuery().Filter("id__in", ids).All(&all)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := make(map[string]model.Album)
|
||||
for _, a := range all {
|
||||
res[a.ID] = model.Album(a)
|
||||
}
|
||||
return res, nil
|
||||
sq := r.selectAlbum(options...)
|
||||
res := model.Albums{}
|
||||
err := r.queryAll(sq, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
// TODO Keep order when paginating
|
||||
func (r *albumRepository) GetRandom(options ...model.QueryOptions) (model.Albums, error) {
|
||||
sq := r.newRawQuery(options...)
|
||||
sq := r.selectAlbum(options...)
|
||||
switch r.ormer.Driver().Type() {
|
||||
case orm.DRMySQL:
|
||||
sq = sq.OrderBy("RAND()")
|
||||
default:
|
||||
sq = sq.OrderBy("RANDOM()")
|
||||
}
|
||||
sql, args, err := sq.ToSql()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var results []album
|
||||
_, err = r.ormer.Raw(sql, args...).QueryRows(&results)
|
||||
return r.toAlbums(results), err
|
||||
}
|
||||
|
||||
func (r *albumRepository) toAlbums(all []album) model.Albums {
|
||||
result := make(model.Albums, len(all))
|
||||
for i, a := range all {
|
||||
result[i] = model.Album(a)
|
||||
}
|
||||
return result
|
||||
results := model.Albums{}
|
||||
err := r.queryAll(sq, &results)
|
||||
return results, err
|
||||
}
|
||||
|
||||
func (r *albumRepository) Refresh(ids ...string) error {
|
||||
type refreshAlbum struct {
|
||||
album
|
||||
model.Album
|
||||
CurrentId string
|
||||
HasCoverArt bool
|
||||
}
|
||||
var albums []refreshAlbum
|
||||
o := r.ormer
|
||||
sql := fmt.Sprintf(`
|
||||
select album_id as id, album as name, f.artist, f.album_artist, f.artist_id, f.compilation, f.genre,
|
||||
max(f.year) as year, sum(f.duration) as duration, max(f.updated_at) as updated_at,
|
||||
min(f.created_at) as created_at, count(*) as song_count, a.id as current_id, f.id as cover_art_id,
|
||||
f.path as cover_art_path, f.has_cover_art
|
||||
from media_file f left outer join album a on f.album_id = a.id
|
||||
where f.album_id in ('%s')
|
||||
group by album_id order by f.id`, strings.Join(ids, "','"))
|
||||
_, err := o.Raw(sql).QueryRows(&albums)
|
||||
sel := Select(`album_id as id, album as name, f.artist, f.album_artist, f.artist_id, f.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`).
|
||||
From("media_file f").
|
||||
LeftJoin("album a on f.album_id = a.id").
|
||||
Where(Eq{"f.album_id": ids}).GroupBy("album_id").OrderBy("f.id")
|
||||
err := r.queryAll(sel, &albums)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var toInsert []album
|
||||
var toUpdate []album
|
||||
toInsert := 0
|
||||
toUpdate := 0
|
||||
for _, al := range albums {
|
||||
if !al.HasCoverArt {
|
||||
al.CoverArtId = ""
|
||||
@@ -150,71 +111,70 @@ group by album_id order by f.id`, strings.Join(ids, "','"))
|
||||
if al.AlbumArtist == "" {
|
||||
al.AlbumArtist = al.Artist
|
||||
}
|
||||
al.UpdatedAt = time.Now()
|
||||
if al.CurrentId != "" {
|
||||
toUpdate = append(toUpdate, al.album)
|
||||
toUpdate++
|
||||
} else {
|
||||
toInsert = append(toInsert, al.album)
|
||||
toInsert++
|
||||
al.CreatedAt = time.Now()
|
||||
}
|
||||
err := r.addToIndex(r.tableName, al.ID, al.Name)
|
||||
err := r.Put(&al.Album)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(toInsert) > 0 {
|
||||
n, err := o.InsertMulti(10, toInsert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Debug("Inserted new albums", "num", n)
|
||||
if toInsert > 0 {
|
||||
log.Debug(r.ctx, "Inserted new albums", "totalInserted", toInsert)
|
||||
}
|
||||
if len(toUpdate) > 0 {
|
||||
for _, al := range toUpdate {
|
||||
_, err := o.Update(&al, "name", "artist_id", "cover_art_path", "cover_art_id", "artist", "album_artist",
|
||||
"year", "compilation", "song_count", "duration", "updated_at", "created_at")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
log.Debug("Updated albums", "num", len(toUpdate))
|
||||
if toUpdate > 0 {
|
||||
log.Debug(r.ctx, "Updated albums", "totalUpdated", toUpdate)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *albumRepository) PurgeEmpty() error {
|
||||
_, err := r.ormer.Raw("delete from album where id not in (select distinct(album_id) from media_file)").Exec()
|
||||
del := Delete(r.tableName).Where("id not in (select distinct(album_id) from media_file)")
|
||||
c, err := r.executeSQL(del)
|
||||
if err == nil {
|
||||
if c > 0 {
|
||||
log.Debug(r.ctx, "Purged empty albums", "totalDeleted", c)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *albumRepository) GetStarred(userId string, options ...model.QueryOptions) (model.Albums, error) {
|
||||
var starred []album
|
||||
sq := r.newRawQuery(options...).Join("annotation").Where("annotation.item_id = " + r.tableName + ".id")
|
||||
sq = sq.Where(squirrel.And{
|
||||
squirrel.Eq{"annotation.user_id": userId},
|
||||
squirrel.Eq{"annotation.starred": true},
|
||||
})
|
||||
sql, args, err := sq.ToSql()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = r.ormer.Raw(sql, args...).QueryRows(&starred)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.toAlbums(starred), nil
|
||||
func (r *albumRepository) GetStarred(options ...model.QueryOptions) (model.Albums, error) {
|
||||
sq := r.selectAlbum(options...).Where("starred = true")
|
||||
starred := model.Albums{}
|
||||
err := r.queryAll(sq, &starred)
|
||||
return starred, err
|
||||
}
|
||||
|
||||
func (r *albumRepository) Search(q string, offset int, size int) (model.Albums, error) {
|
||||
if len(q) <= 2 {
|
||||
return nil, nil
|
||||
}
|
||||
results := model.Albums{}
|
||||
err := r.doSearch(q, offset, size, &results, "name")
|
||||
return results, err
|
||||
}
|
||||
|
||||
var results []album
|
||||
err := r.doSearch(r.tableName, q, offset, size, &results, "name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.toAlbums(results), nil
|
||||
func (r *albumRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(options...))
|
||||
}
|
||||
|
||||
func (r *albumRepository) Read(id string) (interface{}, error) {
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
func (r *albumRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(options...))
|
||||
}
|
||||
|
||||
func (r *albumRepository) EntityName() string {
|
||||
return "album"
|
||||
}
|
||||
|
||||
func (r *albumRepository) NewInstance() interface{} {
|
||||
return &model.Album{}
|
||||
}
|
||||
|
||||
var _ model.AlbumRepository = (*albumRepository)(nil)
|
||||
var _ = model.Album(album{})
|
||||
var _ model.ResourceRepository = (*albumRepository)(nil)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -11,7 +14,18 @@ var _ = Describe("AlbumRepository", func() {
|
||||
var repo model.AlbumRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = NewAlbumRepository(orm.NewOrm())
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"})
|
||||
repo = NewAlbumRepository(ctx, orm.NewOrm())
|
||||
})
|
||||
|
||||
Describe("Get", func() {
|
||||
It("returns an existent album", func() {
|
||||
Expect(repo.Get("3")).To(Equal(&albumRadioactivity))
|
||||
})
|
||||
It("returns ErrNotFound when the album does not exist", func() {
|
||||
_, err := repo.Get("666")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetAll", func() {
|
||||
@@ -20,7 +34,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
})
|
||||
|
||||
It("returns all records sorted", func() {
|
||||
Expect(repo.GetAll(model.QueryOptions{Sort: "Name"})).To(Equal(model.Albums{
|
||||
Expect(repo.GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{
|
||||
albumAbbeyRoad,
|
||||
albumRadioactivity,
|
||||
albumSgtPeppers,
|
||||
@@ -28,7 +42,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
})
|
||||
|
||||
It("returns all records sorted desc", func() {
|
||||
Expect(repo.GetAll(model.QueryOptions{Sort: "Name", Order: "desc"})).To(Equal(model.Albums{
|
||||
Expect(repo.GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{
|
||||
albumSgtPeppers,
|
||||
albumRadioactivity,
|
||||
albumAbbeyRoad,
|
||||
@@ -44,7 +58,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
|
||||
Describe("GetStarred", func() {
|
||||
It("returns all starred records", func() {
|
||||
Expect(repo.GetStarred("userid", model.QueryOptions{})).To(Equal(model.Albums{
|
||||
Expect(repo.GetStarred(model.QueryOptions{})).To(Equal(model.Albums{
|
||||
albumRadioactivity,
|
||||
}))
|
||||
})
|
||||
@@ -52,9 +66,9 @@ var _ = Describe("AlbumRepository", func() {
|
||||
|
||||
Describe("FindByArtist", func() {
|
||||
It("returns all records from a given ArtistID", func() {
|
||||
Expect(repo.FindByArtist("1")).To(Equal(model.Albums{
|
||||
albumAbbeyRoad,
|
||||
Expect(repo.FindByArtist("3")).To(Equal(model.Albums{
|
||||
albumSgtPeppers,
|
||||
albumAbbeyRoad,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type annotation struct {
|
||||
AnnotationID string `orm:"pk;column(ann_id)"`
|
||||
UserID string `orm:"column(user_id)"`
|
||||
ItemID string `orm:"column(item_id)"`
|
||||
ItemType string `orm:"column(item_type)"`
|
||||
PlayCount int `orm:"index;null"`
|
||||
PlayDate time.Time `orm:"index;null"`
|
||||
Rating int `orm:"index;null"`
|
||||
Starred bool `orm:"index"`
|
||||
StarredAt time.Time `orm:"null"`
|
||||
}
|
||||
|
||||
func (u *annotation) TableUnique() [][]string {
|
||||
return [][]string{
|
||||
[]string{"UserID", "ItemID", "ItemType"},
|
||||
}
|
||||
}
|
||||
|
||||
type annotationRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewAnnotationRepository(o orm.Ormer) model.AnnotationRepository {
|
||||
r := &annotationRepository{}
|
||||
r.ormer = o
|
||||
r.tableName = "annotation"
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *annotationRepository) Get(userID, itemType string, itemID string) (*model.Annotation, error) {
|
||||
if userID == "" {
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
q := r.newQuery().Filter("user_id", userID).Filter("item_type", itemType).Filter("item_id", itemID)
|
||||
var ann annotation
|
||||
err := q.One(&ann)
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := model.Annotation(ann)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (r *annotationRepository) GetMap(userID, itemType string, itemID []string) (model.AnnotationMap, error) {
|
||||
if userID == "" {
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
if len(itemID) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
q := r.newQuery().Filter("user_id", userID).Filter("item_type", itemType).Filter("item_id__in", itemID)
|
||||
var res []annotation
|
||||
_, err := q.All(&res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m := make(model.AnnotationMap)
|
||||
for _, a := range res {
|
||||
m[a.ItemID] = model.Annotation(a)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (r *annotationRepository) GetAll(userID, itemType string, options ...model.QueryOptions) ([]model.Annotation, error) {
|
||||
if userID == "" {
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
q := r.newQuery(options...).Filter("user_id", userID).Filter("item_type", itemType)
|
||||
var res []annotation
|
||||
_, err := q.All(&res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
all := make([]model.Annotation, len(res))
|
||||
for i, a := range res {
|
||||
all[i] = model.Annotation(a)
|
||||
}
|
||||
return all, err
|
||||
}
|
||||
|
||||
func (r *annotationRepository) new(userID, itemType string, itemID string) *annotation {
|
||||
id, _ := uuid.NewRandom()
|
||||
return &annotation{
|
||||
AnnotationID: id.String(),
|
||||
UserID: userID,
|
||||
ItemID: itemID,
|
||||
ItemType: itemType,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *annotationRepository) IncPlayCount(userID, itemType string, itemID string, ts time.Time) error {
|
||||
if userID == "" {
|
||||
return model.ErrInvalidAuth
|
||||
}
|
||||
q := r.newQuery().Filter("user_id", userID).Filter("item_type", itemType).Filter("item_id", itemID)
|
||||
c, err := q.Update(orm.Params{
|
||||
"play_count": orm.ColValue(orm.ColAdd, 1),
|
||||
"play_date": ts,
|
||||
})
|
||||
if c == 0 || err == orm.ErrNoRows {
|
||||
ann := r.new(userID, itemType, itemID)
|
||||
ann.PlayCount = 1
|
||||
ann.PlayDate = ts
|
||||
_, err = r.ormer.Insert(ann)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *annotationRepository) SetStar(starred bool, userID, itemType string, ids ...string) error {
|
||||
if userID == "" {
|
||||
return model.ErrInvalidAuth
|
||||
}
|
||||
q := r.newQuery().Filter("user_id", userID).Filter("item_type", itemType).Filter("item_id__in", ids)
|
||||
var starredAt time.Time
|
||||
if starred {
|
||||
starredAt = time.Now()
|
||||
}
|
||||
c, err := q.Update(orm.Params{
|
||||
"starred": starred,
|
||||
"starred_at": starredAt,
|
||||
})
|
||||
if c == 0 || err == orm.ErrNoRows {
|
||||
for _, id := range ids {
|
||||
ann := r.new(userID, itemType, id)
|
||||
ann.Starred = starred
|
||||
ann.StarredAt = starredAt
|
||||
_, err = r.ormer.Insert(ann)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *annotationRepository) SetRating(rating int, userID, itemType string, itemID string) error {
|
||||
if userID == "" {
|
||||
return model.ErrInvalidAuth
|
||||
}
|
||||
q := r.newQuery().Filter("user_id", userID).Filter("item_type", itemType).Filter("item_id", itemID)
|
||||
c, err := q.Update(orm.Params{
|
||||
"rating": rating,
|
||||
})
|
||||
if c == 0 || err == orm.ErrNoRows {
|
||||
ann := r.new(userID, itemType, itemID)
|
||||
ann.Rating = rating
|
||||
_, err = r.ormer.Insert(ann)
|
||||
}
|
||||
return err
|
||||
|
||||
}
|
||||
|
||||
func (r *annotationRepository) Delete(userID, itemType string, itemID ...string) error {
|
||||
q := r.newQuery().Filter("user_id", userID).Filter("item_type", itemType).Filter("item_id__in", itemID)
|
||||
_, err := q.Delete()
|
||||
return err
|
||||
}
|
||||
@@ -1,39 +1,46 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
"github.com/deluan/rest"
|
||||
)
|
||||
|
||||
type artist struct {
|
||||
ID string `json:"id" orm:"pk;column(id)"`
|
||||
Name string `json:"name" orm:"index"`
|
||||
AlbumCount int `json:"albumCount" orm:"column(album_count)"`
|
||||
}
|
||||
|
||||
type artistRepository struct {
|
||||
searchableRepository
|
||||
sqlRepository
|
||||
indexGroups utils.IndexGroups
|
||||
}
|
||||
|
||||
func NewArtistRepository(o orm.Ormer) model.ArtistRepository {
|
||||
func NewArtistRepository(ctx context.Context, o orm.Ormer) model.ArtistRepository {
|
||||
r := &artistRepository{}
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups)
|
||||
r.tableName = "artist"
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *artistRepository) getIndexKey(a *artist) string {
|
||||
func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder {
|
||||
return r.newSelectWithAnnotation("id", options...).Columns("*")
|
||||
}
|
||||
|
||||
func (r *artistRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
return r.count(Select(), options...)
|
||||
}
|
||||
|
||||
func (r *artistRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(Select().Where(Eq{"id": id}))
|
||||
}
|
||||
|
||||
func (r *artistRepository) getIndexKey(a *model.Artist) string {
|
||||
name := strings.ToLower(utils.NoArticle(a.Name))
|
||||
for k, v := range r.indexGroups {
|
||||
key := strings.ToLower(k)
|
||||
@@ -45,28 +52,33 @@ func (r *artistRepository) getIndexKey(a *artist) string {
|
||||
}
|
||||
|
||||
func (r *artistRepository) Put(a *model.Artist) error {
|
||||
ta := artist(*a)
|
||||
return r.put(a.ID, a.Name, &ta)
|
||||
_, err := r.put(a.ID, a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.index(a.ID, a.Name)
|
||||
}
|
||||
|
||||
func (r *artistRepository) Get(id string) (*model.Artist, error) {
|
||||
ta := artist{ID: id}
|
||||
err := r.ormer.Read(&ta)
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := model.Artist(ta)
|
||||
return &a, nil
|
||||
sel := r.selectArtist().Where(Eq{"id": id})
|
||||
var res model.Artist
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, error) {
|
||||
sel := r.selectArtist(options...)
|
||||
res := model.Artists{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
// TODO Cache the index (recalculate when there are changes to the DB)
|
||||
func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
|
||||
var all []artist
|
||||
sq := r.selectArtist().OrderBy("name")
|
||||
var all model.Artists
|
||||
// TODO Paginate
|
||||
_, err := r.newQuery().OrderBy("name").All(&all)
|
||||
err := r.queryAll(sq, &all)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -79,7 +91,7 @@ func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
|
||||
idx = &model.ArtistIndex{ID: ax}
|
||||
fullIdx[ax] = idx
|
||||
}
|
||||
idx.Artists = append(idx.Artists, model.Artist(a))
|
||||
idx.Artists = append(idx.Artists, a)
|
||||
}
|
||||
var result model.ArtistIndexes
|
||||
for _, idx := range fullIdx {
|
||||
@@ -93,30 +105,25 @@ func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
|
||||
|
||||
func (r *artistRepository) Refresh(ids ...string) error {
|
||||
type refreshArtist struct {
|
||||
artist
|
||||
model.Artist
|
||||
CurrentId string
|
||||
AlbumArtist string
|
||||
Compilation bool
|
||||
}
|
||||
var artists []refreshArtist
|
||||
o := r.ormer
|
||||
sql := fmt.Sprintf(`
|
||||
select f.artist_id as id,
|
||||
f.artist as name,
|
||||
f.album_artist,
|
||||
f.compilation,
|
||||
count(*) as album_count,
|
||||
a.id as current_id
|
||||
from album f
|
||||
left outer join artist a on f.artist_id = a.id
|
||||
where f.artist_id in ('%s') group by f.artist_id order by f.id`, strings.Join(ids, "','"))
|
||||
_, err := o.Raw(sql).QueryRows(&artists)
|
||||
sel := Select("f.artist_id as id", "f.artist as name", "f.album_artist", "f.compilation",
|
||||
"count(*) as album_count", "a.id as current_id").
|
||||
From("album f").
|
||||
LeftJoin("artist a on f.artist_id = a.id").
|
||||
Where(Eq{"f.artist_id": ids}).
|
||||
GroupBy("f.artist_id").OrderBy("f.id")
|
||||
err := r.queryAll(sel, &artists)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var toInsert []artist
|
||||
var toUpdate []artist
|
||||
toInsert := 0
|
||||
toUpdate := 0
|
||||
for _, ar := range artists {
|
||||
if ar.Compilation {
|
||||
ar.AlbumArtist = "Various Artists"
|
||||
@@ -125,94 +132,68 @@ where f.artist_id in ('%s') group by f.artist_id order by f.id`, strings.Join(id
|
||||
ar.Name = ar.AlbumArtist
|
||||
}
|
||||
if ar.CurrentId != "" {
|
||||
toUpdate = append(toUpdate, ar.artist)
|
||||
toUpdate++
|
||||
} else {
|
||||
toInsert = append(toInsert, ar.artist)
|
||||
toInsert++
|
||||
}
|
||||
err := r.addToIndex(r.tableName, ar.ID, ar.Name)
|
||||
err := r.Put(&ar.Artist)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(toInsert) > 0 {
|
||||
n, err := o.InsertMulti(10, toInsert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Debug("Inserted new artists", "num", n)
|
||||
if toInsert > 0 {
|
||||
log.Debug(r.ctx, "Inserted new artists", "totalInserted", toInsert)
|
||||
}
|
||||
if len(toUpdate) > 0 {
|
||||
for _, al := range toUpdate {
|
||||
// Don't update Starred
|
||||
_, err := o.Update(&al, "name", "album_count")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
log.Debug("Updated artists", "num", len(toUpdate))
|
||||
if toUpdate > 0 {
|
||||
log.Debug(r.ctx, "Updated artists", "totalUpdated", toUpdate)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *artistRepository) GetStarred(userId string, options ...model.QueryOptions) (model.Artists, error) {
|
||||
var starred []artist
|
||||
sq := r.newRawQuery(options...).Join("annotation").Where("annotation.item_id = " + r.tableName + ".id")
|
||||
sq = sq.Where(squirrel.And{
|
||||
squirrel.Eq{"annotation.user_id": userId},
|
||||
squirrel.Eq{"annotation.starred": true},
|
||||
})
|
||||
sql, args, err := sq.ToSql()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = r.ormer.Raw(sql, args...).QueryRows(&starred)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.toArtists(starred), nil
|
||||
}
|
||||
|
||||
func (r *artistRepository) SetStar(starred bool, ids ...string) error {
|
||||
if len(ids) == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
var starredAt time.Time
|
||||
if starred {
|
||||
starredAt = time.Now()
|
||||
}
|
||||
_, err := r.newQuery().Filter("id__in", ids).Update(orm.Params{
|
||||
"starred": starred,
|
||||
"starred_at": starredAt,
|
||||
})
|
||||
return err
|
||||
func (r *artistRepository) GetStarred(options ...model.QueryOptions) (model.Artists, error) {
|
||||
sq := r.selectArtist(options...).Where("starred = true")
|
||||
starred := model.Artists{}
|
||||
err := r.queryAll(sq, &starred)
|
||||
return starred, err
|
||||
}
|
||||
|
||||
func (r *artistRepository) PurgeEmpty() error {
|
||||
_, err := r.ormer.Raw("delete from artist where id not in (select distinct(artist_id) from album)").Exec()
|
||||
del := Delete(r.tableName).Where("id not in (select distinct(artist_id) from album)")
|
||||
c, err := r.executeSQL(del)
|
||||
if err == nil {
|
||||
if c > 0 {
|
||||
log.Debug(r.ctx, "Purged empty artists", "totalDeleted", c)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *artistRepository) Search(q string, offset int, size int) (model.Artists, error) {
|
||||
if len(q) <= 2 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var results []artist
|
||||
err := r.doSearch(r.tableName, q, offset, size, &results, "name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r.toArtists(results), nil
|
||||
results := model.Artists{}
|
||||
err := r.doSearch(q, offset, size, &results, "name")
|
||||
return results, err
|
||||
}
|
||||
|
||||
func (r *artistRepository) toArtists(all []artist) model.Artists {
|
||||
result := make(model.Artists, len(all))
|
||||
for i, a := range all {
|
||||
result[i] = model.Artist(a)
|
||||
}
|
||||
return result
|
||||
func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(options...))
|
||||
}
|
||||
|
||||
func (r *artistRepository) Read(id string) (interface{}, error) {
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(options...))
|
||||
}
|
||||
|
||||
func (r *artistRepository) EntityName() string {
|
||||
return "artist"
|
||||
}
|
||||
|
||||
func (r *artistRepository) NewInstance() interface{} {
|
||||
return &model.Artist{}
|
||||
}
|
||||
|
||||
var _ model.ArtistRepository = (*artistRepository)(nil)
|
||||
var _ = model.Artist(artist{})
|
||||
var _ model.ArtistRepository = (*artistRepository)(nil)
|
||||
var _ model.ResourceRepository = (*artistRepository)(nil)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -11,22 +14,36 @@ var _ = Describe("ArtistRepository", func() {
|
||||
var repo model.ArtistRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = NewArtistRepository(orm.NewOrm())
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"})
|
||||
repo = NewArtistRepository(ctx, orm.NewOrm())
|
||||
})
|
||||
|
||||
Describe("Put/Get", func() {
|
||||
Describe("Count", func() {
|
||||
It("returns the number of artists in the DB", func() {
|
||||
Expect(repo.CountAll()).To(Equal(int64(2)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Exist", func() {
|
||||
It("returns true for an artist that is in the DB", func() {
|
||||
Expect(repo.Exists("3")).To(BeTrue())
|
||||
})
|
||||
It("returns false for an artist that is in the DB", func() {
|
||||
Expect(repo.Exists("666")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Get", func() {
|
||||
It("saves and retrieves data", func() {
|
||||
Expect(repo.Get("1")).To(Equal(&artistSaaraSaara))
|
||||
Expect(repo.Get("2")).To(Equal(&artistKraftwerk))
|
||||
})
|
||||
})
|
||||
|
||||
It("overrides data if ID already exists", func() {
|
||||
Expect(repo.Put(&model.Artist{ID: "1", Name: "Saara Saara is The Best!", AlbumCount: 3})).To(BeNil())
|
||||
Expect(repo.Get("1")).To(Equal(&model.Artist{ID: "1", Name: "Saara Saara is The Best!", AlbumCount: 3}))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when the ID does not exist", func() {
|
||||
_, err := repo.Get("999")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Describe("GetStarred", func() {
|
||||
It("returns all starred records", func() {
|
||||
Expect(repo.GetStarred(model.QueryOptions{})).To(Equal(model.Artists{
|
||||
artistBeatles,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,12 +64,6 @@ var _ = Describe("ArtistRepository", func() {
|
||||
artistKraftwerk,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "S",
|
||||
Artists: model.Artists{
|
||||
{ID: "1", Name: "Saara Saara is The Best!", AlbumCount: 3},
|
||||
},
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,60 +1,29 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"context"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
type genreRepository struct {
|
||||
ormer orm.Ormer
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewGenreRepository(o orm.Ormer) model.GenreRepository {
|
||||
return &genreRepository{ormer: o}
|
||||
func NewGenreRepository(ctx context.Context, o orm.Ormer) model.GenreRepository {
|
||||
r := &genreRepository{}
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
r.tableName = "media_file"
|
||||
return r
|
||||
}
|
||||
|
||||
func (r genreRepository) GetAll() (model.Genres, error) {
|
||||
genres := make(map[string]model.Genre)
|
||||
|
||||
// Collect SongCount
|
||||
var res []orm.Params
|
||||
_, err := r.ormer.Raw("select genre, count(*) as c from media_file group by genre").Values(&res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range res {
|
||||
name := r["genre"].(string)
|
||||
count := r["c"].(string)
|
||||
g, ok := genres[name]
|
||||
if !ok {
|
||||
g = model.Genre{Name: name}
|
||||
}
|
||||
g.SongCount, _ = strconv.Atoi(count)
|
||||
genres[name] = g
|
||||
}
|
||||
|
||||
// Collect AlbumCount
|
||||
_, err = r.ormer.Raw("select genre, count(*) as c from album group by genre").Values(&res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range res {
|
||||
name := r["genre"].(string)
|
||||
count := r["c"].(string)
|
||||
g, ok := genres[name]
|
||||
if !ok {
|
||||
g = model.Genre{Name: name}
|
||||
}
|
||||
g.AlbumCount, _ = strconv.Atoi(count)
|
||||
genres[name] = g
|
||||
}
|
||||
|
||||
// Build response
|
||||
result := model.Genres{}
|
||||
for _, g := range genres {
|
||||
result = append(result, g)
|
||||
}
|
||||
return result, err
|
||||
sq := Select("genre as name", "count(distinct album_id) as album_count", "count(distinct id) as song_count").
|
||||
From("media_file").GroupBy("genre")
|
||||
res := model.Genres{}
|
||||
err := r.queryAll(sq, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package persistence
|
||||
package persistence_test
|
||||
|
||||
import (
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -11,7 +13,7 @@ var _ = Describe("GenreRepository", func() {
|
||||
var repo model.GenreRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = NewGenreRepository(orm.NewOrm())
|
||||
repo = persistence.NewGenreRepository(log.NewContext(nil), orm.NewOrm())
|
||||
})
|
||||
|
||||
It("returns all records", func() {
|
||||
|
||||
62
persistence/helpers.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func toSqlArgs(rec interface{}) (map[string]interface{}, error) {
|
||||
// Convert to JSON...
|
||||
b, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// ... then convert to map
|
||||
var m map[string]interface{}
|
||||
err = json.Unmarshal(b, &m)
|
||||
r := make(map[string]interface{}, len(m))
|
||||
for f, v := range m {
|
||||
r[toSnakeCase(f)] = v
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)")
|
||||
var matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])")
|
||||
|
||||
func toSnakeCase(str string) string {
|
||||
snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}")
|
||||
snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}")
|
||||
return strings.ToLower(snake)
|
||||
}
|
||||
|
||||
func 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
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
}
|
||||
@@ -1,176 +1,163 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/rest"
|
||||
)
|
||||
|
||||
type mediaFile struct {
|
||||
ID string `json:"id" orm:"pk;column(id)"`
|
||||
Path string `json:"path" orm:"index"`
|
||||
Title string `json:"title" orm:"index"`
|
||||
Album string `json:"album"`
|
||||
Artist string `json:"artist"`
|
||||
ArtistID string `json:"artistId" orm:"column(artist_id)"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
AlbumID string `json:"albumId" orm:"column(album_id);index"`
|
||||
HasCoverArt bool `json:"-"`
|
||||
TrackNumber int `json:"trackNumber"`
|
||||
DiscNumber int `json:"discNumber"`
|
||||
Year int `json:"year"`
|
||||
Size int `json:"size"`
|
||||
Suffix string `json:"suffix"`
|
||||
Duration int `json:"duration"`
|
||||
BitRate int `json:"bitRate"`
|
||||
Genre string `json:"genre" orm:"index"`
|
||||
Compilation bool `json:"compilation"`
|
||||
CreatedAt time.Time `json:"createdAt" orm:"null"`
|
||||
UpdatedAt time.Time `json:"updatedAt" orm:"null"`
|
||||
}
|
||||
|
||||
type mediaFileRepository struct {
|
||||
searchableRepository
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewMediaFileRepository(o orm.Ormer) model.MediaFileRepository {
|
||||
func NewMediaFileRepository(ctx context.Context, o orm.Ormer) *mediaFileRepository {
|
||||
r := &mediaFileRepository{}
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
r.tableName = "media_file"
|
||||
r.sortMappings = map[string]string{
|
||||
"artist": "artist asc, album asc, disc_number asc, track_number asc",
|
||||
"album": "album asc, disc_number asc, track_number asc",
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Put(m *model.MediaFile) error {
|
||||
tm := mediaFile(*m)
|
||||
// Don't update media annotation fields (playcount, starred, etc..)
|
||||
// TODO Validate if this is still necessary, now that we don't have annotations in the mediafile model
|
||||
return r.put(m.ID, m.Title, &tm, "path", "title", "album", "artist", "artist_id", "album_artist",
|
||||
"album_id", "has_cover_art", "track_number", "disc_number", "year", "size", "suffix", "duration",
|
||||
"bit_rate", "genre", "compilation", "updated_at")
|
||||
func (r mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
return r.count(Select(), options...)
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Get(id string) (*model.MediaFile, error) {
|
||||
tm := mediaFile{ID: id}
|
||||
err := r.ormer.Read(&tm)
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, model.ErrNotFound
|
||||
func (r mediaFileRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(Select().Where(Eq{"id": id}))
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) Put(m *model.MediaFile) error {
|
||||
_, err := r.put(m.ID, m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.index(m.ID, m.Title)
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder {
|
||||
return r.newSelectWithAnnotation("media_file.id", options...).Columns("media_file.*")
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) Get(id string) (*model.MediaFile, error) {
|
||||
sel := r.selectMediaFile().Where(Eq{"id": id})
|
||||
var res model.MediaFile
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
sq := r.selectMediaFile(options...)
|
||||
res := model.MediaFiles{}
|
||||
err := r.queryAll(sq, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) FindByAlbum(albumId string) (model.MediaFiles, error) {
|
||||
sel := r.selectMediaFile().Where(Eq{"album_id": albumId}).OrderBy("disc_number", "track_number")
|
||||
res := model.MediaFiles{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) FindByPath(path string) (model.MediaFiles, error) {
|
||||
sel := r.selectMediaFile().Where(Like{"path": path + "%"})
|
||||
res := model.MediaFiles{}
|
||||
err := r.queryAll(sel, &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := model.MediaFile(tm)
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) toMediaFiles(all []mediaFile) model.MediaFiles {
|
||||
result := make(model.MediaFiles, len(all))
|
||||
for i, m := range all {
|
||||
result[i] = model.MediaFile(m)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) FindByAlbum(albumId string) (model.MediaFiles, error) {
|
||||
var mfs []mediaFile
|
||||
_, err := r.newQuery().Filter("album_id", albumId).OrderBy("disc_number", "track_number").All(&mfs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.toMediaFiles(mfs), nil
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) FindByPath(path string) (model.MediaFiles, error) {
|
||||
var mfs []mediaFile
|
||||
_, err := r.newQuery().Filter("path__istartswith", path).OrderBy("disc_number", "track_number").All(&mfs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var filtered []mediaFile
|
||||
// Only return mediafiles that are direct child of requested path
|
||||
filtered := model.MediaFiles{}
|
||||
path = strings.ToLower(path) + string(os.PathSeparator)
|
||||
for _, mf := range mfs {
|
||||
for _, mf := range res {
|
||||
filename := strings.TrimPrefix(strings.ToLower(mf.Path), path)
|
||||
if len(strings.Split(filename, string(os.PathSeparator))) > 1 {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, mf)
|
||||
}
|
||||
return r.toMediaFiles(filtered), nil
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) DeleteByPath(path string) error {
|
||||
var mfs []mediaFile
|
||||
// TODO Paginate this (and all other situations similar)
|
||||
_, err := r.newQuery().Filter("path__istartswith", path).OrderBy("disc_number", "track_number").All(&mfs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var filtered []string
|
||||
path = strings.ToLower(path) + string(os.PathSeparator)
|
||||
for _, mf := range mfs {
|
||||
filename := strings.TrimPrefix(strings.ToLower(mf.Path), path)
|
||||
if len(strings.Split(filename, string(os.PathSeparator))) > 1 {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, mf.ID)
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err = r.newQuery().Filter("id__in", filtered).Delete()
|
||||
return err
|
||||
func (r mediaFileRepository) GetStarred(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
sq := r.selectMediaFile(options...).Where("starred = true")
|
||||
starred := model.MediaFiles{}
|
||||
err := r.queryAll(sq, &starred)
|
||||
return starred, err
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
sq := r.newRawQuery(options...)
|
||||
// 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()")
|
||||
}
|
||||
sql, args, err := sq.ToSql()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var results []mediaFile
|
||||
_, err = r.ormer.Raw(sql, args...).QueryRows(&results)
|
||||
return r.toMediaFiles(results), err
|
||||
results := model.MediaFiles{}
|
||||
err := r.queryAll(sq, &results)
|
||||
return results, err
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) GetStarred(userId string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
var starred []mediaFile
|
||||
sq := r.newRawQuery(options...).Join("annotation").Where("annotation.item_id = " + r.tableName + ".id")
|
||||
sq = sq.Where(squirrel.And{
|
||||
squirrel.Eq{"annotation.user_id": userId},
|
||||
squirrel.Eq{"annotation.starred": true},
|
||||
})
|
||||
sql, args, err := sq.ToSql()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = r.ormer.Raw(sql, args...).QueryRows(&starred)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.toMediaFiles(starred), nil
|
||||
func (r mediaFileRepository) Delete(id string) error {
|
||||
return r.delete(Eq{"id": id})
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Search(q string, offset int, size int) (model.MediaFiles, error) {
|
||||
if len(q) <= 2 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var results []mediaFile
|
||||
err := r.doSearch(r.tableName, q, offset, size, &results, "title")
|
||||
func (r mediaFileRepository) DeleteByPath(path string) error {
|
||||
filtered, err := r.FindByPath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
return r.toMediaFiles(results), nil
|
||||
if len(filtered) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, len(filtered))
|
||||
for i, mf := range filtered {
|
||||
ids[i] = mf.ID
|
||||
}
|
||||
log.Debug(r.ctx, "Deleting mediafiles by path", "path", path, "totalDeleted", len(ids))
|
||||
del := Delete(r.tableName).Where(Eq{"id": ids})
|
||||
_, err = r.executeSQL(del)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) Search(q string, offset int, size int) (model.MediaFiles, error) {
|
||||
results := model.MediaFiles{}
|
||||
err := r.doSearch(q, offset, size, &results, "title")
|
||||
return results, err
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(options...))
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) Read(id string) (interface{}, error) {
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(options...))
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) EntityName() string {
|
||||
return "mediafile"
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) NewInstance() interface{} {
|
||||
return model.MediaFile{}
|
||||
}
|
||||
|
||||
var _ model.MediaFileRepository = (*mediaFileRepository)(nil)
|
||||
var _ = model.MediaFile(mediaFile{})
|
||||
var _ model.ResourceRepository = (*mediaFileRepository)(nil)
|
||||
|
||||
@@ -1,29 +1,119 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/google/uuid"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("MediaFileRepository", func() {
|
||||
var repo model.MediaFileRepository
|
||||
var _ = Describe("MediaRepository", func() {
|
||||
var mr model.MediaFileRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = NewMediaFileRepository(orm.NewOrm())
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"})
|
||||
mr = NewMediaFileRepository(ctx, orm.NewOrm())
|
||||
})
|
||||
|
||||
Describe("FindByPath", func() {
|
||||
It("returns all records from a given ArtistID", func() {
|
||||
path := string(os.PathSeparator) + filepath.Join("beatles", "1")
|
||||
Expect(repo.FindByPath(path)).To(Equal(model.MediaFiles{
|
||||
songComeTogether,
|
||||
}))
|
||||
It("gets mediafile from the DB", func() {
|
||||
Expect(mr.Get("4")).To(Equal(&songAntenna))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound", func() {
|
||||
_, err := mr.Get("56")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("counts the number of mediafiles in the DB", func() {
|
||||
Expect(mr.CountAll()).To(Equal(int64(4)))
|
||||
})
|
||||
|
||||
It("checks existence of mediafiles in the DB", func() {
|
||||
Expect(mr.Exists(songAntenna.ID)).To(BeTrue())
|
||||
Expect(mr.Exists("666")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("find mediafiles by album", func() {
|
||||
Expect(mr.FindByAlbum("3")).To(Equal(model.MediaFiles{
|
||||
songRadioactivity,
|
||||
songAntenna,
|
||||
}))
|
||||
})
|
||||
|
||||
It("returns empty array when no tracks are found", func() {
|
||||
Expect(mr.FindByAlbum("67")).To(Equal(model.MediaFiles{}))
|
||||
})
|
||||
|
||||
It("finds tracks by path", func() {
|
||||
Expect(mr.FindByPath(P("/beatles/1/sgt"))).To(Equal(model.MediaFiles{
|
||||
songDayInALife,
|
||||
}))
|
||||
})
|
||||
|
||||
It("returns starred tracks", func() {
|
||||
Expect(mr.GetStarred()).To(Equal(model.MediaFiles{
|
||||
songComeTogether,
|
||||
}))
|
||||
})
|
||||
|
||||
It("delete tracks by id", func() {
|
||||
random, _ := uuid.NewRandom()
|
||||
id := random.String()
|
||||
Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil())
|
||||
|
||||
Expect(mr.Delete(id)).To(BeNil())
|
||||
|
||||
_, err := mr.Get(id)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("delete tracks by path", func() {
|
||||
id1 := "1111"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id1, Path: P("/abc/123/" + id1 + ".mp3")})).To(BeNil())
|
||||
id2 := "2222"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id2, Path: P("/abc/123/" + id2 + ".mp3")})).To(BeNil())
|
||||
id3 := "3333"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id3, Path: P("/abc/" + id3 + ".mp3")})).To(BeNil())
|
||||
|
||||
Expect(mr.DeleteByPath(P("/abc"))).To(BeNil())
|
||||
|
||||
Expect(mr.Get(id1)).ToNot(BeNil())
|
||||
Expect(mr.Get(id2)).ToNot(BeNil())
|
||||
_, err := mr.Get(id3)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
Context("Annotations", func() {
|
||||
It("increments play count when the tracks does not have annotations", func() {
|
||||
id := "incplay.firsttime"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil())
|
||||
playDate := time.Now()
|
||||
Expect(mr.IncPlayCount(id, playDate)).To(BeNil())
|
||||
|
||||
mf, err := mr.Get(id)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix()))
|
||||
Expect(mf.PlayCount).To(Equal(1))
|
||||
})
|
||||
|
||||
It("increments play count on newly starred items", func() {
|
||||
id := "star.incplay"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil())
|
||||
Expect(mr.SetStar(true, id)).To(BeNil())
|
||||
playDate := time.Now()
|
||||
Expect(mr.IncPlayCount(id, playDate)).To(BeNil())
|
||||
|
||||
mf, err := mr.Get(id)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix()))
|
||||
Expect(mf.PlayCount).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
type mediaFolderRepository struct {
|
||||
model.MediaFolderRepository
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func NewMediaFolderRepository(o orm.Ormer) model.MediaFolderRepository {
|
||||
return &mediaFolderRepository{}
|
||||
func NewMediaFolderRepository(ctx context.Context, o orm.Ormer) model.MediaFolderRepository {
|
||||
return &mediaFolderRepository{ctx}
|
||||
}
|
||||
|
||||
func (r *mediaFolderRepository) Get(id string) (*model.MediaFolder, error) {
|
||||
mediaFolder := hardCoded()
|
||||
return &mediaFolder, nil
|
||||
}
|
||||
|
||||
func (*mediaFolderRepository) GetAll() (model.MediaFolders, error) {
|
||||
mediaFolder := model.MediaFolder{ID: "0", Path: conf.Server.MusicFolder}
|
||||
mediaFolder.Name = "Music Library"
|
||||
mediaFolder := hardCoded()
|
||||
result := make(model.MediaFolders, 1)
|
||||
result[0] = mediaFolder
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func hardCoded() model.MediaFolder {
|
||||
mediaFolder := model.MediaFolder{ID: "0", Path: conf.Server.MusicFolder}
|
||||
mediaFolder.Name = "Music Library"
|
||||
return mediaFolder
|
||||
}
|
||||
|
||||
var _ model.MediaFolderRepository = (*mediaFolderRepository)(nil)
|
||||
|
||||
@@ -61,10 +61,6 @@ func (db *MockDataStore) User(context.Context) model.UserRepository {
|
||||
return db.MockedUser
|
||||
}
|
||||
|
||||
func (db *MockDataStore) Annotation(context.Context) model.AnnotationRepository {
|
||||
return struct{ model.AnnotationRepository }{}
|
||||
}
|
||||
|
||||
func (db *MockDataStore) WithTx(block func(db model.DataStore) error) error {
|
||||
return block(db)
|
||||
}
|
||||
@@ -73,6 +69,10 @@ func (db *MockDataStore) Resource(ctx context.Context, m interface{}) model.Reso
|
||||
return struct{ model.ResourceRepository }{}
|
||||
}
|
||||
|
||||
func (db *MockDataStore) GC(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockedUserRepo struct {
|
||||
model.UserRepository
|
||||
}
|
||||
|
||||
@@ -3,23 +3,16 @@ package persistence
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/db"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
_ "github.com/lib/pq"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const batchSize = 100
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
driver = "sqlite3"
|
||||
mappedModels map[interface{}]interface{}
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
type SQLStore struct {
|
||||
@@ -28,13 +21,7 @@ type SQLStore struct {
|
||||
|
||||
func New() model.DataStore {
|
||||
once.Do(func() {
|
||||
dbPath := conf.Server.DbPath
|
||||
if dbPath == ":memory:" {
|
||||
dbPath = "file::memory:?cache=shared"
|
||||
}
|
||||
log.Debug("Opening DataBase", "dbPath", dbPath, "driver", driver)
|
||||
|
||||
err := initORM(dbPath)
|
||||
err := orm.RegisterDataBase("default", db.Driver, db.Path)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -42,44 +29,51 @@ func New() model.DataStore {
|
||||
return &SQLStore{}
|
||||
}
|
||||
|
||||
func (db *SQLStore) Album(context.Context) model.AlbumRepository {
|
||||
return NewAlbumRepository(db.getOrmer())
|
||||
func (db *SQLStore) Album(ctx context.Context) model.AlbumRepository {
|
||||
return NewAlbumRepository(ctx, db.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) Artist(context.Context) model.ArtistRepository {
|
||||
return NewArtistRepository(db.getOrmer())
|
||||
func (db *SQLStore) Artist(ctx context.Context) model.ArtistRepository {
|
||||
return NewArtistRepository(ctx, db.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) MediaFile(context.Context) model.MediaFileRepository {
|
||||
return NewMediaFileRepository(db.getOrmer())
|
||||
func (db *SQLStore) MediaFile(ctx context.Context) model.MediaFileRepository {
|
||||
return NewMediaFileRepository(ctx, db.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) MediaFolder(context.Context) model.MediaFolderRepository {
|
||||
return NewMediaFolderRepository(db.getOrmer())
|
||||
func (db *SQLStore) MediaFolder(ctx context.Context) model.MediaFolderRepository {
|
||||
return NewMediaFolderRepository(ctx, db.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) Genre(context.Context) model.GenreRepository {
|
||||
return NewGenreRepository(db.getOrmer())
|
||||
func (db *SQLStore) Genre(ctx context.Context) model.GenreRepository {
|
||||
return NewGenreRepository(ctx, db.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) Playlist(context.Context) model.PlaylistRepository {
|
||||
return NewPlaylistRepository(db.getOrmer())
|
||||
func (db *SQLStore) Playlist(ctx context.Context) model.PlaylistRepository {
|
||||
return NewPlaylistRepository(ctx, db.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) Property(context.Context) model.PropertyRepository {
|
||||
return NewPropertyRepository(db.getOrmer())
|
||||
func (db *SQLStore) Property(ctx context.Context) model.PropertyRepository {
|
||||
return NewPropertyRepository(ctx, db.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) User(context.Context) model.UserRepository {
|
||||
return NewUserRepository(db.getOrmer())
|
||||
func (db *SQLStore) User(ctx context.Context) model.UserRepository {
|
||||
return NewUserRepository(ctx, db.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) Annotation(context.Context) model.AnnotationRepository {
|
||||
return NewAnnotationRepository(db.getOrmer())
|
||||
}
|
||||
|
||||
func (db *SQLStore) Resource(ctx context.Context, model interface{}) model.ResourceRepository {
|
||||
return NewResource(db.getOrmer(), model, getMappedModel(model))
|
||||
func (db *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
|
||||
switch m.(type) {
|
||||
case model.User:
|
||||
return db.User(ctx).(model.ResourceRepository)
|
||||
case model.Artist:
|
||||
return db.Artist(ctx).(model.ResourceRepository)
|
||||
case model.Album:
|
||||
return db.Album(ctx).(model.ResourceRepository)
|
||||
case model.MediaFile:
|
||||
return db.MediaFile(ctx).(model.ResourceRepository)
|
||||
}
|
||||
log.Error("Resource no implemented", "model", reflect.TypeOf(m).Name())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *SQLStore) WithTx(block func(tx model.DataStore) error) error {
|
||||
@@ -107,64 +101,41 @@ 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()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = db.Artist(ctx).PurgeEmpty()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = db.MediaFile(ctx).(*mediaFileRepository).cleanSearchIndex()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = db.Album(ctx).(*albumRepository).cleanSearchIndex()
|
||||
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()
|
||||
}
|
||||
|
||||
func (db *SQLStore) getOrmer() orm.Ormer {
|
||||
if db.orm == nil {
|
||||
return orm.NewOrm()
|
||||
}
|
||||
return db.orm
|
||||
}
|
||||
|
||||
func initORM(dbPath string) error {
|
||||
verbose := conf.Server.LogLevel == "trace"
|
||||
orm.Debug = verbose
|
||||
if strings.Contains(dbPath, "postgres") {
|
||||
driver = "postgres"
|
||||
}
|
||||
err := orm.RegisterDataBase("default", driver, dbPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return orm.RunSyncdb("default", false, verbose)
|
||||
}
|
||||
|
||||
func collectField(collection interface{}, getValue func(item interface{}) string) []string {
|
||||
s := reflect.ValueOf(collection)
|
||||
result := make([]string, s.Len())
|
||||
|
||||
for i := 0; i < s.Len(); i++ {
|
||||
result[i] = getValue(s.Index(i).Interface())
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func getType(myvar interface{}) string {
|
||||
if t := reflect.TypeOf(myvar); t.Kind() == reflect.Ptr {
|
||||
return t.Elem().Name()
|
||||
} else {
|
||||
return t.Name()
|
||||
}
|
||||
}
|
||||
|
||||
func registerModel(model interface{}, mappedModel interface{}) {
|
||||
mappedModels[getType(model)] = mappedModel
|
||||
orm.RegisterModel(mappedModel)
|
||||
}
|
||||
|
||||
func getMappedModel(model interface{}) interface{} {
|
||||
return mappedModels[getType(model)]
|
||||
}
|
||||
|
||||
func init() {
|
||||
mappedModels = map[interface{}]interface{}{}
|
||||
|
||||
registerModel(model.Artist{}, new(artist))
|
||||
registerModel(model.Album{}, new(album))
|
||||
registerModel(model.MediaFile{}, new(mediaFile))
|
||||
registerModel(model.Property{}, new(property))
|
||||
registerModel(model.Playlist{}, new(playlist))
|
||||
registerModel(model.User{}, new(user))
|
||||
registerModel(model.Annotation{}, new(annotation))
|
||||
|
||||
orm.RegisterModel(new(search))
|
||||
}
|
||||
|
||||
@@ -1,97 +1,148 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/db"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/tests"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestPersistence(t *testing.T) {
|
||||
tests.Init(t, true)
|
||||
|
||||
//os.Remove("./test-123.db")
|
||||
//conf.Server.Path = "./test-123.db"
|
||||
conf.Server.DbPath = ":memory:"
|
||||
db.Init()
|
||||
New()
|
||||
db.EnsureLatestVersion()
|
||||
log.SetLevel(log.LevelCritical)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Persistence Suite")
|
||||
}
|
||||
|
||||
var artistSaaraSaara = model.Artist{ID: "1", Name: "Saara Saara", AlbumCount: 2}
|
||||
var artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk"}
|
||||
var artistBeatles = model.Artist{ID: "3", Name: "The Beatles"}
|
||||
var testArtists = model.Artists{
|
||||
artistSaaraSaara,
|
||||
artistKraftwerk,
|
||||
artistBeatles,
|
||||
}
|
||||
var (
|
||||
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1}
|
||||
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2}
|
||||
testArtists = model.Artists{
|
||||
artistKraftwerk,
|
||||
artistBeatles,
|
||||
}
|
||||
)
|
||||
|
||||
var albumSgtPeppers = model.Album{ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", ArtistID: "1", Genre: "Rock"}
|
||||
var albumAbbeyRoad = model.Album{ID: "2", Name: "Abbey Road", Artist: "The Beatles", ArtistID: "1", Genre: "Rock"}
|
||||
var albumRadioactivity = model.Album{ID: "3", Name: "Radioactivity", Artist: "Kraftwerk", ArtistID: "2", Genre: "Electronic"}
|
||||
var testAlbums = model.Albums{
|
||||
albumSgtPeppers,
|
||||
albumAbbeyRoad,
|
||||
albumRadioactivity,
|
||||
}
|
||||
var (
|
||||
albumSgtPeppers = model.Album{ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", 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}
|
||||
testAlbums = model.Albums{
|
||||
albumSgtPeppers,
|
||||
albumAbbeyRoad,
|
||||
albumRadioactivity,
|
||||
}
|
||||
)
|
||||
|
||||
var annRadioactivity = model.Annotation{AnnotationID: "1", UserID: "userid", ItemType: model.AlbumItemType, ItemID: "3", Starred: true}
|
||||
var testAnnotations = []model.Annotation{
|
||||
annRadioactivity,
|
||||
}
|
||||
var (
|
||||
songDayInALife = model.MediaFile{ID: "1", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "1", Album: "Sgt Peppers", Genre: "Rock", Path: P("/beatles/1/sgt/a day.mp3")}
|
||||
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")}
|
||||
testSongs = model.MediaFiles{
|
||||
songDayInALife,
|
||||
songComeTogether,
|
||||
songRadioactivity,
|
||||
songAntenna,
|
||||
}
|
||||
)
|
||||
|
||||
var songDayInALife = model.MediaFile{ID: "1", Title: "A Day In A Life", ArtistID: "3", AlbumID: "1", Genre: "Rock", Path: P("/beatles/1/sgt/a day.mp3")}
|
||||
var songComeTogether = model.MediaFile{ID: "2", Title: "Come Together", ArtistID: "3", AlbumID: "2", Genre: "Rock", Path: P("/beatles/1/come together.mp3")}
|
||||
var songRadioactivity = model.MediaFile{ID: "3", Title: "Radioactivity", ArtistID: "2", AlbumID: "3", Genre: "Electronic", Path: P("/kraft/radio/radio.mp3")}
|
||||
var songAntenna = model.MediaFile{ID: "4", Title: "Antenna", ArtistID: "2", AlbumID: "3", Genre: "Electronic", Path: P("/kraft/radio/antenna.mp3")}
|
||||
var testSongs = model.MediaFiles{
|
||||
songDayInALife,
|
||||
songComeTogether,
|
||||
songRadioactivity,
|
||||
songAntenna,
|
||||
}
|
||||
var (
|
||||
plsBest = model.Playlist{
|
||||
ID: "10",
|
||||
Name: "Best",
|
||||
Comment: "No Comments",
|
||||
Duration: 10,
|
||||
Owner: "userid",
|
||||
Public: true,
|
||||
Tracks: model.MediaFiles{{ID: "1"}, {ID: "3"}},
|
||||
}
|
||||
plsCool = model.Playlist{ID: "11", Name: "Cool", Tracks: model.MediaFiles{{ID: "4"}}}
|
||||
testPlaylists = model.Playlists{plsBest, plsCool}
|
||||
)
|
||||
|
||||
func P(path string) string {
|
||||
return strings.ReplaceAll(path, "/", string(os.PathSeparator))
|
||||
return filepath.FromSlash(path)
|
||||
}
|
||||
|
||||
var _ = Describe("Initialize test DB", func() {
|
||||
|
||||
// TODO Load this data setup from file(s)
|
||||
BeforeSuite(func() {
|
||||
conf.Server.DbPath = ":memory:"
|
||||
ds := New()
|
||||
artistRepo := ds.Artist(nil)
|
||||
for _, a := range testArtists {
|
||||
err := artistRepo.Put(&a)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
albumRepository := ds.Album(nil)
|
||||
for _, a := range testAlbums {
|
||||
err := albumRepository.Put(&a)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
mediaFileRepository := ds.MediaFile(nil)
|
||||
o := orm.NewOrm()
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"})
|
||||
mr := NewMediaFileRepository(ctx, o)
|
||||
for _, s := range testSongs {
|
||||
err := mediaFileRepository.Put(&s)
|
||||
err := mr.Put(&s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
o := orm.NewOrm()
|
||||
for _, a := range testAnnotations {
|
||||
ann := annotation(a)
|
||||
_, err := o.Insert(&ann)
|
||||
alr := NewAlbumRepository(ctx, o)
|
||||
for _, a := range testAlbums {
|
||||
err := alr.Put(&a)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
arr := NewArtistRepository(ctx, o)
|
||||
for _, a := range testArtists {
|
||||
err := arr.Put(&a)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
pr := NewPlaylistRepository(ctx, o)
|
||||
for _, pls := range testPlaylists {
|
||||
err := pr.Put(&pls)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare annotations
|
||||
if err := arr.SetStar(true, artistBeatles.ID); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ar, _ := arr.Get(artistBeatles.ID)
|
||||
artistBeatles.Starred = true
|
||||
artistBeatles.StarredAt = ar.StarredAt
|
||||
testArtists[1] = artistBeatles
|
||||
|
||||
if err := alr.SetStar(true, albumRadioactivity.ID); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
al, _ := alr.Get(albumRadioactivity.ID)
|
||||
albumRadioactivity.Starred = true
|
||||
albumRadioactivity.StarredAt = al.StarredAt
|
||||
testAlbums[2] = albumRadioactivity
|
||||
|
||||
if err := mr.SetStar(true, songComeTogether.ID); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
mf, _ := mr.Get(songComeTogether.ID)
|
||||
songComeTogether.Starred = true
|
||||
songComeTogether.StarredAt = mf.StarredAt
|
||||
testSongs[1] = songComeTogether
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,57 +1,59 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type playlist struct {
|
||||
ID string `orm:"pk;column(id)"`
|
||||
Name string `orm:"index"`
|
||||
ID string `orm:"column(id)"`
|
||||
Name string
|
||||
Comment string
|
||||
Duration int
|
||||
Owner string
|
||||
Public bool
|
||||
Tracks string `orm:"type(text)"`
|
||||
Tracks string
|
||||
}
|
||||
|
||||
type playlistRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewPlaylistRepository(o orm.Ormer) model.PlaylistRepository {
|
||||
func NewPlaylistRepository(ctx context.Context, o orm.Ormer) model.PlaylistRepository {
|
||||
r := &playlistRepository{}
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
r.tableName = "playlist"
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *playlistRepository) CountAll() (int64, error) {
|
||||
return r.count(Select())
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(Select().Where(Eq{"id": id}))
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Delete(id string) error {
|
||||
return r.delete(Eq{"id": id})
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Put(p *model.Playlist) error {
|
||||
if p.ID == "" {
|
||||
id, _ := uuid.NewRandom()
|
||||
p.ID = id.String()
|
||||
}
|
||||
tp := r.fromModel(p)
|
||||
err := r.put(p.ID, &tp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pls := r.fromModel(p)
|
||||
_, err := r.put(pls.ID, pls)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Get(id string) (*model.Playlist, error) {
|
||||
tp := &playlist{ID: id}
|
||||
err := r.ormer.Read(tp)
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pls := r.toModel(tp)
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"id": id})
|
||||
var res playlist
|
||||
err := r.queryOne(sel, &res)
|
||||
pls := r.toModel(&res)
|
||||
return &pls, err
|
||||
}
|
||||
|
||||
@@ -60,35 +62,34 @@ func (r *playlistRepository) GetWithTracks(id string) (*model.Playlist, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
qs := r.ormer.QueryTable(&mediaFile{})
|
||||
mfRepo := NewMediaFileRepository(r.ctx, r.ormer)
|
||||
pls.Duration = 0
|
||||
var newTracks model.MediaFiles
|
||||
newTracks := model.MediaFiles{}
|
||||
for _, t := range pls.Tracks {
|
||||
mf := &mediaFile{}
|
||||
if err := qs.Filter("id", t.ID).One(mf); err == nil {
|
||||
pls.Duration += mf.Duration
|
||||
newTracks = append(newTracks, model.MediaFile(*mf))
|
||||
mf, err := mfRepo.Get(t.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
pls.Duration += mf.Duration
|
||||
newTracks = append(newTracks, model.MediaFile(*mf))
|
||||
}
|
||||
pls.Tracks = newTracks
|
||||
return pls, err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playlists, error) {
|
||||
var all []playlist
|
||||
_, err := r.newQuery(options...).All(&all)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.toModels(all)
|
||||
sel := r.newSelect(options...).Columns("*")
|
||||
var res []playlist
|
||||
err := r.queryAll(sel, &res)
|
||||
return r.toModels(res), err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) toModels(all []playlist) (model.Playlists, error) {
|
||||
func (r *playlistRepository) toModels(all []playlist) model.Playlists {
|
||||
result := make(model.Playlists, len(all))
|
||||
for i, p := range all {
|
||||
result[i] = r.toModel(&p)
|
||||
}
|
||||
return result, nil
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *playlistRepository) toModel(p *playlist) model.Playlist {
|
||||
|
||||
77
persistence/playlist_repository_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
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("PlaylistRepository", func() {
|
||||
var repo model.PlaylistRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = NewPlaylistRepository(log.NewContext(nil), orm.NewOrm())
|
||||
})
|
||||
|
||||
Describe("Count", func() {
|
||||
It("returns the number of playlists in the DB", func() {
|
||||
Expect(repo.CountAll()).To(Equal(int64(2)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Exist", func() {
|
||||
It("returns true for an existing playlist", func() {
|
||||
Expect(repo.Exists("11")).To(BeTrue())
|
||||
})
|
||||
It("returns false for a non-existing playlist", func() {
|
||||
Expect(repo.Exists("666")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Get", func() {
|
||||
It("returns an existing playlist", func() {
|
||||
Expect(repo.Get("10")).To(Equal(&plsBest))
|
||||
})
|
||||
It("returns ErrNotFound for a non-existing playlist", func() {
|
||||
_, err := repo.Get("666")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Put/Get/Delete", func() {
|
||||
newPls := model.Playlist{ID: "22", Name: "Great!", Tracks: model.MediaFiles{{ID: "4"}}}
|
||||
It("saves the playlist to the DB", func() {
|
||||
Expect(repo.Put(&newPls)).To(BeNil())
|
||||
})
|
||||
It("returns the newly created playlist", func() {
|
||||
Expect(repo.Get("22")).To(Equal(&newPls))
|
||||
})
|
||||
It("returns deletes the playlist", func() {
|
||||
Expect(repo.Delete("22")).To(BeNil())
|
||||
})
|
||||
It("returns error if tries to retrieve the deleted playlist", func() {
|
||||
_, err := repo.Get("22")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetWithTracks", func() {
|
||||
It("returns an existing playlist", func() {
|
||||
pls, err := repo.GetWithTracks("10")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(pls.Name).To(Equal(plsBest.Name))
|
||||
Expect(pls.Tracks).To(Equal(model.MediaFiles{
|
||||
songDayInALife,
|
||||
songRadioactivity,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetAll", func() {
|
||||
It("returns all playlists from DB", func() {
|
||||
Expect(repo.GetAll()).To(Equal(model.Playlists{plsBest, plsCool}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,9 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
@@ -14,35 +17,41 @@ type propertyRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewPropertyRepository(o orm.Ormer) model.PropertyRepository {
|
||||
func NewPropertyRepository(ctx context.Context, o orm.Ormer) model.PropertyRepository {
|
||||
r := &propertyRepository{}
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
r.tableName = "property"
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *propertyRepository) Put(id string, value string) error {
|
||||
p := &property{ID: id, Value: value}
|
||||
num, err := r.ormer.Update(p)
|
||||
func (r propertyRepository) Put(id string, value string) error {
|
||||
update := squirrel.Update(r.tableName).Set("value", value).Where(squirrel.Eq{"id": id})
|
||||
count, err := r.executeSQL(update)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if num == 0 {
|
||||
_, err = r.ormer.Insert(p)
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
insert := squirrel.Insert(r.tableName).Columns("id", "value").Values(id, value)
|
||||
_, err = r.executeSQL(insert)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *propertyRepository) Get(id string) (string, error) {
|
||||
p := &property{ID: id}
|
||||
err := r.ormer.Read(p)
|
||||
if err == orm.ErrNoRows {
|
||||
return "", model.ErrNotFound
|
||||
func (r propertyRepository) Get(id string) (string, error) {
|
||||
sel := squirrel.Select("value").From(r.tableName).Where(squirrel.Eq{"id": id})
|
||||
resp := struct {
|
||||
Value string
|
||||
}{}
|
||||
err := r.queryOne(sel, &resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return p.Value, err
|
||||
return resp.Value, nil
|
||||
}
|
||||
|
||||
func (r *propertyRepository) DefaultGet(id string, defaultValue string) (string, error) {
|
||||
func (r propertyRepository) DefaultGet(id string, defaultValue string) (string, error) {
|
||||
value, err := r.Get(id)
|
||||
if err == model.ErrNotFound {
|
||||
return defaultValue, nil
|
||||
@@ -52,5 +61,3 @@ func (r *propertyRepository) DefaultGet(id string, defaultValue string) (string,
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
var _ model.PropertyRepository = (*propertyRepository)(nil)
|
||||
|
||||
@@ -2,30 +2,32 @@ 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("PropertyRepository", func() {
|
||||
var repo model.PropertyRepository
|
||||
var _ = Describe("Property Repository", func() {
|
||||
var pr model.PropertyRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = NewPropertyRepository(orm.NewOrm())
|
||||
repo.(*propertyRepository).DeleteAll()
|
||||
pr = NewPropertyRepository(NewContext(nil), orm.NewOrm())
|
||||
})
|
||||
|
||||
It("saves and retrieves data", func() {
|
||||
Expect(repo.Put("1", "test")).To(BeNil())
|
||||
Expect(repo.Get("1")).To(Equal("test"))
|
||||
It("saves and restore a new property", func() {
|
||||
id := "1"
|
||||
value := "a_value"
|
||||
Expect(pr.Put(id, value)).To(BeNil())
|
||||
Expect(pr.Get(id)).To(Equal("a_value"))
|
||||
})
|
||||
|
||||
It("returns default if data is not found", func() {
|
||||
Expect(repo.DefaultGet("2", "default")).To(Equal("default"))
|
||||
It("updates a property", func() {
|
||||
Expect(pr.Put("1", "another_value")).To(BeNil())
|
||||
Expect(pr.Get("1")).To(Equal("another_value"))
|
||||
})
|
||||
|
||||
It("returns value if found", func() {
|
||||
Expect(repo.Put("3", "test")).To(BeNil())
|
||||
Expect(repo.DefaultGet("3", "default")).To(Equal("test"))
|
||||
It("returns a default value if property does not exist", func() {
|
||||
Expect(pr.DefaultGet("2", "default")).To(Equal("default"))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type resourceRepository struct {
|
||||
model.ResourceRepository
|
||||
model interface{}
|
||||
mappedModel interface{}
|
||||
ormer orm.Ormer
|
||||
instanceType reflect.Type
|
||||
sliceType reflect.Type
|
||||
}
|
||||
|
||||
func NewResource(o orm.Ormer, model interface{}, mappedModel interface{}) model.ResourceRepository {
|
||||
r := &resourceRepository{model: model, mappedModel: mappedModel, ormer: o}
|
||||
|
||||
// Get type of mappedModel (which is a *struct)
|
||||
rv := reflect.ValueOf(mappedModel)
|
||||
for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
|
||||
rv = rv.Elem()
|
||||
}
|
||||
r.instanceType = rv.Type()
|
||||
r.sliceType = reflect.SliceOf(r.instanceType)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *resourceRepository) EntityName() string {
|
||||
return r.instanceType.Name()
|
||||
}
|
||||
|
||||
func (r *resourceRepository) newQuery(options ...rest.QueryOptions) orm.QuerySeter {
|
||||
qs := r.ormer.QueryTable(r.mappedModel)
|
||||
if len(options) > 0 {
|
||||
qs = r.addOptions(qs, options)
|
||||
qs = r.addFilters(qs, r.buildFilters(qs, options))
|
||||
}
|
||||
return qs
|
||||
}
|
||||
|
||||
func (r *resourceRepository) NewInstance() interface{} {
|
||||
return reflect.New(r.instanceType).Interface()
|
||||
}
|
||||
|
||||
func (r *resourceRepository) NewSlice() interface{} {
|
||||
slice := reflect.MakeSlice(r.sliceType, 0, 0)
|
||||
x := reflect.New(slice.Type())
|
||||
x.Elem().Set(slice)
|
||||
return x.Interface()
|
||||
}
|
||||
|
||||
func (r *resourceRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
qs := r.newQuery(options...)
|
||||
dataSet := r.NewSlice()
|
||||
_, err := qs.All(dataSet)
|
||||
if err == orm.ErrNoRows {
|
||||
return dataSet, rest.ErrNotFound
|
||||
}
|
||||
return dataSet, err
|
||||
}
|
||||
|
||||
func (r *resourceRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
qs := r.newQuery(options...)
|
||||
count, err := qs.Count()
|
||||
if err == orm.ErrNoRows {
|
||||
err = rest.ErrNotFound
|
||||
}
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *resourceRepository) Read(id string) (interface{}, error) {
|
||||
qs := r.newQuery().Filter("id", id)
|
||||
data := r.NewInstance()
|
||||
err := qs.One(data)
|
||||
if err == orm.ErrNoRows {
|
||||
return data, rest.ErrNotFound
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
func setUUID(p interface{}) {
|
||||
f := reflect.ValueOf(p).Elem().FieldByName("ID")
|
||||
if f.Kind() == reflect.String {
|
||||
id, _ := uuid.NewRandom()
|
||||
f.SetString(id.String())
|
||||
}
|
||||
}
|
||||
|
||||
func (r *resourceRepository) Save(p interface{}) (string, error) {
|
||||
setUUID(p)
|
||||
id, err := r.ormer.Insert(p)
|
||||
if err != nil {
|
||||
if err.Error() != "LastInsertId is not supported by this driver" {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return strconv.FormatInt(id, 10), nil
|
||||
}
|
||||
|
||||
func (r *resourceRepository) Update(p interface{}, cols ...string) error {
|
||||
count, err := r.ormer.Update(p, cols...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *resourceRepository) addOptions(qs orm.QuerySeter, options []rest.QueryOptions) orm.QuerySeter {
|
||||
if len(options) == 0 {
|
||||
return qs
|
||||
}
|
||||
opt := options[0]
|
||||
sort := strings.Split(opt.Sort, ",")
|
||||
reverse := strings.ToLower(opt.Order) == "desc"
|
||||
for i, s := range sort {
|
||||
s = strings.TrimSpace(s)
|
||||
if reverse {
|
||||
if s[0] == '-' {
|
||||
s = strings.TrimPrefix(s, "-")
|
||||
} else {
|
||||
s = "-" + s
|
||||
}
|
||||
}
|
||||
sort[i] = strings.Replace(s, ".", "__", -1)
|
||||
}
|
||||
if opt.Sort != "" {
|
||||
qs = qs.OrderBy(sort...)
|
||||
}
|
||||
if opt.Max > 0 {
|
||||
qs = qs.Limit(opt.Max)
|
||||
}
|
||||
if opt.Offset > 0 {
|
||||
qs = qs.Offset(opt.Offset)
|
||||
}
|
||||
return qs
|
||||
}
|
||||
|
||||
func (r *resourceRepository) addFilters(qs orm.QuerySeter, conditions ...*orm.Condition) orm.QuerySeter {
|
||||
var cond *orm.Condition
|
||||
for _, c := range conditions {
|
||||
if c != nil {
|
||||
if cond == nil {
|
||||
cond = c
|
||||
} else {
|
||||
cond = cond.AndCond(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
if cond != nil {
|
||||
return qs.SetCond(cond)
|
||||
}
|
||||
return qs
|
||||
}
|
||||
|
||||
func unmarshalValue(val interface{}) string {
|
||||
switch v := val.(type) {
|
||||
case float64:
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
return fmt.Sprintf("%v", val)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *resourceRepository) buildFilters(qs orm.QuerySeter, options []rest.QueryOptions) *orm.Condition {
|
||||
if len(options) == 0 {
|
||||
return nil
|
||||
}
|
||||
cond := orm.NewCondition()
|
||||
clauses := cond
|
||||
for f, v := range options[0].Filters {
|
||||
fn := strings.Replace(f, ".", "__", -1)
|
||||
s := unmarshalValue(v)
|
||||
|
||||
if strings.HasSuffix(fn, "Id") || strings.HasSuffix(fn, "__id") {
|
||||
clauses = IdFilter(clauses, fn, s)
|
||||
} else {
|
||||
clauses = StartsWithFilter(clauses, fn, s)
|
||||
}
|
||||
}
|
||||
return clauses
|
||||
}
|
||||
|
||||
func IdFilter(cond *orm.Condition, field, value string) *orm.Condition {
|
||||
field = strings.TrimSuffix(field, "Id") + "__id"
|
||||
return cond.And(field, value)
|
||||
}
|
||||
|
||||
func StartsWithFilter(cond *orm.Condition, field, value string) *orm.Condition {
|
||||
return cond.And(field+"__istartswith", value)
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/kennygrant/sanitize"
|
||||
)
|
||||
|
||||
type search struct {
|
||||
ID string `orm:"pk;column(id)"`
|
||||
Table string `orm:"index"`
|
||||
FullText string `orm:"index"`
|
||||
}
|
||||
|
||||
type searchableRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func (r *searchableRepository) DeleteAll() error {
|
||||
_, err := r.newQuery().Filter("id__isnull", false).Delete()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.removeAllFromIndex(r.ormer, r.tableName)
|
||||
}
|
||||
|
||||
func (r *searchableRepository) put(id string, textToIndex string, a interface{}, fields ...string) error {
|
||||
c, err := r.newQuery().Filter("id", id).Count()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c == 0 {
|
||||
err = r.insert(a)
|
||||
if err != nil && err.Error() == "LastInsertId is not supported by this driver" {
|
||||
err = nil
|
||||
}
|
||||
} else {
|
||||
_, err = r.ormer.Update(a, fields...)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.addToIndex(r.tableName, id, textToIndex)
|
||||
}
|
||||
|
||||
func (r *searchableRepository) addToIndex(table, id, text string) error {
|
||||
item := search{ID: id, Table: table}
|
||||
err := r.ormer.Read(&item)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
sanitizedText := strings.TrimSpace(sanitize.Accents(strings.ToLower(text)))
|
||||
item = search{ID: id, Table: table, FullText: sanitizedText}
|
||||
if err == orm.ErrNoRows {
|
||||
err = r.insert(&item)
|
||||
} else {
|
||||
_, err = r.ormer.Update(&item)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *searchableRepository) removeFromIndex(table string, ids []string) error {
|
||||
var offset int
|
||||
for {
|
||||
var subset = paginateSlice(ids, offset, batchSize)
|
||||
if len(subset) == 0 {
|
||||
break
|
||||
}
|
||||
log.Trace("Deleting searchable items", "table", table, "num", len(subset), "from", offset)
|
||||
offset += len(subset)
|
||||
_, err := r.ormer.QueryTable(&search{}).Filter("table", table).Filter("id__in", subset).Delete()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *searchableRepository) removeAllFromIndex(o orm.Ormer, table string) error {
|
||||
_, err := o.QueryTable(&search{}).Filter("table", table).Delete()
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *searchableRepository) doSearch(table string, q string, offset, size int, results interface{}, orderBys ...string) error {
|
||||
q = strings.TrimSpace(sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*"))))
|
||||
if len(q) <= 2 {
|
||||
return nil
|
||||
}
|
||||
sq := squirrel.Select("*").From(table)
|
||||
sq = sq.Limit(uint64(size)).Offset(uint64(offset))
|
||||
if len(orderBys) > 0 {
|
||||
sq = sq.OrderBy(orderBys...)
|
||||
}
|
||||
sq = sq.Join("search").Where("search.id = " + table + ".id")
|
||||
parts := strings.Split(q, " ")
|
||||
for _, part := range parts {
|
||||
sq = sq.Where(squirrel.Or{
|
||||
squirrel.Like{"full_text": part + "%"},
|
||||
squirrel.Like{"full_text": "%" + part + "%"},
|
||||
})
|
||||
}
|
||||
sql, args, err := sq.ToSql()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = r.ormer.Raw(sql, args...).QueryRows(results)
|
||||
return err
|
||||
}
|
||||
110
persistence/sql_annotations.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/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 {
|
||||
return r.newSelect(options...).
|
||||
LeftJoin("annotation on ("+
|
||||
"annotation.item_id = "+idField+
|
||||
" AND annotation.item_type = '"+r.tableName+"'"+
|
||||
" AND annotation.user_id = '"+userId(r.ctx)+"')").
|
||||
Columns("starred", "starred_at", "play_count", "play_date", "rating")
|
||||
}
|
||||
|
||||
func (r sqlRepository) annId(itemID ...string) And {
|
||||
return And{
|
||||
Eq{"user_id": userId(r.ctx)},
|
||||
Eq{"item_type": r.tableName},
|
||||
Eq{"item_id": itemID},
|
||||
}
|
||||
}
|
||||
|
||||
func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...string) error {
|
||||
upd := Update(annotationTable).Where(r.annId(itemIDs...))
|
||||
for f, v := range values {
|
||||
upd = upd.Set(f, v)
|
||||
}
|
||||
c, err := r.executeSQL(upd)
|
||||
if c == 0 || err == orm.ErrNoRows {
|
||||
for _, itemID := range itemIDs {
|
||||
id, _ := uuid.NewRandom()
|
||||
values["ann_id"] = id.String()
|
||||
values["user_id"] = userId(r.ctx)
|
||||
values["item_type"] = r.tableName
|
||||
values["item_id"] = itemID
|
||||
ins := Insert(annotationTable).SetMap(values)
|
||||
_, err = r.executeSQL(ins)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r sqlRepository) SetStar(starred bool, ids ...string) error {
|
||||
starredAt := time.Now()
|
||||
return r.annUpsert(map[string]interface{}{"starred": starred, "starred_at": starredAt}, ids...)
|
||||
}
|
||||
|
||||
func (r sqlRepository) SetRating(rating int, itemID string) error {
|
||||
return r.annUpsert(map[string]interface{}{"rating": rating}, itemID)
|
||||
}
|
||||
|
||||
func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
|
||||
upd := Update(annotationTable).Where(r.annId(itemID)).
|
||||
Set("play_count", Expr("play_count+1")).
|
||||
Set("play_date", ts)
|
||||
c, err := r.executeSQL(upd)
|
||||
|
||||
if c == 0 || err == orm.ErrNoRows {
|
||||
id, _ := uuid.NewRandom()
|
||||
values := map[string]interface{}{}
|
||||
values["ann_id"] = id.String()
|
||||
values["user_id"] = userId(r.ctx)
|
||||
values["item_type"] = r.tableName
|
||||
values["item_id"] = itemID
|
||||
values["play_count"] = 1
|
||||
values["play_date"] = ts
|
||||
ins := Insert(annotationTable).SetMap(values)
|
||||
_, err = r.executeSQL(ins)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r sqlRepository) cleanAnnotations() error {
|
||||
del := Delete(annotationTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")")
|
||||
c, err := r.executeSQL(del)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c > 0 {
|
||||
log.Debug(r.ctx, "Clean-up annotations", "table", r.tableName, "totalDeleted", c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
226
persistence/sql_base_repository.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/scanner"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type sqlRepository struct {
|
||||
ctx context.Context
|
||||
tableName string
|
||||
ormer orm.Ormer
|
||||
sortMappings map[string]string
|
||||
}
|
||||
|
||||
const invalidUserId = "-1"
|
||||
|
||||
func userId(ctx context.Context) string {
|
||||
user := ctx.Value("user")
|
||||
if user == nil {
|
||||
return invalidUserId
|
||||
}
|
||||
usr := user.(*model.User)
|
||||
return usr.ID
|
||||
}
|
||||
|
||||
func loggedUser(ctx context.Context) *model.User {
|
||||
user := ctx.Value("user")
|
||||
if user == nil {
|
||||
return &model.User{}
|
||||
}
|
||||
return user.(*model.User)
|
||||
}
|
||||
|
||||
func (r sqlRepository) newSelect(options ...model.QueryOptions) SelectBuilder {
|
||||
sq := Select().From(r.tableName)
|
||||
sq = r.applyOptions(sq, options...)
|
||||
sq = r.applyFilters(sq, options...)
|
||||
return sq
|
||||
}
|
||||
|
||||
func (r sqlRepository) applyOptions(sq SelectBuilder, options ...model.QueryOptions) SelectBuilder {
|
||||
if len(options) > 0 {
|
||||
if options[0].Max > 0 {
|
||||
sq = sq.Limit(uint64(options[0].Max))
|
||||
}
|
||||
if options[0].Offset > 0 {
|
||||
sq = sq.Offset(uint64(options[0].Offset))
|
||||
}
|
||||
if options[0].Sort != "" {
|
||||
sort := toSnakeCase(options[0].Sort)
|
||||
if mapping, ok := r.sortMappings[sort]; ok {
|
||||
sort = mapping
|
||||
}
|
||||
if !strings.Contains(sort, "asc") && !strings.Contains(sort, "desc") {
|
||||
sort = sort + " asc"
|
||||
}
|
||||
if options[0].Order == "desc" {
|
||||
var s scanner.Scanner
|
||||
s.Init(strings.NewReader(sort))
|
||||
var newSort string
|
||||
for tok := s.Scan(); tok != scanner.EOF; tok = s.Scan() {
|
||||
switch s.TokenText() {
|
||||
case "asc":
|
||||
newSort += " " + "desc"
|
||||
case "desc":
|
||||
newSort += " " + "asc"
|
||||
default:
|
||||
newSort += " " + s.TokenText()
|
||||
}
|
||||
}
|
||||
sort = newSort
|
||||
}
|
||||
sq = sq.OrderBy(sort)
|
||||
}
|
||||
}
|
||||
return sq
|
||||
}
|
||||
|
||||
func (r sqlRepository) applyFilters(sq SelectBuilder, options ...model.QueryOptions) SelectBuilder {
|
||||
if len(options) > 0 && options[0].Filters != nil {
|
||||
sq = sq.Where(options[0].Filters)
|
||||
}
|
||||
return sq
|
||||
}
|
||||
|
||||
func (r sqlRepository) executeSQL(sq Sqlizer) (int64, error) {
|
||||
query, args, err := sq.ToSql()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
start := time.Now()
|
||||
res, err := r.ormer.Raw(query, args...).Exec()
|
||||
c, _ := res.RowsAffected()
|
||||
r.logSQL(query, args, err, c, start)
|
||||
if err != nil {
|
||||
if err.Error() != "LastInsertId is not supported by this driver" {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (r sqlRepository) queryOne(sq Sqlizer, response interface{}) error {
|
||||
query, args, err := sq.ToSql()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
start := time.Now()
|
||||
err = r.ormer.Raw(query, args...).QueryRow(response)
|
||||
if err == orm.ErrNoRows {
|
||||
r.logSQL(query, args, nil, 1, start)
|
||||
return model.ErrNotFound
|
||||
}
|
||||
r.logSQL(query, args, err, 1, start)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r sqlRepository) queryAll(sq Sqlizer, response interface{}) error {
|
||||
query, args, err := sq.ToSql()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
start := time.Now()
|
||||
c, err := r.ormer.Raw(query, args...).QueryRows(response)
|
||||
if err == orm.ErrNoRows {
|
||||
r.logSQL(query, args, nil, c, start)
|
||||
return model.ErrNotFound
|
||||
}
|
||||
r.logSQL(query, args, nil, c, start)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r sqlRepository) exists(existsQuery SelectBuilder) (bool, error) {
|
||||
existsQuery = existsQuery.Columns("count(*) as exist").From(r.tableName)
|
||||
var res struct{ Exist int64 }
|
||||
err := r.queryOne(existsQuery, &res)
|
||||
return res.Exist > 0, err
|
||||
}
|
||||
|
||||
func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOptions) (int64, error) {
|
||||
countQuery = countQuery.Columns("count(*) as count").From(r.tableName)
|
||||
countQuery = r.applyFilters(countQuery, options...)
|
||||
var res struct{ Count int64 }
|
||||
err := r.queryOne(countQuery, &res)
|
||||
return res.Count, err
|
||||
}
|
||||
|
||||
func (r sqlRepository) put(id string, m interface{}) (newId string, err error) {
|
||||
values, _ := toSqlArgs(m)
|
||||
if id != "" {
|
||||
update := Update(r.tableName).Where(Eq{"id": id}).SetMap(values)
|
||||
count, err := r.executeSQL(update)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if count > 0 {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
// if does not have an id OR could not update (new record with predefined id)
|
||||
if id == "" {
|
||||
rand, _ := uuid.NewRandom()
|
||||
id = rand.String()
|
||||
values["id"] = id
|
||||
}
|
||||
insert := Insert(r.tableName).SetMap(values)
|
||||
_, err = r.executeSQL(insert)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (r sqlRepository) delete(cond Sqlizer) error {
|
||||
del := Delete(r.tableName).Where(cond)
|
||||
_, err := r.executeSQL(del)
|
||||
if err == orm.ErrNoRows {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r sqlRepository) logSQL(sql string, args []interface{}, err error, rowsAffected int64, start time.Time) {
|
||||
lapsed := time.Since(start)
|
||||
var fmtArgs []string
|
||||
for i := range args {
|
||||
var f string
|
||||
switch a := args[i].(type) {
|
||||
case string:
|
||||
f = `'` + a + `'`
|
||||
default:
|
||||
f = fmt.Sprintf("%v", a)
|
||||
}
|
||||
fmtArgs = append(fmtArgs, f)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, "lapsedTime", lapsed, err)
|
||||
} else {
|
||||
log.Trace(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, "lapsedTime", lapsed)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
type sqlRepository struct {
|
||||
tableName string
|
||||
ormer orm.Ormer
|
||||
}
|
||||
|
||||
func (r *sqlRepository) newQuery(options ...model.QueryOptions) orm.QuerySeter {
|
||||
q := r.ormer.QueryTable(r.tableName)
|
||||
if len(options) > 0 {
|
||||
opts := options[0]
|
||||
q = q.Offset(opts.Offset)
|
||||
if opts.Max > 0 {
|
||||
q = q.Limit(opts.Max)
|
||||
}
|
||||
if opts.Sort != "" {
|
||||
if opts.Order == "desc" {
|
||||
q = q.OrderBy("-" + opts.Sort)
|
||||
} else {
|
||||
q = q.OrderBy(opts.Sort)
|
||||
}
|
||||
}
|
||||
for field, value := range opts.Filters {
|
||||
q = q.Filter(field, value)
|
||||
}
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func (r *sqlRepository) newRawQuery(options ...model.QueryOptions) squirrel.SelectBuilder {
|
||||
sq := squirrel.Select("*").From(r.tableName)
|
||||
if len(options) > 0 {
|
||||
if options[0].Max > 0 {
|
||||
sq = sq.Limit(uint64(options[0].Max))
|
||||
}
|
||||
if options[0].Offset > 0 {
|
||||
sq = sq.Offset(uint64(options[0].Max))
|
||||
}
|
||||
if options[0].Sort != "" {
|
||||
if options[0].Order == "desc" {
|
||||
sq = sq.OrderBy(options[0].Sort + " desc")
|
||||
} else {
|
||||
sq = sq.OrderBy(options[0].Sort)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sq
|
||||
}
|
||||
|
||||
func (r *sqlRepository) CountAll() (int64, error) {
|
||||
return r.newQuery().Count()
|
||||
}
|
||||
|
||||
func (r *sqlRepository) Exists(id string) (bool, error) {
|
||||
c, err := r.newQuery().Filter("id", id).Count()
|
||||
return c == 1, err
|
||||
}
|
||||
|
||||
// "Hack" to bypass Postgres driver limitation
|
||||
func (r *sqlRepository) insert(record interface{}) error {
|
||||
_, err := r.ormer.Insert(record)
|
||||
if err != nil && err.Error() != "LastInsertId is not supported by this driver" {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sqlRepository) put(id string, a interface{}) error {
|
||||
c, err := r.newQuery().Filter("id", id).Count()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c == 0 {
|
||||
err = r.insert(a)
|
||||
if err != nil && err.Error() == "LastInsertId is not supported by this driver" {
|
||||
err = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
_, err = r.ormer.Update(a)
|
||||
return err
|
||||
}
|
||||
|
||||
func paginateSlice(slice []string, skip int, size int) []string {
|
||||
if skip > len(slice) {
|
||||
skip = len(slice)
|
||||
}
|
||||
|
||||
end := skip + size
|
||||
if end > len(slice) {
|
||||
end = len(slice)
|
||||
}
|
||||
|
||||
return slice[skip:end]
|
||||
}
|
||||
|
||||
func difference(slice1 []string, slice2 []string) []string {
|
||||
var diffStr []string
|
||||
m := map[string]int{}
|
||||
|
||||
for _, s1Val := range slice1 {
|
||||
m[s1Val] = 1
|
||||
}
|
||||
for _, s2Val := range slice2 {
|
||||
m[s2Val] = m[s2Val] + 1
|
||||
}
|
||||
|
||||
for mKey, mVal := range m {
|
||||
if mVal == 1 {
|
||||
diffStr = append(diffStr, mKey)
|
||||
}
|
||||
}
|
||||
|
||||
return diffStr
|
||||
}
|
||||
|
||||
func (r *sqlRepository) Delete(id string) error {
|
||||
_, err := r.newQuery().Filter("id", id).Delete()
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *sqlRepository) DeleteAll() error {
|
||||
_, err := r.newQuery().Filter("id__isnull", false).Delete()
|
||||
return err
|
||||
}
|
||||
66
persistence/sql_search.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"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 {
|
||||
sanitizedText := strings.TrimSpace(sanitize.Accents(strings.ToLower(text)))
|
||||
|
||||
values := map[string]interface{}{
|
||||
"id": id,
|
||||
"item_type": r.tableName,
|
||||
"full_text": sanitizedText,
|
||||
}
|
||||
update := Update(searchTable).Where(Eq{"id": id}).SetMap(values)
|
||||
count, err := r.executeSQL(update)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
insert := Insert(searchTable).SetMap(values)
|
||||
_, err = r.executeSQL(insert)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r sqlRepository) doSearch(q string, offset, size int, results interface{}, orderBys ...string) error {
|
||||
q = strings.TrimSpace(sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*"))))
|
||||
if len(q) <= 2 {
|
||||
return nil
|
||||
}
|
||||
sq := Select("*").From(r.tableName)
|
||||
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{
|
||||
Like{"full_text": part + "%"},
|
||||
Like{"full_text": "%" + part + "%"},
|
||||
})
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -1,92 +1,163 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type user struct {
|
||||
ID string `json:"id" orm:"pk;column(id)"`
|
||||
UserName string `json:"userName" orm:"index;unique"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email" orm:"unique"`
|
||||
Password string `json:"password"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
LastLoginAt *time.Time `json:"lastLoginAt" orm:"null"`
|
||||
LastAccessAt *time.Time `json:"lastAccessAt" orm:"null"`
|
||||
CreatedAt time.Time `json:"createdAt" orm:"auto_now_add;type(datetime)"`
|
||||
UpdatedAt time.Time `json:"updatedAt" orm:"auto_now;type(datetime)"`
|
||||
}
|
||||
|
||||
type userRepository struct {
|
||||
ormer orm.Ormer
|
||||
userResource model.ResourceRepository
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewUserRepository(o orm.Ormer) model.UserRepository {
|
||||
r := &userRepository{ormer: o}
|
||||
r.userResource = NewResource(o, model.User{}, new(user))
|
||||
func NewUserRepository(ctx context.Context, o orm.Ormer) model.UserRepository {
|
||||
r := &userRepository{}
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
r.tableName = "user"
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *userRepository) CountAll(qo ...model.QueryOptions) (int64, error) {
|
||||
if len(qo) > 0 {
|
||||
return r.userResource.Count(rest.QueryOptions(qo[0]))
|
||||
}
|
||||
return r.userResource.Count()
|
||||
return r.count(Select(), qo...)
|
||||
}
|
||||
|
||||
func (r *userRepository) Get(id string) (*model.User, error) {
|
||||
u, err := r.userResource.Read(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := model.User(u.(user))
|
||||
return &res, nil
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"id": id})
|
||||
var res model.User
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *userRepository) GetAll(options ...model.QueryOptions) (model.Users, error) {
|
||||
sel := r.newSelect(options...).Columns("*")
|
||||
res := model.Users{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *userRepository) Put(u *model.User) error {
|
||||
tu := user(*u)
|
||||
c, err := r.CountAll()
|
||||
if u.ID == "" {
|
||||
id, _ := uuid.NewRandom()
|
||||
u.ID = id.String()
|
||||
}
|
||||
u.UserName = strings.ToLower(u.UserName)
|
||||
u.UpdatedAt = time.Now()
|
||||
values, _ := toSqlArgs(*u)
|
||||
update := Update(r.tableName).Where(Eq{"id": u.ID}).SetMap(values)
|
||||
count, err := r.executeSQL(update)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c == 0 {
|
||||
_, err = r.userResource.Save(&tu)
|
||||
return err
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
return r.userResource.Update(&tu, "user_name", "is_admin", "password")
|
||||
values["created_at"] = time.Now()
|
||||
insert := Insert(r.tableName).SetMap(values)
|
||||
_, err = r.executeSQL(insert)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *userRepository) FindByUsername(username string) (*model.User, error) {
|
||||
tu := user{}
|
||||
err := r.ormer.QueryTable(user{}).Filter("user_name__iexact", username).One(&tu)
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u := model.User(tu)
|
||||
return &u, err
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"user_name": username})
|
||||
var usr model.User
|
||||
err := r.queryOne(sel, &usr)
|
||||
return &usr, err
|
||||
}
|
||||
|
||||
func (r *userRepository) UpdateLastLoginAt(id string) error {
|
||||
now := time.Now()
|
||||
tu := user{ID: id, LastLoginAt: &now}
|
||||
_, err := r.ormer.Update(&tu, "last_login_at")
|
||||
upd := Update(r.tableName).Where(Eq{"id": id}).Set("last_login_at", time.Now())
|
||||
_, err := r.executeSQL(upd)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *userRepository) UpdateLastAccessAt(id string) error {
|
||||
now := time.Now()
|
||||
tu := user{ID: id, LastAccessAt: &now}
|
||||
_, err := r.ormer.Update(&tu, "last_access_at")
|
||||
upd := Update(r.tableName).Where(Eq{"id": id}).Set("last_access_at", now)
|
||||
_, err := r.executeSQL(upd)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *userRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin {
|
||||
return 0, rest.ErrPermissionDenied
|
||||
}
|
||||
return r.CountAll(r.parseRestOptions(options...))
|
||||
}
|
||||
|
||||
func (r *userRepository) Read(id string) (interface{}, error) {
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin && usr.ID != id {
|
||||
return nil, rest.ErrPermissionDenied
|
||||
}
|
||||
usr, err := r.Get(id)
|
||||
if err == model.ErrNotFound {
|
||||
return nil, rest.ErrNotFound
|
||||
}
|
||||
return usr, err
|
||||
}
|
||||
|
||||
func (r *userRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin {
|
||||
return nil, rest.ErrPermissionDenied
|
||||
}
|
||||
return r.GetAll(r.parseRestOptions(options...))
|
||||
}
|
||||
|
||||
func (r *userRepository) EntityName() string {
|
||||
return "user"
|
||||
}
|
||||
|
||||
func (r *userRepository) NewInstance() interface{} {
|
||||
return &model.User{}
|
||||
}
|
||||
|
||||
func (r *userRepository) Save(entity interface{}) (string, error) {
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin {
|
||||
return "", rest.ErrPermissionDenied
|
||||
}
|
||||
u := entity.(*model.User)
|
||||
err := r.Put(u)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.ID, err
|
||||
}
|
||||
|
||||
func (r *userRepository) Update(entity interface{}, cols ...string) error {
|
||||
u := entity.(*model.User)
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin && usr.ID != u.ID {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
err := r.Put(u)
|
||||
if err == model.ErrNotFound {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *userRepository) Delete(id string) error {
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin && usr.ID != id {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
err := r.delete(Eq{"id": id})
|
||||
if err == model.ErrNotFound {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var _ = model.User(user{})
|
||||
var _ model.UserRepository = (*userRepository)(nil)
|
||||
var _ rest.Repository = (*userRepository)(nil)
|
||||
var _ rest.Persistable = (*userRepository)(nil)
|
||||
|
||||
@@ -2,8 +2,7 @@ package scanner
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
@@ -63,7 +62,7 @@ func (s *ChangeDetector) loadDir(dirPath string) (children []string, lastUpdated
|
||||
}
|
||||
for _, f := range files {
|
||||
if f.IsDir() {
|
||||
children = append(children, path.Join(dirPath, f.Name()))
|
||||
children = append(children, filepath.Join(dirPath, f.Name()))
|
||||
} else {
|
||||
if f.ModTime().After(lastUpdated) {
|
||||
lastUpdated = f.ModTime()
|
||||
@@ -73,8 +72,8 @@ func (s *ChangeDetector) loadDir(dirPath string) (children []string, lastUpdated
|
||||
return
|
||||
}
|
||||
|
||||
func (s *ChangeDetector) loadMap(dirMap dirInfoMap, rootPath string, since time.Time, maybe bool) error {
|
||||
children, lastUpdated, err := s.loadDir(rootPath)
|
||||
func (s *ChangeDetector) loadMap(dirMap dirInfoMap, path string, since time.Time, maybe bool) error {
|
||||
children, lastUpdated, err := s.loadDir(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -86,14 +85,14 @@ func (s *ChangeDetector) loadMap(dirMap dirInfoMap, rootPath string, since time.
|
||||
}
|
||||
}
|
||||
|
||||
dir := s.getRelativePath(rootPath)
|
||||
dir := s.getRelativePath(path)
|
||||
dirMap[dir] = dirInfo{mdate: lastUpdated, maybe: maybe}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ChangeDetector) getRelativePath(subfolder string) string {
|
||||
dir := strings.TrimPrefix(subfolder, s.rootFolder)
|
||||
func (s *ChangeDetector) getRelativePath(subFolder string) string {
|
||||
dir, _ := filepath.Rel(s.rootFolder, subFolder)
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
}
|
||||
@@ -111,6 +110,7 @@ func (s *ChangeDetector) checkForUpdates(lastModifiedSince time.Time, newMap dir
|
||||
oldLastUpdated = time.Time{}
|
||||
}
|
||||
}
|
||||
|
||||
if lastUpdated.After(oldLastUpdated) {
|
||||
changed = append(changed, dir)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package scanner
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo"
|
||||
@@ -18,7 +18,7 @@ var _ = Describe("ChangeDetector", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
testFolder, _ = ioutil.TempDir("", "navidrome_tests")
|
||||
err := os.MkdirAll(testFolder, 0700)
|
||||
err := os.MkdirAll(testFolder, 0777)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -33,69 +33,69 @@ var _ = Describe("ChangeDetector", func() {
|
||||
Expect(changed).To(ConsistOf("."))
|
||||
|
||||
// Add one subfolder
|
||||
lastModifiedSince = time.Now()
|
||||
err = os.MkdirAll(path.Join(testFolder, "a"), 0700)
|
||||
lastModifiedSince = nowWithDelay()
|
||||
err = os.MkdirAll(filepath.Join(testFolder, "a"), 0777)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
changed, deleted, err = scanner.Scan(lastModifiedSince)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(deleted).To(BeEmpty())
|
||||
Expect(changed).To(ConsistOf(".", "/a"))
|
||||
Expect(changed).To(ConsistOf(".", P("a")))
|
||||
|
||||
// Add more subfolders
|
||||
lastModifiedSince = time.Now()
|
||||
err = os.MkdirAll(path.Join(testFolder, "a", "b", "c"), 0700)
|
||||
lastModifiedSince = nowWithDelay()
|
||||
err = os.MkdirAll(filepath.Join(testFolder, "a", "b", "c"), 0777)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
changed, deleted, err = scanner.Scan(lastModifiedSince)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(deleted).To(BeEmpty())
|
||||
Expect(changed).To(ConsistOf("/a", "/a/b", "/a/b/c"))
|
||||
Expect(changed).To(ConsistOf(P("a"), P("a/b"), P("a/b/c")))
|
||||
|
||||
// Scan with no changes
|
||||
lastModifiedSince = time.Now()
|
||||
lastModifiedSince = nowWithDelay()
|
||||
changed, deleted, err = scanner.Scan(lastModifiedSince)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(deleted).To(BeEmpty())
|
||||
Expect(changed).To(BeEmpty())
|
||||
|
||||
// New file in subfolder
|
||||
lastModifiedSince = time.Now()
|
||||
_, err = os.Create(path.Join(testFolder, "a", "b", "empty.txt"))
|
||||
lastModifiedSince = nowWithDelay()
|
||||
_, err = os.Create(filepath.Join(testFolder, "a", "b", "empty.txt"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
changed, deleted, err = scanner.Scan(lastModifiedSince)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(deleted).To(BeEmpty())
|
||||
Expect(changed).To(ConsistOf("/a/b"))
|
||||
Expect(changed).To(ConsistOf(P("a/b")))
|
||||
|
||||
// Delete file in subfolder
|
||||
lastModifiedSince = time.Now()
|
||||
err = os.Remove(path.Join(testFolder, "a", "b", "empty.txt"))
|
||||
lastModifiedSince = nowWithDelay()
|
||||
err = os.Remove(filepath.Join(testFolder, "a", "b", "empty.txt"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
changed, deleted, err = scanner.Scan(lastModifiedSince)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(deleted).To(BeEmpty())
|
||||
Expect(changed).To(ConsistOf("/a/b"))
|
||||
Expect(changed).To(ConsistOf(P("a/b")))
|
||||
|
||||
// Delete subfolder
|
||||
lastModifiedSince = time.Now()
|
||||
err = os.Remove(path.Join(testFolder, "a", "b", "c"))
|
||||
lastModifiedSince = nowWithDelay()
|
||||
err = os.Remove(filepath.Join(testFolder, "a", "b", "c"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
changed, deleted, err = scanner.Scan(lastModifiedSince)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(deleted).To(ConsistOf("/a/b/c"))
|
||||
Expect(changed).To(ConsistOf("/a/b"))
|
||||
Expect(deleted).To(ConsistOf(P("a/b/c")))
|
||||
Expect(changed).To(ConsistOf(P("a/b")))
|
||||
|
||||
// Only returns changes after lastModifiedSince
|
||||
lastModifiedSince = time.Now()
|
||||
lastModifiedSince = nowWithDelay()
|
||||
newScanner := NewChangeDetector(testFolder)
|
||||
changed, deleted, err = newScanner.Scan(lastModifiedSince)
|
||||
Expect(err).To(BeNil())
|
||||
@@ -103,11 +103,18 @@ var _ = Describe("ChangeDetector", func() {
|
||||
Expect(changed).To(BeEmpty())
|
||||
Expect(changed).To(BeEmpty())
|
||||
|
||||
f, err := os.Create(path.Join(testFolder, "a", "b", "new.txt"))
|
||||
f, err := os.Create(filepath.Join(testFolder, "a", "b", "new.txt"))
|
||||
f.Close()
|
||||
changed, deleted, err = newScanner.Scan(lastModifiedSince)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(deleted).To(BeEmpty())
|
||||
Expect(changed).To(ConsistOf("/a/b"))
|
||||
Expect(changed).To(ConsistOf(P("a/b")))
|
||||
})
|
||||
})
|
||||
|
||||
// I hate time-based tests....
|
||||
func nowWithDelay() time.Time {
|
||||
now := time.Now()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
return now
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -29,7 +30,7 @@ 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.parseInt("year") }
|
||||
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" }
|
||||
@@ -56,7 +57,7 @@ func ExtractAllMetadata(dirPath string) (map[string]*Metadata, error) {
|
||||
if f.IsDir() {
|
||||
continue
|
||||
}
|
||||
filePath := path.Join(dirPath, f.Name())
|
||||
filePath := filepath.Join(dirPath, f.Name())
|
||||
extension := path.Ext(filePath)
|
||||
if !isAudioFile(extension) {
|
||||
continue
|
||||
@@ -73,7 +74,7 @@ func ExtractAllMetadata(dirPath string) (map[string]*Metadata, error) {
|
||||
func probe(inputs []string) (map[string]*Metadata, error) {
|
||||
cmdLine, args := createProbeCommand(inputs)
|
||||
|
||||
log.Trace("Executing command", "cmdLine", cmdLine, "args", args)
|
||||
log.Trace("Executing command", "arg0", cmdLine, "args", args)
|
||||
cmd := exec.Command(cmdLine, args...)
|
||||
output, _ := cmd.CombinedOutput()
|
||||
mds := map[string]*Metadata{}
|
||||
@@ -83,6 +84,7 @@ func probe(inputs []string) (map[string]*Metadata, error) {
|
||||
infos := parseOutput(string(output))
|
||||
for file, info := range infos {
|
||||
md, err := extractMetadata(file, info)
|
||||
// Skip files with errors
|
||||
if err == nil {
|
||||
mds[file] = md
|
||||
}
|
||||
@@ -94,7 +96,7 @@ var inputRegex = regexp.MustCompile(`(?m)^Input #\d+,.*,\sfrom\s'(.*)'`)
|
||||
|
||||
func parseOutput(output string) map[string]string {
|
||||
split := map[string]string{}
|
||||
all := inputRegex.FindAllStringSubmatchIndex(string(output), -1)
|
||||
all := inputRegex.FindAllStringSubmatchIndex(output, -1)
|
||||
for i, loc := range all {
|
||||
// Filename is the first captured group
|
||||
file := output[loc[2]:loc[3]]
|
||||
@@ -117,9 +119,16 @@ func parseOutput(output string) map[string]string {
|
||||
func extractMetadata(filePath, info string) (*Metadata, error) {
|
||||
m := &Metadata{filePath: filePath, tags: map[string]string{}}
|
||||
m.suffix = strings.ToLower(strings.TrimPrefix(path.Ext(filePath), "."))
|
||||
var err error
|
||||
m.fileInfo, err = os.Stat(filePath)
|
||||
if err != nil {
|
||||
log.Warn("Error stating file. Skipping", "filePath", filePath, err)
|
||||
return nil, errors.New("error stating file")
|
||||
}
|
||||
|
||||
m.parseInfo(info)
|
||||
m.fileInfo, _ = os.Stat(filePath)
|
||||
if len(m.tags) == 0 {
|
||||
log.Trace("Not a media file. Skipping", "filePath", filePath)
|
||||
return nil, errors.New("not a media file")
|
||||
}
|
||||
return m, nil
|
||||
@@ -183,6 +192,30 @@ 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 {
|
||||
if v, ok := m.tags[tagName]; ok {
|
||||
match := dateRegex.FindStringSubmatch(v)
|
||||
if len(match) == 0 {
|
||||
log.Error("Error parsing year from ffmpeg date field. Please report this issue", "file", m.filePath, "date", v)
|
||||
return 0
|
||||
}
|
||||
year, _ := strconv.Atoi(match[1])
|
||||
return year
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m *Metadata) parseTuple(numTag string, totalTag string) (int, int) {
|
||||
if v, ok := m.tags[numTag]; ok {
|
||||
tuple := strings.Split(v, "/")
|
||||
@@ -224,10 +257,15 @@ func createProbeCommand(inputs []string) (string, []string) {
|
||||
|
||||
split := strings.Split(cmd, " ")
|
||||
args := make([]string, 0)
|
||||
first := true
|
||||
for _, s := range split {
|
||||
if s == "%s" {
|
||||
for _, inp := range inputs {
|
||||
args = append(args, "-i", inp)
|
||||
if !first {
|
||||
args = append(args, "-i")
|
||||
}
|
||||
args = append(args, inp)
|
||||
first = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -6,51 +6,54 @@ import (
|
||||
)
|
||||
|
||||
var _ = Describe("Metadata", func() {
|
||||
It("correctly parses metadata from all files in folder", func() {
|
||||
mds, err := ExtractAllMetadata("../tests/fixtures")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(mds).To(HaveLen(3))
|
||||
// TODO Need to mock `ffmpeg`
|
||||
XContext("ExtractAllMetadata", func() {
|
||||
It("correctly parses metadata from all files in folder", func() {
|
||||
mds, err := ExtractAllMetadata("tests/fixtures")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(mds).To(HaveLen(3))
|
||||
|
||||
m := mds["../tests/fixtures/test.mp3"]
|
||||
Expect(m.Title()).To(Equal("Song"))
|
||||
Expect(m.Album()).To(Equal("Album"))
|
||||
Expect(m.Artist()).To(Equal("Artist"))
|
||||
Expect(m.AlbumArtist()).To(Equal("Album Artist"))
|
||||
Expect(m.Composer()).To(Equal("Composer"))
|
||||
Expect(m.Compilation()).To(BeTrue())
|
||||
Expect(m.Genre()).To(Equal("Rock"))
|
||||
Expect(m.Year()).To(Equal(2014))
|
||||
n, t := m.TrackNumber()
|
||||
Expect(n).To(Equal(2))
|
||||
Expect(t).To(Equal(10))
|
||||
n, t = m.DiscNumber()
|
||||
Expect(n).To(Equal(1))
|
||||
Expect(t).To(Equal(2))
|
||||
Expect(m.HasPicture()).To(BeTrue())
|
||||
Expect(m.Duration()).To(Equal(1))
|
||||
Expect(m.BitRate()).To(Equal(476))
|
||||
Expect(m.FilePath()).To(Equal("../tests/fixtures/test.mp3"))
|
||||
Expect(m.Suffix()).To(Equal("mp3"))
|
||||
Expect(m.Size()).To(Equal(60845))
|
||||
m := mds["tests/fixtures/test.mp3"]
|
||||
Expect(m.Title()).To(Equal("Song"))
|
||||
Expect(m.Album()).To(Equal("Album"))
|
||||
Expect(m.Artist()).To(Equal("Artist"))
|
||||
Expect(m.AlbumArtist()).To(Equal("Album Artist"))
|
||||
Expect(m.Composer()).To(Equal("Composer"))
|
||||
Expect(m.Compilation()).To(BeTrue())
|
||||
Expect(m.Genre()).To(Equal("Rock"))
|
||||
Expect(m.Year()).To(Equal(2014))
|
||||
n, t := m.TrackNumber()
|
||||
Expect(n).To(Equal(2))
|
||||
Expect(t).To(Equal(10))
|
||||
n, t = m.DiscNumber()
|
||||
Expect(n).To(Equal(1))
|
||||
Expect(t).To(Equal(2))
|
||||
Expect(m.HasPicture()).To(BeTrue())
|
||||
Expect(m.Duration()).To(Equal(1))
|
||||
Expect(m.BitRate()).To(Equal(476))
|
||||
Expect(m.FilePath()).To(Equal("tests/fixtures/test.mp3"))
|
||||
Expect(m.Suffix()).To(Equal("mp3"))
|
||||
Expect(m.Size()).To(Equal(60845))
|
||||
|
||||
m = mds["../tests/fixtures/test.ogg"]
|
||||
Expect(err).To(BeNil())
|
||||
Expect(m.Title()).To(BeEmpty())
|
||||
Expect(m.HasPicture()).To(BeFalse())
|
||||
Expect(m.Duration()).To(Equal(3))
|
||||
Expect(m.BitRate()).To(Equal(9))
|
||||
Expect(m.Suffix()).To(Equal("ogg"))
|
||||
Expect(m.FilePath()).To(Equal("../tests/fixtures/test.ogg"))
|
||||
Expect(m.Size()).To(Equal(4408))
|
||||
})
|
||||
m = mds["tests/fixtures/test.ogg"]
|
||||
Expect(err).To(BeNil())
|
||||
Expect(m.Title()).To(BeEmpty())
|
||||
Expect(m.HasPicture()).To(BeFalse())
|
||||
Expect(m.Duration()).To(Equal(3))
|
||||
Expect(m.BitRate()).To(Equal(9))
|
||||
Expect(m.Suffix()).To(Equal("ogg"))
|
||||
Expect(m.FilePath()).To(Equal("tests/fixtures/test.ogg"))
|
||||
Expect(m.Size()).To(Equal(4408))
|
||||
})
|
||||
|
||||
It("returns error if path does not exist", func() {
|
||||
_, err := ExtractAllMetadata("./INVALID/PATH")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
It("returns error if path does not exist", func() {
|
||||
_, err := ExtractAllMetadata("./INVALID/PATH")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns empty map if there are no audio files in path", func() {
|
||||
Expect(ExtractAllMetadata(".")).To(BeEmpty())
|
||||
It("returns empty map if there are no audio files in path", func() {
|
||||
Expect(ExtractAllMetadata(".")).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("extractMetadata", func() {
|
||||
@@ -58,22 +61,9 @@ var _ = Describe("Metadata", func() {
|
||||
const outputWithOverlappingTitleTag = `
|
||||
Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3':
|
||||
Metadata:
|
||||
iTunSMPB : 00000000 000002D6 00000216 0000000000CB9F94 02000003 0049D539 00000000 00000000 00000000 00000000 00000000 00000000
|
||||
iTunNORM : 000002FF 0000027E 00000FEF 00000C17 0002E647 00044605 00007F02 00007A92 0000273E 0000273E
|
||||
title : Pablo's Blues
|
||||
artist : Gare Du Nord
|
||||
album : Putumayo Presents Blues Lounge
|
||||
TT1 : Putumayo
|
||||
track : 9/10
|
||||
compilation : 1
|
||||
genre : Blues
|
||||
date : 2004
|
||||
Duration: 00:05:02.63, start: 0.000000, bitrate: 140 kb/s
|
||||
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 128 kb/s
|
||||
Stream #0:1: Video: png, rgb24(pc), 500x478 [SAR 2835:2835 DAR 250:239], 90k tbr, 90k tbn, 90k tbc
|
||||
Metadata:
|
||||
comment : Other`
|
||||
md, _ := extractMetadata("pablo's blues.mp3", outputWithOverlappingTitleTag)
|
||||
Duration: 00:05:02.63, start: 0.000000, bitrate: 140 kb/s`
|
||||
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithOverlappingTitleTag)
|
||||
Expect(md.Compilation()).To(BeTrue())
|
||||
})
|
||||
|
||||
@@ -82,24 +72,11 @@ Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/
|
||||
Input #0, mp3, from 'groovin.mp3':
|
||||
Metadata:
|
||||
title : Groovin' (feat. Daniel Sneijers, Susanne Alt)
|
||||
artist : Bone 40
|
||||
track : 1
|
||||
album : Groovin'
|
||||
album_artist : Bone 40
|
||||
comment : Visit http://bone40.bandcamp.com
|
||||
date : 2016
|
||||
Duration: 00:03:34.28, start: 0.025056, bitrate: 323 kb/s
|
||||
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 320 kb/s
|
||||
Metadata:
|
||||
encoder : LAME3.99r
|
||||
Side data:
|
||||
replaygain: track gain - -6.000000, track peak - unknown, album gain - unknown, album peak - unknown,
|
||||
Stream #0:1: Video: mjpeg, yuvj444p(pc, bt470bg/unknown/unknown), 700x700 [SAR 72:72 DAR 1:1], 90k tbr, 90k tbn, 90k tbc
|
||||
Metadata:
|
||||
title : cover
|
||||
comment : Cover (front)
|
||||
At least one output file must be specified`
|
||||
md, _ := extractMetadata("groovin.mp3", outputWithOverlappingTitleTag)
|
||||
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithOverlappingTitleTag)
|
||||
Expect(md.Title()).To(Equal("Groovin' (feat. Daniel Sneijers, Susanne Alt)"))
|
||||
})
|
||||
|
||||
@@ -108,25 +85,15 @@ At least one output file must be specified`
|
||||
Input #0, flac, from '/Users/deluan/Downloads/06. Back In Black.flac':
|
||||
Metadata:
|
||||
ALBUM : Back In Black
|
||||
album_artist : AC/DC
|
||||
ARTIST : AC/DC
|
||||
COMPOSER : Angus Young;Malcolm Young;Brian Johnson
|
||||
DATE : 1980.07.25
|
||||
disc : 1
|
||||
GENRE : Hard Rock
|
||||
LANGUAGE : EN
|
||||
RATING : 2
|
||||
TITLE : Back In Black
|
||||
DISCTOTAL : 1
|
||||
TRACKTOTAL : 10
|
||||
track : 6
|
||||
REPLAYGAIN_TRACK_GAIN: -8.51 dB
|
||||
REPLAYGAIN_TRACK_PEAK: 0.998322
|
||||
Duration: 00:04:16.00, start: 0.000000, bitrate: 995 kb/s
|
||||
Stream #0:0: Audio: flac, 44100 Hz, stereo, s16
|
||||
Side data:
|
||||
replaygain: track gain - -8.510000, track peak - 0.000023, album gain - unknown, album peak - unknown,`
|
||||
md, _ := extractMetadata("groovin.mp3", outputWithOverlappingTitleTag)
|
||||
Duration: 00:04:16.00, start: 0.000000, bitrate: 995 kb/s`
|
||||
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithOverlappingTitleTag)
|
||||
Expect(md.Title()).To(Equal("Back In Black"))
|
||||
Expect(md.Album()).To(Equal("Back In Black"))
|
||||
Expect(md.Genre()).To(Equal("Hard Rock"))
|
||||
@@ -136,7 +103,7 @@ Input #0, flac, from '/Users/deluan/Downloads/06. Back In Black.flac':
|
||||
n, t = md.DiscNumber()
|
||||
Expect(n).To(Equal(1))
|
||||
Expect(t).To(Equal(1))
|
||||
|
||||
Expect(md.Year()).To(Equal(1980))
|
||||
})
|
||||
|
||||
// TODO Handle multiline tags
|
||||
@@ -144,14 +111,6 @@ Input #0, flac, from '/Users/deluan/Downloads/06. Back In Black.flac':
|
||||
const outputWithMultilineComment = `
|
||||
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'modulo.m4a':
|
||||
Metadata:
|
||||
major_brand : mp42
|
||||
minor_version : 0
|
||||
compatible_brands: M4A mp42isom
|
||||
creation_time : 2014-05-10T21:11:57.000000Z
|
||||
iTunSMPB : 00000000 00000920 000000E0 00000000021CA200 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
|
||||
encoder : Nero AAC codec / 1.5.4.0
|
||||
title : Módulo Especial
|
||||
artist : Saara Saara
|
||||
comment : https://www.mixcloud.com/codigorock/30-minutos-com-saara-saara/
|
||||
:
|
||||
: Tracklist:
|
||||
@@ -164,18 +123,7 @@ Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'modulo.m4a':
|
||||
: 06. Doktor Fritz
|
||||
: 07. Wunderbar
|
||||
: 08. Quarta Dimensão
|
||||
album : Módulo Especial
|
||||
genre : Electronic
|
||||
track : 1
|
||||
Duration: 00:26:46.96, start: 0.052971, bitrate: 69 kb/s
|
||||
Chapter #0:0: start 0.105941, end 1607.013149
|
||||
Metadata:
|
||||
title :
|
||||
Stream #0:0(und): Audio: aac (HE-AAC) (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 69 kb/s (default)
|
||||
Metadata:
|
||||
creation_time : 2014-05-10T21:11:57.000000Z
|
||||
handler_name : Sound Media Handler
|
||||
At least one output file must be specified`
|
||||
Duration: 00:26:46.96, start: 0.052971, bitrate: 69 kb/s`
|
||||
const expectedComment = `https://www.mixcloud.com/codigorock/30-minutos-com-saara-saara/
|
||||
|
||||
Tracklist:
|
||||
@@ -189,8 +137,31 @@ Tracklist:
|
||||
07. Wunderbar
|
||||
08. Quarta Dimensão
|
||||
`
|
||||
md, _ := extractMetadata("modulo.mp3", outputWithMultilineComment)
|
||||
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithMultilineComment)
|
||||
Expect(md.Comment()).To(Equal(expectedComment))
|
||||
})
|
||||
})
|
||||
|
||||
Context("parseYear", func() {
|
||||
It("parses the year correctly", func() {
|
||||
var examples = map[string]int{
|
||||
"1985": 1985,
|
||||
"2002-01": 2002,
|
||||
"1969.06": 1969,
|
||||
"1980.07.25": 1980,
|
||||
"2004-00-00": 2004,
|
||||
"2013-May-12": 2013,
|
||||
"May 12, 2016": 0,
|
||||
}
|
||||
for tag, expected := range examples {
|
||||
md := &Metadata{tags: map[string]string{"year": tag}}
|
||||
Expect(md.Year()).To(Equal(expected))
|
||||
}
|
||||
})
|
||||
|
||||
It("returns 0 if year is invalid", func() {
|
||||
md := &Metadata{tags: map[string]string{"year": "invalid"}}
|
||||
Expect(md.Year()).To(Equal(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,13 +34,12 @@ func (s *Scanner) Rescan(mediaFolder string, fullRescan bool) error {
|
||||
log.Debug("Scanning folder (full scan)", "folder", mediaFolder)
|
||||
}
|
||||
|
||||
err := folderScanner.Scan(nil, lastModifiedSince)
|
||||
err := folderScanner.Scan(log.NewContext(nil), lastModifiedSince)
|
||||
if err != nil {
|
||||
log.Error("Error importing MediaFolder", "folder", mediaFolder, err)
|
||||
}
|
||||
|
||||
s.updateLastModifiedSince(mediaFolder, start)
|
||||
log.Debug("Finished scanning folder", "folder", mediaFolder, "elapsed", time.Since(start))
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// TODO Fix OS dependencies
|
||||
func xTestScanner(t *testing.T) {
|
||||
func TestScanner(t *testing.T) {
|
||||
tests.Init(t, true)
|
||||
log.SetLevel(log.LevelCritical)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Scanner Suite")
|
||||
}
|
||||
|
||||
func P(path string) string {
|
||||
return filepath.FromSlash(path)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -40,16 +39,23 @@ func NewTagScanner(rootFolder string, ds model.DataStore) *TagScanner {
|
||||
// refresh the collected albums and artists with the metadata from the mediafiles
|
||||
// Delete all empty albums, delete all empty Artists
|
||||
func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time) error {
|
||||
start := time.Now()
|
||||
changed, deleted, err := s.detector.Scan(lastModifiedSince)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(changed)+len(deleted) == 0 {
|
||||
log.Debug(ctx, "No changes found in Music Folder", "folder", s.rootFolder)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Info("Folder changes found", "changed", len(changed), "deleted", len(deleted))
|
||||
if log.CurrentLevel() >= log.LevelTrace {
|
||||
log.Info(ctx, "Folder changes found", "numChanged", len(changed), "numDeleted", len(deleted),
|
||||
"changed", strings.Join(changed, ";"), "deleted", strings.Join(deleted, ";"))
|
||||
} else {
|
||||
log.Info(ctx, "Folder changes found", "numChanged", len(changed), "numDeleted", len(deleted))
|
||||
}
|
||||
|
||||
sort.Strings(changed)
|
||||
sort.Strings(deleted)
|
||||
@@ -104,17 +110,10 @@ func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time) erro
|
||||
return err
|
||||
}
|
||||
|
||||
err = s.ds.Album(ctx).PurgeEmpty()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.ds.GC(log.NewContext(nil))
|
||||
log.Info("Finished Music Folder", "folder", s.rootFolder, "elapsed", time.Since(start))
|
||||
|
||||
err = s.ds.Artist(ctx).PurgeEmpty()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TagScanner) refreshAlbums(ctx context.Context, updatedAlbums map[string]bool) error {
|
||||
@@ -134,8 +133,7 @@ func (s *TagScanner) refreshArtists(ctx context.Context, updatedArtists map[stri
|
||||
}
|
||||
|
||||
func (s *TagScanner) processChangedDir(ctx context.Context, dir string, updatedArtists map[string]bool, updatedAlbums map[string]bool) error {
|
||||
dir = path.Join(s.rootFolder, dir)
|
||||
|
||||
dir = filepath.Join(s.rootFolder, dir)
|
||||
start := time.Now()
|
||||
|
||||
// Load folder's current tracks from DB into a map
|
||||
@@ -187,12 +185,13 @@ func (s *TagScanner) processChangedDir(ctx context.Context, dir string, updatedA
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug("Finished processing changed folder", "dir", dir, "updated", numUpdatedTracks, "purged", numPurgedTracks, "elapsed", time.Since(start))
|
||||
log.Info("Finished processing changed folder", "dir", dir, "updated", numUpdatedTracks, "purged", numPurgedTracks, "elapsed", time.Since(start))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TagScanner) processDeletedDir(ctx context.Context, dir string, updatedArtists map[string]bool, updatedAlbums map[string]bool) error {
|
||||
dir = path.Join(s.rootFolder, dir)
|
||||
dir = filepath.Join(s.rootFolder, dir)
|
||||
start := time.Now()
|
||||
|
||||
ct, err := s.ds.MediaFile(ctx).FindByPath(dir)
|
||||
if err != nil {
|
||||
@@ -203,6 +202,7 @@ func (s *TagScanner) processDeletedDir(ctx context.Context, dir string, updatedA
|
||||
updatedAlbums[t.AlbumID] = true
|
||||
}
|
||||
|
||||
log.Info("Finished processing deleted folder", "dir", dir, "deleted", len(ct), "elapsed", time.Since(start))
|
||||
return s.ds.MediaFile(ctx).DeleteByPath(dir)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,19 +7,13 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/assets"
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/engine/auth"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/jwtauth"
|
||||
)
|
||||
|
||||
var initialUser = model.User{
|
||||
UserName: "admin",
|
||||
Name: "Admin",
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
ds model.DataStore
|
||||
mux http.Handler
|
||||
@@ -39,24 +33,23 @@ func (app *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
func (app *Router) routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Basic unauthenticated ping
|
||||
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"response":"pong"}`)) })
|
||||
|
||||
r.Post("/login", Login(app.ds))
|
||||
r.Post("/createAdmin", CreateAdmin(app.ds))
|
||||
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
if !conf.Server.DevDisableAuthentication {
|
||||
r.Use(jwtauth.Verifier(TokenAuth))
|
||||
r.Use(Authenticator(app.ds))
|
||||
}
|
||||
r.Use(jwtauth.Verifier(auth.TokenAuth))
|
||||
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{})
|
||||
|
||||
// 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())))
|
||||
|
||||
return r
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/engine/auth"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/rest"
|
||||
@@ -20,13 +21,11 @@ import (
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
jwtSecret []byte
|
||||
TokenAuth *jwtauth.JWTAuth
|
||||
ErrFirstTime = errors.New("no users created")
|
||||
)
|
||||
|
||||
func Login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) {
|
||||
initTokenAuth(ds)
|
||||
auth.InitTokenAuth(ds)
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, err := getCredentialsFromBody(r)
|
||||
@@ -52,7 +51,7 @@ func handleLogin(ds model.DataStore, username string, password string, w http.Re
|
||||
return
|
||||
}
|
||||
|
||||
tokenString, err := createToken(user)
|
||||
tokenString, err := auth.CreateToken(user)
|
||||
if err != nil {
|
||||
rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authenticating user. Please try again")
|
||||
return
|
||||
@@ -63,6 +62,8 @@ func handleLogin(ds model.DataStore, username string, password string, w http.Re
|
||||
"token": tokenString,
|
||||
"name": user.Name,
|
||||
"username": username,
|
||||
"isAdmin": user.IsAdmin,
|
||||
"version": consts.Version(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -80,7 +81,7 @@ func getCredentialsFromBody(r *http.Request) (username string, password string,
|
||||
}
|
||||
|
||||
func CreateAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) {
|
||||
initTokenAuth(ds)
|
||||
auth.InitTokenAuth(ds)
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, err := getCredentialsFromBody(r)
|
||||
@@ -109,14 +110,16 @@ func CreateAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request
|
||||
|
||||
func createDefaultUser(ctx context.Context, ds model.DataStore, username, password string) error {
|
||||
id, _ := uuid.NewRandom()
|
||||
log.Warn("Creating initial user", "user", consts.InitialUserName)
|
||||
log.Warn("Creating initial user", "user", username)
|
||||
now := time.Now()
|
||||
initialUser := model.User{
|
||||
ID: id.String(),
|
||||
UserName: username,
|
||||
Name: strings.Title(username),
|
||||
Email: "",
|
||||
Password: password,
|
||||
IsAdmin: true,
|
||||
ID: id.String(),
|
||||
UserName: username,
|
||||
Name: strings.Title(username),
|
||||
Email: "",
|
||||
Password: password,
|
||||
IsAdmin: true,
|
||||
LastLoginAt: &now,
|
||||
}
|
||||
err := ds.User(ctx).Put(&initialUser)
|
||||
if err != nil {
|
||||
@@ -125,16 +128,6 @@ func createDefaultUser(ctx context.Context, ds model.DataStore, username, passwo
|
||||
return nil
|
||||
}
|
||||
|
||||
func initTokenAuth(ds model.DataStore) {
|
||||
once.Do(func() {
|
||||
secret, err := ds.Property(nil).DefaultGet(consts.JWTSecretKey, "not so secret")
|
||||
if err != nil {
|
||||
log.Error("No JWT secret found in DB. Setting a temp one, but please report this error", err)
|
||||
}
|
||||
jwtSecret = []byte(secret)
|
||||
TokenAuth = jwtauth.New("HS256", jwtSecret, nil)
|
||||
})
|
||||
}
|
||||
func validateLogin(userRepo model.UserRepository, userName, password string) (*model.User, error) {
|
||||
u, err := userRepo.FindByUsername(userName)
|
||||
if err == model.ErrNotFound {
|
||||
@@ -146,10 +139,6 @@ func validateLogin(userRepo model.UserRepository, userName, password string) (*m
|
||||
if u.Password != password {
|
||||
return nil, nil
|
||||
}
|
||||
if !u.IsAdmin {
|
||||
log.Warn("Non-admin user tried to login", "user", userName)
|
||||
return nil, nil
|
||||
}
|
||||
err = userRepo.UpdateLastLoginAt(u.ID)
|
||||
if err != nil {
|
||||
log.Error("Could not update LastLoginAt", "user", userName)
|
||||
@@ -157,28 +146,10 @@ func validateLogin(userRepo model.UserRepository, userName, password string) (*m
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func createToken(u *model.User) (string, error) {
|
||||
token := jwt.New(jwt.SigningMethodHS256)
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims["iss"] = consts.JWTIssuer
|
||||
claims["sub"] = u.UserName
|
||||
|
||||
return touchToken(token)
|
||||
}
|
||||
|
||||
func touchToken(token *jwt.Token) (string, error) {
|
||||
expireIn := time.Now().Add(consts.JWTTokenExpiration).Unix()
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims["exp"] = expireIn
|
||||
|
||||
return token.SignedString(jwtSecret)
|
||||
}
|
||||
|
||||
func userFrom(claims jwt.MapClaims) *model.User {
|
||||
user := &model.User{
|
||||
UserName: claims["sub"].(string),
|
||||
}
|
||||
return user
|
||||
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)
|
||||
}
|
||||
|
||||
func getToken(ds model.DataStore, ctx context.Context) (*jwt.Token, error) {
|
||||
@@ -199,7 +170,7 @@ func getToken(ds model.DataStore, ctx context.Context) (*jwt.Token, error) {
|
||||
}
|
||||
|
||||
func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
initTokenAuth(ds)
|
||||
auth.InitTokenAuth(ds)
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -215,8 +186,8 @@ func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
|
||||
newCtx := context.WithValue(r.Context(), "loggedUser", userFrom(claims))
|
||||
newTokenString, err := touchToken(token)
|
||||
newCtx := contextWithUser(r.Context(), ds, claims)
|
||||
newTokenString, err := auth.TouchToken(token)
|
||||
if err != nil {
|
||||
log.Error(r, "signing new token", err)
|
||||
rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
|
||||
|
||||
43
server/app/serve_index.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
|
||||
"github.com/deluan/navidrome/assets"
|
||||
"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 {
|
||||
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))
|
||||
appConfig := map[string]interface{}{
|
||||
"firstTime": firstTime,
|
||||
}
|
||||
j, _ := json.Marshal(appConfig)
|
||||
data := map[string]interface{}{
|
||||
"AppConfig": string(j),
|
||||
}
|
||||
err = t.Execute(w, data)
|
||||
if err != nil {
|
||||
log.Error(r, "Could not execute `index.html` template", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
@@ -20,11 +23,47 @@ func initialSetup(ds model.DataStore) {
|
||||
return err
|
||||
}
|
||||
|
||||
if conf.Server.DevAutoCreateAdminPassword != "" {
|
||||
if err = createInitialAdminUser(ds); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = ds.Property(nil).Put(consts.InitialSetupFlagKey, time.Now().String())
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func createInitialAdminUser(ds model.DataStore) error {
|
||||
ctx := context.Background()
|
||||
c, err := ds.User(ctx).CountAll()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Could not access User table: %s", err))
|
||||
}
|
||||
if c == 0 {
|
||||
id, _ := uuid.NewRandom()
|
||||
random, _ := uuid.NewRandom()
|
||||
initialPassword := random.String()
|
||||
if conf.Server.DevAutoCreateAdminPassword != "" {
|
||||
initialPassword = conf.Server.DevAutoCreateAdminPassword
|
||||
}
|
||||
log.Warn("Creating initial admin user. This should only be used for development purposes!!", "user", consts.DevInitialUserName, "password", initialPassword)
|
||||
initialUser := model.User{
|
||||
ID: id.String(),
|
||||
UserName: consts.DevInitialUserName,
|
||||
Name: consts.DevInitialName,
|
||||
Email: "",
|
||||
Password: initialPassword,
|
||||
IsAdmin: true,
|
||||
}
|
||||
err := ds.User(ctx).Put(&initialUser)
|
||||
if err != nil {
|
||||
log.Error("Could not create initial admin user", "user", initialUser, err)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func createJWTSecret(ds model.DataStore) error {
|
||||
_, err := ds.Property(nil).Get(consts.JWTSecretKey)
|
||||
if err == nil {
|
||||
|
||||
50
server/middlewares.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
)
|
||||
|
||||
func RequestLogger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
next.ServeHTTP(ww, r)
|
||||
status := ww.Status()
|
||||
|
||||
message := fmt.Sprintf("HTTP: %s %s://%s%s", r.Method, scheme, r.Host, r.RequestURI)
|
||||
logArgs := []interface{}{
|
||||
r.Context(),
|
||||
message,
|
||||
"remoteAddr", r.RemoteAddr,
|
||||
"lapsedTime", time.Since(start),
|
||||
"httpStatus", ww.Status(),
|
||||
"responseSize", ww.BytesWritten(),
|
||||
}
|
||||
if log.CurrentLevel() >= log.LevelDebug {
|
||||
logArgs = append(logArgs, "userAgent", r.UserAgent())
|
||||
}
|
||||
|
||||
switch {
|
||||
case status >= 500:
|
||||
log.Error(logArgs...)
|
||||
case status >= 400:
|
||||
log.Warn(logArgs...)
|
||||
default:
|
||||
if log.CurrentLevel() >= log.LevelDebug {
|
||||
log.Debug(logArgs...)
|
||||
} else {
|
||||
log.Info(logArgs...)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -23,7 +23,6 @@ type Server struct {
|
||||
|
||||
func New(scanner *scanner.Scanner, ds model.DataStore) *Server {
|
||||
a := &Server{Scanner: scanner, ds: ds}
|
||||
initMimeTypes()
|
||||
initialSetup(ds)
|
||||
a.initRoutes()
|
||||
a.initScanner()
|
||||
@@ -33,7 +32,7 @@ func New(scanner *scanner.Scanner, ds model.DataStore) *Server {
|
||||
func (a *Server) MountRouter(path string, subRouter http.Handler) {
|
||||
log.Info("Mounting routes", "path", path)
|
||||
a.router.Group(func(r chi.Router) {
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(RequestLogger)
|
||||
r.Mount(path, subRouter)
|
||||
})
|
||||
}
|
||||
@@ -67,8 +66,12 @@ func (a *Server) initRoutes() {
|
||||
|
||||
func (a *Server) initScanner() {
|
||||
interval, err := time.ParseDuration(conf.Server.ScanInterval)
|
||||
if interval == 0 {
|
||||
log.Info("Scanner is disabled", "interval", conf.Server.ScanInterval, err)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("Invalid interval specification. Using default of 5m", "conf", conf.Server.ScanInterval, err)
|
||||
log.Error("Invalid interval specification. Using default of 5m", "interval", conf.Server.ScanInterval, err)
|
||||
interval = 5 * time.Minute
|
||||
} else {
|
||||
log.Info("Starting scanner", "interval", interval.String())
|
||||
|
||||
@@ -47,8 +47,8 @@ func (c *AlbumListController) getAlbumList(r *http.Request) (engine.Entries, err
|
||||
return nil, errors.New("Not implemented!")
|
||||
}
|
||||
|
||||
offset := ParamInt(r, "offset", 0)
|
||||
size := utils.MinInt(ParamInt(r, "size", 10), 500)
|
||||
offset := utils.ParamInt(r, "offset", 0)
|
||||
size := utils.MinInt(utils.ParamInt(r, "size", 10), 500)
|
||||
|
||||
albums, err := listFunc(r.Context(), offset, size)
|
||||
if err != nil {
|
||||
@@ -132,8 +132,8 @@ func (c *AlbumListController) GetNowPlaying(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
func (c *AlbumListController) GetRandomSongs(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
size := utils.MinInt(ParamInt(r, "size", 10), 500)
|
||||
genre := ParamString(r, "genre")
|
||||
size := utils.MinInt(utils.ParamInt(r, "size", 10), 500)
|
||||
genre := utils.ParamString(r, "genre")
|
||||
|
||||
songs, err := c.listGen.GetRandomSongs(r.Context(), size, genre)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,9 +6,10 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/engine"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/server/subsonic/responses"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
"github.com/go-chi/chi"
|
||||
)
|
||||
|
||||
@@ -25,15 +26,17 @@ type Router struct {
|
||||
Scrobbler engine.Scrobbler
|
||||
Search engine.Search
|
||||
Users engine.Users
|
||||
Streamer engine.MediaStreamer
|
||||
|
||||
mux http.Handler
|
||||
}
|
||||
|
||||
func New(browser engine.Browser, cover engine.Cover, listGenerator engine.ListGenerator, users engine.Users,
|
||||
playlists engine.Playlists, ratings engine.Ratings, scrobbler engine.Scrobbler, search engine.Search) *Router {
|
||||
playlists engine.Playlists, ratings engine.Ratings, scrobbler engine.Scrobbler, search engine.Search,
|
||||
streamer engine.MediaStreamer) *Router {
|
||||
|
||||
r := &Router{Browser: browser, Cover: cover, ListGenerator: listGenerator, Playlists: playlists,
|
||||
Ratings: ratings, Scrobbler: scrobbler, Search: search, Users: users}
|
||||
Ratings: ratings, Scrobbler: scrobbler, Search: search, Users: users, Streamer: streamer}
|
||||
r.mux = r.routes()
|
||||
return r
|
||||
}
|
||||
@@ -48,11 +51,9 @@ func (api *Router) routes() http.Handler {
|
||||
r.Use(postFormToQueryParams)
|
||||
r.Use(checkRequiredParameters)
|
||||
|
||||
// Add validation middleware if not disabled
|
||||
if !conf.Server.DevDisableAuthentication {
|
||||
r.Use(authenticate(api.Users))
|
||||
// TODO Validate version
|
||||
}
|
||||
// Add validation middleware
|
||||
r.Use(authenticate(api.Users))
|
||||
// TODO Validate version
|
||||
|
||||
// Subsonic endpoints, grouped by controller
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -151,18 +152,19 @@ func HGone(r chi.Router, path string) {
|
||||
}
|
||||
|
||||
func SendError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
response := &responses.Subsonic{Version: Version, Status: "fail"}
|
||||
response := NewResponse()
|
||||
code := responses.ErrorGeneric
|
||||
if e, ok := err.(SubsonicError); ok {
|
||||
code = e.code
|
||||
}
|
||||
response.Status = "fail"
|
||||
response.Error = &responses.Error{Code: code, Message: err.Error()}
|
||||
|
||||
SendResponse(w, r, response)
|
||||
}
|
||||
|
||||
func SendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Subsonic) {
|
||||
f := ParamString(r, "f")
|
||||
f := utils.ParamString(r, "f")
|
||||
var response []byte
|
||||
switch f {
|
||||
case "json":
|
||||
@@ -171,7 +173,7 @@ func SendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub
|
||||
response, _ = json.Marshal(wrapper)
|
||||
case "jsonp":
|
||||
w.Header().Set("Content-Type", "application/javascript")
|
||||
callback := ParamString(r, "callback")
|
||||
callback := utils.ParamString(r, "callback")
|
||||
wrapper := &responses.JsonWrapper{Subsonic: *payload}
|
||||
data, _ := json.Marshal(wrapper)
|
||||
response = []byte(fmt.Sprintf("%s(%s)", callback, data))
|
||||
@@ -179,5 +181,14 @@ func SendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
response, _ = xml.Marshal(payload)
|
||||
}
|
||||
if payload.Status == "ok" {
|
||||
if log.CurrentLevel() >= log.LevelTrace {
|
||||
log.Info(r.Context(), "API: Successful response", "status", "OK", "body", string(response))
|
||||
} else {
|
||||
log.Info(r.Context(), "API: Successful response", "status", "OK")
|
||||
}
|
||||
} else {
|
||||
log.Warn(r.Context(), "API: Failed response", "error", payload.Error.Code, "message", payload.Error.Message)
|
||||
}
|
||||
w.Write(response)
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@ func (c *BrowsingController) GetMusicFolders(w http.ResponseWriter, r *http.Requ
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *BrowsingController) getArtistIndex(r *http.Request, ifModifiedSince time.Time) (*responses.Indexes, error) {
|
||||
indexes, lastModified, err := c.browser.Indexes(r.Context(), ifModifiedSince)
|
||||
func (c *BrowsingController) getArtistIndex(r *http.Request, musicFolderId string, ifModifiedSince time.Time) (*responses.Indexes, error) {
|
||||
indexes, lastModified, err := c.browser.Indexes(r.Context(), musicFolderId, ifModifiedSince)
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving Indexes", "error", err)
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
@@ -59,9 +59,10 @@ func (c *BrowsingController) getArtistIndex(r *http.Request, ifModifiedSince tim
|
||||
}
|
||||
|
||||
func (c *BrowsingController) GetIndexes(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
ifModifiedSince := ParamTime(r, "ifModifiedSince", time.Time{})
|
||||
musicFolderId := utils.ParamString(r, "musicFolderId")
|
||||
ifModifiedSince := utils.ParamTime(r, "ifModifiedSince", time.Time{})
|
||||
|
||||
res, err := c.getArtistIndex(r, ifModifiedSince)
|
||||
res, err := c.getArtistIndex(r, musicFolderId, ifModifiedSince)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -72,7 +73,8 @@ func (c *BrowsingController) GetIndexes(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (c *BrowsingController) GetArtists(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
res, err := c.getArtistIndex(r, time.Time{})
|
||||
musicFolderId := utils.ParamString(r, "musicFolderId")
|
||||
res, err := c.getArtistIndex(r, musicFolderId, time.Time{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -83,7 +85,7 @@ func (c *BrowsingController) GetArtists(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (c *BrowsingController) GetMusicDirectory(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
id := ParamString(r, "id")
|
||||
id := utils.ParamString(r, "id")
|
||||
dir, err := c.browser.Directory(r.Context(), id)
|
||||
switch {
|
||||
case err == model.ErrNotFound:
|
||||
@@ -100,7 +102,7 @@ func (c *BrowsingController) GetMusicDirectory(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
func (c *BrowsingController) GetArtist(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
id := ParamString(r, "id")
|
||||
id := utils.ParamString(r, "id")
|
||||
dir, err := c.browser.Artist(r.Context(), id)
|
||||
switch {
|
||||
case err == model.ErrNotFound:
|
||||
@@ -117,7 +119,7 @@ func (c *BrowsingController) GetArtist(w http.ResponseWriter, r *http.Request) (
|
||||
}
|
||||
|
||||
func (c *BrowsingController) GetAlbum(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
id := ParamString(r, "id")
|
||||
id := utils.ParamString(r, "id")
|
||||
dir, err := c.browser.Album(r.Context(), id)
|
||||
switch {
|
||||
case err == model.ErrNotFound:
|
||||
@@ -134,7 +136,7 @@ func (c *BrowsingController) GetAlbum(w http.ResponseWriter, r *http.Request) (*
|
||||
}
|
||||
|
||||
func (c *BrowsingController) GetSong(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
id := ParamString(r, "id")
|
||||
id := utils.ParamString(r, "id")
|
||||
song, err := c.browser.GetSong(r.Context(), id)
|
||||
switch {
|
||||
case err == model.ErrNotFound:
|
||||
|
||||
@@ -2,11 +2,11 @@ package subsonic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/engine"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/server/subsonic/responses"
|
||||
@@ -14,11 +14,11 @@ import (
|
||||
)
|
||||
|
||||
func NewResponse() *responses.Subsonic {
|
||||
return &responses.Subsonic{Status: "ok", Version: Version}
|
||||
return &responses.Subsonic{Status: "ok", Version: Version, Type: consts.AppName, ServerVersion: consts.Version()}
|
||||
}
|
||||
|
||||
func RequiredParamString(r *http.Request, param string, msg string) (string, error) {
|
||||
p := ParamString(r, param)
|
||||
p := utils.ParamString(r, param)
|
||||
if p == "" {
|
||||
return "", NewError(responses.ErrorMissingParameter, msg)
|
||||
}
|
||||
@@ -26,83 +26,19 @@ func RequiredParamString(r *http.Request, param string, msg string) (string, err
|
||||
}
|
||||
|
||||
func RequiredParamStrings(r *http.Request, param string, msg string) ([]string, error) {
|
||||
ps := ParamStrings(r, param)
|
||||
ps := utils.ParamStrings(r, param)
|
||||
if len(ps) == 0 {
|
||||
return nil, NewError(responses.ErrorMissingParameter, msg)
|
||||
}
|
||||
return ps, nil
|
||||
}
|
||||
|
||||
func ParamString(r *http.Request, param string) string {
|
||||
return r.URL.Query().Get(param)
|
||||
}
|
||||
|
||||
func ParamStrings(r *http.Request, param string) []string {
|
||||
return r.URL.Query()[param]
|
||||
}
|
||||
|
||||
func ParamTimes(r *http.Request, param string) []time.Time {
|
||||
pStr := ParamStrings(r, param)
|
||||
times := make([]time.Time, len(pStr))
|
||||
for i, t := range pStr {
|
||||
ti, err := strconv.ParseInt(t, 10, 64)
|
||||
if err == nil {
|
||||
times[i] = utils.ToTime(ti)
|
||||
}
|
||||
}
|
||||
return times
|
||||
}
|
||||
|
||||
func ParamTime(r *http.Request, param string, def time.Time) time.Time {
|
||||
v := ParamString(r, param)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
value, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return utils.ToTime(value)
|
||||
}
|
||||
|
||||
func RequiredParamInt(r *http.Request, param string, msg string) (int, error) {
|
||||
p := ParamString(r, param)
|
||||
p := utils.ParamString(r, param)
|
||||
if p == "" {
|
||||
return 0, NewError(responses.ErrorMissingParameter, msg)
|
||||
}
|
||||
return ParamInt(r, param, 0), nil
|
||||
}
|
||||
|
||||
func ParamInt(r *http.Request, param string, def int) int {
|
||||
v := ParamString(r, param)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
value, err := strconv.ParseInt(v, 10, 32)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return int(value)
|
||||
}
|
||||
|
||||
func ParamInts(r *http.Request, param string) []int {
|
||||
pStr := ParamStrings(r, param)
|
||||
ints := make([]int, 0, len(pStr))
|
||||
for _, s := range pStr {
|
||||
i, err := strconv.ParseInt(s, 10, 32)
|
||||
if err == nil {
|
||||
ints = append(ints, int(i))
|
||||
}
|
||||
}
|
||||
return ints
|
||||
}
|
||||
|
||||
func ParamBool(r *http.Request, param string, def bool) bool {
|
||||
p := ParamString(r, param)
|
||||
if p == "" {
|
||||
return def
|
||||
}
|
||||
return strings.Index("/true/on/1/", "/"+p+"/") != -1
|
||||
return utils.ParamInt(r, param, 0), nil
|
||||
}
|
||||
|
||||
type SubsonicError struct {
|
||||
@@ -199,6 +135,9 @@ func ToChild(entry engine.Entry) responses.Child {
|
||||
child.Type = entry.Type
|
||||
child.UserRating = entry.UserRating
|
||||
child.SongCount = entry.SongCount
|
||||
// TODO Must be dynamic, based on player/transcoding config
|
||||
child.TranscodedSuffix = "mp3"
|
||||
child.TranscodedContentType = mime.TypeByExtension(".mp3")
|
||||
return child
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/server/subsonic/responses"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
type MediaAnnotationController struct {
|
||||
@@ -49,9 +50,9 @@ func (c *MediaAnnotationController) SetRating(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
func (c *MediaAnnotationController) Star(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
ids := ParamStrings(r, "id")
|
||||
albumIds := ParamStrings(r, "albumId")
|
||||
artistIds := ParamStrings(r, "artistId")
|
||||
ids := utils.ParamStrings(r, "id")
|
||||
albumIds := utils.ParamStrings(r, "albumId")
|
||||
artistIds := utils.ParamStrings(r, "artistId")
|
||||
if len(ids)+len(albumIds)+len(artistIds) == 0 {
|
||||
return nil, NewError(responses.ErrorMissingParameter, "Required id parameter is missing")
|
||||
}
|
||||
@@ -84,9 +85,9 @@ func (c *MediaAnnotationController) star(ctx context.Context, starred bool, ids
|
||||
}
|
||||
|
||||
func (c *MediaAnnotationController) Unstar(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
ids := ParamStrings(r, "id")
|
||||
albumIds := ParamStrings(r, "albumId")
|
||||
artistIds := ParamStrings(r, "artistId")
|
||||
ids := utils.ParamStrings(r, "id")
|
||||
albumIds := utils.ParamStrings(r, "albumId")
|
||||
artistIds := utils.ParamStrings(r, "artistId")
|
||||
if len(ids)+len(albumIds)+len(artistIds) == 0 {
|
||||
return nil, NewError(responses.ErrorMissingParameter, "Required id parameter is missing")
|
||||
}
|
||||
@@ -106,14 +107,14 @@ func (c *MediaAnnotationController) Scrobble(w http.ResponseWriter, r *http.Requ
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
times := ParamTimes(r, "time")
|
||||
times := utils.ParamTimes(r, "time")
|
||||
if len(times) > 0 && len(times) != len(ids) {
|
||||
return nil, NewError(responses.ErrorGeneric, "Wrong number of timestamps: %d, should be %d", len(times), len(ids))
|
||||
}
|
||||
submission := ParamBool(r, "submission", true)
|
||||
submission := utils.ParamBool(r, "submission", true)
|
||||
playerId := 1 // TODO Multiple players, based on playerName/username/clientIP(?)
|
||||
playerName := ParamString(r, "c")
|
||||
username := ParamString(r, "u")
|
||||
playerName := utils.ParamString(r, "c")
|
||||
username := utils.ParamString(r, "u")
|
||||
|
||||
log.Debug(r, "Scrobbling tracks", "ids", ids, "times", times, "submission", submission)
|
||||
for i, id := range ids {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/server/subsonic/responses"
|
||||
"github.com/deluan/navidrome/static"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
type MediaRetrievalController struct {
|
||||
@@ -36,7 +37,7 @@ func (c *MediaRetrievalController) GetCoverArt(w http.ResponseWriter, r *http.Re
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size := ParamInt(r, "size", 0)
|
||||
size := utils.ParamInt(r, "size", 0)
|
||||
|
||||
err = c.cover.Get(r.Context(), id, size, w)
|
||||
|
||||
|
||||