Compare commits

...

64 Commits

Author SHA1 Message Date
Deluan
0ca849a61a feat: show year range in album view and match ranges in year filter. #118 2020-03-27 21:11:06 -04:00
Deluan
53e8a92fed feat: rename year to max_year and add min_year to album. #118 2020-03-27 21:11:06 -04:00
Deluan
fc650cd127 chore: upgrade to Node 13.11 2020-03-27 19:23:52 -04:00
Deluan
b03519b09c fix: configured transcodings not appearing in players view 2020-03-27 19:12:11 -04:00
Deluan
39b9f818be feat: use ND_PORT env var in health check 2020-03-26 15:26:40 -04:00
Deluan
7febe05ed5 feat: add health check to docker image 2020-03-26 15:15:40 -04:00
Deluan
2c42e4e12e feat: add icons for playlists 2020-03-26 12:33:30 -04:00
Deluan
dcb3b3b5d1 fix: various album_artists <-> artists mismatches 2020-03-26 09:08:53 -04:00
Deluan
5331732236 fix: remove sql injection 2020-03-25 20:40:18 -04:00
Deluan
dc973ae670 refactor: remove unused code 2020-03-25 20:40:18 -04:00
Deluan
100db2bcfd feat: add artist filter to album view 2020-03-25 20:40:18 -04:00
dependabot-preview[bot]
c84a58ff7d build(deps): bump github.com/go-chi/chi
Bumps [github.com/go-chi/chi](https://github.com/go-chi/chi) from 4.0.3+incompatible to 4.0.4+incompatible.
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v4.0.3...v4.0.4)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-03-25 18:12:33 -04:00
Deluan
2d7fda1b2f docs: add default config vars to docker-compose.yml example 2020-03-24 12:34:31 -04:00
Deluan
3cba5f70fd chore: add tests for all utils, removed unused functions 2020-03-24 11:59:10 -04:00
Deluan
b4c7cac964 refactor: moved magic strings to consts 2020-03-24 11:59:10 -04:00
dependabot-preview[bot]
5ef80d2490 build(deps): bump github.com/sirupsen/logrus from 1.4.2 to 1.5.0
Bumps [github.com/sirupsen/logrus](https://github.com/sirupsen/logrus) from 1.4.2 to 1.5.0.
- [Release notes](https://github.com/sirupsen/logrus/releases)
- [Changelog](https://github.com/sirupsen/logrus/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sirupsen/logrus/compare/v1.4.2...v1.5.0)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-03-24 09:09:12 -04:00
dependabot-preview[bot]
3b798cf943 build(deps): bump react-scripts from 3.4.0 to 3.4.1 in /ui
Bumps [react-scripts](https://github.com/facebook/create-react-app/tree/HEAD/packages/react-scripts) from 3.4.0 to 3.4.1.
- [Release notes](https://github.com/facebook/create-react-app/releases)
- [Changelog](https://github.com/facebook/create-react-app/blob/master/CHANGELOG.md)
- [Commits](https://github.com/facebook/create-react-app/commits/react-scripts@3.4.1/packages/react-scripts)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-03-23 08:49:40 -04:00
dependabot-preview[bot]
50b7756159 build(deps): bump react from 16.13.0 to 16.13.1 in /ui
Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) from 16.13.0 to 16.13.1.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/master/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v16.13.1/packages/react)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-03-23 08:48:48 -04:00
Deluan
15606770ca chore: removed non-working config flag 2020-03-22 01:13:55 -04:00
Deluan
f403a8da34 feat: add version to index.html description meta tag 2020-03-22 01:04:10 -04:00
Deluan
20075ae68d refactor: extracted restful helpers into their own composable struct 2020-03-21 20:00:46 -04:00
Deluan
91a743623a feat: always show artist name in Album view 2020-03-21 19:15:39 -04:00
Deluan
e23a290812 fix: logging of scanner startup 2020-03-21 14:20:22 -04:00
Deluan
dee68559ab docs: uses less space for client list 2020-03-21 14:11:57 -04:00
Deluan
9f42e330b4 fix: change web requests log level to debug 2020-03-21 13:03:04 -04:00
jvoisin
ad63b8b1b4 Add a systemd startup unit 2020-03-21 12:47:05 -04:00
Deluan
0d8a2b310f fix: the default session timeout must be 30 minutes, not seconds! 2020-03-21 12:17:20 -04:00
Deluan
3977575563 build: add a simple build as default target, trying to make LGTM work 2020-03-20 12:21:41 -04:00
Deluan
47244cb770 refactor: remove unused static file 2020-03-20 12:00:14 -04:00
Deluan
57aaf5a26b refactor: remove unused property 2020-03-20 00:30:16 -04:00
Deluan
352d686d94 chore: upgrade react-admin to 3.3.1 2020-03-20 00:23:04 -04:00
Deluan
f6e448c1ba refactor: removed unused code, unnecessary typecasts and fixed small warnings 2020-03-20 00:07:36 -04:00
Deluan
270b0ae74e feat: add "Compilation" filter to albums 2020-03-19 23:25:40 -04:00
Deluan
8401d85f78 feat: search in WebUI now is more flexible, searching in all relevant fields in the current view 2020-03-19 22:26:18 -04:00
Deluan
32fbf2e9eb refactor: drop search table, integrated full_text into main tables 2020-03-19 21:44:48 -04:00
Deluan
8cdd4e317d feat: allow restful filter customization per field 2020-03-19 21:09:57 -04:00
Deluan
97d95ea794 fix: group compilations together in the restful API. fix #93 2020-03-19 15:02:11 -04:00
Deluan
cbbebb3264 fix: version position under banner 2020-03-18 23:21:01 -04:00
Deluan
8b108905a3 feat: use Navidrome's icon in getAvatar 2020-03-18 22:46:47 -04:00
Deluan
5b40ec400e build: go mod tidy 2020-03-18 21:35:15 -04:00
Deluan
29e661e1fe docs: update README 2020-03-18 21:23:45 -04:00
Deluan
b466ec75a4 build: always add latest tag to version 2020-03-18 21:05:17 -04:00
Deluan
c8cd755451 feat: use human readable sizes in cache size configuration 2020-03-18 20:39:10 -04:00
Deluan
faac303eff feat: allow session timeout to be configurable. closes #101 2020-03-18 20:16:18 -04:00
Deluan
ced87be57b fix: when searching player by id, create new player if client name does not match the one found 2020-03-17 19:10:09 -04:00
Deluan
811703ab60 fix: create default transcodings on existing installations 2020-03-17 16:49:37 -04:00
Deluan
bc1f767123 docs: Update README 2020-03-17 15:22:37 -04:00
Deluan
7055dc514b docs: update basic transcoding info 2020-03-17 15:20:35 -04:00
Deluan
e02f3d3ec9 refactor: clean up unused config options 2020-03-17 15:20:35 -04:00
Deluan
68a49befc8 feat: allow regular users to change their players' configuration 2020-03-17 15:20:35 -04:00
Deluan
c8b0d2bfae feat: select correct transcoding for streaming 2020-03-17 15:20:35 -04:00
Deluan
39993810b3 feat: add transcodedSuffix to Subsonic API responses 2020-03-17 15:20:35 -04:00
Deluan
45180115a6 feat: player CRUD 2020-03-17 15:20:35 -04:00
Deluan
353c48d8d8 refactor: rename player to audioplayer 2020-03-17 15:20:35 -04:00
Deluan
da36941252 feat: better getPlayer middleware setup 2020-03-17 15:20:35 -04:00
Deluan
8ec78900c5 feat: transcoding and player datastores and configuration 2020-03-17 15:20:35 -04:00
dependabot-preview[bot]
a0e0fbad58 build(deps): bump @testing-library/react from 9.5.0 to 10.0.1 in /ui
Bumps [@testing-library/react](https://github.com/testing-library/react-testing-library) from 9.5.0 to 10.0.1.
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/master/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v9.5.0...v10.0.1)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-03-16 10:06:23 -04:00
dependabot-preview[bot]
75e7ba8b1e build(deps): bump github.com/go-chi/cors from 1.0.0 to 1.0.1
Bumps [github.com/go-chi/cors](https://github.com/go-chi/cors) from 1.0.0 to 1.0.1.
- [Release notes](https://github.com/go-chi/cors/releases)
- [Commits](https://github.com/go-chi/cors/compare/v1.0.0...v1.0.1)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-03-16 10:04:16 -04:00
Deluan
74c30b5a66 docs: add list of tested clients 2020-03-15 13:26:48 -04:00
Deluan Quintão
e67bdbbc32 docs: add link to transcoding issue 2020-03-15 13:09:43 -04:00
Deluan
9554c8f783 build: rename generated archives 2020-03-14 21:09:39 -04:00
Deluan
e36a42f356 build: generate binaries for Linux armv6, armv7 and arm68 (v8) (fixes #92) 2020-03-14 21:09:39 -04:00
dependabot-preview[bot]
9d1960232c build(deps): [security] bump acorn from 5.7.3 to 5.7.4 in /ui
Bumps [acorn](https://github.com/acornjs/acorn) from 5.7.3 to 5.7.4. **This update includes a security fix.**
- [Release notes](https://github.com/acornjs/acorn/releases)
- [Commits](https://github.com/acornjs/acorn/compare/5.7.3...5.7.4)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-03-13 18:01:31 -04:00
Deluan
d3547544bf feat: new WebUI icon 2020-03-11 20:18:22 -04:00
150 changed files with 3119 additions and 1468 deletions

View File

@@ -3,6 +3,7 @@ ui/node_modules
ui/build
Jamstash-master
Dockerfile
docker-compose*.yml
data
*.db
testDB

View File

@@ -15,7 +15,7 @@ jobs:
fetch-depth: 0
- uses: actions/setup-node@v1
with:
node-version: 13.10
node-version: 13.11
- name: Build UI
run: |
cd ui

View File

@@ -2,6 +2,8 @@
before:
hooks:
- apt-get update
- apt-get install -y gcc-arm-linux-gnueabi gcc-aarch64-linux-gnu
- go get -u github.com/go-bindata/go-bindata/...
- go-bindata -fs -prefix ui/build -tags embed -nocompress -pkg assets -o assets/embedded_gen.go ui/build/...
- git checkout .
@@ -21,7 +23,7 @@ builds:
ldflags:
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
- id: navidrome_linux
- id: navidrome_linux_amd64
env:
- CGO_ENABLED=1
goos:
@@ -34,6 +36,38 @@ builds:
- "-extldflags '-static'"
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
- id: navidrome_linux_arm
env:
- CGO_ENABLED=1
- CC=arm-linux-gnueabi-gcc
goos:
- linux
goarch:
- arm
goarm:
- 6
- 7
flags:
- -tags=embed
ldflags:
- "-extldflags '-static'"
- "-extld=$CC"
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
- id: navidrome_linux_arm64
env:
- CGO_ENABLED=1
- CC=aarch64-linux-gnu-gcc
goos:
- linux
goarch:
- arm64
flags:
- -tags=embed
ldflags:
- "-extldflags '-static'"
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
- id: navidrome_windows_i686
env:
- CGO_ENABLED=1
@@ -69,8 +103,15 @@ archives:
format_overrides:
- goos: windows
format: zip
replacements:
darwin: macOS
linux: Linux
windows: Windows
386: i386
amd64: x86_64
checksum:
name_template: 'checksums.txt'
name_template: '{{ .ProjectName }}_checksums.txt'
snapshot:
name_template: "{{ .Tag }}-next"

2
.nvmrc
View File

@@ -1 +1 @@
v13.10.1
v13.11.0

View File

@@ -54,7 +54,7 @@ Navidrome is actively being tested with:
| `deletePlaylist` | |
| ||
| _MEDIA RETRIEVAL_ ||
| `stream` | Experimental Transcoding/Downsampling support available |
| `stream` | |
| `download` | |
| `getCoverArt` | Only gets embedded artwork |
| `getAvatar` | Always returns the same image |

View File

@@ -1,6 +1,6 @@
#####################################################
### Build UI bundles
FROM node:13.10-alpine AS jsbuilder
FROM node:13.11-alpine AS jsbuilder
WORKDIR /src
COPY ui/package.json ui/package-lock.json ./
RUN npm ci
@@ -36,7 +36,7 @@ COPY --from=jsbuilder /src/build/* /src/ui/build/
COPY --from=jsbuilder /src/build/static/css/* /src/ui/build/static/css/
COPY --from=jsbuilder /src/build/static/js/* /src/ui/build/static/js/
RUN rm -rf /src/build/css /src/build/js
RUN GIT_TAG=$(git name-rev --name-only HEAD) && \
RUN GIT_TAG=$(git describe --tags `git rev-list --tags --max-count=1`) && \
GIT_TAG=${GIT_TAG#"tags/"} && \
GIT_SHA=$(git rev-parse --short HEAD) && \
echo "Building version: ${GIT_TAG} (${GIT_SHA})" && \
@@ -63,10 +63,13 @@ VOLUME ["/data", "/music"]
ENV ND_MUSICFOLDER /music
ENV ND_DATAFOLDER /data
ENV ND_SCANINTERVAL 1m
ENV ND_TRANSCODINGCACHESIZE 100MB
ENV ND_SESSIONTIMEOUT 30m
ENV ND_LOGLEVEL info
ENV ND_PORT 4533
EXPOSE 4533
EXPOSE ${ND_PORT}
HEALTHCHECK CMD wget -O- http://localhost:${ND_PORT}/ping || exit 1
WORKDIR /app
ENTRYPOINT ["/tini", "--"]

View File

@@ -3,28 +3,32 @@ NODE_VERSION=$(shell cat .nvmrc)
GIT_SHA=$(shell git rev-parse --short HEAD)
.PHONY: dev
## Default target just build the Go project.
default:
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=master"
.PHONY: default
dev: check_env
@goreman -f Procfile.dev -b 4533 start
.PHONY: dev
.PHONY: server
server: check_go_env
@reflex -d none -c reflex.conf
.PHONY: server
.PHONY: watch
watch: check_go_env
ginkgo watch -notify ./...
.PHONY: watch
.PHONY: test
test: check_go_env
go test ./... -v
# @(cd ./ui && npm test -- --watchAll=false)
.PHONY: test
.PHONY: testall
testall: check_go_env test
@(cd ./ui && npm test -- --watchAll=false)
.PHONY: testall
.PHONY: setup
setup: Jamstash-master
@which goconvey || (echo "Installing GoConvey" && GO111MODULE=off go get -u github.com/smartystreets/goconvey)
@which wire || (echo "Installing Wire" && GO111MODULE=off go get -u github.com/google/wire/cmd/wire)
@@ -35,40 +39,40 @@ setup: Jamstash-master
@which goose || (echo "Installing Goose" && GO111MODULE=off go get -u github.com/pressly/goose/cmd/goose)
go mod download
@(cd ./ui && npm ci)
.PHONY: setup
.PHONY: static
static:
cd static && go-bindata -fs -prefix "static" -nocompress -ignore="\\\*.go" -pkg static .
.PHONY: static
Jamstash-master:
wget -N https://github.com/tsquillario/Jamstash/archive/master.zip
unzip -o master.zip
rm master.zip
.PHONE: check_env
check_env: check_go_env check_node_env
.PHONE: check_env
.PHONY: check_go_env
check_go_env:
@(hash go) || (echo "\nERROR: GO environment not setup properly!\n"; exit 1)
@go version | grep -q $(GO_VERSION) || (echo "\nERROR: Please upgrade your GO version\nThis project requires version $(GO_VERSION)"; exit 1)
.PHONY: check_go_env
.PHONY: check_node_env
check_node_env:
@(hash node) || (echo "\nERROR: Node environment not setup properly!\n"; exit 1)
@node --version | grep -q $(NODE_VERSION) || (echo "\nERROR: Please check your Node version. Should be $(NODE_VERSION)\n"; exit 1)
.PHONY: check_node_env
.PHONY: build
build: check_go_env
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=master"
.PHONY: build
.PHONY: buildall
buildall: check_env
@(cd ./ui && npm run build)
go-bindata -fs -prefix "ui/build" -tags embed -nocompress -pkg assets -o assets/embedded_gen.go ui/build/...
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=master" -tags=embed
.PHONY: buildall
.PHONY: release
release:
@if [[ ! "${V}" =~ ^[0-9]+\.[0-9]+\.[0-9]+.*$$ ]]; then echo "Usage: make release V=X.X.X"; exit 1; fi
go mod tidy
@@ -76,7 +80,8 @@ release:
make test
git tag v${V}
git push origin v${V}
.PHONY: release
.PHONY: dist
dist:
docker run -it -v $(PWD):/github/workspace -w /github/workspace bepsays/ci-goreleaser:1.14-1 goreleaser release --rm-dist --skip-publish --snapshot
.PHONY: dist

View File

@@ -9,7 +9,8 @@ Navidrome is an open source web-based music collection server and streamer. It g
music collection from any browser or mobile device. It's like your personal Spotify!
__Any feedback is welcome!__ If you need/want a new feature, find a bug or think of any way to improve Navidrome,
please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join the chat in our [Discord server](https://discord.gg/xh7j7yF)
please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join the chat in
our [Discord server](https://discord.gg/xh7j7yF)
## Features
@@ -20,21 +21,34 @@ please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join
- Multi-user, each user has their own play counts, playlists, favourites, etc..
- Very low resource usage: Ex: with a library of 300GB (~29000 songs), it uses less than 50MB of RAM
- Multi-platform, runs on macOS, Linux and Windows. Docker images are also provided
- Ready to use Raspberry Pi binaries available
- Automatically monitors your library for changes, importing new files and reloading new metadata
- Modern and responsive Web interface based on Material UI, to manage users and browse your library
- Compatible with the huge selection of clients for [Subsonic](http://www.subsonic.org),
[Airsonic](https://airsonic.github.io/) and [Madsonic](https://www.madsonic.org/).
See the [complete list of available mobile and web apps](https://airsonic.github.io/docs/apps/)
- Transcoding/Downsampling on-the-fly (WIP. Experimental support is available)
- Compatible with all Subsonic/Madsonic/Airsonic clients. See bellow for a list of tested clients
- Transcoding/Downsampling on-the-fly. Can be set per user/player. Opus encoding is supported
- Integrated music player (WIP)
Navidrome should be compatible with all Subsonic clients. The following clients are tested and confirmed to work properly:
- Android: [DSub](https://play.google.com/store/apps/details?id=github.daneren2005.dsub),
[Ultrasonic](https://play.google.com/store/apps/details?id=org.moire.ultrasonic) and
[Music Stash](https://play.google.com/store/apps/details?id=com.ghenry22.mymusicstash)
- iOS: [play:Sub](http://michaelsapps.dk/playsubapp/)
- Web: [Jamstash](http://jamstash.com),
[Aurial](http://shrimpza.github.io/aurial/),
[Subfire](http://p.subfireplayer.net/) and
[Subplayer](https://github.com/peguerosdc/subplayer)
For more options, look at the [list of clients](https://airsonic.github.io/docs/apps/) maintained by
the Airsonic project. Please open an [issue](https://github.com/deluan/navidrome/issues) if you have any
trouble with the client of your choice.
## Road map
This project is being actively worked on. Expect a more polished experience and new features/releases
on a frequent basis. Some upcoming features planned:
- Last.FM integration
- Pre-build binaries for Raspberry Pi
- Smart/dynamic playlists (similar to iTunes)
- Support for audiobooks (bookmarking)
- Jukebox mode
@@ -49,17 +63,19 @@ Various options are available:
### Pre-built executables
Just head to the [releases page](https://github.com/deluan/navidrome/releases) and download the latest version for you
platform. There are builds available for Linux, macOS and Windows (32 and 64 bits).
platform. There are builds available for Linux (amd64 and arm), macOS and Windows (32 and 64 bits).
For Raspberry Pi (tested with Raspbian Buster on Pi 4), use the Linux arm builds.
Remember to install [ffmpeg](https://ffmpeg.org/download.html) in your system, a requirement for Navidrome to work properly.
You may find the latest static build for your platform here: https://johnvansickle.com/ffmpeg/
Remember to install [ffmpeg](https://ffmpeg.org/download.html) in your system, a requirement for Navidrome to work
properly. You may find the latest static build for your platform here: https://johnvansickle.com/ffmpeg/
If you have any issues with these binaries, or need a binary for a different platform, please
[open an issue](https://github.com/deluan/navidrome/issues)
### Docker
[Docker images](https://hub.docker.com/r/deluan/navidrome) are available. They include everything needed to run Navidrome. Example of usage:
[Docker images](https://hub.docker.com/r/deluan/navidrome) are available. They include everything needed
to run Navidrome. Example of usage:
```yaml
# This is just an example. Customize it to your needs.
@@ -77,16 +93,18 @@ services:
ND_SCANINTERVAL: 1m
ND_LOGLEVEL: info
ND_PORT: 4533
ND_TRANSCODINGCACHESIZE: 100MB
ND_SESSIONTIMEOUT: 30m
volumes:
- "./data:/data"
- "/path/to/your/music/folder:/music"
- "/path/to/your/music/folder:/music:ro"
```
To get the cutting-edge, latest version from master, use the image `deluan/navidrome:develop`
### Build from source
You will need to install [Go 1.14](https://golang.org/dl/) and [Node 13.10.1](http://nodejs.org).
You will need to install [Go 1.14](https://golang.org/dl/) and [Node 13.11.0](http://nodejs.org).
You'll also need [ffmpeg](https://ffmpeg.org) installed in your system. The setup is very strict, and
the steps bellow only work with these specific versions (enforced in the Makefile)
@@ -114,6 +132,12 @@ user.
For more options, run `navidrome --help`
### Running as a service
Check the [contrib](https://github.com/deluan/navidrome/tree/master/contrib)
folder for startup files for your init system.
## Screenshots
<p align="center">
@@ -121,7 +145,7 @@ For more options, run `navidrome --help`
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-login.png">
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-player.png">
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-album-view.png">
<img width="900"src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-desktop-player.png">
<img width="900" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-desktop-player.png">
</p>
</p>

View File

@@ -13,24 +13,21 @@ import (
)
type nd struct {
Port string `default:"4533"`
MusicFolder string `default:"./music"`
DataFolder string `default:"./"`
ScanInterval string `default:"1m"`
DbPath string
LogLevel string `default:"info"`
Port string `default:"4533"`
MusicFolder string `default:"./music"`
DataFolder string `default:"./"`
ScanInterval string `default:"1m"`
DbPath string ``
LogLevel string `default:"info"`
SessionTimeout string `default:"30m"`
IgnoredArticles string `default:"The El La Los Las Le Les Os As O A"`
IndexGroups string `default:"A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)"`
EnableDownsampling bool `default:"false"`
MaxBitRate int `default:"0"`
MaxTranscodingCacheSize int64 `default:"100"` // in MB
DownsampleCommand string `default:"ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -"`
ProbeCommand string `default:"ffmpeg -i %s -f ffmetadata"`
TranscodingCacheSize string `default:"100MB"` // in MB
ProbeCommand string `default:"ffmpeg -i %s -f ffmetadata"`
// DevFlags. These are used to enable/disable debugging and incomplete features
DevDisableBanner bool `default:"false"`
DevLogSourceLine bool `default:"false"`
DevAutoCreateAdminPassword string `default:""`
}

View File

@@ -3,17 +3,18 @@ package consts
import (
"fmt"
"strings"
"unicode"
"github.com/deluan/navidrome/static"
)
func getBanner() string {
data, _ := static.Asset("banner.txt")
return strings.TrimSuffix(string(data), "\n")
return strings.TrimRightFunc(string(data), unicode.IsSpace)
}
func Banner() string {
version := "Version: " + Version()
padding := strings.Repeat(" ", 52-len(version))
return fmt.Sprintf("%s%s%s\n", getBanner(), padding, version)
return fmt.Sprintf("%s\n%s%s\n", getBanner(), padding, version)
}

View File

@@ -1,6 +1,11 @@
package consts
import "time"
import (
"crypto/md5"
"fmt"
"strings"
"time"
)
const (
AppName = "navidrome"
@@ -9,14 +14,41 @@ const (
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL"
InitialSetupFlagKey = "InitialSetup"
JWTSecretKey = "JWTSecret"
JWTIssuer = "ND"
JWTTokenExpiration = 30 * time.Minute
JWTSecretKey = "JWTSecret"
JWTIssuer = "ND"
DefaultSessionTimeout = 30 * time.Minute
UIAssetsLocalPath = "ui/build"
CacheDir = "cache"
CacheDir = "cache"
DefaultTranscodingCacheSize = 100 * 1024 * 1024 // 100MB
DefaultTranscodingCacheMaxItems = 0 // Unlimited
DefaultTranscodingCachePurgeInterval = 10 * time.Minute
DevInitialUserName = "admin"
DevInitialName = "Dev Admin"
)
var (
DefaultTranscodings = []map[string]interface{}{
{
"name": "mp3 audio",
"targetFormat": "mp3",
"defaultBitRate": 192,
"command": "ffmpeg -i %s -ab %bk -v 0 -f mp3 -",
},
{
"name": "opus audio",
"targetFormat": "oga",
"defaultBitRate": 128,
"command": "ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -c:a libopus -f opus -",
},
}
)
var (
VariousArtists = "Various Artists"
VariousArtistsID = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(VariousArtists))))
UnknownArtist = "[Unknown Artist]"
UnknownArtistID = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(UnknownArtist))))
)

38
contrib/navidrome.service Normal file
View File

@@ -0,0 +1,38 @@
# This file ususaly goes in /etc/systemd/system
[Unit]
Description=Navidrome Daemon
After=network.target
[Service]
User=navidrome
Group=navidrome
Type=simple
ExecStart=/opt/navidrome/navidrome
WorkingDirectory=/opt/navidrome
TimeoutStopSec=20
KillMode=process
Restart=on-failure
# See https://www.freedesktop.org/software/systemd/man/systemd.exec.html
DevicePolicy=closed
NoNewPrivileges=yes
PrivateTmp=yes
PrivateUsers=yes
ProtectControlGroups=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
SystemCallFilter=~@clock @debug @module @mount @obsolete @privileged @reboot @setuid @swap
ReadWritePaths=/opt/navidrome/
PrivateDevices=yes
ProtectSystem=full
ProtectHome=true
MemoryDenyWriteExecute=yes
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,53 @@
package migration
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(Up20200310181627, Down20200310181627)
}
func Up20200310181627(tx *sql.Tx) error {
_, err := tx.Exec(`
create table transcoding
(
id varchar(255) not null primary key,
name varchar(255) not null,
target_format varchar(255) not null,
command varchar(255) default '' not null,
default_bit_rate int default 192,
unique (name),
unique (target_format)
);
create table player
(
id varchar(255) not null primary key,
name varchar not null,
type varchar,
user_name varchar not null,
client varchar not null,
ip_address varchar,
last_seen timestamp,
max_bit_rate int default 0,
transcoding_id varchar,
unique (name),
foreign key (transcoding_id)
references transcoding(id)
on update restrict
on delete restrict
);
`)
return err
}
func Down20200310181627(tx *sql.Tx) error {
_, err := tx.Exec(`
drop table transcoding;
drop table player;
`)
return err
}

View File

@@ -0,0 +1,42 @@
package migration
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(Up20200319211049, Down20200319211049)
}
func Up20200319211049(tx *sql.Tx) error {
_, err := tx.Exec(`
alter table media_file
add full_text varchar(255) default '';
create index if not exists media_file_full_text
on media_file (full_text);
alter table album
add full_text varchar(255) default '';
create index if not exists album_full_text
on album (full_text);
alter table artist
add full_text varchar(255) default '';
create index if not exists artist_full_text
on artist (full_text);
drop table if exists search;
`)
if err != nil {
return err
}
notice(tx, "A full rescan will be performed!")
return forceFullRescan(tx)
}
func Down20200319211049(tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@@ -0,0 +1,33 @@
package migration
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(Up20200325185135, Down20200325185135)
}
func Up20200325185135(tx *sql.Tx) error {
_, err := tx.Exec(`
alter table album
add album_artist_id varchar(255) default '';
create index album_artist_album_id
on album (album_artist_id);
alter table media_file
add album_artist_id varchar(255) default '';
create index media_file_artist_album_id
on media_file (album_artist_id);
`)
if err != nil {
return err
}
notice(tx, "A full rescan will be performed!")
return forceFullRescan(tx)
}
func Down20200325185135(tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,19 @@
package migration
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(Up20200326090707, Down20200326090707)
}
func Up20200326090707(tx *sql.Tx) error {
notice(tx, "A full rescan will be performed!")
return forceFullRescan(tx)
}
func Down20200326090707(tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,80 @@
package migration
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(Up20200327193744, Down20200327193744)
}
func Up20200327193744(tx *sql.Tx) error {
_, err := tx.Exec(`
create table album_dg_tmp
(
id varchar(255) not null
primary key,
name varchar(255) default '' not null,
artist_id varchar(255) default '' not null,
cover_art_path varchar(255) default '' not null,
cover_art_id varchar(255) default '' not null,
artist varchar(255) default '' not null,
album_artist varchar(255) default '' not null,
min_year int default 0 not null,
max_year integer default 0 not null,
compilation bool default FALSE not null,
song_count integer default 0 not null,
duration real default 0 not null,
genre varchar(255) default '' not null,
created_at datetime,
updated_at datetime,
full_text varchar(255) default '',
album_artist_id varchar(255) default ''
);
insert into album_dg_tmp(id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, max_year, compilation, song_count, duration, genre, created_at, updated_at, full_text, album_artist_id) select id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, year, compilation, song_count, duration, genre, created_at, updated_at, full_text, album_artist_id from album;
drop table album;
alter table album_dg_tmp rename to album;
create index album_artist
on album (artist);
create index album_artist_album
on album (artist);
create index album_artist_album_id
on album (album_artist_id);
create index album_artist_id
on album (artist_id);
create index album_full_text
on album (full_text);
create index album_genre
on album (genre);
create index album_name
on album (name);
create index album_min_year
on album (min_year);
create index album_max_year
on album (max_year);
`)
if err != nil {
return err
}
notice(tx, "A full rescan will be performed!")
return forceFullRescan(tx)
}
func Down20200327193744(tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@@ -13,6 +13,8 @@ services:
ND_SCANINTERVAL: 1m
ND_LOGLEVEL: info
ND_PORT: 4533
ND_TRANSCODINGCACHESIZE: 100MB
ND_SESSIONTIMEOUT: 30m
volumes:
- "./data:/data"
- "./music:/music"

View File

@@ -5,6 +5,7 @@ import (
"sync"
"time"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
@@ -13,9 +14,10 @@ import (
)
var (
once sync.Once
JwtSecret []byte
TokenAuth *jwtauth.JWTAuth
once sync.Once
JwtSecret []byte
TokenAuth *jwtauth.JWTAuth
sessionTimeOut time.Duration
)
func InitTokenAuth(ds model.DataStore) {
@@ -39,8 +41,21 @@ func CreateToken(u *model.User) (string, error) {
return TouchToken(token)
}
func getSessionTimeOut() time.Duration {
if sessionTimeOut == 0 {
if to, err := time.ParseDuration(conf.Server.SessionTimeout); err != nil {
sessionTimeOut = consts.DefaultSessionTimeout
} else {
sessionTimeOut = to
}
log.Info("Setting Session Timeout", "value", sessionTimeOut)
}
return sessionTimeOut
}
func TouchToken(token *jwt.Token) (string, error) {
expireIn := time.Now().Add(consts.JWTTokenExpiration).Unix()
timeout := getSessionTimeOut()
expireIn := time.Now().Add(timeout).Unix()
claims := token.Claims.(jwt.MapClaims)
claims["exp"] = expireIn

View File

@@ -37,7 +37,7 @@ func (b *browser) MediaFolders(ctx context.Context) (model.MediaFolders, error)
func (b *browser) Indexes(ctx context.Context, mediaFolderId string, ifModifiedSince time.Time) (model.ArtistIndexes, time.Time, error) {
// TODO Proper handling of mediaFolderId param
folder, err := b.ds.MediaFolder(ctx).Get(mediaFolderId)
folder, _ := b.ds.MediaFolder(ctx).Get(mediaFolderId)
l, err := b.ds.Property(ctx).DefaultGet(model.PropLastScan+"-"+folder.Path, "-1")
ms, _ := strconv.ParseInt(l, 10, 64)
@@ -155,13 +155,13 @@ func (b *browser) buildAlbumDir(al *model.Album, tracks model.MediaFiles) *Direc
dir := &DirectoryInfo{
Id: al.ID,
Name: al.Name,
Parent: al.ArtistID,
Artist: al.Artist,
ArtistId: al.ArtistID,
Parent: al.AlbumArtistID,
Artist: al.AlbumArtist,
ArtistId: al.AlbumArtistID,
SongCount: al.SongCount,
Duration: int(al.Duration),
Created: al.CreatedAt,
Year: al.Year,
Year: al.MaxYear,
Genre: al.Genre,
CoverArt: al.CoverArtId,
PlayCount: int32(al.PlayCount),

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"time"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/model"
)
@@ -62,15 +63,15 @@ func FromAlbum(al *model.Album) Entry {
e.Id = al.ID
e.Title = al.Name
e.IsDir = true
e.Parent = al.ArtistID
e.Parent = al.AlbumArtistID
e.Album = al.Name
e.Year = al.Year
e.Year = al.MaxYear
e.Artist = al.AlbumArtist
e.Genre = al.Genre
e.CoverArt = al.CoverArtId
e.Created = al.CreatedAt
e.AlbumId = al.ID
e.ArtistId = al.ArtistID
e.ArtistId = al.AlbumArtistID
e.Duration = int(al.Duration)
e.SongCount = al.SongCount
if al.Starred {
@@ -121,7 +122,7 @@ func FromMediaFile(mf *model.MediaFile) Entry {
func realArtistName(mf *model.MediaFile) string {
switch {
case mf.Compilation:
return "Various Artists"
return consts.VariousArtists
case mf.AlbumArtist != "":
return mf.AlbumArtist
}

View File

@@ -14,12 +14,12 @@ import (
"github.com/deluan/navidrome/engine/transcoder"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/utils"
"github.com/djherbis/fscache"
"github.com/dustin/go-humanize"
)
type MediaStreamer interface {
NewStream(ctx context.Context, id string, maxBitRate int, format string) (*Stream, error)
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error)
}
func NewMediaStreamer(ds model.DataStore, ffm transcoder.Transcoder, cache fscache.Cache) MediaStreamer {
@@ -32,18 +32,23 @@ type mediaStreamer struct {
cache fscache.Cache
}
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, maxBitRate int, reqFormat string) (*Stream, error) {
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error) {
mf, err := ms.ds.MediaFile(ctx).Get(id)
if err != nil {
return nil, err
}
bitRate, format := selectTranscodingOptions(mf, maxBitRate, reqFormat)
format, bitRate := selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate)
log.Trace(ctx, "Selected transcoding options",
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format,
)
s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
if format == "raw" {
log.Debug(ctx, "Streaming raw file", "id", mf.ID, "path", mf.Path,
"requestBitrate", maxBitRate, "requestFormat", reqFormat,
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
f, err := os.Open(mf.Path)
if err != nil {
@@ -66,7 +71,12 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, maxBitRate in
// If this is a brand new transcoding request, not in the cache, start transcoding
if w != nil {
log.Trace(ctx, "Cache miss. Starting new transcoding session", "id", mf.ID)
out, err := ms.ffm.Start(ctx, mf.Path, bitRate, format)
t, err := ms.ds.Transcoding(ctx).FindByFormat(format)
if err != nil {
log.Error(ctx, "Error loading transcoding command", "format", format, err)
return nil, os.ErrInvalid
}
out, err := ms.ffm.Start(ctx, t.Command, mf.Path, bitRate, format)
if err != nil {
log.Error(ctx, "Error starting transcoder", "id", mf.ID, err)
return nil, os.ErrInvalid
@@ -79,7 +89,7 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, maxBitRate in
size := getFinalCachedSize(r)
if size > 0 {
log.Debug(ctx, "Streaming cached file", "id", mf.ID, "path", mf.Path,
"requestBitrate", maxBitRate, "requestFormat", reqFormat,
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix, "size", size)
sr := io.NewSectionReader(r, 0, size)
s.Reader = sr
@@ -91,7 +101,7 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, maxBitRate in
}
log.Debug(ctx, "Streaming transcoded file", "id", mf.ID, "path", mf.Path,
"requestBitrate", maxBitRate, "requestFormat", reqFormat,
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
// All other cases, just return a ReadCloser, without Seek capabilities
s.Reader = r
@@ -131,27 +141,46 @@ func (s *Stream) ContentType() string { return mime.TypeByExtension("." + s.form
func (s *Stream) Name() string { return s.mf.Path }
func (s *Stream) ModTime() time.Time { return s.mf.UpdatedAt }
func selectTranscodingOptions(mf *model.MediaFile, maxBitRate int, format string) (int, string) {
var bitRate int
if format == "raw" || !conf.Server.EnableDownsampling {
return bitRate, "raw"
// TODO This function deserves some love (refactoring)
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) {
format = "raw"
if reqFormat == "raw" {
return
}
trc, hasDefault := ctx.Value("transcoding").(model.Transcoding)
var cFormat string
var cBitRate int
if reqFormat != "" {
cFormat = reqFormat
} else {
if maxBitRate == 0 {
bitRate = mf.BitRate
} else {
bitRate = utils.MinInt(mf.BitRate, maxBitRate)
if hasDefault {
cFormat = trc.TargetFormat
cBitRate = trc.DefaultBitRate
if p, ok := ctx.Value("player").(model.Player); ok {
cBitRate = p.MaxBitRate
}
}
format = "mp3" //mf.Suffix
}
if conf.Server.MaxBitRate != 0 {
bitRate = utils.MinInt(bitRate, conf.Server.MaxBitRate)
if reqBitRate > 0 {
cBitRate = reqBitRate
}
if bitRate == mf.BitRate {
return bitRate, "raw"
if cBitRate == 0 && cFormat == "" {
return
}
return bitRate, format
t, err := ds.Transcoding(ctx).FindByFormat(cFormat)
if err == nil {
format = t.TargetFormat
if cBitRate != 0 {
bitRate = cBitRate
} else {
bitRate = t.DefaultBitRate
}
}
if format == mf.Suffix && bitRate > mf.BitRate {
format = "raw"
bitRate = 0
}
return
}
func cacheKey(id string, bitRate int, format string) string {
@@ -170,9 +199,15 @@ func getFinalCachedSize(r fscache.ReadAtCloser) int64 {
}
func NewTranscodingCache() (fscache.Cache, error) {
lru := fscache.NewLRUHaunter(0, conf.Server.MaxTranscodingCacheSize*1024*1024, 10*time.Minute)
cacheSize, err := humanize.ParseBytes(conf.Server.TranscodingCacheSize)
if err != nil {
cacheSize = consts.DefaultTranscodingCacheSize
}
lru := fscache.NewLRUHaunter(consts.DefaultTranscodingCacheMaxItems, int64(cacheSize), consts.DefaultTranscodingCachePurgeInterval)
h := fscache.NewLRUHaunterStrategy(lru)
cacheFolder := filepath.Join(conf.Server.DataFolder, consts.CacheDir)
log.Info("Creating transcoding cache", "path", cacheFolder, "maxSize", humanize.Bytes(cacheSize),
"cleanUpInterval", consts.DefaultTranscodingCachePurgeInterval)
fs, err := fscache.NewFs(cacheFolder, 0755)
if err != nil {
return nil, err

View File

@@ -7,7 +7,6 @@ import (
"os"
"strings"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/persistence"
@@ -31,9 +30,8 @@ var _ = Describe("MediaStreamer", func() {
})
BeforeEach(func() {
conf.Server.EnableDownsampling = true
ds = &persistence.MockDataStore{}
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "bitRate": 128, "duration": 257.0}]`, 1)
ds = &persistence.MockDataStore{MockedTranscoding: &mockTranscodingRepository{}}
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "suffix": "mp3", "bitRate": 128, "duration": 257.0}]`, 1)
streamer = NewMediaStreamer(ds, ffmpeg, cache)
})
@@ -43,33 +41,140 @@ var _ = Describe("MediaStreamer", func() {
Context("NewStream", func() {
It("returns a seekable stream if format is 'raw'", func() {
s, err := streamer.NewStream(ctx, "123", 0, "raw")
s, err := streamer.NewStream(ctx, "123", "raw", 0)
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a seekable stream if maxBitRate is 0", func() {
s, err := streamer.NewStream(ctx, "123", 0, "mp3")
s, err := streamer.NewStream(ctx, "123", "mp3", 0)
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a seekable stream if maxBitRate is higher than file bitRate", func() {
s, err := streamer.NewStream(ctx, "123", 320, "mp3")
s, err := streamer.NewStream(ctx, "123", "mp3", 320)
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a NON seekable stream if transcode is required", func() {
s, err := streamer.NewStream(ctx, "123", 64, "mp3")
s, err := streamer.NewStream(ctx, "123", "mp3", 64)
Expect(err).To(BeNil())
Expect(s.Seekable()).To(BeFalse())
Expect(s.Duration()).To(Equal(float32(257.0)))
})
It("returns a seekable stream if the file is complete in the cache", func() {
Eventually(func() bool { return ffmpeg.closed }).Should(BeTrue())
s, err := streamer.NewStream(ctx, "123", 64, "mp3")
s, err := streamer.NewStream(ctx, "123", "mp3", 64)
Expect(err).To(BeNil())
Expect(s.Seekable()).To(BeTrue())
})
})
Context("selectTranscodingOptions", func() {
mf := &model.MediaFile{}
Context("player is not configured", func() {
It("returns raw if raw is requested", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
Expect(format).To(Equal("raw"))
})
It("returns raw if a transcoder does not exists", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0)
Expect(format).To(Equal("raw"))
})
It("returns the requested format if a transcoder exists", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
Expect(format).To(Equal("mp3"))
Expect(bitRate).To(Equal(160)) // Default Bit Rate
})
It("returns raw if requested format is the same as the original and it is not necessary to downsample", func() {
mf.Suffix = "mp3"
mf.BitRate = 112
format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128)
Expect(format).To(Equal("raw"))
})
It("returns the requested format if requested BitRate is lower than original", func() {
mf.Suffix = "mp3"
mf.BitRate = 320
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
Expect(format).To(Equal("mp3"))
Expect(bitRate).To(Equal(192))
})
})
Context("player has format configured", func() {
BeforeEach(func() {
t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
ctx = context.WithValue(ctx, "transcoding", t)
})
It("returns raw if raw is requested", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
Expect(format).To(Equal("raw"))
})
It("returns configured format/bitrate as default", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(96))
})
It("returns requested format", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
Expect(format).To(Equal("mp3"))
Expect(bitRate).To(Equal(160)) // Default Bit Rate
})
It("returns requested bitrate", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(80))
})
})
Context("player has maxBitRate configured", func() {
BeforeEach(func() {
t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
p := model.Player{ID: "player1", TranscodingId: t.ID, MaxBitRate: 80}
ctx = context.WithValue(ctx, "transcoding", t)
ctx = context.WithValue(ctx, "player", p)
})
It("returns raw if raw is requested", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
Expect(format).To(Equal("raw"))
})
It("returns configured format/bitrate as default", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(80))
})
It("returns requested format", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
Expect(format).To(Equal("mp3"))
Expect(bitRate).To(Equal(160)) // Default Bit Rate
})
It("returns requested bitrate", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(80))
})
})
})
})
type fakeFFmpeg struct {
@@ -78,7 +183,7 @@ type fakeFFmpeg struct {
closed bool
}
func (ff *fakeFFmpeg) Start(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
func (ff *fakeFFmpeg) Start(ctx context.Context, cmd, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
ff.r = strings.NewReader(ff.Data)
return ff, nil
}

View File

@@ -1,86 +0,0 @@
package engine
import (
"errors"
"time"
)
func CreateMockNowPlayingRepo() *MockNowPlaying {
return &MockNowPlaying{}
}
type MockNowPlaying struct {
NowPlayingRepository
data []NowPlayingInfo
t time.Time
err bool
}
func (m *MockNowPlaying) SetError(err bool) {
m.err = err
}
func (m *MockNowPlaying) Enqueue(info *NowPlayingInfo) error {
if m.err {
return errors.New("Error!")
}
m.data = append(m.data, NowPlayingInfo{})
copy(m.data[1:], m.data[0:])
m.data[0] = *info
if !m.t.IsZero() {
m.data[0].Start = m.t
m.t = time.Time{}
}
return nil
}
func (m *MockNowPlaying) Dequeue(playerId int) (*NowPlayingInfo, error) {
if len(m.data) == 0 {
return nil, nil
}
l := len(m.data)
info := m.data[l-1]
m.data = m.data[:l-1]
return &info, nil
}
func (m *MockNowPlaying) Count(playerId int) (int64, error) {
return int64(len(m.data)), nil
}
func (m *MockNowPlaying) GetAll() ([]*NowPlayingInfo, error) {
np, err := m.Head(1)
if np == nil || err != nil {
return nil, err
}
return []*NowPlayingInfo{np}, err
}
func (m *MockNowPlaying) Head(playerId int) (*NowPlayingInfo, error) {
if len(m.data) == 0 {
return nil, nil
}
info := m.data[0]
return &info, nil
}
func (m *MockNowPlaying) Tail(playerId int) (*NowPlayingInfo, error) {
if len(m.data) == 0 {
return nil, nil
}
info := m.data[len(m.data)-1]
return &info, nil
}
func (m *MockNowPlaying) ClearAll() {
m.data = make([]NowPlayingInfo, 0)
m.err = false
}
func (m *MockNowPlaying) OverrideNow(t time.Time) {
m.t = t
}

View File

@@ -1,46 +0,0 @@
package engine
import (
"errors"
"github.com/deluan/navidrome/model"
)
func CreateMockPropertyRepo() *MockProperty {
return &MockProperty{data: make(map[string]string)}
}
type MockProperty struct {
model.PropertyRepository
data map[string]string
err bool
}
func (m *MockProperty) SetError(err bool) {
m.err = err
}
func (m *MockProperty) Put(id string, value string) error {
if m.err {
return errors.New("Error!")
}
m.data[id] = value
return nil
}
func (m *MockProperty) Get(id string) (string, error) {
if m.err {
return "", errors.New("Error!")
}
return m.data[id], nil
}
func (m *MockProperty) DefaultGet(id string, defaultValue string) (string, error) {
v, err := m.Get(id)
if v == "" {
v = defaultValue
}
return v, err
}

View File

@@ -0,0 +1,22 @@
package engine
import "github.com/deluan/navidrome/model"
type mockTranscodingRepository struct {
model.TranscodingRepository
}
func (m *mockTranscodingRepository) Get(id string) (*model.Transcoding, error) {
return &model.Transcoding{ID: id, TargetFormat: "mp3", DefaultBitRate: 160}, nil
}
func (m *mockTranscodingRepository) FindByFormat(format string) (*model.Transcoding, error) {
switch format {
case "mp3":
return &model.Transcoding{ID: "mp31", TargetFormat: "mp3", DefaultBitRate: 160}, nil
case "oga":
return &model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 128}, nil
default:
return nil, model.ErrNotFound
}
}

67
engine/players.go Normal file
View File

@@ -0,0 +1,67 @@
package engine
import (
"context"
"fmt"
"time"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/google/uuid"
)
type Players interface {
Get(ctx context.Context, playerId string) (*model.Player, error)
Register(ctx context.Context, id, client, typ, ip string) (*model.Player, *model.Transcoding, error)
}
func NewPlayers(ds model.DataStore) Players {
return &players{ds}
}
type players struct {
ds model.DataStore
}
func (p *players) Register(ctx context.Context, id, client, typ, ip string) (*model.Player, *model.Transcoding, error) {
var plr *model.Player
var trc *model.Transcoding
var err error
userName := ctx.Value("username").(string)
if id != "" {
plr, err = p.ds.Player(ctx).Get(id)
if err == nil && plr.Client != client {
id = ""
}
}
if err != nil || id == "" {
plr, err = p.ds.Player(ctx).FindByName(client, userName)
if err == nil {
log.Debug("Found player by name", "id", plr.ID, "client", client, "userName", userName)
} else {
r, _ := uuid.NewRandom()
plr = &model.Player{
ID: r.String(),
Name: fmt.Sprintf("%s (%s)", client, userName),
UserName: userName,
Client: client,
}
log.Info("Registering new player", "id", plr.ID, "client", client, "userName", userName)
}
}
plr.LastSeen = time.Now()
plr.Type = typ
plr.IPAddress = ip
err = p.ds.Player(ctx).Put(plr)
if err != nil {
return nil, nil, err
}
if plr.TranscodingId != "" {
trc, err = p.ds.Transcoding(ctx).Get(plr.TranscodingId)
}
return plr, trc, err
}
func (p *players) Get(ctx context.Context, playerId string) (*model.Player, error) {
return p.ds.Player(ctx).Get(playerId)
}

138
engine/players_test.go Normal file
View File

@@ -0,0 +1,138 @@
package engine
import (
"context"
"time"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/persistence"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("Players", func() {
var players Players
var repo *mockPlayerRepository
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid", UserName: "johndoe"})
ctx = context.WithValue(ctx, "username", "johndoe")
var beforeRegister time.Time
BeforeEach(func() {
repo = &mockPlayerRepository{}
ds := &persistence.MockDataStore{MockedPlayer: repo, MockedTranscoding: &mockTranscodingRepository{}}
players = NewPlayers(ds)
beforeRegister = time.Now()
})
Describe("Register", func() {
It("creates a new player when no ID is specified", func() {
p, trc, err := players.Register(ctx, "", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.ID).ToNot(BeEmpty())
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
Expect(p.Client).To(Equal("client"))
Expect(p.UserName).To(Equal("johndoe"))
Expect(p.Type).To(Equal("chrome"))
Expect(repo.lastSaved).To(Equal(p))
Expect(trc).To(BeNil())
})
It("creates a new player if it cannot find any matching player", func() {
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.ID).ToNot(BeEmpty())
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
Expect(repo.lastSaved).To(Equal(p))
Expect(trc).To(BeNil())
})
It("creates a new player if client does not match the one in DB", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client1111", LastSeen: time.Time{}}
repo.add(plr)
p, trc, err := players.Register(ctx, "123", "client2222", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.ID).ToNot(BeEmpty())
Expect(p.ID).ToNot(Equal("123"))
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
Expect(p.Client).To(Equal("client2222"))
Expect(trc).To(BeNil())
})
It("finds players by ID", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}}
repo.add(plr)
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.ID).To(Equal("123"))
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
Expect(repo.lastSaved).To(Equal(p))
Expect(trc).To(BeNil())
})
It("finds player by client and user names when ID is not found", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserName: "johndoe", LastSeen: time.Time{}}
repo.add(plr)
p, _, err := players.Register(ctx, "999", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.ID).To(Equal("123"))
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
Expect(repo.lastSaved).To(Equal(p))
})
It("finds player by client and user names when not ID is provided", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserName: "johndoe", LastSeen: time.Time{}}
repo.add(plr)
p, _, err := players.Register(ctx, "", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.ID).To(Equal("123"))
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
Expect(repo.lastSaved).To(Equal(p))
})
It("finds player by ID and return its transcoding", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}, TranscodingId: "1"}
repo.add(plr)
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.ID).To(Equal("123"))
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
Expect(repo.lastSaved).To(Equal(p))
Expect(trc.ID).To(Equal("1"))
})
})
})
type mockPlayerRepository struct {
model.PlayerRepository
lastSaved *model.Player
data map[string]model.Player
}
func (m *mockPlayerRepository) add(p *model.Player) {
if m.data == nil {
m.data = make(map[string]model.Player)
}
m.data[p.ID] = *p
}
func (m *mockPlayerRepository) Get(id string) (*model.Player, error) {
if p, ok := m.data[id]; ok {
return &p, nil
}
return nil, model.ErrNotFound
}
func (m *mockPlayerRepository) FindByName(client, userName string) (*model.Player, error) {
for _, p := range m.data {
if p.Client == client && p.UserName == userName {
return &p, nil
}
}
return nil, model.ErrNotFound
}
func (m *mockPlayerRepository) Put(p *model.Player) error {
m.lastSaved = p
return nil
}

View File

@@ -51,7 +51,7 @@ func (p *playlists) Create(ctx context.Context, playlistId, name string, ids []s
}
func (p *playlists) getUser(ctx context.Context) string {
user, ok := ctx.Value("user").(*model.User)
user, ok := ctx.Value("user").(model.User)
if ok {
return user.UserName
}
@@ -73,15 +73,15 @@ func (p *playlists) Delete(ctx context.Context, playlistId string) error {
func (p *playlists) Update(ctx context.Context, playlistId string, name *string, idsToAdd []string, idxToRemove []int) error {
pls, err := p.ds.Playlist(ctx).Get(playlistId)
if err != nil {
return err
}
owner := p.getUser(ctx)
if owner != pls.Owner {
return model.ErrNotAuthorized
}
if err != nil {
return err
}
if name != nil {
pls.Name = *name
}

View File

@@ -8,12 +8,11 @@ import (
"strconv"
"strings"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/log"
)
type Transcoder interface {
Start(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error)
Start(ctx context.Context, command, path string, maxBitRate int, format string) (f io.ReadCloser, err error)
}
func New() Transcoder {
@@ -22,8 +21,8 @@ func New() Transcoder {
type ffmpeg struct{}
func (ff *ffmpeg) Start(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
arg0, args := createTranscodeCommand(path, maxBitRate, format)
func (ff *ffmpeg) Start(ctx context.Context, command, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
arg0, args := createTranscodeCommand(command, path, maxBitRate, format)
log.Trace(ctx, "Executing ffmpeg command", "cmd", arg0, "args", args)
cmd := exec.Command(arg0, args...)
@@ -38,9 +37,7 @@ func (ff *ffmpeg) Start(ctx context.Context, path string, maxBitRate int, format
return
}
func createTranscodeCommand(path string, maxBitRate int, format string) (string, []string) {
cmd := conf.Server.DownsampleCommand
func createTranscodeCommand(cmd, path string, maxBitRate int, format string) (string, []string) {
split := strings.Split(cmd, " ")
for i, s := range split {
s = strings.Replace(s, "%s", path, -1)

View File

@@ -3,7 +3,6 @@ package transcoder
import (
"testing"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/tests"
. "github.com/onsi/ginkgo"
@@ -18,11 +17,8 @@ func TestTranscoder(t *testing.T) {
}
var _ = Describe("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, "")
cmd, args := createTranscodeCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, "")
Expect(cmd).To(Equal("ffmpeg"))
Expect(args).To(Equal([]string{"-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
})

View File

@@ -18,4 +18,5 @@ var Set = wire.NewSet(
NewMediaStreamer,
transcoder.New,
NewTranscodingCache,
NewPlayers,
)

9
go.mod
View File

@@ -7,14 +7,15 @@ require (
github.com/Masterminds/squirrel v1.2.0
github.com/astaxie/beego v1.12.1
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8
github.com/djherbis/fscache v0.10.0
github.com/dustin/go-humanize v1.0.0
github.com/fatih/camelcase v0.0.0-20160318181535-f6a740d52f96 // indirect
github.com/fatih/structs v1.0.0 // indirect
github.com/go-chi/chi v4.0.3+incompatible
github.com/go-chi/cors v1.0.0
github.com/go-chi/chi v4.0.4+incompatible
github.com/go-chi/cors v1.0.1
github.com/go-chi/jwtauth v4.0.4+incompatible
github.com/go-sql-driver/mysql v1.5.0 // indirect
github.com/golang/protobuf v1.3.1 // indirect
@@ -30,7 +31,7 @@ require (
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/sirupsen/logrus v1.5.0
github.com/smartystreets/assertions v1.0.1 // indirect
github.com/smartystreets/goconvey v1.6.4
github.com/stretchr/testify v1.4.0 // indirect

16
go.sum
View File

@@ -22,14 +22,16 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4 h1:VSoAcWJvj656TSyWbJ5KuGsi/J8dO5+iO9+5/7I8wao=
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0 h1:qHX6TTBIsrpg0JkYNkoePIpstV37lhRVLj23bHUDNwk=
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8 h1:nmFnmD8VZXkjDHIb1Gnfz50cgzUvGN72zLjPRXBW/hU=
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8/go.mod h1:SniNVYuaD1jmdEEvi+7ywb1QFR7agjeTdGKyFb0p7Rw=
github.com/djherbis/fscache v0.10.0 h1:+O3s3LwKL1Jfz7txRHpgKOz8/krh9w+NzfzxtFdbsQg=
github.com/djherbis/fscache v0.10.0/go.mod h1:yyPYtkNnnPXsW+81lAcQS6yab3G2CRfnPLotBvtbf0c=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
@@ -39,10 +41,10 @@ github.com/fatih/structs v1.0.0 h1:BrX964Rv5uQ3wwS+KRUAJCBBw5PQmgJfJ6v4yly5QwU=
github.com/fatih/structs v1.0.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/go-chi/chi v4.0.3+incompatible h1:gakN3pDJnzZN5jqFV2TEdF66rTfKeITyR8qu6ekICEY=
github.com/go-chi/chi v4.0.3+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/cors v1.0.0 h1:e6x8k7uWbUwYs+aXDoiUzeQFT6l0cygBYyNhD7/1Tg0=
github.com/go-chi/cors v1.0.0/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
github.com/go-chi/chi v4.0.4+incompatible h1:7fVnpr0gAXG15uDbtH+LwSeMztvIvlHrBNRkTzgphS0=
github.com/go-chi/chi v4.0.4+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/cors v1.0.1 h1:56TT/uWGoLWZpnMI/AwAmCneikXr5eLsiIq27wrKecw=
github.com/go-chi/cors v1.0.1/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
github.com/go-chi/jwtauth v4.0.4+incompatible h1:LGIxg6YfvSBzxU2BljXbrzVc1fMlgqSKBQgKOGAVtPY=
github.com/go-chi/jwtauth v4.0.4+incompatible/go.mod h1:Q5EIArY/QnD6BdS+IyDw7B2m6iNbnPxtfd6/BcmtWbs=
github.com/go-redis/redis v6.14.2+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
@@ -117,6 +119,8 @@ github.com/siddontang/rdb v0.0.0-20150307021120-fc89ed2e418d h1:NVwnfyR3rENtlz62
github.com/siddontang/rdb v0.0.0-20150307021120-fc89ed2e418d/go.mod h1:AMEsy7v5z92TR1JKMkLLoaOQk++LVnOKL3ScbJ8GNGA=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.5.0 h1:1N5EYkVAPEywqZRJd7cwnRtCb6xJx7NH3T3WUTF980Q=
github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w=

View File

@@ -9,9 +9,7 @@ import (
)
func main() {
if !conf.Server.DevDisableBanner {
println(consts.Banner())
}
println(consts.Banner())
conf.Load()
db.EnsureLatestVersion()

View File

@@ -3,20 +3,23 @@ package model
import "time"
type Album struct {
ID string `json:"id" orm:"column(id)"`
Name string `json:"name"`
ArtistID string `json:"artistId" orm:"pk;column(artist_id)"`
CoverArtPath string `json:"coverArtPath"`
CoverArtId string `json:"coverArtId"`
Artist string `json:"artist"`
AlbumArtist string `json:"albumArtist"`
Year int `json:"year"`
Compilation bool `json:"compilation"`
SongCount int `json:"songCount"`
Duration float32 `json:"duration"`
Genre string `json:"genre"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id" orm:"column(id)"`
Name string `json:"name"`
CoverArtPath string `json:"coverArtPath"`
CoverArtId string `json:"coverArtId"`
ArtistID string `json:"artistId" orm:"pk;column(artist_id)"`
Artist string `json:"artist"`
AlbumArtistID string `json:"albumArtistId" orm:"pk;column(album_artist_id)"`
AlbumArtist string `json:"albumArtist"`
MaxYear int `json:"maxYear"`
MinYear int `json:"minYear"`
Compilation bool `json:"compilation"`
SongCount int `json:"songCount"`
Duration float32 `json:"duration"`
Genre string `json:"genre"`
FullText string `json:"fullText"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// Annotations
PlayCount int `json:"-" orm:"-"`
@@ -33,7 +36,7 @@ type AlbumRepository interface {
Exists(id string) (bool, error)
Put(m *Album) error
Get(id string) (*Album, error)
FindByArtist(artistId string) (Albums, error)
FindByArtist(albumArtistId string) (Albums, error)
GetAll(...QueryOptions) (Albums, error)
GetRandom(...QueryOptions) (Albums, error)
GetStarred(options ...QueryOptions) (Albums, error)

View File

@@ -6,6 +6,7 @@ type Artist struct {
ID string `json:"id" orm:"column(id)"`
Name string `json:"name"`
AlbumCount int `json:"albumCount" orm:"column(album_count)"`
FullText string `json:"fullText"`
// Annotations
PlayCount int `json:"-" orm:"-"`

View File

@@ -28,6 +28,8 @@ type DataStore interface {
Playlist(ctx context.Context) PlaylistRepository
Property(ctx context.Context) PropertyRepository
User(ctx context.Context) UserRepository
Transcoding(ctx context.Context) TranscodingRepository
Player(ctx context.Context) PlayerRepository
Resource(ctx context.Context, model interface{}) ResourceRepository

View File

@@ -6,26 +6,28 @@ import (
)
type MediaFile struct {
ID string `json:"id" orm:"pk;column(id)"`
Path string `json:"path"`
Title string `json:"title"`
Album string `json:"album"`
Artist string `json:"artist"`
ArtistID string `json:"artistId" orm:"pk;column(artist_id)"`
AlbumArtist string `json:"albumArtist"`
AlbumID string `json:"albumId" orm:"pk;column(album_id)"`
HasCoverArt bool `json:"hasCoverArt"`
TrackNumber int `json:"trackNumber"`
DiscNumber int `json:"discNumber"`
Year int `json:"year"`
Size int `json:"size"`
Suffix string `json:"suffix"`
Duration float32 `json:"duration"`
BitRate int `json:"bitRate"`
Genre string `json:"genre"`
Compilation bool `json:"compilation"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id" orm:"pk;column(id)"`
Path string `json:"path"`
Title string `json:"title"`
Album string `json:"album"`
ArtistID string `json:"artistId" orm:"pk;column(artist_id)"`
Artist string `json:"artist"`
AlbumArtistID string `json:"albumArtistId"`
AlbumArtist string `json:"albumArtist"`
AlbumID string `json:"albumId" orm:"pk;column(album_id)"`
HasCoverArt bool `json:"hasCoverArt"`
TrackNumber int `json:"trackNumber"`
DiscNumber int `json:"discNumber"`
Year int `json:"year"`
Size int `json:"size"`
Suffix string `json:"suffix"`
Duration float32 `json:"duration"`
BitRate int `json:"bitRate"`
Genre string `json:"genre"`
FullText string `json:"fullText"`
Compilation bool `json:"compilation"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// Annotations
PlayCount int `json:"-" orm:"-"`

25
model/player.go Normal file
View File

@@ -0,0 +1,25 @@
package model
import (
"time"
)
type Player struct {
ID string `json:"id" orm:"column(id)"`
Name string `json:"name"`
Type string `json:"type"`
UserName string `json:"userName"`
Client string `json:"client"`
IPAddress string `json:"ipAddress"`
LastSeen time.Time `json:"lastSeen"`
TranscodingId string `json:"transcodingId"`
MaxBitRate int `json:"maxBitRate"`
}
type Players []Player
type PlayerRepository interface {
Get(id string) (*Player, error)
FindByName(client, userName string) (*Player, error)
Put(p *Player) error
}

18
model/transcoding.go Normal file
View File

@@ -0,0 +1,18 @@
package model
type Transcoding struct {
ID string `json:"id" orm:"column(id)"`
Name string `json:"name"`
TargetFormat string `json:"targetFormat"`
Command string `json:"command"`
DefaultBitRate int `json:"defaultBitRate"`
}
type Transcodings []Transcoding
type TranscodingRepository interface {
Get(id string) (*Transcoding, error)
CountAll(...QueryOptions) (int64, error)
Put(*Transcoding) error
FindByFormat(format string) (*Transcoding, error)
}

View File

@@ -6,6 +6,7 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/rest"
@@ -13,6 +14,7 @@ import (
type albumRepository struct {
sqlRepository
sqlRestful
}
func NewAlbumRepository(ctx context.Context, o orm.Ormer) model.AlbumRepository {
@@ -20,9 +22,40 @@ func NewAlbumRepository(ctx context.Context, o orm.Ormer) model.AlbumRepository
r.ctx = ctx
r.ormer = o
r.tableName = "album"
r.sortMappings = map[string]string{
"artist": "compilation asc, album_artist asc, name asc",
}
r.filterMappings = map[string]filterFunc{
"name": fullTextFilter,
"compilation": booleanFilter,
"artist_id": artistFilter,
"year": yearFilter,
}
return r
}
func yearFilter(field string, value interface{}) Sqlizer {
return Or{
And{
Gt{"min_year": 0},
LtOrEq{"min_year": value},
GtOrEq{"max_year": value},
},
Eq{"max_year": value},
}
}
func artistFilter(field string, value interface{}) Sqlizer {
return Exists("media_file", And{
ConcatExpr("album_id=album.id"),
Or{
Eq{"artist_id": value},
Eq{"album_artist_id": value},
},
})
}
func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) {
return r.count(Select(), options...)
}
@@ -32,11 +65,9 @@ func (r *albumRepository) Exists(id string) (bool, error) {
}
func (r *albumRepository) Put(a *model.Album) error {
a.FullText = r.getFullText(a.Name, a.Artist, a.AlbumArtist)
_, err := r.put(a.ID, a)
if err != nil {
return err
}
return r.index(a.ID, a.Name, a.Artist, a.AlbumArtist)
return err
}
func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder {
@@ -54,7 +85,7 @@ func (r *albumRepository) Get(id string) (*model.Album, error) {
}
func (r *albumRepository) FindByArtist(artistId string) (model.Albums, error) {
sq := r.selectAlbum().Where(Eq{"artist_id": artistId}).OrderBy("year")
sq := r.selectAlbum().Where(Eq{"album_artist_id": artistId}).OrderBy("max_year")
res := model.Albums{}
err := r.queryAll(sq, &res)
return res, err
@@ -83,9 +114,9 @@ func (r *albumRepository) Refresh(ids ...string) error {
HasCoverArt bool
}
var albums []refreshAlbum
sel := Select(`album_id as id, album as name, f.artist, f.album_artist, f.artist_id, f.compilation, f.genre,
max(f.year) as year, sum(f.duration) as duration, count(*) as song_count, a.id as current_id,
f.id as cover_art_id, f.path as cover_art_path, f.has_cover_art`).
sel := Select(`album_id as id, album as name, f.artist, f.album_artist, f.artist_id, f.album_artist_id,
f.compilation, f.genre, max(f.year) as max_year, min(f.year) as min_year, sum(f.duration) as duration,
count(*) as song_count, a.id as current_id, f.id as cover_art_id, f.path as cover_art_path, 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")
@@ -101,10 +132,12 @@ func (r *albumRepository) Refresh(ids ...string) error {
al.CoverArtId = ""
}
if al.Compilation {
al.AlbumArtist = "Various Artists"
al.AlbumArtist = consts.VariousArtists
al.AlbumArtistID = consts.VariousArtistsID
}
if al.AlbumArtist == "" {
al.AlbumArtist = al.Artist
al.AlbumArtistID = al.ArtistID
}
al.UpdatedAt = time.Now()
if al.CurrentId != "" {

View File

@@ -14,7 +14,7 @@ var _ = Describe("AlbumRepository", func() {
var repo model.AlbumRepository
BeforeEach(func() {
ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"})
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
repo = NewAlbumRepository(ctx, orm.NewOrm())
})

View File

@@ -16,6 +16,7 @@ import (
type artistRepository struct {
sqlRepository
sqlRestful
indexGroups utils.IndexGroups
}
@@ -25,6 +26,9 @@ func NewArtistRepository(ctx context.Context, o orm.Ormer) model.ArtistRepositor
r.ormer = o
r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups)
r.tableName = "artist"
r.filterMappings = map[string]filterFunc{
"name": fullTextFilter,
}
return r
}
@@ -52,11 +56,9 @@ func (r *artistRepository) getIndexKey(a *model.Artist) string {
}
func (r *artistRepository) Put(a *model.Artist) error {
a.FullText = r.getFullText(a.Name)
_, err := r.put(a.ID, a)
if err != nil {
return err
}
return r.index(a.ID, a.Name)
return err
}
func (r *artistRepository) Get(id string) (*model.Artist, error) {
@@ -106,17 +108,14 @@ func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
func (r *artistRepository) Refresh(ids ...string) error {
type refreshArtist struct {
model.Artist
CurrentId string
AlbumArtist string
Compilation bool
CurrentId string
}
var artists []refreshArtist
sel := Select("f.artist_id as id", "f.artist as name", "f.album_artist", "f.compilation",
"count(*) as album_count", "a.id as current_id").
sel := Select("f.album_artist_id as id", "f.album_artist as name", "count(*) as album_count", "a.id as current_id").
From("album f").
LeftJoin("artist a on f.artist_id = a.id").
Where(Eq{"f.artist_id": ids}).
GroupBy("f.artist_id").OrderBy("f.id")
LeftJoin("artist a on f.album_artist_id = a.id").
Where(Eq{"f.album_artist_id": ids}).
GroupBy("f.album_artist_id").OrderBy("f.id")
err := r.queryAll(sel, &artists)
if err != nil {
return err
@@ -125,12 +124,6 @@ func (r *artistRepository) Refresh(ids ...string) error {
toInsert := 0
toUpdate := 0
for _, ar := range artists {
if ar.Compilation {
ar.AlbumArtist = "Various Artists"
}
if ar.AlbumArtist != "" {
ar.Name = ar.AlbumArtist
}
if ar.CurrentId != "" {
toUpdate++
} else {
@@ -158,7 +151,7 @@ func (r *artistRepository) GetStarred(options ...model.QueryOptions) (model.Arti
}
func (r *artistRepository) PurgeEmpty() error {
del := Delete(r.tableName).Where("id not in (select distinct(artist_id) from album)")
del := Delete(r.tableName).Where("id not in (select distinct(album_artist_id) from album)")
c, err := r.executeSQL(del)
if err == nil {
if c > 0 {

View File

@@ -14,7 +14,7 @@ var _ = Describe("ArtistRepository", func() {
var repo model.ArtistRepository
BeforeEach(func() {
ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"})
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
repo = NewArtistRepository(ctx, orm.NewOrm())
})
@@ -24,7 +24,7 @@ var _ = Describe("ArtistRepository", func() {
})
})
Describe("Exist", func() {
Describe("Exists", func() {
It("returns true for an artist that is in the DB", func() {
Expect(repo.Exists("3")).To(BeTrue())
})

View File

@@ -5,6 +5,8 @@ import (
"fmt"
"regexp"
"strings"
"github.com/Masterminds/squirrel"
)
func toSqlArgs(rec interface{}) (map[string]interface{}, error) {
@@ -33,30 +35,17 @@ func toSnakeCase(str string) string {
return strings.ToLower(snake)
}
func ToStruct(m map[string]interface{}, rec interface{}, fieldNames []string) error {
var r = make(map[string]interface{}, len(m))
for _, f := range fieldNames {
v, ok := m[f]
if !ok {
return fmt.Errorf("invalid field '%s'", f)
}
r[toCamelCase(f)] = v
}
// Convert to JSON...
b, err := json.Marshal(r)
if err != nil {
return err
}
// ... then convert to struct
err = json.Unmarshal(b, &rec)
return err
func Exists(subTable string, cond squirrel.Sqlizer) exists {
return exists{subTable: subTable, cond: cond}
}
var matchUnderscore = regexp.MustCompile("_([A-Za-z])")
func toCamelCase(str string) string {
return matchUnderscore.ReplaceAllStringFunc(str, func(s string) string {
return strings.ToUpper(strings.Replace(s, "_", "", -1))
})
type exists struct {
subTable string
cond squirrel.Sqlizer
}
func (e exists) ToSql() (string, []interface{}, error) {
sql, args, err := e.cond.ToSql()
sql = fmt.Sprintf("exists (select 1 from %s where %s)", e.subTable, sql)
return sql, args, err
}

View File

@@ -0,0 +1,19 @@
package persistence
import (
"github.com/Masterminds/squirrel"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("Helpers", func() {
Describe("Exists", func() {
It("constructs the correct EXISTS query", func() {
e := Exists("album", squirrel.Eq{"id": 1})
sql, args, err := e.ToSql()
Expect(sql).To(Equal("exists (select 1 from album where id = ?)"))
Expect(args).To(Equal([]interface{}{1}))
Expect(err).To(BeNil())
})
})
})

View File

@@ -14,6 +14,7 @@ import (
type mediaFileRepository struct {
sqlRepository
sqlRestful
}
func NewMediaFileRepository(ctx context.Context, o orm.Ormer) *mediaFileRepository {
@@ -25,6 +26,9 @@ func NewMediaFileRepository(ctx context.Context, o orm.Ormer) *mediaFileReposito
"artist": "artist asc, album asc, disc_number asc, track_number asc",
"album": "album asc, disc_number asc, track_number asc",
}
r.filterMappings = map[string]filterFunc{
"title": fullTextFilter,
}
return r
}
@@ -37,11 +41,9 @@ func (r mediaFileRepository) Exists(id string) (bool, error) {
}
func (r mediaFileRepository) Put(m *model.MediaFile) error {
m.FullText = r.getFullText(m.Title, m.Album, m.Artist, m.AlbumArtist)
_, err := r.put(m.ID, m)
if err != nil {
return err
}
return r.index(m.ID, m.Title, m.Album, m.Artist, m.AlbumArtist)
return err
}
func (r mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder {

View File

@@ -16,7 +16,7 @@ var _ = Describe("MediaRepository", func() {
var mr model.MediaFileRepository
BeforeEach(func() {
ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"})
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
mr = NewMediaFileRepository(ctx, orm.NewOrm())
})

View File

@@ -68,7 +68,7 @@ func (m *MockAlbum) FindByArtist(artistId string) (model.Albums, error) {
var res = make(model.Albums, len(m.data))
i := 0
for _, a := range m.data {
if a.ArtistID == artistId {
if a.AlbumArtistID == artistId {
res[i] = *a
i++
}

View File

@@ -7,11 +7,13 @@ import (
)
type MockDataStore struct {
MockedGenre model.GenreRepository
MockedAlbum model.AlbumRepository
MockedArtist model.ArtistRepository
MockedMediaFile model.MediaFileRepository
MockedUser model.UserRepository
MockedGenre model.GenreRepository
MockedAlbum model.AlbumRepository
MockedArtist model.ArtistRepository
MockedMediaFile model.MediaFileRepository
MockedUser model.UserRepository
MockedPlayer model.PlayerRepository
MockedTranscoding model.TranscodingRepository
}
func (db *MockDataStore) Album(context.Context) model.AlbumRepository {
@@ -61,6 +63,20 @@ func (db *MockDataStore) User(context.Context) model.UserRepository {
return db.MockedUser
}
func (db *MockDataStore) Transcoding(context.Context) model.TranscodingRepository {
if db.MockedTranscoding != nil {
return db.MockedTranscoding
}
return struct{ model.TranscodingRepository }{}
}
func (db *MockDataStore) Player(context.Context) model.PlayerRepository {
if db.MockedPlayer != nil {
return db.MockedPlayer
}
return struct{ model.PlayerRepository }{}
}
func (db *MockDataStore) WithTx(block func(db model.DataStore) error) error {
return block(db)
}

View File

@@ -3,7 +3,6 @@ package persistence
import (
"context"
"reflect"
"sync"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/db"
@@ -11,10 +10,6 @@ import (
"github.com/deluan/navidrome/model"
)
var (
once sync.Once
)
type SQLStore struct {
orm orm.Ormer
}
@@ -55,10 +50,22 @@ func (s *SQLStore) User(ctx context.Context) model.UserRepository {
return NewUserRepository(ctx, s.getOrmer())
}
func (s *SQLStore) Transcoding(ctx context.Context) model.TranscodingRepository {
return NewTranscodingRepository(ctx, s.getOrmer())
}
func (s *SQLStore) Player(ctx context.Context) model.PlayerRepository {
return NewPlayerRepository(ctx, s.getOrmer())
}
func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
switch m.(type) {
case model.User:
return s.User(ctx).(model.ResourceRepository)
case model.Transcoding:
return s.Transcoding(ctx).(model.ResourceRepository)
case model.Player:
return s.Player(ctx).(model.ResourceRepository)
case model.Artist:
return s.Artist(ctx).(model.ResourceRepository)
case model.Album:
@@ -107,18 +114,6 @@ func (s *SQLStore) GC(ctx context.Context) error {
if err != nil {
return err
}
err = s.MediaFile(ctx).(*mediaFileRepository).cleanSearchIndex()
if err != nil {
return err
}
err = s.Album(ctx).(*albumRepository).cleanSearchIndex()
if err != nil {
return err
}
err = s.Artist(ctx).(*artistRepository).cleanSearchIndex()
if err != nil {
return err
}
err = s.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations()
if err != nil {
return err

View File

@@ -31,8 +31,8 @@ func TestPersistence(t *testing.T) {
}
var (
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1}
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2}
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1, FullText: "kraftwerk"}
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2, FullText: "the beatles"}
testArtists = model.Artists{
artistKraftwerk,
artistBeatles,
@@ -40,9 +40,9 @@ var (
)
var (
albumSgtPeppers = model.Album{ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", ArtistID: "3", Genre: "Rock", CoverArtId: "1", CoverArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, Year: 1967}
albumAbbeyRoad = model.Album{ID: "2", Name: "Abbey Road", Artist: "The Beatles", ArtistID: "3", Genre: "Rock", CoverArtId: "2", CoverArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, Year: 1969}
albumRadioactivity = model.Album{ID: "3", Name: "Radioactivity", Artist: "Kraftwerk", ArtistID: "2", Genre: "Electronic", CoverArtId: "3", CoverArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2}
albumSgtPeppers = model.Album{ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", AlbumArtistID: "3", Genre: "Rock", CoverArtId: "1", CoverArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967, FullText: "sgt peppers the beatles"}
albumAbbeyRoad = model.Album{ID: "2", Name: "Abbey Road", Artist: "The Beatles", AlbumArtistID: "3", Genre: "Rock", CoverArtId: "2", CoverArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969, FullText: "abbey road the beatles"}
albumRadioactivity = model.Album{ID: "3", Name: "Radioactivity", Artist: "Kraftwerk", AlbumArtistID: "2", Genre: "Electronic", CoverArtId: "3", CoverArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2, FullText: "radioactivity kraftwerk"}
testAlbums = model.Albums{
albumSgtPeppers,
albumAbbeyRoad,
@@ -51,10 +51,10 @@ var (
)
var (
songDayInALife = model.MediaFile{ID: "1", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "1", Album: "Sgt Peppers", Genre: "Rock", Path: P("/beatles/1/sgt/a day.mp3")}
songComeTogether = model.MediaFile{ID: "2", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "2", Album: "Abbey Road", Genre: "Rock", Path: P("/beatles/1/come together.mp3")}
songRadioactivity = model.MediaFile{ID: "3", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Album: "Radioactivity", Genre: "Electronic", Path: P("/kraft/radio/radio.mp3")}
songAntenna = model.MediaFile{ID: "4", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Genre: "Electronic", Path: P("/kraft/radio/antenna.mp3")}
songDayInALife = model.MediaFile{ID: "1", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "1", Album: "Sgt Peppers", Genre: "Rock", Path: P("/beatles/1/sgt/a day.mp3"), FullText: "a day in a life sgt peppers the beatles"}
songComeTogether = model.MediaFile{ID: "2", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "2", Album: "Abbey Road", Genre: "Rock", Path: P("/beatles/1/come together.mp3"), FullText: "come together abbey road the beatles"}
songRadioactivity = model.MediaFile{ID: "3", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Album: "Radioactivity", Genre: "Electronic", Path: P("/kraft/radio/radio.mp3"), FullText: "radioactivity radioactivity kraftwerk"}
songAntenna = model.MediaFile{ID: "4", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Genre: "Electronic", Path: P("/kraft/radio/antenna.mp3"), FullText: "antenna kraftwerk"}
testSongs = model.MediaFiles{
songDayInALife,
songComeTogether,
@@ -86,7 +86,7 @@ var _ = Describe("Initialize test DB", func() {
// TODO Load this data setup from file(s)
BeforeSuite(func() {
o := orm.NewOrm()
ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"})
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
mr := NewMediaFileRepository(ctx, o)
for _, s := range testSongs {
err := mr.Put(&s)

View File

@@ -0,0 +1,118 @@
package persistence
import (
"context"
. "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/model"
"github.com/deluan/rest"
)
type playerRepository struct {
sqlRepository
sqlRestful
}
func NewPlayerRepository(ctx context.Context, o orm.Ormer) model.PlayerRepository {
r := &playerRepository{}
r.ctx = ctx
r.ormer = o
r.tableName = "player"
return r
}
func (r *playerRepository) Put(p *model.Player) error {
_, err := r.put(p.ID, p)
return err
}
func (r *playerRepository) Get(id string) (*model.Player, error) {
sel := r.newSelect().Columns("*").Where(Eq{"id": id})
var res model.Player
err := r.queryOne(sel, &res)
return &res, err
}
func (r *playerRepository) FindByName(client, userName string) (*model.Player, error) {
sel := r.newSelect().Columns("*").Where(And{Eq{"client": client}, Eq{"user_name": userName}})
var res model.Player
err := r.queryOne(sel, &res)
return &res, err
}
func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBuilder {
s := r.newSelect(options...)
u := loggedUser(r.ctx)
if u.IsAdmin {
return s
}
return s.Where(Eq{"user_name": u.UserName})
}
func (r *playerRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.count(r.newRestSelect(), r.parseRestOptions(options...))
}
func (r *playerRepository) Read(id string) (interface{}, error) {
sel := r.newRestSelect().Columns("*").Where(Eq{"id": id})
var res model.Player
err := r.queryOne(sel, &res)
return &res, err
}
func (r *playerRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
sel := r.newRestSelect(r.parseRestOptions(options...)).Columns("*")
res := model.Players{}
err := r.queryAll(sel, &res)
return res, err
}
func (r *playerRepository) EntityName() string {
return "player"
}
func (r *playerRepository) NewInstance() interface{} {
return &model.Player{}
}
func (r *playerRepository) isPermitted(p *model.Player) bool {
u := loggedUser(r.ctx)
return u.IsAdmin || p.UserName == u.UserName
}
func (r *playerRepository) Save(entity interface{}) (string, error) {
t := entity.(*model.Player)
if !r.isPermitted(t) {
return "", rest.ErrPermissionDenied
}
id, err := r.put(t.ID, t)
if err == model.ErrNotFound {
return "", rest.ErrNotFound
}
return id, err
}
func (r *playerRepository) Update(entity interface{}, cols ...string) error {
t := entity.(*model.Player)
if !r.isPermitted(t) {
return rest.ErrPermissionDenied
}
_, err := r.put(t.ID, t)
if err == model.ErrNotFound {
return rest.ErrNotFound
}
return err
}
func (r *playerRepository) Delete(id string) error {
err := r.delete(And{Eq{"id": id}, Eq{"user_name": loggedUser(r.ctx).UserName}})
if err == model.ErrNotFound {
return rest.ErrNotFound
}
return err
}
var _ model.PlayerRepository = (*playerRepository)(nil)
var _ rest.Repository = (*playerRepository)(nil)
var _ rest.Persistable = (*playerRepository)(nil)

View File

@@ -71,7 +71,7 @@ func (r *playlistRepository) GetWithTracks(id string) (*model.Playlist, error) {
continue
}
pls.Duration += mf.Duration
newTracks = append(newTracks, model.MediaFile(*mf))
newTracks = append(newTracks, *mf)
}
pls.Tracks = newTracks
return pls, err

View File

@@ -21,7 +21,7 @@ var _ = Describe("PlaylistRepository", func() {
})
})
Describe("Exist", func() {
Describe("Exists", func() {
It("returns true for an existing playlist", func() {
Expect(repo.Exists("11")).To(BeTrue())
})

View File

@@ -8,11 +8,6 @@ import (
"github.com/deluan/navidrome/model"
)
type property struct {
ID string `orm:"pk;column(id)"`
Value string
}
type propertyRepository struct {
sqlRepository
}

View File

@@ -10,18 +10,6 @@ import (
"github.com/google/uuid"
)
type annotation struct {
AnnID string `json:"annID" orm:"pk;column(ann_id)"`
UserID string `json:"userID" orm:"pk;column(user_id)"`
ItemID string `json:"itemID" orm:"pk;column(item_id)"`
ItemType string `json:"itemType"`
PlayCount int `json:"playCount"`
PlayDate time.Time `json:"playDate"`
Rating int `json:"rating"`
Starred bool `json:"starred"`
StarredAt time.Time `json:"starredAt"`
}
const annotationTable = "annotation"
func (r sqlRepository) newSelectWithAnnotation(idField string, options ...model.QueryOptions) SelectBuilder {

View File

@@ -11,7 +11,6 @@ import (
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/rest"
"github.com/google/uuid"
)
@@ -29,7 +28,7 @@ func userId(ctx context.Context) string {
if user == nil {
return invalidUserId
}
usr := user.(*model.User)
usr := user.(model.User)
return usr.ID
}
@@ -38,7 +37,8 @@ func loggedUser(ctx context.Context) *model.User {
if user == nil {
return &model.User{}
}
return user.(*model.User)
u := user.(model.User)
return &u
}
func (r sqlRepository) newSelect(options ...model.QueryOptions) SelectBuilder {
@@ -214,21 +214,3 @@ func (r sqlRepository) logSQL(sql string, args []interface{}, err error, rowsAff
log.Trace(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, "elapsedTime", elapsed)
}
}
func (r sqlRepository) parseRestOptions(options ...rest.QueryOptions) model.QueryOptions {
qo := model.QueryOptions{}
if len(options) > 0 {
qo.Sort = options[0].Sort
qo.Order = strings.ToLower(options[0].Order)
qo.Max = options[0].Max
qo.Offset = options[0].Offset
if len(options[0].Filters) > 0 {
filters := And{}
for f, v := range options[0].Filters {
filters = append(filters, Like{f: fmt.Sprintf("%s%%", v)})
}
qo.Filters = filters
}
}
return qo
}

View File

@@ -0,0 +1,73 @@
package persistence
import (
"fmt"
"strings"
. "github.com/Masterminds/squirrel"
"github.com/deluan/navidrome/model"
"github.com/deluan/rest"
"github.com/kennygrant/sanitize"
)
type filterFunc = func(field string, value interface{}) Sqlizer
type sqlRestful struct {
filterMappings map[string]filterFunc
}
func (r sqlRestful) parseRestFilters(options rest.QueryOptions) Sqlizer {
if len(options.Filters) == 0 {
return nil
}
filters := And{}
for f, v := range options.Filters {
if ff, ok := r.filterMappings[f]; ok {
filters = append(filters, ff(f, v))
} else if f == "id" {
filters = append(filters, eqFilter(f, v))
} else {
filters = append(filters, startsWithFilter(f, v))
}
}
return filters
}
func (r sqlRestful) parseRestOptions(options ...rest.QueryOptions) model.QueryOptions {
qo := model.QueryOptions{}
if len(options) > 0 {
qo.Sort = options[0].Sort
qo.Order = strings.ToLower(options[0].Order)
qo.Max = options[0].Max
qo.Offset = options[0].Offset
qo.Filters = r.parseRestFilters(options[0])
}
return qo
}
func eqFilter(field string, value interface{}) Sqlizer {
return Eq{field: value}
}
func startsWithFilter(field string, value interface{}) Sqlizer {
return Like{field: fmt.Sprintf("%s%%", value)}
}
func booleanFilter(field string, value interface{}) Sqlizer {
v := strings.ToLower(value.(string))
return Eq{field: strings.ToLower(v) == "true"}
}
func fullTextFilter(field string, value interface{}) Sqlizer {
q := value.(string)
q = strings.TrimSpace(sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*"))))
parts := strings.Split(q, " ")
filters := And{}
for _, part := range parts {
filters = append(filters, Or{
Like{"full_text": part + "%"},
Like{"full_text": "%" + part + "%"},
})
}
return filters
}

View File

@@ -0,0 +1,49 @@
package persistence
import (
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("sqlRestful", func() {
Describe("parseRestFilters", func() {
var r sqlRestful
var options rest.QueryOptions
BeforeEach(func() {
r = sqlRestful{}
})
It("returns nil if filters is empty", func() {
options.Filters = nil
Expect(r.parseRestFilters(options)).To(BeNil())
})
It("returns a '=' condition for 'id' filter", func() {
options.Filters = map[string]interface{}{"id": "123"}
Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Eq{"id": "123"}}))
})
It("returns a 'in' condition for multiples 'id' filters", func() {
options.Filters = map[string]interface{}{"id": []string{"123", "456"}}
Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Eq{"id": []string{"123", "456"}}}))
})
It("returns a 'like' condition for other filters", func() {
options.Filters = map[string]interface{}{"name": "joe"}
Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Like{"name": "joe%"}}))
})
It("uses the custom filter", func() {
r.filterMappings = map[string]filterFunc{
"test": func(field string, value interface{}) squirrel.Sqlizer {
return squirrel.Gt{field: value}
},
}
options.Filters = map[string]interface{}{"test": 100}
Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Gt{"test": 100}}))
})
})
})

View File

@@ -4,34 +4,15 @@ 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 {
func (r sqlRepository) getFullText(text ...string) string {
sanitizedText := strings.Builder{}
for _, txt := range text {
sanitizedText.WriteString(strings.TrimSpace(sanitize.Accents(strings.ToLower(txt))) + " ")
}
values := map[string]interface{}{
"id": id,
"item_type": r.tableName,
"full_text": strings.TrimSpace(sanitizedText.String()),
}
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
return strings.TrimSpace(sanitizedText.String())
}
func (r sqlRepository) doSearch(q string, offset, size int, results interface{}, orderBys ...string) error {
@@ -44,7 +25,6 @@ func (r sqlRepository) doSearch(q string, offset, size int, results interface{},
if len(orderBys) > 0 {
sq = sq.OrderBy(orderBys...)
}
sq = sq.Join("search").Where("search.id = " + r.tableName + ".id")
parts := strings.Split(q, " ")
for _, part := range parts {
sq = sq.Where(Or{
@@ -55,15 +35,3 @@ func (r sqlRepository) doSearch(q string, offset, size int, results interface{},
err := r.queryAll(sq, results)
return err
}
func (r sqlRepository) cleanSearchIndex() error {
del := Delete(searchTable).Where(Eq{"item_type": r.tableName}).Where("id not in (select id from " + r.tableName + ")")
c, err := r.executeSQL(del)
if err != nil {
return err
}
if c > 0 {
log.Debug(r.ctx, "Clean-up search index", "table", r.tableName, "totalDeleted", c)
}
return nil
}

View File

@@ -0,0 +1,99 @@
package persistence
import (
"context"
. "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/model"
"github.com/deluan/rest"
)
type transcodingRepository struct {
sqlRepository
sqlRestful
}
func NewTranscodingRepository(ctx context.Context, o orm.Ormer) model.TranscodingRepository {
r := &transcodingRepository{}
r.ctx = ctx
r.ormer = o
r.tableName = "transcoding"
return r
}
func (r *transcodingRepository) Get(id string) (*model.Transcoding, error) {
sel := r.newSelect().Columns("*").Where(Eq{"id": id})
var res model.Transcoding
err := r.queryOne(sel, &res)
return &res, err
}
func (r *transcodingRepository) CountAll(qo ...model.QueryOptions) (int64, error) {
return r.count(Select(), qo...)
}
func (r *transcodingRepository) FindByFormat(format string) (*model.Transcoding, error) {
sel := r.newSelect().Columns("*").Where(Eq{"target_format": format})
var res model.Transcoding
err := r.queryOne(sel, &res)
return &res, err
}
func (r *transcodingRepository) Put(t *model.Transcoding) error {
_, err := r.put(t.ID, t)
return err
}
func (r *transcodingRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.count(Select(), r.parseRestOptions(options...))
}
func (r *transcodingRepository) Read(id string) (interface{}, error) {
return r.Get(id)
}
func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
sel := r.newSelect(r.parseRestOptions(options...)).Columns("*")
res := model.Transcodings{}
err := r.queryAll(sel, &res)
return res, err
}
func (r *transcodingRepository) EntityName() string {
return "transcoding"
}
func (r *transcodingRepository) NewInstance() interface{} {
return &model.Transcoding{}
}
func (r *transcodingRepository) Save(entity interface{}) (string, error) {
t := entity.(*model.Transcoding)
id, err := r.put(t.ID, t)
if err == model.ErrNotFound {
return "", rest.ErrNotFound
}
return id, err
}
func (r *transcodingRepository) Update(entity interface{}, cols ...string) error {
t := entity.(*model.Transcoding)
_, err := r.put(t.ID, t)
if err == model.ErrNotFound {
return rest.ErrNotFound
}
return err
}
func (r *transcodingRepository) Delete(id string) error {
err := r.delete(Eq{"id": id})
if err == model.ErrNotFound {
return rest.ErrNotFound
}
return err
}
var _ model.TranscodingRepository = (*transcodingRepository)(nil)
var _ rest.Repository = (*transcodingRepository)(nil)
var _ rest.Persistable = (*transcodingRepository)(nil)

View File

@@ -14,6 +14,7 @@ import (
type userRepository struct {
sqlRepository
sqlRestful
}
func NewUserRepository(ctx context.Context, o orm.Ormer) model.UserRepository {

View File

@@ -46,10 +46,10 @@ func (s *ChangeDetector) Scan(lastModifiedSince time.Time) (changed []string, de
func (s *ChangeDetector) loadDir(dirPath string) (children []string, lastUpdated time.Time, err error) {
dir, err := os.Open(dirPath)
defer dir.Close()
if err != nil {
return
}
defer dir.Close()
dirInfo, err := os.Stat(dirPath)
if err != nil {
return

View File

@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
)
@@ -180,7 +181,7 @@ func (s *TagScanner) processChangedDir(ctx context.Context, dir string, updatedA
numPurgedTracks := 0
for _, n := range newTracks {
err := s.ds.MediaFile(ctx).Put(&n)
updatedArtists[n.ArtistID] = true
updatedArtists[n.AlbumArtistID] = true
updatedAlbums[n.AlbumID] = true
numUpdatedTracks++
if err != nil {
@@ -191,7 +192,7 @@ func (s *TagScanner) processChangedDir(ctx context.Context, dir string, updatedA
// Remaining tracks from DB that are not in the folder are deleted
for _, ct := range currentTracks {
numPurgedTracks++
updatedArtists[ct.ArtistID] = true
updatedArtists[ct.AlbumArtistID] = true
updatedAlbums[ct.AlbumID] = true
if err := s.ds.MediaFile(ctx).Delete(ct.ID); err != nil {
return err
@@ -211,7 +212,7 @@ func (s *TagScanner) processDeletedDir(ctx context.Context, dir string, updatedA
return err
}
for _, t := range ct {
updatedArtists[t.ArtistID] = true
updatedArtists[t.AlbumArtistID] = true
updatedAlbums[t.AlbumID] = true
}
@@ -240,13 +241,10 @@ func (s *TagScanner) toMediaFile(md *Metadata) model.MediaFile {
mf.Album = md.Album()
mf.AlbumID = s.albumID(md)
mf.Album = s.mapAlbumName(md)
if md.Artist() == "" {
mf.Artist = "[Unknown Artist]"
} else {
mf.Artist = md.Artist()
}
mf.ArtistID = s.artistID(md)
mf.AlbumArtist = md.AlbumArtist()
mf.Artist = s.mapArtistName(md)
mf.AlbumArtistID = s.albumArtistID(md)
mf.AlbumArtist = s.mapAlbumArtistName(md)
mf.Genre = md.Genre()
mf.Compilation = md.Compilation()
mf.Year = md.Year()
@@ -275,19 +273,26 @@ func (s *TagScanner) mapTrackTitle(md *Metadata) string {
return md.Title()
}
func (s *TagScanner) mapArtistName(md *Metadata) string {
func (s *TagScanner) mapAlbumArtistName(md *Metadata) string {
switch {
case md.Compilation():
return "Various Artists"
return consts.VariousArtists
case md.AlbumArtist() != "":
return md.AlbumArtist()
case md.Artist() != "":
return md.Artist()
default:
return "[Unknown Artist]"
return consts.UnknownArtist
}
}
func (s *TagScanner) mapArtistName(md *Metadata) string {
if md.Artist() != "" {
return md.Artist()
}
return consts.UnknownArtist
}
func (s *TagScanner) mapAlbumName(md *Metadata) string {
name := md.Album()
if name == "" {
@@ -301,10 +306,14 @@ func (s *TagScanner) trackID(md *Metadata) string {
}
func (s *TagScanner) albumID(md *Metadata) string {
albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", s.mapArtistName(md), s.mapAlbumName(md)))
albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", s.mapAlbumArtistName(md), s.mapAlbumName(md)))
return fmt.Sprintf("%x", md5.Sum([]byte(albumPath)))
}
func (s *TagScanner) artistID(md *Metadata) string {
return fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(s.mapArtistName(md)))))
}
func (s *TagScanner) albumArtistID(md *Metadata) string {
return fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(s.mapAlbumArtistName(md)))))
}

View File

@@ -43,6 +43,8 @@ func (app *Router) routes() http.Handler {
app.R(r, "/song", model.MediaFile{})
app.R(r, "/album", model.Album{})
app.R(r, "/artist", model.Artist{})
app.R(r, "/transcoding", model.Transcoding{})
app.R(r, "/player", model.Player{})
// Keepalive endpoint to be used to keep the session valid (ex: while playing songs)
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"response":"ok"}`)) })

View File

@@ -149,7 +149,7 @@ func validateLogin(userRepo model.UserRepository, userName, password string) (*m
func contextWithUser(ctx context.Context, ds model.DataStore, claims jwt.MapClaims) context.Context {
userName := claims["sub"].(string)
user, _ := ds.User(ctx).FindByUsername(userName)
return context.WithValue(ctx, "user", user)
return context.WithValue(ctx, "user", *user)
}
func getToken(ds model.DataStore, ctx context.Context) (*jwt.Token, error) {

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"github.com/deluan/navidrome/assets"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
)
@@ -34,6 +35,7 @@ func ServeIndex(ds model.DataStore) http.HandlerFunc {
j, _ := json.Marshal(appConfig)
data := map[string]interface{}{
"AppConfig": string(j),
"Version": consts.Version(),
}
err = t.Execute(w, data)
if err != nil {

View File

@@ -1,7 +1,7 @@
package server
import (
"context"
"encoding/json"
"fmt"
"time"
@@ -14,6 +14,10 @@ import (
func initialSetup(ds model.DataStore) {
_ = ds.WithTx(func(tx model.DataStore) error {
if err := createDefaultTranscodings(ds); err != nil {
return err
}
_, err := ds.Property(nil).Get(consts.InitialSetupFlagKey)
if err == nil {
return nil
@@ -35,8 +39,7 @@ func initialSetup(ds model.DataStore) {
}
func createInitialAdminUser(ds model.DataStore) error {
ctx := context.Background()
c, err := ds.User(ctx).CountAll()
c, err := ds.User(nil).CountAll()
if err != nil {
panic(fmt.Sprintf("Could not access User table: %s", err))
}
@@ -56,7 +59,7 @@ func createInitialAdminUser(ds model.DataStore) error {
Password: initialPassword,
IsAdmin: true,
}
err := ds.User(ctx).Put(&initialUser)
err := ds.User(nil).Put(&initialUser)
if err != nil {
log.Error("Could not create initial admin user", "user", initialUser, err)
}
@@ -77,3 +80,27 @@ func createJWTSecret(ds model.DataStore) error {
}
return err
}
func createDefaultTranscodings(ds model.DataStore) error {
repo := ds.Transcoding(nil)
c, _ := repo.CountAll()
if c != 0 {
return nil
}
for _, d := range consts.DefaultTranscodings {
var j []byte
var err error
if j, err = json.Marshal(d); err != nil {
return err
}
var t model.Transcoding
if err = json.Unmarshal(j, &t); err != nil {
return err
}
log.Info("Creating default transcoding config", "name", t.Name)
if err = repo.Put(&t); err != nil {
return err
}
}
return nil
}

View File

@@ -40,11 +40,7 @@ func RequestLogger(next http.Handler) http.Handler {
case status >= 400:
log.Warn(logArgs...)
default:
if log.CurrentLevel() >= log.LevelDebug {
log.Debug(logArgs...)
} else {
log.Info(logArgs...)
}
log.Debug(logArgs...)
}
})
}

View File

@@ -66,16 +66,15 @@ 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", "interval", conf.Server.ScanInterval, err)
interval = 5 * time.Minute
} else {
log.Info("Starting scanner", "interval", interval.String())
}
if interval == 0 {
log.Warn("Scanner is disabled", "interval", conf.Server.ScanInterval)
return
}
log.Info("Starting scanner", "interval", interval.String())
go func() {
time.Sleep(2 * time.Second)
for {

View File

@@ -66,7 +66,7 @@ func (c *AlbumListController) GetAlbumList(w http.ResponseWriter, r *http.Reques
}
response := NewResponse()
response.AlbumList = &responses.AlbumList{Album: ToChildren(albums)}
response.AlbumList = &responses.AlbumList{Album: ToChildren(r.Context(), albums)}
return response, nil
}
@@ -77,7 +77,7 @@ func (c *AlbumListController) GetAlbumList2(w http.ResponseWriter, r *http.Reque
}
response := NewResponse()
response.AlbumList2 = &responses.AlbumList{Album: ToAlbums(albums)}
response.AlbumList2 = &responses.AlbumList{Album: ToAlbums(r.Context(), albums)}
return response, nil
}
@@ -91,8 +91,8 @@ func (c *AlbumListController) GetStarred(w http.ResponseWriter, r *http.Request)
response := NewResponse()
response.Starred = &responses.Starred{}
response.Starred.Artist = ToArtists(artists)
response.Starred.Album = ToChildren(albums)
response.Starred.Song = ToChildren(mediaFiles)
response.Starred.Album = ToChildren(r.Context(), albums)
response.Starred.Song = ToChildren(r.Context(), mediaFiles)
return response, nil
}
@@ -106,8 +106,8 @@ func (c *AlbumListController) GetStarred2(w http.ResponseWriter, r *http.Request
response := NewResponse()
response.Starred2 = &responses.Starred{}
response.Starred2.Artist = ToArtists(artists)
response.Starred2.Album = ToAlbums(albums)
response.Starred2.Song = ToChildren(mediaFiles)
response.Starred2.Album = ToAlbums(r.Context(), albums)
response.Starred2.Song = ToChildren(r.Context(), mediaFiles)
return response, nil
}
@@ -122,7 +122,7 @@ func (c *AlbumListController) GetNowPlaying(w http.ResponseWriter, r *http.Reque
response.NowPlaying = &responses.NowPlaying{}
response.NowPlaying.Entry = make([]responses.NowPlayingEntry, len(npInfos))
for i, entry := range npInfos {
response.NowPlaying.Entry[i].Child = ToChild(entry)
response.NowPlaying.Entry[i].Child = ToChild(r.Context(), entry)
response.NowPlaying.Entry[i].UserName = entry.UserName
response.NowPlaying.Entry[i].MinutesAgo = entry.MinutesAgo
response.NowPlaying.Entry[i].PlayerId = entry.PlayerId
@@ -143,9 +143,6 @@ func (c *AlbumListController) GetRandomSongs(w http.ResponseWriter, r *http.Requ
response := NewResponse()
response.RandomSongs = &responses.Songs{}
response.RandomSongs.Songs = make([]responses.Child, len(songs))
for i, entry := range songs {
response.RandomSongs.Songs[i] = ToChild(entry)
}
response.RandomSongs.Songs = ToChildren(r.Context(), songs)
return response, nil
}

View File

@@ -27,16 +27,17 @@ type Router struct {
Search engine.Search
Users engine.Users
Streamer engine.MediaStreamer
Players engine.Players
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,
streamer engine.MediaStreamer) *Router {
streamer engine.MediaStreamer, players engine.Players) *Router {
r := &Router{Browser: browser, Cover: cover, ListGenerator: listGenerator, Playlists: playlists,
Ratings: ratings, Scrobbler: scrobbler, Search: search, Users: users, Streamer: streamer}
Ratings: ratings, Scrobbler: scrobbler, Search: search, Users: users, Streamer: streamer, Players: players}
r.mux = r.routes()
return r
}
@@ -50,39 +51,39 @@ func (api *Router) routes() http.Handler {
r.Use(postFormToQueryParams)
r.Use(checkRequiredParameters)
// Add validation middleware
r.Use(authenticate(api.Users))
// TODO Validate version
// Subsonic endpoints, grouped by controller
r.Group(func(r chi.Router) {
c := initSystemController(api)
H(r, "ping", c.Ping)
H(r, "getLicense", c.GetLicense)
withPlayer := r.With(getPlayer(api.Players))
H(withPlayer, "ping", c.Ping)
H(withPlayer, "getLicense", c.GetLicense)
})
r.Group(func(r chi.Router) {
c := initBrowsingController(api)
H(r, "getMusicFolders", c.GetMusicFolders)
H(r, "getMusicFolders", c.GetMusicFolders)
H(r, "getIndexes", c.GetIndexes)
H(r, "getArtists", c.GetArtists)
H(r, "getGenres", c.GetGenres)
H(r, "getMusicDirectory", c.GetMusicDirectory)
H(r, "getArtist", c.GetArtist)
H(r, "getAlbum", c.GetAlbum)
H(r, "getSong", c.GetSong)
H(r, "getArtistInfo", c.GetArtistInfo)
H(r, "getArtistInfo2", c.GetArtistInfo2)
withPlayer := r.With(getPlayer(api.Players))
H(withPlayer, "getMusicFolders", c.GetMusicFolders)
H(withPlayer, "getIndexes", c.GetIndexes)
H(withPlayer, "getArtists", c.GetArtists)
H(withPlayer, "getGenres", c.GetGenres)
H(withPlayer, "getMusicDirectory", c.GetMusicDirectory)
H(withPlayer, "getArtist", c.GetArtist)
H(withPlayer, "getAlbum", c.GetAlbum)
H(withPlayer, "getSong", c.GetSong)
H(withPlayer, "getArtistInfo", c.GetArtistInfo)
H(withPlayer, "getArtistInfo2", c.GetArtistInfo2)
})
r.Group(func(r chi.Router) {
c := initAlbumListController(api)
H(r, "getAlbumList", c.GetAlbumList)
H(r, "getAlbumList2", c.GetAlbumList2)
H(r, "getStarred", c.GetStarred)
H(r, "getStarred2", c.GetStarred2)
H(r, "getNowPlaying", c.GetNowPlaying)
H(r, "getRandomSongs", c.GetRandomSongs)
withPlayer := r.With(getPlayer(api.Players))
H(withPlayer, "getAlbumList", c.GetAlbumList)
H(withPlayer, "getAlbumList2", c.GetAlbumList2)
H(withPlayer, "getStarred", c.GetStarred)
H(withPlayer, "getStarred2", c.GetStarred2)
H(withPlayer, "getNowPlaying", c.GetNowPlaying)
H(withPlayer, "getRandomSongs", c.GetRandomSongs)
})
r.Group(func(r chi.Router) {
c := initMediaAnnotationController(api)
@@ -93,16 +94,18 @@ func (api *Router) routes() http.Handler {
})
r.Group(func(r chi.Router) {
c := initPlaylistsController(api)
H(r, "getPlaylists", c.GetPlaylists)
H(r, "getPlaylist", c.GetPlaylist)
H(r, "createPlaylist", c.CreatePlaylist)
H(r, "deletePlaylist", c.DeletePlaylist)
H(r, "updatePlaylist", c.UpdatePlaylist)
withPlayer := r.With(getPlayer(api.Players))
H(withPlayer, "getPlaylists", c.GetPlaylists)
H(withPlayer, "getPlaylist", c.GetPlaylist)
H(withPlayer, "createPlaylist", c.CreatePlaylist)
H(withPlayer, "deletePlaylist", c.DeletePlaylist)
H(withPlayer, "updatePlaylist", c.UpdatePlaylist)
})
r.Group(func(r chi.Router) {
c := initSearchingController(api)
H(r, "search2", c.Search2)
H(r, "search3", c.Search3)
withPlayer := r.With(getPlayer(api.Players))
H(withPlayer, "search2", c.Search2)
H(withPlayer, "search3", c.Search3)
})
r.Group(func(r chi.Router) {
c := initUsersController(api)
@@ -115,8 +118,9 @@ func (api *Router) routes() http.Handler {
})
r.Group(func(r chi.Router) {
c := initStreamController(api)
H(r, "stream", c.Stream)
H(r, "download", c.Download)
withPlayer := r.With(getPlayer(api.Players))
H(withPlayer, "stream", c.Stream)
H(withPlayer, "download", c.Download)
})
// Deprecated/Out of scope endpoints

View File

@@ -1,6 +1,7 @@
package subsonic
import (
"context"
"fmt"
"net/http"
"time"
@@ -97,7 +98,7 @@ func (c *BrowsingController) GetMusicDirectory(w http.ResponseWriter, r *http.Re
}
response := NewResponse()
response.Directory = c.buildDirectory(dir)
response.Directory = c.buildDirectory(r.Context(), dir)
return response, nil
}
@@ -114,7 +115,7 @@ func (c *BrowsingController) GetArtist(w http.ResponseWriter, r *http.Request) (
}
response := NewResponse()
response.ArtistWithAlbumsID3 = c.buildArtist(dir)
response.ArtistWithAlbumsID3 = c.buildArtist(r.Context(), dir)
return response, nil
}
@@ -131,7 +132,7 @@ func (c *BrowsingController) GetAlbum(w http.ResponseWriter, r *http.Request) (*
}
response := NewResponse()
response.AlbumWithSongsID3 = c.buildAlbum(dir)
response.AlbumWithSongsID3 = c.buildAlbum(r.Context(), dir)
return response, nil
}
@@ -148,7 +149,7 @@ func (c *BrowsingController) GetSong(w http.ResponseWriter, r *http.Request) (*r
}
response := NewResponse()
child := ToChild(*song)
child := ToChild(r.Context(), *song)
response.Song = &child
return response, nil
}
@@ -189,7 +190,7 @@ func (c *BrowsingController) GetArtistInfo2(w http.ResponseWriter, r *http.Reque
return response, nil
}
func (c *BrowsingController) buildDirectory(d *engine.DirectoryInfo) *responses.Directory {
func (c *BrowsingController) buildDirectory(ctx context.Context, d *engine.DirectoryInfo) *responses.Directory {
dir := &responses.Directory{
Id: d.Id,
Name: d.Name,
@@ -202,11 +203,11 @@ func (c *BrowsingController) buildDirectory(d *engine.DirectoryInfo) *responses.
dir.Starred = &d.Starred
}
dir.Child = ToChildren(d.Entries)
dir.Child = ToChildren(ctx, d.Entries)
return dir
}
func (c *BrowsingController) buildArtist(d *engine.DirectoryInfo) *responses.ArtistWithAlbumsID3 {
func (c *BrowsingController) buildArtist(ctx context.Context, d *engine.DirectoryInfo) *responses.ArtistWithAlbumsID3 {
dir := &responses.ArtistWithAlbumsID3{}
dir.Id = d.Id
dir.Name = d.Name
@@ -216,11 +217,11 @@ func (c *BrowsingController) buildArtist(d *engine.DirectoryInfo) *responses.Art
dir.Starred = &d.Starred
}
dir.Album = ToAlbums(d.Entries)
dir.Album = ToAlbums(ctx, d.Entries)
return dir
}
func (c *BrowsingController) buildAlbum(d *engine.DirectoryInfo) *responses.AlbumWithSongsID3 {
func (c *BrowsingController) buildAlbum(ctx context.Context, d *engine.DirectoryInfo) *responses.AlbumWithSongsID3 {
dir := &responses.AlbumWithSongsID3{}
dir.Id = d.Id
dir.Name = d.Name
@@ -239,6 +240,6 @@ func (c *BrowsingController) buildAlbum(d *engine.DirectoryInfo) *responses.Albu
dir.Starred = &d.Starred
}
dir.Song = ToChildren(d.Entries)
dir.Song = ToChildren(ctx, d.Entries)
return dir
}

View File

@@ -1,6 +1,7 @@
package subsonic
import (
"context"
"fmt"
"mime"
"net/http"
@@ -63,16 +64,16 @@ func (e SubsonicError) Error() string {
return msg
}
func ToAlbums(entries engine.Entries) []responses.Child {
func ToAlbums(ctx context.Context, entries engine.Entries) []responses.Child {
children := make([]responses.Child, len(entries))
for i, entry := range entries {
children[i] = ToAlbum(entry)
children[i] = ToAlbum(ctx, entry)
}
return children
}
func ToAlbum(entry engine.Entry) responses.Child {
album := ToChild(entry)
func ToAlbum(ctx context.Context, entry engine.Entry) responses.Child {
album := ToChild(ctx, entry)
album.Name = album.Title
album.Title = ""
album.Parent = ""
@@ -96,15 +97,15 @@ func ToArtists(entries engine.Entries) []responses.Artist {
return artists
}
func ToChildren(entries engine.Entries) []responses.Child {
func ToChildren(ctx context.Context, entries engine.Entries) []responses.Child {
children := make([]responses.Child, len(entries))
for i, entry := range entries {
children[i] = ToChild(entry)
children[i] = ToChild(ctx, entry)
}
return children
}
func ToChild(entry engine.Entry) responses.Child {
func ToChild(ctx context.Context, entry engine.Entry) responses.Child {
child := responses.Child{}
child.Id = entry.Id
child.Title = entry.Title
@@ -136,9 +137,11 @@ func ToChild(entry engine.Entry) responses.Child {
child.IsVideo = false
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")
format, _ := getTranscoding(ctx)
if entry.Suffix != "" && format != "" && entry.Suffix != format {
child.TranscodedSuffix = format
child.TranscodedContentType = mime.TypeByExtension("." + format)
}
return child
}
@@ -149,3 +152,13 @@ func ToGenres(genres model.Genres) *responses.Genres {
}
return &responses.Genres{Genre: response}
}
func getTranscoding(ctx context.Context) (format string, bitRate int) {
if trc, ok := ctx.Value("transcoding").(model.Transcoding); ok {
format = trc.TargetFormat
}
if plr, ok := ctx.Value("player").(model.Player); ok {
bitRate = plr.MaxBitRate
}
return
}

View File

@@ -21,7 +21,7 @@ func NewMediaRetrievalController(cover engine.Cover) *MediaRetrievalController {
}
func (c *MediaRetrievalController) GetAvatar(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
f, err := static.AssetFile().Open("itunes.png")
f, err := static.AssetFile().Open("navidrone-310x310.png")
if err != nil {
log.Error(r, "Image not found", err)
return nil, NewError(responses.ErrorDataNotFound, "Avatar image not found")

View File

@@ -3,6 +3,7 @@ package subsonic
import (
"context"
"fmt"
"net"
"net/http"
"net/url"
"strings"
@@ -14,6 +15,10 @@ import (
"github.com/deluan/navidrome/utils"
)
const (
cookieExpiry = 365 * 24 * 3600 // One year
)
func postFormToQueryParams(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
@@ -82,10 +87,57 @@ func authenticate(users engine.Users) func(next http.Handler) http.Handler {
}
ctx := r.Context()
ctx = context.WithValue(ctx, "user", usr)
ctx = context.WithValue(ctx, "user", *usr)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
}
func getPlayer(players engine.Players) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
userName := ctx.Value("username").(string)
client := ctx.Value("client").(string)
playerId := playerIDFromCookie(r, userName)
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
player, trc, err := players.Register(ctx, playerId, client, r.Header.Get("user-agent"), ip)
if err != nil {
log.Error("Could not register player", "userName", userName, "client", client)
} else {
ctx = context.WithValue(ctx, "player", *player)
if trc != nil {
ctx = context.WithValue(ctx, "transcoding", *trc)
}
r = r.WithContext(ctx)
}
cookie := &http.Cookie{
Name: playerIDCookieName(userName),
Value: player.ID,
MaxAge: cookieExpiry,
HttpOnly: true,
Path: "/",
}
http.SetCookie(w, cookie)
next.ServeHTTP(w, r)
})
}
}
func playerIDFromCookie(r *http.Request, userName string) string {
cookieName := playerIDCookieName(userName)
var playerId string
if c, err := r.Cookie(cookieName); err == nil {
playerId = c.Value
log.Trace(r, "playerId found in cookies", "playerId", playerId)
}
return playerId
}
func playerIDCookieName(userName string) string {
cookieName := fmt.Sprintf("nd-player-%x", userName)
return cookieName
}

View File

@@ -107,35 +107,102 @@ var _ = Describe("Middlewares", func() {
})
Describe("Authenticate", func() {
var mockedUser *mockUsers
var mockedUsers *mockUsers
BeforeEach(func() {
mockedUser = &mockUsers{}
mockedUsers = &mockUsers{}
})
It("passes all parameters to users.Authenticate ", func() {
r := newGetRequest("u=valid", "p=password", "t=token", "s=salt", "jwt=jwt")
cp := authenticate(mockedUser)(next)
cp := authenticate(mockedUsers)(next)
cp.ServeHTTP(w, r)
Expect(mockedUser.username).To(Equal("valid"))
Expect(mockedUser.password).To(Equal("password"))
Expect(mockedUser.token).To(Equal("token"))
Expect(mockedUser.salt).To(Equal("salt"))
Expect(mockedUser.jwt).To(Equal("jwt"))
Expect(mockedUsers.username).To(Equal("valid"))
Expect(mockedUsers.password).To(Equal("password"))
Expect(mockedUsers.token).To(Equal("token"))
Expect(mockedUsers.salt).To(Equal("salt"))
Expect(mockedUsers.jwt).To(Equal("jwt"))
Expect(next.called).To(BeTrue())
user := next.req.Context().Value("user").(*model.User)
user := next.req.Context().Value("user").(model.User)
Expect(user.UserName).To(Equal("valid"))
})
It("fails authentication with wrong password", func() {
r := newGetRequest("u=invalid", "", "", "")
cp := authenticate(mockedUser)(next)
cp := authenticate(mockedUsers)(next)
cp.ServeHTTP(w, r)
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
Expect(next.called).To(BeFalse())
})
})
Describe("GetPlayer", func() {
var mockedPlayers *mockPlayers
var r *http.Request
BeforeEach(func() {
mockedPlayers = &mockPlayers{}
r = newGetRequest()
ctx := context.WithValue(r.Context(), "username", "someone")
ctx = context.WithValue(ctx, "client", "client")
r = r.WithContext(ctx)
})
It("returns a new player in the cookies when none is specified", func() {
gp := getPlayer(mockedPlayers)(next)
gp.ServeHTTP(w, r)
cookieStr := w.Header().Get("Set-Cookie")
Expect(cookieStr).To(ContainSubstring(playerIDCookieName("someone")))
})
Context("PlayerId specified in Cookies", func() {
BeforeEach(func() {
cookie := &http.Cookie{
Name: playerIDCookieName("someone"),
Value: "123",
MaxAge: cookieExpiry,
}
r.AddCookie(cookie)
gp := getPlayer(mockedPlayers)(next)
gp.ServeHTTP(w, r)
})
It("stores the player in the context", func() {
Expect(next.called).To(BeTrue())
player := next.req.Context().Value("player").(model.Player)
Expect(player.ID).To(Equal("123"))
Expect(next.req.Context().Value("transcoding")).To(BeNil())
})
It("returns the playerId in the cookie", func() {
cookieStr := w.Header().Get("Set-Cookie")
Expect(cookieStr).To(ContainSubstring(playerIDCookieName("someone") + "=123"))
})
})
Context("Player has transcoding configured", func() {
BeforeEach(func() {
cookie := &http.Cookie{
Name: playerIDCookieName("someone"),
Value: "123",
MaxAge: cookieExpiry,
}
r.AddCookie(cookie)
mockedPlayers.transcoding = &model.Transcoding{ID: "12"}
gp := getPlayer(mockedPlayers)(next)
gp.ServeHTTP(w, r)
})
It("stores the player in the context", func() {
player := next.req.Context().Value("player").(model.Player)
Expect(player.ID).To(Equal("123"))
transcoding := next.req.Context().Value("transcoding").(model.Transcoding)
Expect(transcoding.ID).To(Equal("12"))
})
})
})
})
type mockHandler struct {
@@ -164,3 +231,16 @@ func (m *mockUsers) Authenticate(ctx context.Context, username, password, token,
}
return nil, model.ErrInvalidAuth
}
type mockPlayers struct {
engine.Players
transcoding *model.Transcoding
}
func (mp *mockPlayers) Get(ctx context.Context, playerId string) (*model.Player, error) {
return &model.Player{ID: playerId}, nil
}
func (mp *mockPlayers) Register(ctx context.Context, id, client, typ, ip string) (*model.Player, *model.Transcoding, error) {
return &model.Player{ID: id}, mp.transcoding, nil
}

View File

@@ -1,6 +1,7 @@
package subsonic
import (
"context"
"errors"
"fmt"
"net/http"
@@ -57,7 +58,7 @@ func (c *PlaylistsController) GetPlaylist(w http.ResponseWriter, r *http.Request
}
response := NewResponse()
response.Playlist = c.buildPlaylist(pinfo)
response.Playlist = c.buildPlaylist(r.Context(), pinfo)
return response, nil
}
@@ -124,7 +125,7 @@ func (c *PlaylistsController) UpdatePlaylist(w http.ResponseWriter, r *http.Requ
return NewResponse(), nil
}
func (c *PlaylistsController) buildPlaylist(d *engine.PlaylistInfo) *responses.PlaylistWithSongs {
func (c *PlaylistsController) buildPlaylist(ctx context.Context, d *engine.PlaylistInfo) *responses.PlaylistWithSongs {
pls := &responses.PlaylistWithSongs{}
pls.Id = d.Id
pls.Name = d.Name
@@ -133,6 +134,6 @@ func (c *PlaylistsController) buildPlaylist(d *engine.PlaylistInfo) *responses.P
pls.Duration = d.Duration
pls.Public = d.Public
pls.Entry = ToChildren(d.Entries)
pls.Entry = ToChildren(ctx, d.Entries)
return pls
}

View File

@@ -72,8 +72,8 @@ func (c *SearchingController) Search2(w http.ResponseWriter, r *http.Request) (*
response := NewResponse()
searchResult2 := &responses.SearchResult2{}
searchResult2.Artist = ToArtists(as)
searchResult2.Album = ToChildren(als)
searchResult2.Song = ToChildren(mfs)
searchResult2.Album = ToChildren(r.Context(), als)
searchResult2.Song = ToChildren(r.Context(), mfs)
response.SearchResult2 = searchResult2
return response, nil
}
@@ -99,8 +99,8 @@ func (c *SearchingController) Search3(w http.ResponseWriter, r *http.Request) (*
searchResult3.Artist[i].Starred = &e.Starred
}
}
searchResult3.Album = ToAlbums(als)
searchResult3.Song = ToChildren(mfs)
searchResult3.Album = ToAlbums(r.Context(), als)
searchResult3.Song = ToChildren(r.Context(), mfs)
response.SearchResult3 = searchResult3
return response, nil
}

View File

@@ -27,7 +27,7 @@ func (c *StreamController) Stream(w http.ResponseWriter, r *http.Request) (*resp
maxBitRate := utils.ParamInt(r, "maxBitRate", 0)
format := utils.ParamString(r, "format")
stream, err := c.streamer.NewStream(r.Context(), id, maxBitRate, format)
stream, err := c.streamer.NewStream(r.Context(), id, format, maxBitRate)
if err != nil {
return nil, err
}
@@ -62,7 +62,7 @@ func (c *StreamController) Download(w http.ResponseWriter, r *http.Request) (*re
return nil, err
}
stream, err := c.streamer.NewStream(r.Context(), id, 0, "raw")
stream, err := c.streamer.NewStream(r.Context(), id, "raw", 0)
if err != nil {
return nil, err
}

View File

File diff suppressed because one or more lines are too long

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

1285
ui/package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -4,19 +4,19 @@
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.1.1",
"@testing-library/react": "^9.5.0",
"@testing-library/react": "^10.0.1",
"@testing-library/user-event": "^10.0.0",
"deepmerge": "^4.2.2",
"jwt-decode": "^2.2.0",
"md5-hex": "^3.0.1",
"prop-types": "^15.7.2",
"ra-data-json-server": "^3.2.3",
"react": "^16.13.0",
"react-admin": "^3.2.3",
"ra-data-json-server": "^3.3.1",
"react": "^16.13.1",
"react-admin": "^3.3.1",
"react-dom": "^16.13.0",
"react-jinke-music-player": "^4.10.1",
"react-redux": "^7.2.0",
"react-scripts": "^3.4.0"
"react-scripts": "^3.4.1"
},
"scripts": {
"start": "react-scripts start",

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Some files were not shown because too many files have changed in this diff Show More