mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-06 05:48:09 -05:00
Compare commits
99 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47311d16cf | ||
|
|
ef3466787d | ||
|
|
b7fd116bd8 | ||
|
|
34ad740e07 | ||
|
|
79454d7a92 | ||
|
|
87cc397bc3 | ||
|
|
37602a2049 | ||
|
|
56ea380bb3 | ||
|
|
177ace1cee | ||
|
|
61e3fe21ff | ||
|
|
8dcca76ec9 | ||
|
|
1dd3a794f8 | ||
|
|
6c5dd245fe | ||
|
|
3b3ad65612 | ||
|
|
e6f798811d | ||
|
|
371e8ab6ca | ||
|
|
69c19e946c | ||
|
|
d7edbf93f0 | ||
|
|
fb4d920fba | ||
|
|
5a072fbd10 | ||
|
|
79c9d8f4f4 | ||
|
|
871bf5a70a | ||
|
|
e4af235ce9 | ||
|
|
00384a60f3 | ||
|
|
f7b3ff4b34 | ||
|
|
eaa48306fc | ||
|
|
f5572b8447 | ||
|
|
a756751cc6 | ||
|
|
b8a3af090d | ||
|
|
d534cb96a9 | ||
|
|
f1e1d3bc07 | ||
|
|
694be54428 | ||
|
|
76531fb1cd | ||
|
|
716f4c5cf7 | ||
|
|
ba2d4b6859 | ||
|
|
2ec5e47328 | ||
|
|
b3f70538a9 | ||
|
|
de115ff466 | ||
|
|
129f02b36b | ||
|
|
1a8d219197 | ||
|
|
80c8d85cb9 | ||
|
|
db02f5f07f | ||
|
|
579294b0f1 | ||
|
|
f83d0d471d | ||
|
|
3b7d7bdb04 | ||
|
|
05958f5195 | ||
|
|
6cf4b81de9 | ||
|
|
689449df9e | ||
|
|
dae938de6f | ||
|
|
f6617ff77d | ||
|
|
defdc2ea6b | ||
|
|
1fd6571a87 | ||
|
|
4c0250f9f8 | ||
|
|
0e1735e7a9 | ||
|
|
a698e434fd | ||
|
|
95f658336c | ||
|
|
69dc4d97b3 | ||
|
|
4aeb63c16e | ||
|
|
e5efadf99e | ||
|
|
d117d5794d | ||
|
|
d09a2182e0 | ||
|
|
b8b09820b1 | ||
|
|
2cfd7babb3 | ||
|
|
161a9b340c | ||
|
|
605253446a | ||
|
|
f8d9b1508e | ||
|
|
3c4de3c8b5 | ||
|
|
a6c9bf1b15 | ||
|
|
bf6ec67528 | ||
|
|
289ba68824 | ||
|
|
2dfe01963a | ||
|
|
5ed1d5c19f | ||
|
|
db4479e720 | ||
|
|
66275d3b94 | ||
|
|
57f2c3f823 | ||
|
|
afba4c9915 | ||
|
|
f0d18d2cb3 | ||
|
|
da45bcf448 | ||
|
|
3a54246b15 | ||
|
|
2b06f20f41 | ||
|
|
88f44b2e77 | ||
|
|
4dff067e0b | ||
|
|
d81bf8a518 | ||
|
|
adfaf39489 | ||
|
|
f6a15905d7 | ||
|
|
52b8c5f151 | ||
|
|
c4eab5db86 | ||
|
|
4b1c76e307 | ||
|
|
e476a5f6f1 | ||
|
|
9fb4f5ef52 | ||
|
|
e232c5c561 | ||
|
|
803a5776ae | ||
|
|
a6dfcafdab | ||
|
|
8f2c7b7913 | ||
|
|
2ab647efe1 | ||
|
|
04eb421186 | ||
|
|
6a3a66975c | ||
|
|
1ef4fa970f | ||
|
|
b34523e196 |
@@ -1,6 +1,5 @@
|
||||
.DS_Store
|
||||
ui/node_modules
|
||||
ui/build
|
||||
Jamstash-master
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
@@ -11,4 +10,4 @@ navidrome
|
||||
navidrome.db
|
||||
navidrome.toml
|
||||
assets/*gen.go
|
||||
dist
|
||||
|
||||
|
||||
2
.git-blame-ignore-revs
Normal file
2
.git-blame-ignore-revs
Normal file
@@ -0,0 +1,2 @@
|
||||
# Upgrade Prettier to 2.0.4. Reformatted all JS files
|
||||
b3f70538a9138bc279a451f4f358605097210d41
|
||||
53
.github/workflows/build.yml
vendored
53
.github/workflows/build.yml
vendored
@@ -1,53 +0,0 @@
|
||||
name: Build
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
go:
|
||||
name: Test Server on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
# TODO Fix tests in Windows
|
||||
# os: [macOS-latest, ubuntu-latest, windows-latest]
|
||||
os: [macOS-latest, ubuntu-latest]
|
||||
|
||||
steps:
|
||||
- name: Set up Go 1.14
|
||||
uses: actions/setup-go@v1
|
||||
with:
|
||||
go-version: 1.14
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Test
|
||||
run: go test -cover ./... -v
|
||||
|
||||
js:
|
||||
name: Test UI
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 13
|
||||
|
||||
- name: npm install dependencies
|
||||
run: |
|
||||
cd ui
|
||||
npm ci
|
||||
|
||||
# TODO: Enable when there are tests to run
|
||||
# - name: npm test
|
||||
# run: |
|
||||
# cd ui
|
||||
# CI=test npm test
|
||||
|
||||
- name: npm build
|
||||
run: |
|
||||
cd ui
|
||||
npm run build
|
||||
17
.github/workflows/docker-tags.sh
vendored
Executable file
17
.github/workflows/docker-tags.sh
vendored
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
|
||||
GIT_TAG="${GITHUB_REF##refs/tags/}"
|
||||
GIT_BRANCH="${GITHUB_REF##refs/heads/}"
|
||||
GIT_SHA=$(git rev-parse --short HEAD)
|
||||
|
||||
DOCKER_IMAGE_TAG="--tag ${DOCKER_IMAGE}:sha-${GIT_SHA}"
|
||||
|
||||
if [[ $GITHUB_REF != $GIT_TAG ]]; then
|
||||
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:${GIT_TAG#v} --tag ${DOCKER_IMAGE}:latest"
|
||||
elif [[ $GITHUB_REF == "refs/heads/master" ]]; then
|
||||
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:develop"
|
||||
elif [[ $GIT_BRANCH = feature/* ]]; then
|
||||
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:$(echo $GIT_BRANCH | tr / -)"
|
||||
fi
|
||||
|
||||
echo ${DOCKER_IMAGE_TAG}
|
||||
40
.github/workflows/pipeline.dockerfile
vendored
Normal file
40
.github/workflows/pipeline.dockerfile
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
#####################################################
|
||||
### Copy platform specific binary
|
||||
FROM bash as copy-binary
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
RUN echo "Target Platform = ${TARGETPLATFORM}"
|
||||
|
||||
COPY dist .
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then cp navidrome_linux_musl_amd64_linux_amd64/navidrome /navidrome; fi
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then cp navidrome_linux_arm64_linux_arm64/navidrome /navidrome; fi
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then cp navidrome_linux_arm_linux_arm_6/navidrome /navidrome; fi
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then cp navidrome_linux_arm_linux_arm_7/navidrome /navidrome; fi
|
||||
RUN chmod +x /navidrome
|
||||
|
||||
|
||||
#####################################################
|
||||
### Build Final Image
|
||||
FROM alpine as release
|
||||
LABEL maintainer="deluan@navidrome.org"
|
||||
|
||||
# Install ffmpeg and output build config
|
||||
RUN apk add --no-cache ffmpeg
|
||||
RUN ffmpeg -buildconf
|
||||
|
||||
COPY --from=copy-binary /navidrome /app/
|
||||
|
||||
VOLUME ["/data", "/music"]
|
||||
ENV ND_MUSICFOLDER /music
|
||||
ENV ND_DATAFOLDER /data
|
||||
ENV ND_SCANINTERVAL 1m
|
||||
ENV ND_TRANSCODINGCACHESIZE 100MB
|
||||
ENV ND_SESSIONTIMEOUT 30m
|
||||
ENV ND_LOGLEVEL info
|
||||
ENV ND_PORT 4533
|
||||
|
||||
EXPOSE ${ND_PORT}
|
||||
HEALTHCHECK CMD wget -O- http://localhost:${ND_PORT}/ping || exit 1
|
||||
WORKDIR /app
|
||||
|
||||
ENTRYPOINT ["/app/navidrome"]
|
||||
138
.github/workflows/pipeline.yml
vendored
Normal file
138
.github/workflows/pipeline.yml
vendored
Normal file
@@ -0,0 +1,138 @@
|
||||
name: Pipeline
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
jobs:
|
||||
go:
|
||||
name: Test Server on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
# TODO Fix tests in Windows
|
||||
# os: [macOS-latest, ubuntu-latest, windows-latest]
|
||||
os: [macOS-latest, ubuntu-latest]
|
||||
|
||||
steps:
|
||||
- name: Set up Go 1.14
|
||||
uses: actions/setup-go@v1
|
||||
with:
|
||||
go-version: 1.14
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- uses: actions/cache@v1
|
||||
id: cache-go
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Download dependencies
|
||||
if: steps.cache-go.outputs.cache-hit != 'true'
|
||||
run: go mod download
|
||||
|
||||
- name: Test
|
||||
run: go test -cover ./... -v
|
||||
js:
|
||||
name: Build JS bundle
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 13
|
||||
|
||||
- uses: actions/cache@v1
|
||||
id: cache-npm
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('ui/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: npm install dependencies
|
||||
run: |
|
||||
cd ui
|
||||
npm ci
|
||||
|
||||
- name: npm build
|
||||
run: |
|
||||
cd ui
|
||||
npm run build
|
||||
|
||||
- uses: actions/upload-artifact@v1
|
||||
with:
|
||||
name: js-bundle
|
||||
path: ui/build
|
||||
|
||||
binaries:
|
||||
name: Binaries
|
||||
needs: [js, go]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Unshallow
|
||||
run: git fetch --prune --unshallow
|
||||
|
||||
- uses: actions/download-artifact@v1
|
||||
with:
|
||||
name: js-bundle
|
||||
path: ui/build
|
||||
|
||||
- name: Run GoReleaser - SNAPSHOT
|
||||
if: startsWith(github.ref, 'refs/tags/') != true
|
||||
uses: docker://deluan/ci-goreleaser:1.14.1-1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: goreleaser release --rm-dist --skip-publish --snapshot
|
||||
|
||||
- name: Run GoReleaser - RELEASE
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: docker://deluan/ci-goreleaser:1.14.1-1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: goreleaser release --rm-dist
|
||||
|
||||
- uses: actions/upload-artifact@v1
|
||||
with:
|
||||
name: binaries
|
||||
path: dist
|
||||
|
||||
docker:
|
||||
name: Docker images
|
||||
needs: [binaries]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: crazy-max/ghaction-docker-buildx@v1
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- uses: actions/download-artifact@v1
|
||||
with:
|
||||
name: binaries
|
||||
path: dist
|
||||
|
||||
- name: Build the Docker image and push
|
||||
env:
|
||||
DOCKER_IMAGE: ${{secrets.DOCKER_IMAGE}}
|
||||
DOCKER_PLATFORM: linux/amd64,linux/arm/v7,linux/arm64
|
||||
run: |
|
||||
echo ${{secrets.DOCKER_PASSWORD}} | docker login -u ${{secrets.DOCKER_USERNAME}} --password-stdin
|
||||
docker buildx build --platform ${DOCKER_PLATFORM} `.github/workflows/docker-tags.sh` -f .github/workflows/pipeline.dockerfile --push .
|
||||
31
.github/workflows/release.yml
vendored
31
.github/workflows/release.yml
vendored
@@ -1,31 +0,0 @@
|
||||
name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 13.12
|
||||
- name: Build UI
|
||||
run: |
|
||||
cd ui
|
||||
npm ci
|
||||
npm run build
|
||||
- name: Fetch tags
|
||||
run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
|
||||
- name: Run GoReleaser
|
||||
uses: docker://bepsays/ci-goreleaser:1.14-1
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: goreleaser release --rm-dist
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -9,7 +9,6 @@ vendor/*/
|
||||
wiki
|
||||
TODO.md
|
||||
var
|
||||
Artwork
|
||||
navidrome.toml
|
||||
master.zip
|
||||
Jamstash-master
|
||||
@@ -20,3 +19,7 @@ navidrome.db
|
||||
dist
|
||||
music
|
||||
docker-compose.override.yml
|
||||
navidrome.db-shm
|
||||
navidrome.db-wal
|
||||
tags
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
# GoReleaser config
|
||||
project_name: navidrome
|
||||
|
||||
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 .
|
||||
|
||||
builds:
|
||||
- id: navidrome_darwin
|
||||
@@ -21,7 +18,7 @@ builds:
|
||||
flags:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_linux_amd64
|
||||
env:
|
||||
@@ -34,7 +31,21 @@ builds:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_linux_musl_amd64
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=musl-gcc
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
flags:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_linux_arm
|
||||
env:
|
||||
@@ -51,8 +62,7 @@ builds:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- "-extld=$CC"
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_linux_arm64
|
||||
env:
|
||||
@@ -66,7 +76,7 @@ builds:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_windows_i686
|
||||
env:
|
||||
@@ -81,7 +91,7 @@ builds:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_windows_x64
|
||||
env:
|
||||
@@ -96,10 +106,24 @@ builds:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
archives:
|
||||
-
|
||||
- id: musl
|
||||
builds:
|
||||
- navidrome_linux_musl_amd64
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_musl_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
|
||||
replacements:
|
||||
linux: Linux
|
||||
amd64: x86_64
|
||||
- id: default
|
||||
builds:
|
||||
- navidrome_darwin
|
||||
- navidrome_linux_amd64
|
||||
- navidrome_linux_arm
|
||||
- navidrome_linux_arm64
|
||||
- navidrome_windows_i686
|
||||
- navidrome_windows_x64
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
@@ -111,16 +135,16 @@ archives:
|
||||
amd64: x86_64
|
||||
|
||||
checksum:
|
||||
name_template: '{{ .ProjectName }}_checksums.txt'
|
||||
name_template: "{{ .ProjectName }}_checksums.txt"
|
||||
|
||||
snapshot:
|
||||
name_template: "{{ .Tag }}-next"
|
||||
name_template: "{{ .Tag }}-SNAPSHOT"
|
||||
|
||||
release:
|
||||
draft: true
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
# sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- "^docs:"
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
|
||||
### Supported Subsonic API endpoints
|
||||
|
||||
Navidrome is currently compatible with [Subsonic API](http://www.subsonic.org/pages/api.jsp) v1.8.0, with some exceptions.
|
||||
|
||||
This is an (almost) up to date list of all Subsonic API endpoints implemented by Navidrome.
|
||||
Check the "Notes" column for limitations/missing behaviour. Also keep in mind these differences between
|
||||
Navidrome and Subsonic:
|
||||
|
||||
* Right now, Navidrome only works with a single Music Library (Music Folder)
|
||||
* Navidrome does not mark songs as played by calls to `stream`, only when
|
||||
`scrobble` is called with `submission=true`
|
||||
* Next features to be implemented: Last.FM integration, Jukebox, Sharing, Bookmarks, Podcasts, Internet Radio.
|
||||
|
||||
Navidrome is actively being tested with:
|
||||
[DSub](http://www.subsonic.org/pages/apps.jsp#dsub),
|
||||
[Music Stash](https://play.google.com/store/apps/details?id=com.ghenry22.mymusicstash) and
|
||||
[Jamstash](http://www.subsonic.org/pages/apps.jsp#jamstash))
|
||||
|
||||
|
||||
| ENDPOINT | NOTES |
|
||||
|------------------------|-------|
|
||||
| _SYSTEM_ ||
|
||||
| `ping` | |
|
||||
| `getLicense` | Always valid ;) |
|
||||
| ||
|
||||
| _BROWSING_ ||
|
||||
| `getMusicFolders` | Hardcoded to just one, set with ND_MUSICFOLDER configuration |
|
||||
| `getIndexes` | Doesn't support shortcuts, nor direct children |
|
||||
| `getMusicDirectory` | |
|
||||
| `getSong` | |
|
||||
| `getArtists` | |
|
||||
| `getArtist` | |
|
||||
| `getAlbum` | |
|
||||
| `getGenres` | |
|
||||
| ||
|
||||
| _ALBUM/SONGS LISTS_ ||
|
||||
| `getAlbumList` | `byYear` and `byGenre` are not implemented |
|
||||
| `getAlbumList2` | `byYear` and `byGenre` are not implemented |
|
||||
| `getStarred` | |
|
||||
| `getStarred2` | |
|
||||
| `getNowPlaying` | |
|
||||
| `getRandomSongs` | Ignores `fromYear` and `toYear` parameters |
|
||||
| ||
|
||||
| _SEARCHING_ ||
|
||||
| `search2` | Doesn't support Lucene queries, only simple auto complete queries |
|
||||
| `search3` | Doesn't support Lucene queries, only simple auto complete queries |
|
||||
| ||
|
||||
| _PLAYLISTS_ ||
|
||||
| `getPlaylists` | `username` parameter is not implemented |
|
||||
| `getPlaylist` | |
|
||||
| `createPlaylist` | Return empty response on success |
|
||||
| `updatePlaylist` | `comment` and `public` are not implemented. All playlists are public |
|
||||
| `deletePlaylist` | |
|
||||
| ||
|
||||
| _MEDIA RETRIEVAL_ ||
|
||||
| `stream` | |
|
||||
| `download` | |
|
||||
| `getCoverArt` | Only gets embedded artwork |
|
||||
| `getAvatar` | Always returns the same image |
|
||||
| ||
|
||||
| _MEDIA ANNOTATION_ ||
|
||||
| `star` | |
|
||||
| `unstar` | |
|
||||
| `setRating` | |
|
||||
| `scrobble` | No Last.FM support yet. It is used to update play count and last played |
|
||||
| ||
|
||||
| _USER MANAGEMENT_ ||
|
||||
| `getUser` | Hardcoded all roles, ignores `username` parameter|
|
||||
21
Dockerfile
21
Dockerfile
@@ -1,6 +1,6 @@
|
||||
#####################################################
|
||||
### Build UI bundles
|
||||
FROM node:13.12-alpine AS jsbuilder
|
||||
FROM node:13-alpine AS jsbuilder
|
||||
WORKDIR /src
|
||||
COPY ui/package.json ui/package-lock.json ./
|
||||
RUN npm ci
|
||||
@@ -17,11 +17,6 @@ RUN mkdir -p /src/ui/build
|
||||
RUN apk add -U --no-cache build-base git
|
||||
RUN go get -u github.com/go-bindata/go-bindata/...
|
||||
|
||||
# Download and unpack static ffmpeg
|
||||
ARG FFMPEG_URL=https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
|
||||
RUN wget -O /tmp/ffmpeg.tar.xz ${FFMPEG_URL}
|
||||
RUN cd /tmp && tar xJf ffmpeg.tar.xz && rm ffmpeg.tar.xz
|
||||
|
||||
# Download project dependencies
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
@@ -46,17 +41,12 @@ RUN GIT_TAG=$(git describe --tags `git rev-list --tags --max-count=1`) && \
|
||||
#####################################################
|
||||
### Build Final Image
|
||||
FROM alpine as release
|
||||
MAINTAINER Deluan Quintao <navidrome@deluan.com>
|
||||
|
||||
# Download Tini
|
||||
ENV TINI_VERSION v0.18.0
|
||||
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-static /tini
|
||||
RUN chmod +x /tini
|
||||
LABEL maintainer="deluan@navidrome.org"
|
||||
|
||||
COPY --from=gobuilder /src/navidrome /app/
|
||||
COPY --from=gobuilder /tmp/ffmpeg*/ffmpeg /usr/bin/
|
||||
|
||||
# Check if ffmpeg runs properly
|
||||
# Install ffmpeg and output build config
|
||||
RUN apk add --no-cache ffmpeg
|
||||
RUN ffmpeg -buildconf
|
||||
|
||||
VOLUME ["/data", "/music"]
|
||||
@@ -72,5 +62,4 @@ EXPOSE ${ND_PORT}
|
||||
HEALTHCHECK CMD wget -O- http://localhost:${ND_PORT}/ping || exit 1
|
||||
WORKDIR /app
|
||||
|
||||
ENTRYPOINT ["/tini", "--"]
|
||||
CMD ["/app/navidrome"]
|
||||
ENTRYPOINT ["/app/navidrome"]
|
||||
|
||||
9
Makefile
9
Makefile
@@ -2,6 +2,7 @@ GO_VERSION=$(shell grep -e "^go " go.mod | cut -f 2 -d ' ')
|
||||
NODE_VERSION=$(shell cat .nvmrc)
|
||||
|
||||
GIT_SHA=$(shell git rev-parse --short HEAD)
|
||||
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)
|
||||
|
||||
## Default target just build the Go project.
|
||||
default:
|
||||
@@ -33,7 +34,7 @@ testall: check_go_env test
|
||||
@(cd ./ui && npm test -- --watchAll=false)
|
||||
.PHONY: testall
|
||||
|
||||
setup: Jamstash-master
|
||||
setup:
|
||||
@which wire || (echo "Installing Wire" && GO111MODULE=off go get -u github.com/google/wire/cmd/wire)
|
||||
@which go-bindata || (echo "Installing BinData" && GO111MODULE=off go get -u github.com/go-bindata/go-bindata/...)
|
||||
@which reflex || (echo "Installing Reflex" && GO111MODULE=off go get -u github.com/cespare/reflex)
|
||||
@@ -76,13 +77,13 @@ check_node_env:
|
||||
.PHONY: check_node_env
|
||||
|
||||
build: check_go_env
|
||||
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=master"
|
||||
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT"
|
||||
.PHONY: build
|
||||
|
||||
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
|
||||
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT" -tags=embed
|
||||
.PHONY: buildall
|
||||
|
||||
release:
|
||||
@@ -95,5 +96,5 @@ release:
|
||||
.PHONY: release
|
||||
|
||||
snapshot:
|
||||
docker run -it -v $(PWD):/workspace -w /workspace bepsays/ci-goreleaser:1.14-1 goreleaser release --rm-dist --skip-publish --snapshot
|
||||
docker run -it -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:1.14.1-1 goreleaser release --rm-dist --skip-publish --snapshot
|
||||
.PHONY: snapshot
|
||||
|
||||
@@ -110,7 +110,7 @@ To get the cutting-edge, latest version from master, use the image `deluan/navid
|
||||
|
||||
### Build from source
|
||||
|
||||
You will need to install [Go 1.14](https://golang.org/dl/) and [Node 13.12.0](http://nodejs.org).
|
||||
You will need to install [Go 1.14](https://golang.org/dl/) and [Node 13](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)
|
||||
|
||||
@@ -158,5 +158,5 @@ folder for startup files for your init system.
|
||||
|
||||
## Subsonic API Version Compatibility
|
||||
|
||||
Check the up to date [compatibility table](https://github.com/deluan/navidrome/blob/master/API_COMPATIBILITY.md)
|
||||
Check the up to date [compatibility table](https://www.navidrome.org/docs/developers/subsonic-api)
|
||||
for the latest Subsonic features available.
|
||||
|
||||
14
bin/fmt.sh
14
bin/fmt.sh
@@ -1,14 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
gofmtcmd=`which goimports || echo "gofmt"`
|
||||
|
||||
gofiles=$(git diff --name-only --diff-filter=ACM | grep '.go$')
|
||||
[ -z "$gofiles" ] && exit 0
|
||||
|
||||
unformatted=`$gofmtcmd -l $gofiles`
|
||||
[ -z "$unformatted" ] && exit 0
|
||||
|
||||
for f in $unformatted; do
|
||||
$gofmtcmd -w -l "$f"
|
||||
gofmt -s -w -l "$f"
|
||||
done
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2012 The Go Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style
|
||||
# license that can be found in the LICENSE file.
|
||||
|
||||
# git gofmt pre-commit hook
|
||||
#
|
||||
# To use, store as .git/hooks/pre-commit inside your repository and make sure
|
||||
# it has execute permissions.
|
||||
#
|
||||
# This script does not handle file names that contain spaces.
|
||||
|
||||
gofmtcmd=`which goimports || echo "gofmt"`
|
||||
|
||||
gofiles=$(git diff --cached --name-only --diff-filter=ACM | grep '.go$')
|
||||
[ -z "$gofiles" ] && exit 0
|
||||
|
||||
unformatted=$($gofmtcmd -l $gofiles)
|
||||
[ -z "$unformatted" ] && exit 0
|
||||
|
||||
# Some files are not gofmt'd. Print message and fail.
|
||||
|
||||
echo >&2 "Go files must be formatted with $gofmcmd. Please run:"
|
||||
for fn in $unformatted; do
|
||||
echo >&2 " $gofmtcmd -w $PWD/$fn"
|
||||
done
|
||||
|
||||
exit 1
|
||||
@@ -29,7 +29,7 @@ type nd struct {
|
||||
|
||||
TranscodingCacheSize string `default:"100MB"` // in MB
|
||||
ImageCacheSize string `default:"100MB"` // in MB
|
||||
ProbeCommand string `default:"ffmpeg -i %s -f ffmetadata"`
|
||||
ProbeCommand string `default:"ffmpeg %s -f ffmetadata"`
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
DevLogSourceLine bool `default:"false"`
|
||||
|
||||
@@ -26,6 +26,9 @@ const (
|
||||
|
||||
URLPathUI = "/app"
|
||||
URLPathSubsonicAPI = "/rest"
|
||||
|
||||
RequestThrottleBacklogLimit = 100
|
||||
RequestThrottleBacklogTimeout = time.Minute
|
||||
)
|
||||
|
||||
// Cache options
|
||||
|
||||
@@ -37,7 +37,7 @@ update annotation set item_type = 'media_file' where item_type = 'mediaFile';
|
||||
}
|
||||
|
||||
func Down20200131183653(tx *sql.Tx) error {
|
||||
tx.Exec(`
|
||||
_, err := tx.Exec(`
|
||||
create table search_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
@@ -59,5 +59,5 @@ create index search_table
|
||||
|
||||
update annotation set item_type = 'mediaFile' where item_type = 'media_file';
|
||||
`)
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -51,6 +51,5 @@ create index annotation_starred
|
||||
}
|
||||
|
||||
func Down20200208222418(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -16,6 +16,5 @@ func Up20200310171621(tx *sql.Tx) error {
|
||||
}
|
||||
|
||||
func Down20200310171621(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -37,6 +37,5 @@ drop table if exists search;
|
||||
}
|
||||
|
||||
func Down20200319211049(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -75,6 +75,5 @@ create index album_max_year
|
||||
}
|
||||
|
||||
func Down20200327193744(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -25,6 +25,5 @@ create index if not exists media_file_track_number
|
||||
}
|
||||
|
||||
func Down20200404214704(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200411164603, Down20200411164603)
|
||||
}
|
||||
|
||||
func Up20200411164603(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table playlist
|
||||
add created_at datetime;
|
||||
alter table playlist
|
||||
add updated_at datetime;
|
||||
update playlist
|
||||
set created_at = datetime('now'), updated_at = datetime('now');
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200411164603(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
19
db/migration/20200418110522_reindex_to_fix_album_years.go
Normal file
19
db/migration/20200418110522_reindex_to_fix_album_years.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200418110522, Down20200418110522)
|
||||
}
|
||||
|
||||
func Up20200418110522(tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to fix search Albums by year")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200418110522(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200419222708, Down20200419222708)
|
||||
}
|
||||
|
||||
func Up20200419222708(tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to change the search behaviour")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200419222708(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
64
db/migration/20200423204116_add_sort_fields.go
Normal file
64
db/migration/20200423204116_add_sort_fields.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200423204116, Down20200423204116)
|
||||
}
|
||||
|
||||
func Up20200423204116(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table artist
|
||||
add order_artist_name varchar(255) collate nocase;
|
||||
alter table artist
|
||||
add sort_artist_name varchar(255) collate nocase;
|
||||
create index if not exists artist_order_artist_name
|
||||
on artist (order_artist_name);
|
||||
|
||||
alter table album
|
||||
add order_album_name varchar(255) collate nocase;
|
||||
alter table album
|
||||
add order_album_artist_name varchar(255) collate nocase;
|
||||
alter table album
|
||||
add sort_album_name varchar(255) collate nocase;
|
||||
alter table album
|
||||
add sort_artist_name varchar(255) collate nocase;
|
||||
alter table album
|
||||
add sort_album_artist_name varchar(255) collate nocase;
|
||||
create index if not exists album_order_album_name
|
||||
on album (order_album_name);
|
||||
create index if not exists album_order_album_artist_name
|
||||
on album (order_album_artist_name);
|
||||
|
||||
alter table media_file
|
||||
add order_album_name varchar(255) collate nocase;
|
||||
alter table media_file
|
||||
add order_album_artist_name varchar(255) collate nocase;
|
||||
alter table media_file
|
||||
add order_artist_name varchar(255) collate nocase;
|
||||
alter table media_file
|
||||
add sort_album_name varchar(255) collate nocase;
|
||||
alter table media_file
|
||||
add sort_artist_name varchar(255) collate nocase;
|
||||
alter table media_file
|
||||
add sort_album_artist_name varchar(255) collate nocase;
|
||||
alter table media_file
|
||||
add sort_title varchar(255) collate nocase;
|
||||
create index if not exists media_file_order_album_name
|
||||
on media_file (order_album_name);
|
||||
create index if not exists media_file_order_artist_name
|
||||
on media_file (order_artist_name);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed to change the search behaviour")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200423204116(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@@ -46,6 +46,18 @@ func (c *cover) Get(ctx context.Context, id string, size int, out io.Writer) err
|
||||
return err
|
||||
}
|
||||
|
||||
// If cache is disabled, just read the coverart directly from file
|
||||
if c.cache == nil {
|
||||
log.Trace(ctx, "Retrieving cover art from file", "path", path, "size", size, err)
|
||||
reader, err := c.getCover(ctx, path, size)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading cover art", "path", path, "size", size, err)
|
||||
} else {
|
||||
_, err = io.Copy(out, reader)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
cacheKey := imageCacheKey(path, size, lastUpdate)
|
||||
r, w, err := c.cache.Get(cacheKey)
|
||||
if err != nil {
|
||||
@@ -158,5 +170,5 @@ func readFromTag(path string) ([]byte, error) {
|
||||
}
|
||||
|
||||
func NewImageCache() (ImageCache, error) {
|
||||
return newFileCache("image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems)
|
||||
return newFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems)
|
||||
}
|
||||
|
||||
@@ -20,86 +20,106 @@ var _ = Describe("Cover", func() {
|
||||
ds = &persistence.MockDataStore{MockedTranscoding: &mockTranscodingRepository{}}
|
||||
ds.Album(ctx).(*persistence.MockAlbum).SetData(`[{"id": "222", "coverArtId": "123"}, {"id": "333", "coverArtId": ""}]`)
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "hasCoverArt": true, "updatedAt":"2020-04-02T21:29:31.6377Z"}]`)
|
||||
cover = NewCover(ds, testCache)
|
||||
})
|
||||
|
||||
It("retrieves the original cover art from an album", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "222", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
|
||||
It("accepts albumIds with 'al-' prefix", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "al-222", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
|
||||
It("returns the default cover if album does not have cover", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "333", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("png"))
|
||||
})
|
||||
|
||||
It("returns the default cover if album is not found", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "444", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("png"))
|
||||
})
|
||||
|
||||
It("retrieves the original cover art from a media_file", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 0, buf)).To(BeNil())
|
||||
|
||||
img, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(600))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(600))
|
||||
})
|
||||
|
||||
It("resized cover art as requested", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 200, buf)).To(BeNil())
|
||||
|
||||
img, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
|
||||
Context("Errors", func() {
|
||||
It("returns err if gets error from album table", func() {
|
||||
ds.Album(ctx).(*persistence.MockAlbum).SetError(true)
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "222", 0, buf)).To(MatchError("Error!"))
|
||||
Context("Cache is configured", func() {
|
||||
BeforeEach(func() {
|
||||
cover = NewCover(ds, testCache)
|
||||
})
|
||||
|
||||
It("returns err if gets error from media_file table", func() {
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetError(true)
|
||||
It("retrieves the original cover art from an album", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 0, buf)).To(MatchError("Error!"))
|
||||
Expect(cover.Get(ctx, "222", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
|
||||
It("accepts albumIds with 'al-' prefix", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "al-222", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
|
||||
It("returns the default cover if album does not have cover", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "333", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("png"))
|
||||
})
|
||||
|
||||
It("returns the default cover if album is not found", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "444", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("png"))
|
||||
})
|
||||
|
||||
It("retrieves the original cover art from a media_file", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 0, buf)).To(BeNil())
|
||||
|
||||
img, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(600))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(600))
|
||||
})
|
||||
|
||||
It("resized cover art as requested", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 200, buf)).To(BeNil())
|
||||
|
||||
img, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
|
||||
Context("Errors", func() {
|
||||
It("returns err if gets error from album table", func() {
|
||||
ds.Album(ctx).(*persistence.MockAlbum).SetError(true)
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "222", 0, buf)).To(MatchError("Error!"))
|
||||
})
|
||||
|
||||
It("returns err if gets error from media_file table", func() {
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetError(true)
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 0, buf)).To(MatchError("Error!"))
|
||||
})
|
||||
})
|
||||
})
|
||||
Context("Cache is NOT configured", func() {
|
||||
BeforeEach(func() {
|
||||
cover = NewCover(ds, nil)
|
||||
})
|
||||
|
||||
It("retrieves the original cover art from an album", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "222", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,10 @@ import (
|
||||
)
|
||||
|
||||
func newFileCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cache, error) {
|
||||
if cacheSize == "0" {
|
||||
log.Warn(fmt.Sprintf("%s cache disabled", name))
|
||||
return nil, nil
|
||||
}
|
||||
size, err := humanize.ParseBytes(cacheSize)
|
||||
if err != nil {
|
||||
size = consts.DefaultCacheSize
|
||||
|
||||
@@ -29,5 +29,9 @@ var _ = Describe("File Caches", func() {
|
||||
It("creates the cache folder with invalid size", func() {
|
||||
Expect(newFileCache("test", "abc", "test", 10)).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("returns empty if cache size is '0'", func() {
|
||||
Expect(newFileCache("test", "0", "test", 10)).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,23 +9,98 @@ import (
|
||||
)
|
||||
|
||||
type ListGenerator interface {
|
||||
GetNewest(ctx context.Context, offset int, size int) (Entries, error)
|
||||
GetRecent(ctx context.Context, offset int, size int) (Entries, error)
|
||||
GetFrequent(ctx context.Context, offset int, size int) (Entries, error)
|
||||
GetHighest(ctx context.Context, offset int, size int) (Entries, error)
|
||||
GetRandom(ctx context.Context, offset int, size int) (Entries, error)
|
||||
GetByName(ctx context.Context, offset int, size int) (Entries, error)
|
||||
GetByArtist(ctx context.Context, offset int, size int) (Entries, error)
|
||||
GetStarred(ctx context.Context, offset int, size int) (Entries, error)
|
||||
GetAllStarred(ctx context.Context) (artists Entries, albums Entries, mediaFiles Entries, err error)
|
||||
GetNowPlaying(ctx context.Context) (Entries, error)
|
||||
GetRandomSongs(ctx context.Context, size int, genre string) (Entries, error)
|
||||
GetSongs(ctx context.Context, offset, size int, filter ListFilter) (Entries, error)
|
||||
GetAlbums(ctx context.Context, offset, size int, filter ListFilter) (Entries, error)
|
||||
}
|
||||
|
||||
func NewListGenerator(ds model.DataStore, npRepo NowPlayingRepository) ListGenerator {
|
||||
return &listGenerator{ds, npRepo}
|
||||
}
|
||||
|
||||
type ListFilter model.QueryOptions
|
||||
|
||||
func ByNewest() ListFilter {
|
||||
return ListFilter{Sort: "createdAt", Order: "desc"}
|
||||
}
|
||||
|
||||
func ByRecent() ListFilter {
|
||||
return ListFilter{Sort: "playDate", Order: "desc", Filters: squirrel.Gt{"play_date": time.Time{}}}
|
||||
}
|
||||
|
||||
func ByFrequent() ListFilter {
|
||||
return ListFilter{Sort: "playCount", Order: "desc", Filters: squirrel.Gt{"play_count": 0}}
|
||||
}
|
||||
|
||||
func ByRandom() ListFilter {
|
||||
return ListFilter{Sort: "random()"}
|
||||
}
|
||||
|
||||
func ByName() ListFilter {
|
||||
return ListFilter{Sort: "name"}
|
||||
}
|
||||
|
||||
func ByArtist() ListFilter {
|
||||
return ListFilter{Sort: "artist"}
|
||||
}
|
||||
|
||||
func ByStarred() ListFilter {
|
||||
return ListFilter{Sort: "starred_at", Order: "desc", Filters: squirrel.Eq{"starred": true}}
|
||||
}
|
||||
|
||||
func ByRating() ListFilter {
|
||||
return ListFilter{Sort: "Rating", Order: "desc", Filters: squirrel.Gt{"rating": 0}}
|
||||
}
|
||||
|
||||
func ByGenre(genre string) ListFilter {
|
||||
return ListFilter{
|
||||
Sort: "genre asc, name asc",
|
||||
Filters: squirrel.Eq{"genre": genre},
|
||||
}
|
||||
}
|
||||
|
||||
func ByYear(fromYear, toYear int) ListFilter {
|
||||
return ListFilter{
|
||||
Sort: "max_year, name",
|
||||
Filters: squirrel.Or{
|
||||
squirrel.And{
|
||||
squirrel.GtOrEq{"min_year": fromYear},
|
||||
squirrel.LtOrEq{"min_year": toYear},
|
||||
},
|
||||
squirrel.And{
|
||||
squirrel.GtOrEq{"max_year": fromYear},
|
||||
squirrel.LtOrEq{"max_year": toYear},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func SongsByGenre(genre string) ListFilter {
|
||||
return ListFilter{
|
||||
Sort: "genre asc, title asc",
|
||||
Filters: squirrel.Eq{"genre": genre},
|
||||
}
|
||||
}
|
||||
|
||||
func SongsByRandom(genre string, fromYear, toYear int) ListFilter {
|
||||
options := ListFilter{
|
||||
Sort: "random()",
|
||||
}
|
||||
ff := squirrel.And{}
|
||||
if genre != "" {
|
||||
ff = append(ff, squirrel.Eq{"genre": genre})
|
||||
}
|
||||
if fromYear != 0 {
|
||||
ff = append(ff, squirrel.GtOrEq{"year": fromYear})
|
||||
}
|
||||
if toYear != 0 {
|
||||
ff = append(ff, squirrel.LtOrEq{"year": toYear})
|
||||
}
|
||||
options.Filters = ff
|
||||
return options
|
||||
}
|
||||
|
||||
type listGenerator struct {
|
||||
ds model.DataStore
|
||||
npRepo NowPlayingRepository
|
||||
@@ -43,54 +118,11 @@ func (g *listGenerator) query(ctx context.Context, qo model.QueryOptions) (Entri
|
||||
return FromAlbums(albums), err
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetNewest(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
qo := model.QueryOptions{Sort: "CreatedAt", Order: "desc", Offset: offset, Max: size}
|
||||
return g.query(ctx, qo)
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetRecent(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
qo := model.QueryOptions{Sort: "PlayDate", Order: "desc", Offset: offset, Max: size,
|
||||
Filters: squirrel.Gt{"play_date": time.Time{}}}
|
||||
return g.query(ctx, qo)
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetFrequent(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
qo := model.QueryOptions{Sort: "PlayCount", Order: "desc", Offset: offset, Max: size,
|
||||
Filters: squirrel.Gt{"play_count": 0}}
|
||||
return g.query(ctx, qo)
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetHighest(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
qo := model.QueryOptions{Sort: "Rating", Order: "desc", Offset: offset, Max: size,
|
||||
Filters: squirrel.Gt{"rating": 0}}
|
||||
return g.query(ctx, qo)
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetByName(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
qo := model.QueryOptions{Sort: "Name", Offset: offset, Max: size}
|
||||
return g.query(ctx, qo)
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetByArtist(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
qo := model.QueryOptions{Sort: "Artist", Offset: offset, Max: size}
|
||||
return g.query(ctx, qo)
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetRandom(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
albums, err := g.ds.Album(ctx).GetRandom(model.QueryOptions{Max: size, Offset: offset})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return FromAlbums(albums), nil
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetRandomSongs(ctx context.Context, size int, genre string) (Entries, error) {
|
||||
options := model.QueryOptions{Max: size}
|
||||
if genre != "" {
|
||||
options.Filters = squirrel.Eq{"genre": genre}
|
||||
}
|
||||
mediaFiles, err := g.ds.MediaFile(ctx).GetRandom(options)
|
||||
func (g *listGenerator) GetSongs(ctx context.Context, offset, size int, filter ListFilter) (Entries, error) {
|
||||
qo := model.QueryOptions(filter)
|
||||
qo.Offset = offset
|
||||
qo.Max = size
|
||||
mediaFiles, err := g.ds.MediaFile(ctx).GetAll(qo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -98,6 +130,18 @@ func (g *listGenerator) GetRandomSongs(ctx context.Context, size int, genre stri
|
||||
return FromMediaFiles(mediaFiles), nil
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetAlbums(ctx context.Context, offset, size int, filter ListFilter) (Entries, error) {
|
||||
qo := model.QueryOptions(filter)
|
||||
qo.Offset = offset
|
||||
qo.Max = size
|
||||
albums, err := g.ds.Album(ctx).GetAll(qo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return FromAlbums(albums), nil
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetStarred(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
qo := model.QueryOptions{Offset: offset, Max: size, Sort: "starred_at", Order: "desc"}
|
||||
albums, err := g.ds.Album(ctx).GetStarred(qo)
|
||||
|
||||
@@ -83,7 +83,7 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat str
|
||||
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)
|
||||
out, err := ms.ffm.Start(ctx, t.Command, mf.Path, bitRate)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error starting transcoder", "id", mf.ID, err)
|
||||
return nil, os.ErrInvalid
|
||||
@@ -213,5 +213,5 @@ func getFinalCachedSize(r fscache.ReadAtCloser) int64 {
|
||||
}
|
||||
|
||||
func NewTranscodingCache() (TranscodingCache, error) {
|
||||
return newFileCache("transcoding", conf.Server.TranscodingCacheSize, consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems)
|
||||
return newFileCache("Transcoding", conf.Server.TranscodingCacheSize, consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems)
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ type fakeFFmpeg struct {
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (ff *fakeFFmpeg) Start(ctx context.Context, cmd, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
|
||||
func (ff *fakeFFmpeg) Start(ctx context.Context, cmd, path string, maxBitRate int) (f io.ReadCloser, err error) {
|
||||
ff.r = strings.NewReader(ff.Data)
|
||||
return ff, nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
@@ -118,10 +119,12 @@ type PlaylistInfo struct {
|
||||
Public bool
|
||||
Owner string
|
||||
Comment string
|
||||
Created time.Time
|
||||
Changed time.Time
|
||||
}
|
||||
|
||||
func (p *playlists) Get(ctx context.Context, id string) (*PlaylistInfo, error) {
|
||||
pl, err := p.ds.Playlist(ctx).GetWithTracks(id)
|
||||
pl, err := p.ds.Playlist(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -135,6 +138,8 @@ func (p *playlists) Get(ctx context.Context, id string) (*PlaylistInfo, error) {
|
||||
Public: pl.Public,
|
||||
Owner: pl.Owner,
|
||||
Comment: pl.Comment,
|
||||
Changed: pl.UpdatedAt,
|
||||
Created: pl.CreatedAt,
|
||||
}
|
||||
|
||||
plsInfo.Entries = FromMediaFiles(pl.Tracks)
|
||||
|
||||
@@ -12,20 +12,25 @@ import (
|
||||
)
|
||||
|
||||
type Transcoder interface {
|
||||
Start(ctx context.Context, command, path string, maxBitRate int, format string) (f io.ReadCloser, err error)
|
||||
Start(ctx context.Context, command, path string, maxBitRate int) (f io.ReadCloser, err error)
|
||||
}
|
||||
|
||||
func New() Transcoder {
|
||||
path, err := exec.LookPath("ffmpeg")
|
||||
if err != nil {
|
||||
log.Error("Unable to find ffmpeg", err)
|
||||
}
|
||||
log.Debug("Found ffmpeg", "path", path)
|
||||
return &ffmpeg{}
|
||||
}
|
||||
|
||||
type ffmpeg struct{}
|
||||
|
||||
func (ff *ffmpeg) Start(ctx context.Context, command, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
|
||||
arg0, args := createTranscodeCommand(command, path, maxBitRate, format)
|
||||
func (ff *ffmpeg) Start(ctx context.Context, command, path string, maxBitRate int) (f io.ReadCloser, err error) {
|
||||
args := createTranscodeCommand(command, path, maxBitRate)
|
||||
|
||||
log.Trace(ctx, "Executing ffmpeg command", "cmd", arg0, "args", args)
|
||||
cmd := exec.Command(arg0, args...)
|
||||
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd.Stderr = os.Stderr
|
||||
if f, err = cmd.StdoutPipe(); err != nil {
|
||||
return
|
||||
@@ -38,7 +43,7 @@ func (ff *ffmpeg) Start(ctx context.Context, command, path string, maxBitRate in
|
||||
}
|
||||
|
||||
// Path will always be an absolute path
|
||||
func createTranscodeCommand(cmd, path string, maxBitRate int, format string) (string, []string) {
|
||||
func createTranscodeCommand(cmd, path string, maxBitRate int) []string {
|
||||
split := strings.Split(cmd, " ")
|
||||
for i, s := range split {
|
||||
s = strings.Replace(s, "%s", path, -1)
|
||||
@@ -46,5 +51,5 @@ func createTranscodeCommand(cmd, path string, maxBitRate int, format string) (st
|
||||
split[i] = s
|
||||
}
|
||||
|
||||
return split[0], split[1:]
|
||||
return split
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@ func TestTranscoder(t *testing.T) {
|
||||
|
||||
var _ = Describe("createTranscodeCommand", func() {
|
||||
It("creates a valid command line", func() {
|
||||
cmd, args := createTranscodeCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, "")
|
||||
Expect(cmd).To(Equal("ffmpeg"))
|
||||
Expect(args).To(Equal([]string{"-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
args := createTranscodeCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
})
|
||||
})
|
||||
|
||||
10
go.mod
10
go.mod
@@ -9,14 +9,14 @@ require (
|
||||
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible
|
||||
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8
|
||||
github.com/dhowden/tag v0.0.0-20200412032933-5d76b8eaae27
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/djherbis/fscache v0.10.0
|
||||
github.com/djherbis/fscache v0.10.1
|
||||
github.com/dustin/go-humanize v1.0.0
|
||||
github.com/fatih/camelcase v0.0.0-20160318181535-f6a740d52f96 // indirect
|
||||
github.com/fatih/structs v1.0.0 // indirect
|
||||
github.com/go-chi/chi v4.1.0+incompatible
|
||||
github.com/go-chi/cors v1.0.1
|
||||
github.com/go-chi/chi v4.1.1+incompatible
|
||||
github.com/go-chi/cors v1.1.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
|
||||
@@ -39,6 +39,6 @@ require (
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
|
||||
gopkg.in/djherbis/atime.v1 v1.0.0 // indirect
|
||||
gopkg.in/djherbis/stream.v1 v1.2.0 // indirect
|
||||
gopkg.in/djherbis/stream.v1 v1.3.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.2.8 // indirect
|
||||
)
|
||||
|
||||
20
go.sum
20
go.sum
@@ -26,12 +26,12 @@ github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0 h1:qHX6TTBIsrpg0JkYNko
|
||||
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/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/dhowden/tag v0.0.0-20200412032933-5d76b8eaae27 h1:Z6xaGRBbqfLR797upHuzQ6w4zg33BLKfAKtVCcmMDgg=
|
||||
github.com/dhowden/tag v0.0.0-20200412032933-5d76b8eaae27/go.mod h1:SniNVYuaD1jmdEEvi+7ywb1QFR7agjeTdGKyFb0p7Rw=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/djherbis/fscache v0.10.0 h1:+O3s3LwKL1Jfz7txRHpgKOz8/krh9w+NzfzxtFdbsQg=
|
||||
github.com/djherbis/fscache v0.10.0/go.mod h1:yyPYtkNnnPXsW+81lAcQS6yab3G2CRfnPLotBvtbf0c=
|
||||
github.com/djherbis/fscache v0.10.1 h1:hDv+RGyvD+UDKyRYuLoVNbuRTnf2SrA2K3VyR1br9lk=
|
||||
github.com/djherbis/fscache v0.10.1/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=
|
||||
@@ -43,10 +43,10 @@ github.com/fatih/structs v1.0.0 h1:BrX964Rv5uQ3wwS+KRUAJCBBw5PQmgJfJ6v4yly5QwU=
|
||||
github.com/fatih/structs v1.0.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/go-chi/chi v4.1.0+incompatible h1:ETj3cggsVIY2Xao5ExCu6YhEh5MD6JTfcBzS37R260w=
|
||||
github.com/go-chi/chi v4.1.0+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||
github.com/go-chi/cors v1.0.1 h1:56TT/uWGoLWZpnMI/AwAmCneikXr5eLsiIq27wrKecw=
|
||||
github.com/go-chi/cors v1.0.1/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
|
||||
github.com/go-chi/chi v4.1.1+incompatible h1:MmTgB0R8Bt/jccxp+t6S/1VGIKdJw5J74CK/c9tTfA4=
|
||||
github.com/go-chi/chi v4.1.1+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||
github.com/go-chi/cors v1.1.1 h1:eHuqxsIw89iXcWnWUN8R72JMibABJTN/4IOYI5WERvw=
|
||||
github.com/go-chi/cors v1.1.1/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
|
||||
github.com/go-chi/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=
|
||||
@@ -181,8 +181,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/djherbis/atime.v1 v1.0.0 h1:eMRqB/JrLKocla2PBPKgQYg/p5UG4L6AUAs92aP7F60=
|
||||
gopkg.in/djherbis/atime.v1 v1.0.0/go.mod h1:hQIUStKmJfvf7xdh/wtK84qe+DsTV5LnA9lzxxtPpJ8=
|
||||
gopkg.in/djherbis/stream.v1 v1.2.0 h1:3tZuXO+RK8opjw8/BJr780h+eAPwOFfLHCKRKyYxk3s=
|
||||
gopkg.in/djherbis/stream.v1 v1.2.0/go.mod h1:aEV8CBVRmSpLamVJfM903Npic1IKmb2qS30VAZ+sssg=
|
||||
gopkg.in/djherbis/stream.v1 v1.3.1 h1:uGfmsOY1qqMjQQphhRBSGLyA9qumJ56exkRu9ASTjCw=
|
||||
gopkg.in/djherbis/stream.v1 v1.3.1/go.mod h1:aEV8CBVRmSpLamVJfM903Npic1IKmb2qS30VAZ+sssg=
|
||||
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
|
||||
@@ -3,23 +3,28 @@ package model
|
||||
import "time"
|
||||
|
||||
type Album struct {
|
||||
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"`
|
||||
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"`
|
||||
SortAlbumName string `json:"sortAlbumName"`
|
||||
SortArtistName string `json:"sortArtistName"`
|
||||
SortAlbumArtistName string `json:"sortAlbumArtistName"`
|
||||
OrderAlbumName string `json:"orderAlbumName"`
|
||||
OrderAlbumArtistName string `json:"orderAlbumArtistName"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
// Annotations
|
||||
PlayCount int `json:"playCount" orm:"-"`
|
||||
|
||||
@@ -3,10 +3,12 @@ package model
|
||||
import "time"
|
||||
|
||||
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"`
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
AlbumCount int `json:"albumCount" orm:"column(album_count)"`
|
||||
FullText string `json:"fullText"`
|
||||
SortArtistName string `json:"sortArtistName"`
|
||||
OrderArtistName string `json:"orderArtistName"`
|
||||
|
||||
// Annotations
|
||||
PlayCount int `json:"playCount" orm:"-"`
|
||||
|
||||
@@ -6,28 +6,35 @@ import (
|
||||
)
|
||||
|
||||
type MediaFile struct {
|
||||
ID string `json:"id" orm:"pk;column(id)"`
|
||||
Path string `json:"path"`
|
||||
Title string `json:"title"`
|
||||
Album string `json:"album"`
|
||||
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"`
|
||||
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"`
|
||||
SortTitle string `json:"sortTitle"`
|
||||
SortAlbumName string `json:"sortAlbumName"`
|
||||
SortArtistName string `json:"sortArtistName"`
|
||||
SortAlbumArtistName string `json:"sortAlbumArtistName"`
|
||||
OrderAlbumName string `json:"orderAlbumName"`
|
||||
OrderArtistName string `json:"orderArtistName"`
|
||||
OrderAlbumArtistName string `json:"orderAlbumArtistName"`
|
||||
Compilation bool `json:"compilation"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
// Annotations
|
||||
PlayCount int `json:"playCount" orm:"-"`
|
||||
@@ -48,6 +55,7 @@ type MediaFileRepository interface {
|
||||
Exists(id string) (bool, error)
|
||||
Put(m *MediaFile) error
|
||||
Get(id string) (*MediaFile, error)
|
||||
GetAll(options ...QueryOptions) (MediaFiles, error)
|
||||
FindByAlbum(albumId string) (MediaFiles, error)
|
||||
FindByPath(path string) (MediaFiles, error)
|
||||
GetStarred(options ...QueryOptions) (MediaFiles, error)
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Playlist struct {
|
||||
ID string
|
||||
Name string
|
||||
Comment string
|
||||
Duration float32
|
||||
Owner string
|
||||
Public bool
|
||||
Tracks MediaFiles
|
||||
ID string
|
||||
Name string
|
||||
Comment string
|
||||
Duration float32
|
||||
Owner string
|
||||
Public bool
|
||||
Tracks MediaFiles
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type PlaylistRepository interface {
|
||||
@@ -15,7 +19,6 @@ type PlaylistRepository interface {
|
||||
Exists(id string) (bool, error)
|
||||
Put(pls *Playlist) error
|
||||
Get(id string) (*Playlist, error)
|
||||
GetWithTracks(id string) (*Playlist, error)
|
||||
GetAll(options ...QueryOptions) (Playlists, error)
|
||||
Delete(id string) error
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
@@ -23,7 +26,7 @@ func NewAlbumRepository(ctx context.Context, o orm.Ormer) model.AlbumRepository
|
||||
r.ormer = o
|
||||
r.tableName = "album"
|
||||
r.sortMappings = map[string]string{
|
||||
"artist": "compilation asc, album_artist asc, name asc",
|
||||
"artist": "compilation asc, order_album_artist_name asc, order_album_name asc",
|
||||
"random": "RANDOM()",
|
||||
}
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
@@ -108,12 +111,15 @@ func (r *albumRepository) Refresh(ids ...string) error {
|
||||
CurrentId string
|
||||
HasCoverArt bool
|
||||
SongArtists string
|
||||
Years string
|
||||
}
|
||||
var albums []refreshAlbum
|
||||
sel := Select(`album_id as id, album as name, f.artist, f.album_artist, f.artist_id, f.album_artist_id,
|
||||
f.compilation, f.genre, max(f.year) as max_year, min(f.year) as min_year, sum(f.duration) as duration,
|
||||
count(*) as song_count, a.id as current_id, f.id as cover_art_id, f.path as cover_art_path,
|
||||
group_concat(f.artist, ' ') as song_artists, f.has_cover_art`).
|
||||
f.sort_album_name, f.sort_artist_name, f.sort_album_artist_name,
|
||||
f.order_album_name, f.order_album_artist_name,
|
||||
f.compilation, f.genre, max(f.year) as max_year, sum(f.duration) as duration,
|
||||
count(*) as song_count, a.id as current_id, f.id as cover_art_id, f.path as cover_art_path, f.has_cover_art,
|
||||
group_concat(f.artist, ' ') as song_artists, group_concat(f.year, ' ') as years`).
|
||||
From("media_file f").
|
||||
LeftJoin("album a on f.album_id = a.id").
|
||||
Where(Eq{"f.album_id": ids}).GroupBy("album_id").OrderBy("f.id")
|
||||
@@ -136,6 +142,7 @@ func (r *albumRepository) Refresh(ids ...string) error {
|
||||
al.AlbumArtist = al.Artist
|
||||
al.AlbumArtistID = al.ArtistID
|
||||
}
|
||||
al.MinYear = getMinYear(al.Years)
|
||||
al.UpdatedAt = time.Now()
|
||||
if al.CurrentId != "" {
|
||||
toUpdate++
|
||||
@@ -143,7 +150,8 @@ func (r *albumRepository) Refresh(ids ...string) error {
|
||||
toInsert++
|
||||
al.CreatedAt = time.Now()
|
||||
}
|
||||
al.FullText = r.getFullText(al.Name, al.Artist, al.AlbumArtist, al.SongArtists)
|
||||
al.FullText = getFullText(al.Name, al.Artist, al.AlbumArtist, al.SongArtists,
|
||||
al.SortAlbumName, al.SortArtistName, al.SortAlbumArtistName)
|
||||
_, err := r.put(al.ID, al.Album)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -158,6 +166,18 @@ func (r *albumRepository) Refresh(ids ...string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func getMinYear(years string) int {
|
||||
ys := strings.Fields(years)
|
||||
sort.Strings(ys)
|
||||
for _, y := range ys {
|
||||
if y != "0" {
|
||||
r, _ := strconv.Atoi(y)
|
||||
return r
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (r *albumRepository) PurgeEmpty() error {
|
||||
del := Delete(r.tableName).Where("id not in (select distinct(album_id) from media_file)")
|
||||
c, err := r.executeSQL(del)
|
||||
|
||||
@@ -20,7 +20,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
|
||||
Describe("Get", func() {
|
||||
It("returns an existent album", func() {
|
||||
Expect(repo.Get("3")).To(Equal(&albumRadioactivity))
|
||||
Expect(repo.Get("103")).To(Equal(&albumRadioactivity))
|
||||
})
|
||||
It("returns ErrNotFound when the album does not exist", func() {
|
||||
_, err := repo.Get("666")
|
||||
@@ -73,4 +73,16 @@ var _ = Describe("AlbumRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("getMinYear", func() {
|
||||
It("returns 0 when there's no valid year", func() {
|
||||
Expect(getMinYear("a b c")).To(Equal(0))
|
||||
Expect(getMinYear("")).To(Equal(0))
|
||||
})
|
||||
It("returns 0 when all values are 0", func() {
|
||||
Expect(getMinYear("0 0 0 ")).To(Equal(0))
|
||||
})
|
||||
It("returns the smallest value from the list", func() {
|
||||
Expect(getMinYear("2000 0 1800")).To(Equal(1800))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,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.sortMappings = map[string]string{
|
||||
"name": "order_artist_name",
|
||||
}
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"name": fullTextFilter,
|
||||
}
|
||||
@@ -44,19 +47,8 @@ func (r *artistRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(Select().Where(Eq{"id": id}))
|
||||
}
|
||||
|
||||
func (r *artistRepository) getIndexKey(a *model.Artist) string {
|
||||
name := strings.ToLower(utils.NoArticle(a.Name))
|
||||
for k, v := range r.indexGroups {
|
||||
key := strings.ToLower(k)
|
||||
if strings.HasPrefix(name, key) {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return "#"
|
||||
}
|
||||
|
||||
func (r *artistRepository) Put(a *model.Artist) error {
|
||||
a.FullText = r.getFullText(a.Name)
|
||||
a.FullText = getFullText(a.Name, a.SortArtistName)
|
||||
_, err := r.put(a.ID, a)
|
||||
return err
|
||||
}
|
||||
@@ -75,11 +67,21 @@ func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists,
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *artistRepository) getIndexKey(a *model.Artist) string {
|
||||
name := strings.ToLower(utils.NoArticle(a.Name))
|
||||
for k, v := range r.indexGroups {
|
||||
key := strings.ToLower(k)
|
||||
if strings.HasPrefix(name, key) {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return "#"
|
||||
}
|
||||
|
||||
// TODO Cache the index (recalculate when there are changes to the DB)
|
||||
func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
|
||||
sq := r.selectArtist().OrderBy("name")
|
||||
sq := r.selectArtist().OrderBy("order_artist_name")
|
||||
var all model.Artists
|
||||
// TODO Paginate
|
||||
err := r.queryAll(sq, &all)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -111,7 +113,9 @@ func (r *artistRepository) Refresh(ids ...string) error {
|
||||
CurrentId string
|
||||
}
|
||||
var artists []refreshArtist
|
||||
sel := Select("f.album_artist_id as id", "f.album_artist as name", "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",
|
||||
"f.sort_album_artist_name as sort_artist_name",
|
||||
"f.order_album_artist_name as order_artist_name").
|
||||
From("album f").
|
||||
LeftJoin("artist a on f.album_artist_id = a.id").
|
||||
Where(Eq{"f.album_artist_id": ids}).
|
||||
|
||||
@@ -23,8 +23,8 @@ func NewMediaFileRepository(ctx context.Context, o orm.Ormer) *mediaFileReposito
|
||||
r.ormer = o
|
||||
r.tableName = "media_file"
|
||||
r.sortMappings = map[string]string{
|
||||
"artist": "artist asc, album asc, disc_number asc, track_number asc",
|
||||
"album": "album asc, disc_number asc, track_number asc",
|
||||
"artist": "order_artist_name asc, album asc, disc_number asc, track_number asc",
|
||||
"album": "order_album_name asc, disc_number asc, track_number asc",
|
||||
}
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"title": fullTextFilter,
|
||||
@@ -41,7 +41,8 @@ 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)
|
||||
m.FullText = getFullText(m.Title, m.Album, m.Artist, m.AlbumArtist,
|
||||
m.SortTitle, m.SortAlbumName, m.SortArtistName, m.SortAlbumArtistName)
|
||||
_, err := r.put(m.ID, m)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
})
|
||||
|
||||
It("gets mediafile from the DB", func() {
|
||||
Expect(mr.Get("4")).To(Equal(&songAntenna))
|
||||
Expect(mr.Get("1004")).To(Equal(&songAntenna))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound", func() {
|
||||
@@ -39,7 +39,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
})
|
||||
|
||||
It("find mediafiles by album", func() {
|
||||
Expect(mr.FindByAlbum("3")).To(Equal(model.MediaFiles{
|
||||
Expect(mr.FindByAlbum("103")).To(Equal(model.MediaFiles{
|
||||
songRadioactivity,
|
||||
songAntenna,
|
||||
}))
|
||||
|
||||
@@ -31,8 +31,8 @@ func TestPersistence(t *testing.T) {
|
||||
}
|
||||
|
||||
var (
|
||||
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1, FullText: "kraftwerk"}
|
||||
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2, FullText: "beatles the"}
|
||||
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1, FullText: " kraftwerk"}
|
||||
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2, FullText: " beatles the"}
|
||||
testArtists = model.Artists{
|
||||
artistKraftwerk,
|
||||
artistBeatles,
|
||||
@@ -40,9 +40,9 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
albumSgtPeppers = model.Album{ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", AlbumArtistID: "3", Genre: "Rock", CoverArtId: "1", CoverArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967, FullText: "beatles peppers sgt the"}
|
||||
albumAbbeyRoad = model.Album{ID: "2", Name: "Abbey Road", Artist: "The Beatles", AlbumArtistID: "3", Genre: "Rock", CoverArtId: "2", CoverArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969, FullText: "abbey beatles road the"}
|
||||
albumRadioactivity = model.Album{ID: "3", Name: "Radioactivity", Artist: "Kraftwerk", AlbumArtistID: "2", Genre: "Electronic", CoverArtId: "3", CoverArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2, FullText: "kraftwerk radioactivity"}
|
||||
albumSgtPeppers = model.Album{ID: "101", Name: "Sgt Peppers", Artist: "The Beatles", AlbumArtistID: "3", Genre: "Rock", CoverArtId: "1", CoverArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967, FullText: " beatles peppers sgt the"}
|
||||
albumAbbeyRoad = model.Album{ID: "102", Name: "Abbey Road", Artist: "The Beatles", AlbumArtistID: "3", Genre: "Rock", CoverArtId: "2", CoverArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969, FullText: " abbey beatles road the"}
|
||||
albumRadioactivity = model.Album{ID: "103", Name: "Radioactivity", Artist: "Kraftwerk", AlbumArtistID: "2", Genre: "Electronic", CoverArtId: "3", CoverArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2, FullText: " kraftwerk radioactivity"}
|
||||
testAlbums = model.Albums{
|
||||
albumSgtPeppers,
|
||||
albumAbbeyRoad,
|
||||
@@ -51,10 +51,10 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
songDayInALife = model.MediaFile{ID: "1", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "1", Album: "Sgt Peppers", Genre: "Rock", Path: P("/beatles/1/sgt/a day.mp3"), FullText: "a beatles day in life peppers sgt the"}
|
||||
songComeTogether = model.MediaFile{ID: "2", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "2", Album: "Abbey Road", Genre: "Rock", Path: P("/beatles/1/come together.mp3"), FullText: "abbey beatles come road the together"}
|
||||
songRadioactivity = model.MediaFile{ID: "3", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Album: "Radioactivity", Genre: "Electronic", Path: P("/kraft/radio/radio.mp3"), FullText: "kraftwerk radioactivity"}
|
||||
songAntenna = model.MediaFile{ID: "4", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Genre: "Electronic", Path: P("/kraft/radio/antenna.mp3"), FullText: "antenna kraftwerk"}
|
||||
songDayInALife = model.MediaFile{ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Genre: "Rock", Path: P("/beatles/1/sgt/a day.mp3"), FullText: " a beatles day in life peppers sgt the"}
|
||||
songComeTogether = model.MediaFile{ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Genre: "Rock", Path: P("/beatles/1/come together.mp3"), FullText: " abbey beatles come road the together"}
|
||||
songRadioactivity = model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Genre: "Electronic", Path: P("/kraft/radio/radio.mp3"), FullText: " kraftwerk radioactivity"}
|
||||
songAntenna = model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Genre: "Electronic", Path: P("/kraft/radio/antenna.mp3"), FullText: " antenna kraftwerk"}
|
||||
testSongs = model.MediaFiles{
|
||||
songDayInALife,
|
||||
songComeTogether,
|
||||
@@ -65,15 +65,14 @@ var (
|
||||
|
||||
var (
|
||||
plsBest = model.Playlist{
|
||||
ID: "10",
|
||||
Name: "Best",
|
||||
Comment: "No Comments",
|
||||
Duration: 10,
|
||||
Owner: "userid",
|
||||
Public: true,
|
||||
Tracks: model.MediaFiles{{ID: "1"}, {ID: "3"}},
|
||||
ID: "10",
|
||||
Name: "Best",
|
||||
Comment: "No Comments",
|
||||
Owner: "userid",
|
||||
Public: true,
|
||||
Tracks: model.MediaFiles{{ID: "1001"}, {ID: "1003"}},
|
||||
}
|
||||
plsCool = model.Playlist{ID: "11", Name: "Cool", Tracks: model.MediaFiles{{ID: "4"}}}
|
||||
plsCool = model.Playlist{ID: "11", Name: "Cool", Tracks: model.MediaFiles{{ID: "1004"}}}
|
||||
testPlaylists = model.Playlists{plsBest, plsCool}
|
||||
)
|
||||
|
||||
|
||||
@@ -3,20 +3,24 @@ package persistence
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
type playlist struct {
|
||||
ID string `orm:"column(id)"`
|
||||
Name string
|
||||
Comment string
|
||||
Duration float32
|
||||
Owner string
|
||||
Public bool
|
||||
Tracks string
|
||||
ID string `orm:"column(id)"`
|
||||
Name string
|
||||
Comment string
|
||||
Duration float32
|
||||
Owner string
|
||||
Public bool
|
||||
Tracks string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type playlistRepository struct {
|
||||
@@ -44,6 +48,10 @@ func (r *playlistRepository) Delete(id string) error {
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Put(p *model.Playlist) error {
|
||||
if p.ID == "" {
|
||||
p.CreatedAt = time.Now()
|
||||
}
|
||||
p.UpdatedAt = time.Now()
|
||||
pls := r.fromModel(p)
|
||||
_, err := r.put(pls.ID, pls)
|
||||
return err
|
||||
@@ -57,26 +65,6 @@ func (r *playlistRepository) Get(id string) (*model.Playlist, error) {
|
||||
return &pls, err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) GetWithTracks(id string) (*model.Playlist, error) {
|
||||
pls, err := r.Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mfRepo := NewMediaFileRepository(r.ctx, r.ormer)
|
||||
pls.Duration = 0
|
||||
newTracks := model.MediaFiles{}
|
||||
for _, t := range pls.Tracks {
|
||||
mf, err := mfRepo.Get(t.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
pls.Duration += mf.Duration
|
||||
newTracks = append(newTracks, *mf)
|
||||
}
|
||||
pls.Tracks = newTracks
|
||||
return pls, err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playlists, error) {
|
||||
sel := r.newSelect(options...).Columns("*")
|
||||
var res []playlist
|
||||
@@ -94,12 +82,14 @@ func (r *playlistRepository) toModels(all []playlist) model.Playlists {
|
||||
|
||||
func (r *playlistRepository) toModel(p *playlist) model.Playlist {
|
||||
pls := model.Playlist{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Comment: p.Comment,
|
||||
Duration: p.Duration,
|
||||
Owner: p.Owner,
|
||||
Public: p.Public,
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Comment: p.Comment,
|
||||
Duration: p.Duration,
|
||||
Owner: p.Owner,
|
||||
Public: p.Public,
|
||||
CreatedAt: p.CreatedAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
}
|
||||
if strings.TrimSpace(p.Tracks) != "" {
|
||||
tracks := strings.Split(p.Tracks, ",")
|
||||
@@ -107,24 +97,74 @@ func (r *playlistRepository) toModel(p *playlist) model.Playlist {
|
||||
pls.Tracks = append(pls.Tracks, model.MediaFile{ID: t})
|
||||
}
|
||||
}
|
||||
pls.Tracks = r.loadTracks(&pls)
|
||||
return pls
|
||||
}
|
||||
|
||||
func (r *playlistRepository) fromModel(p *model.Playlist) playlist {
|
||||
pls := playlist{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Comment: p.Comment,
|
||||
Duration: p.Duration,
|
||||
Owner: p.Owner,
|
||||
Public: p.Public,
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Comment: p.Comment,
|
||||
Owner: p.Owner,
|
||||
Public: p.Public,
|
||||
CreatedAt: p.CreatedAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
}
|
||||
p.Tracks = r.loadTracks(p)
|
||||
var newTracks []string
|
||||
for _, t := range p.Tracks {
|
||||
newTracks = append(newTracks, t.ID)
|
||||
pls.Duration += t.Duration
|
||||
}
|
||||
pls.Tracks = strings.Join(newTracks, ",")
|
||||
return pls
|
||||
}
|
||||
|
||||
// TODO: Introduce a relation table for Playlist <-> MediaFiles, and rewrite this method in pure SQL
|
||||
func (r *playlistRepository) loadTracks(p *model.Playlist) model.MediaFiles {
|
||||
if len(p.Tracks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect all ids
|
||||
ids := make([]string, len(p.Tracks))
|
||||
for i, t := range p.Tracks {
|
||||
ids[i] = t.ID
|
||||
}
|
||||
|
||||
// Break the list in chunks, up to 50 items, to avoid hitting SQLITE_MAX_FUNCTION_ARG limit
|
||||
const chunkSize = 50
|
||||
var chunks [][]string
|
||||
for i := 0; i < len(ids); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
|
||||
chunks = append(chunks, ids[i:end])
|
||||
}
|
||||
|
||||
// Query each chunk of media_file ids and store results in a map
|
||||
mfRepo := NewMediaFileRepository(r.ctx, r.ormer)
|
||||
trackMap := map[string]model.MediaFile{}
|
||||
for i := range chunks {
|
||||
idsFilter := Eq{"id": chunks[i]}
|
||||
tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: idsFilter})
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Could not load playlist's tracks", "playlistName", p.Name, "playlistId", p.ID, err)
|
||||
}
|
||||
for _, t := range tracks {
|
||||
trackMap[t.ID] = t
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new list of tracks with the same order as the original
|
||||
newTracks := make(model.MediaFiles, len(p.Tracks))
|
||||
for i, t := range p.Tracks {
|
||||
newTracks[i] = trackMap[t.ID]
|
||||
}
|
||||
return newTracks
|
||||
}
|
||||
|
||||
var _ model.PlaylistRepository = (*playlistRepository)(nil)
|
||||
|
||||
@@ -32,34 +32,25 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
|
||||
Describe("Get", func() {
|
||||
It("returns an existing playlist", func() {
|
||||
Expect(repo.Get("10")).To(Equal(&plsBest))
|
||||
p, err := repo.Get("10")
|
||||
Expect(err).To(BeNil())
|
||||
// Compare all but Tracks and timestamps
|
||||
p2 := *p
|
||||
p2.Tracks = plsBest.Tracks
|
||||
p2.UpdatedAt = plsBest.UpdatedAt
|
||||
p2.CreatedAt = plsBest.CreatedAt
|
||||
Expect(p2).To(Equal(plsBest))
|
||||
// Compare tracks
|
||||
for i := range p.Tracks {
|
||||
Expect(p.Tracks[i].ID).To(Equal(plsBest.Tracks[i].ID))
|
||||
}
|
||||
})
|
||||
It("returns ErrNotFound for a non-existing playlist", func() {
|
||||
_, err := repo.Get("666")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Put/Get/Delete", func() {
|
||||
newPls := model.Playlist{ID: "22", Name: "Great!", Tracks: model.MediaFiles{{ID: "4"}}}
|
||||
It("saves the playlist to the DB", func() {
|
||||
Expect(repo.Put(&newPls)).To(BeNil())
|
||||
})
|
||||
It("returns the newly created playlist", func() {
|
||||
Expect(repo.Get("22")).To(Equal(&newPls))
|
||||
})
|
||||
It("returns deletes the playlist", func() {
|
||||
Expect(repo.Delete("22")).To(BeNil())
|
||||
})
|
||||
It("returns error if tries to retrieve the deleted playlist", func() {
|
||||
_, err := repo.Get("22")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetWithTracks", func() {
|
||||
It("returns an existing playlist", func() {
|
||||
pls, err := repo.GetWithTracks("10")
|
||||
It("returns all tracks", func() {
|
||||
pls, err := repo.Get("10")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(pls.Name).To(Equal(plsBest.Name))
|
||||
Expect(pls.Tracks).To(Equal(model.MediaFiles{
|
||||
@@ -69,9 +60,40 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Put/Exists/Delete", func() {
|
||||
var newPls model.Playlist
|
||||
BeforeEach(func() {
|
||||
newPls = model.Playlist{ID: "22", Name: "Great!", Tracks: model.MediaFiles{{ID: "1004"}, {ID: "1003"}}}
|
||||
})
|
||||
It("saves the playlist to the DB", func() {
|
||||
Expect(repo.Put(&newPls)).To(BeNil())
|
||||
})
|
||||
It("adds repeated songs to a playlist and keeps the order", func() {
|
||||
newPls.Tracks = append(newPls.Tracks, model.MediaFile{ID: "1004"})
|
||||
Expect(repo.Put(&newPls)).To(BeNil())
|
||||
saved, _ := repo.Get("22")
|
||||
Expect(saved.Tracks).To(HaveLen(3))
|
||||
Expect(saved.Tracks[0].ID).To(Equal("1004"))
|
||||
Expect(saved.Tracks[1].ID).To(Equal("1003"))
|
||||
Expect(saved.Tracks[2].ID).To(Equal("1004"))
|
||||
})
|
||||
It("returns the newly created playlist", func() {
|
||||
Expect(repo.Exists("22")).To(BeTrue())
|
||||
})
|
||||
It("returns deletes the playlist", func() {
|
||||
Expect(repo.Delete("22")).To(BeNil())
|
||||
})
|
||||
It("returns error if tries to retrieve the deleted playlist", func() {
|
||||
Expect(repo.Exists("22")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetAll", func() {
|
||||
It("returns all playlists from DB", func() {
|
||||
Expect(repo.GetAll()).To(Equal(model.Playlists{plsBest, plsCool}))
|
||||
all, err := repo.GetAll()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(all[0].ID).To(Equal(plsBest.ID))
|
||||
Expect(all[1].ID).To(Equal(plsCool.ID))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -160,6 +160,7 @@ func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOpt
|
||||
|
||||
func (r sqlRepository) put(id string, m interface{}) (newId string, err error) {
|
||||
values, _ := toSqlArgs(m)
|
||||
// Remove created_at from args and save it for later, if needed fo insert
|
||||
createdAt := values["created_at"]
|
||||
delete(values, "created_at")
|
||||
if id != "" {
|
||||
@@ -178,6 +179,7 @@ func (r sqlRepository) put(id string, m interface{}) (newId string, err error) {
|
||||
id = rand.String()
|
||||
values["id"] = id
|
||||
}
|
||||
// It is a insert, if there was a created_at, add it back to args
|
||||
if createdAt != nil {
|
||||
values["created_at"] = createdAt
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
. "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
|
||||
@@ -59,15 +58,11 @@ func booleanFilter(field string, value interface{}) Sqlizer {
|
||||
}
|
||||
|
||||
func fullTextFilter(field string, value interface{}) Sqlizer {
|
||||
q := value.(string)
|
||||
q = strings.TrimSpace(sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*"))))
|
||||
q := sanitizeStrings(value.(string))
|
||||
parts := strings.Split(q, " ")
|
||||
filters := And{}
|
||||
for _, part := range parts {
|
||||
filters = append(filters, Or{
|
||||
Like{"full_text": part + "%"},
|
||||
Like{"full_text": "%" + part + "%"},
|
||||
})
|
||||
filters = append(filters, Like{"full_text": "% " + part + "%"})
|
||||
}
|
||||
return filters
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -8,7 +9,14 @@ import (
|
||||
"github.com/kennygrant/sanitize"
|
||||
)
|
||||
|
||||
func (r sqlRepository) getFullText(text ...string) string {
|
||||
var quotesRegex = regexp.MustCompile("[“”‘’'\"]")
|
||||
|
||||
func getFullText(text ...string) string {
|
||||
fullText := sanitizeStrings(text...)
|
||||
return " " + fullText
|
||||
}
|
||||
|
||||
func sanitizeStrings(text ...string) string {
|
||||
sanitizedText := strings.Builder{}
|
||||
for _, txt := range text {
|
||||
sanitizedText.WriteString(strings.TrimSpace(sanitize.Accents(strings.ToLower(txt))) + " ")
|
||||
@@ -19,14 +27,18 @@ func (r sqlRepository) getFullText(text ...string) string {
|
||||
}
|
||||
var fullText []string
|
||||
for w := range words {
|
||||
fullText = append(fullText, w)
|
||||
w = quotesRegex.ReplaceAllString(w, "")
|
||||
if w != "" {
|
||||
fullText = append(fullText, w)
|
||||
}
|
||||
}
|
||||
sort.Strings(fullText)
|
||||
return strings.Join(fullText, " ")
|
||||
}
|
||||
|
||||
func (r sqlRepository) doSearch(q string, offset, size int, results interface{}, orderBys ...string) error {
|
||||
q = strings.TrimSpace(sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*"))))
|
||||
q = strings.TrimSuffix(q, "*")
|
||||
q = sanitizeStrings(q)
|
||||
if len(q) < 2 {
|
||||
return nil
|
||||
}
|
||||
@@ -37,10 +49,7 @@ func (r sqlRepository) doSearch(q string, offset, size int, results interface{},
|
||||
}
|
||||
parts := strings.Split(q, " ")
|
||||
for _, part := range parts {
|
||||
sq = sq.Where(Or{
|
||||
Like{"full_text": part + "%"},
|
||||
Like{"full_text": "%" + part + "%"},
|
||||
})
|
||||
sq = sq.Where(Like{"full_text": "% " + part + "%"})
|
||||
}
|
||||
err := r.queryAll(sq, results)
|
||||
return err
|
||||
|
||||
@@ -6,23 +6,25 @@ import (
|
||||
)
|
||||
|
||||
var _ = Describe("sqlRepository", func() {
|
||||
var sqlRepository = &sqlRepository{}
|
||||
|
||||
Describe("getFullText", func() {
|
||||
It("returns all lowercase chars", func() {
|
||||
Expect(sqlRepository.getFullText("Some Text")).To(Equal("some text"))
|
||||
Expect(getFullText("Some Text")).To(Equal(" some text"))
|
||||
})
|
||||
|
||||
It("removes accents", func() {
|
||||
Expect(sqlRepository.getFullText("Quintão")).To(Equal("quintao"))
|
||||
Expect(getFullText("Quintão")).To(Equal(" quintao"))
|
||||
})
|
||||
|
||||
It("remove extra spaces", func() {
|
||||
Expect(sqlRepository.getFullText(" some text ")).To(Equal("some text"))
|
||||
Expect(getFullText(" some text ")).To(Equal(" some text"))
|
||||
})
|
||||
|
||||
It("remove duplicated words", func() {
|
||||
Expect(sqlRepository.getFullText("legião urbana urbana legiÃo")).To(Equal("legiao urbana"))
|
||||
Expect(getFullText("legião urbana urbana legiÃo")).To(Equal(" legiao urbana"))
|
||||
})
|
||||
|
||||
It("remove symbols", func() {
|
||||
Expect(getFullText("Tom’s Diner ' “40” ‘A’")).To(Equal(" 40 a diner toms"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,7 +61,12 @@ func (s *ChangeDetector) loadDir(dirPath string) (children []string, lastUpdated
|
||||
return
|
||||
}
|
||||
for _, f := range files {
|
||||
if f.IsDir() {
|
||||
isDir, err := IsDirOrSymlinkToDir(dirPath, f)
|
||||
// Skip invalid symlinks
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if isDir {
|
||||
children = append(children, filepath.Join(dirPath, f.Name()))
|
||||
} else {
|
||||
if f.ModTime().After(lastUpdated) {
|
||||
@@ -72,6 +77,26 @@ func (s *ChangeDetector) loadDir(dirPath string) (children []string, lastUpdated
|
||||
return
|
||||
}
|
||||
|
||||
// IsDirOrSymlinkToDir returns true if and only if the Dirent represents a file
|
||||
// system directory, or a symbolic link to a directory. Note that if the Dirent
|
||||
// is not a directory but is a symbolic link, this method will resolve by
|
||||
// sending a request to the operating system to follow the symbolic link.
|
||||
// Copied from github.com/karrick/godirwalk
|
||||
func IsDirOrSymlinkToDir(baseDir string, info os.FileInfo) (bool, error) {
|
||||
if info.IsDir() {
|
||||
return true, nil
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
return false, nil
|
||||
}
|
||||
// Does this symlink point to a directory?
|
||||
info, err := os.Stat(filepath.Join(baseDir, info.Name()))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return info.IsDir(), nil
|
||||
}
|
||||
|
||||
func (s *ChangeDetector) loadMap(dirMap dirInfoMap, path string, since time.Time, maybe bool) error {
|
||||
children, lastUpdated, err := s.loadDir(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -110,6 +110,25 @@ var _ = Describe("ChangeDetector", func() {
|
||||
Expect(deleted).To(BeEmpty())
|
||||
Expect(changed).To(ConsistOf(P("a/b")))
|
||||
})
|
||||
|
||||
Describe("IsDirOrSymlinkToDir", func() {
|
||||
It("returns true for normal dirs", func() {
|
||||
dir, _ := os.Stat("tests/fixtures")
|
||||
Expect(IsDirOrSymlinkToDir("tests", dir)).To(BeTrue())
|
||||
})
|
||||
It("returns true for symlinks to dirs", func() {
|
||||
dir, _ := os.Stat("tests/fixtures/symlink2dir")
|
||||
Expect(IsDirOrSymlinkToDir("tests/fixtures", dir)).To(BeTrue())
|
||||
})
|
||||
It("returns false for files", func() {
|
||||
dir, _ := os.Stat("tests/fixtures/test.mp3")
|
||||
Expect(IsDirOrSymlinkToDir("tests/fixtures", dir)).To(BeFalse())
|
||||
})
|
||||
It("returns false for symlinks to files", func() {
|
||||
dir, _ := os.Stat("tests/fixtures/symlink")
|
||||
Expect(IsDirOrSymlinkToDir("tests/fixtures", dir)).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// I hate time-based tests....
|
||||
|
||||
@@ -3,6 +3,7 @@ package scanner
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -24,10 +25,16 @@ type Metadata struct {
|
||||
tags map[string]string
|
||||
}
|
||||
|
||||
func (m *Metadata) Title() string { return m.getTag("title", "sort_name") }
|
||||
func (m *Metadata) Album() string { return m.getTag("album", "sort_album") }
|
||||
func (m *Metadata) Artist() string { return m.getTag("artist", "sort_artist") }
|
||||
func (m *Metadata) AlbumArtist() string { return m.getTag("album_artist") }
|
||||
func (m *Metadata) Title() string { return m.getTag("title", "sort_name") }
|
||||
func (m *Metadata) Album() string { return m.getTag("album", "sort_album") }
|
||||
func (m *Metadata) Artist() string { return m.getTag("artist", "sort_artist") }
|
||||
func (m *Metadata) AlbumArtist() string { return m.getTag("album_artist", "albumartist") }
|
||||
func (m *Metadata) SortTitle() string { return m.getSortTag("", "title", "name") }
|
||||
func (m *Metadata) SortAlbum() string { return m.getSortTag("", "album") }
|
||||
func (m *Metadata) SortArtist() string { return m.getSortTag("", "artist") }
|
||||
func (m *Metadata) SortAlbumArtist() string {
|
||||
return m.getSortTag("tso2", "albumartist", "album_artist")
|
||||
}
|
||||
func (m *Metadata) Composer() string { return m.getTag("composer", "tcm", "sort_composer") }
|
||||
func (m *Metadata) Genre() string { return m.getTag("genre") }
|
||||
func (m *Metadata) Year() int { return m.parseYear("date") }
|
||||
@@ -74,10 +81,10 @@ func LoadAllAudioFiles(dirPath string) (map[string]os.FileInfo, error) {
|
||||
}
|
||||
|
||||
func ExtractAllMetadata(inputs []string) (map[string]*Metadata, error) {
|
||||
cmdLine, args := createProbeCommand(inputs)
|
||||
args := createProbeCommand(inputs)
|
||||
|
||||
log.Trace("Executing command", "arg0", cmdLine, "args", args)
|
||||
cmd := exec.Command(cmdLine, args...)
|
||||
log.Trace("Executing command", "args", args)
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
output, _ := cmd.CombinedOutput()
|
||||
mds := map[string]*Metadata{}
|
||||
if len(output) == 0 {
|
||||
@@ -99,7 +106,7 @@ var (
|
||||
inputRegex = regexp.MustCompile(`(?m)^Input #\d+,.*,\sfrom\s'(.*)'`)
|
||||
|
||||
// TITLE : Back In Black
|
||||
tagsRx = regexp.MustCompile(`(?i)^\s{4,6}(\w+)\s*:(.*)`)
|
||||
tagsRx = regexp.MustCompile(`(?i)^\s{4,6}([\w-]+)\s*:(.*)`)
|
||||
|
||||
// Duration: 00:04:16.00, start: 0.000000, bitrate: 995 kb/s`
|
||||
durationRx = regexp.MustCompile(`^\s\sDuration: ([\d.:]+).*bitrate: (\d+)`)
|
||||
@@ -212,7 +219,7 @@ func (m *Metadata) parseYear(tagName string) int {
|
||||
if v, ok := m.tags[tagName]; ok {
|
||||
match := dateRegex.FindStringSubmatch(v)
|
||||
if len(match) == 0 {
|
||||
log.Error("Error parsing year from ffmpeg date field. Please report this issue", "file", m.filePath, "date", v)
|
||||
log.Warn("Error parsing year from ffmpeg date field", "file", m.filePath, "date", v)
|
||||
return 0
|
||||
}
|
||||
year, _ := strconv.Atoi(match[1])
|
||||
@@ -230,6 +237,18 @@ func (m *Metadata) getTag(tags ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *Metadata) getSortTag(originalTag string, tags ...string) string {
|
||||
formats := []string{"sort%s", "sort_%s", "sort-%s", "%ssort", "%s_sort", "%s-sort"}
|
||||
all := []string{originalTag}
|
||||
for _, tag := range tags {
|
||||
for _, format := range formats {
|
||||
name := fmt.Sprintf(format, tag)
|
||||
all = append(all, name)
|
||||
}
|
||||
}
|
||||
return m.getTag(all...)
|
||||
}
|
||||
|
||||
func (m *Metadata) parseTuple(tags ...string) (int, int) {
|
||||
for _, tagName := range tags {
|
||||
if v, ok := m.tags[tagName]; ok {
|
||||
@@ -269,25 +288,18 @@ func (m *Metadata) parseDuration(tagName string) float32 {
|
||||
}
|
||||
|
||||
// Inputs will always be absolute paths
|
||||
func createProbeCommand(inputs []string) (string, []string) {
|
||||
cmd := conf.Server.ProbeCommand
|
||||
|
||||
split := strings.Split(cmd, " ")
|
||||
func createProbeCommand(inputs []string) []string {
|
||||
split := strings.Split(conf.Server.ProbeCommand, " ")
|
||||
args := make([]string, 0)
|
||||
first := true
|
||||
|
||||
for _, s := range split {
|
||||
if s == "%s" {
|
||||
for _, inp := range inputs {
|
||||
if !first {
|
||||
args = append(args, "-i")
|
||||
}
|
||||
args = append(args, inp)
|
||||
first = false
|
||||
args = append(args, "-i", inp)
|
||||
}
|
||||
continue
|
||||
} else {
|
||||
args = append(args, s)
|
||||
}
|
||||
args = append(args, s)
|
||||
}
|
||||
|
||||
return args[0], args[1:]
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ var _ = Describe("Metadata", func() {
|
||||
})
|
||||
|
||||
It("returns empty map if there are no audio files in path", func() {
|
||||
Expect(LoadAllAudioFiles(".")).To(BeEmpty())
|
||||
Expect(LoadAllAudioFiles("tests/empty_folder")).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -204,6 +204,30 @@ Tracklist:
|
||||
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithMultilineComment)
|
||||
Expect(md.Comment()).To(Equal(expectedComment))
|
||||
})
|
||||
|
||||
It("parses sort tags correctly", func() {
|
||||
const output = `
|
||||
Input #0, mp3, from '/Users/deluan/Downloads/椎名林檎 - 加爾基 精液 栗ノ花 - 2003/02 - ドツペルゲンガー.mp3':
|
||||
Metadata:
|
||||
title-sort : Dopperugengā
|
||||
album : 加爾基 精液 栗ノ花
|
||||
artist : 椎名林檎
|
||||
album_artist : 椎名林檎
|
||||
title : ドツペルゲンガー
|
||||
albumsort : Kalk Samen Kuri No Hana
|
||||
artist_sort : Shiina, Ringo
|
||||
ALBUMARTISTSORT : Shiina, Ringo
|
||||
`
|
||||
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
|
||||
Expect(md.Title()).To(Equal("ドツペルゲンガー"))
|
||||
Expect(md.Album()).To(Equal("加爾基 精液 栗ノ花"))
|
||||
Expect(md.Artist()).To(Equal("椎名林檎"))
|
||||
Expect(md.AlbumArtist()).To(Equal("椎名林檎"))
|
||||
Expect(md.SortTitle()).To(Equal("Dopperugengā"))
|
||||
Expect(md.SortAlbum()).To(Equal("Kalk Samen Kuri No Hana"))
|
||||
Expect(md.SortArtist()).To(Equal("Shiina, Ringo"))
|
||||
Expect(md.SortAlbumArtist()).To(Equal("Shiina, Ringo"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("parseYear", func() {
|
||||
@@ -228,4 +252,10 @@ Tracklist:
|
||||
Expect(md.Year()).To(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
It("creates a valid command line", func() {
|
||||
args := createProbeCommand([]string{"/music library/one.mp3", "/music library/two.mp3"})
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/one.mp3", "-i", "/music library/two.mp3", "-f", "ffmetadata"}))
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
"github.com/kennygrant/sanitize"
|
||||
)
|
||||
|
||||
type TagScanner struct {
|
||||
@@ -241,7 +243,7 @@ func (s *TagScanner) loadTracks(filePaths []string) (model.MediaFiles, error) {
|
||||
}
|
||||
|
||||
func (s *TagScanner) toMediaFile(md *Metadata) model.MediaFile {
|
||||
mf := model.MediaFile{}
|
||||
mf := &model.MediaFile{}
|
||||
mf.ID = s.trackID(md)
|
||||
mf.Title = s.mapTrackTitle(md)
|
||||
mf.Album = md.Album()
|
||||
@@ -262,12 +264,25 @@ func (s *TagScanner) toMediaFile(md *Metadata) model.MediaFile {
|
||||
mf.Suffix = md.Suffix()
|
||||
mf.Size = md.Size()
|
||||
mf.HasCoverArt = md.HasPicture()
|
||||
mf.SortTitle = md.SortTitle()
|
||||
mf.SortAlbumName = md.SortAlbum()
|
||||
mf.SortArtistName = md.SortArtist()
|
||||
mf.SortAlbumArtistName = md.SortAlbumArtist()
|
||||
mf.OrderAlbumName = sanitizeFieldForSorting(mf.Album)
|
||||
mf.OrderArtistName = sanitizeFieldForSorting(mf.Artist)
|
||||
mf.OrderAlbumArtistName = sanitizeFieldForSorting(mf.AlbumArtist)
|
||||
|
||||
// TODO Get Creation time. https://github.com/djherbis/times ?
|
||||
mf.CreatedAt = md.ModificationTime()
|
||||
mf.UpdatedAt = md.ModificationTime()
|
||||
|
||||
return mf
|
||||
return *mf
|
||||
}
|
||||
|
||||
func sanitizeFieldForSorting(originalValue string) string {
|
||||
v := utils.NoArticle(originalValue)
|
||||
v = strings.TrimSpace(sanitize.Accents(v))
|
||||
return utils.NoArticle(v)
|
||||
}
|
||||
|
||||
func (s *TagScanner) mapTrackTitle(md *Metadata) string {
|
||||
|
||||
21
scanner/tag_scanner_test.go
Normal file
21
scanner/tag_scanner_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"github.com/deluan/navidrome/conf"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("TagScanner", func() {
|
||||
Describe("sanitizeFieldForSorting", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.IgnoredArticles = "The"
|
||||
})
|
||||
It("sanitize accents", func() {
|
||||
Expect(sanitizeFieldForSorting("Céu")).To(Equal("Ceu"))
|
||||
})
|
||||
It("removes articles", func() {
|
||||
Expect(sanitizeFieldForSorting("The Beatles")).To(Equal("Beatles"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -54,7 +54,7 @@ func (a *Server) Run(addr string) {
|
||||
func (a *Server) initRoutes() {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(cors.Default().Handler)
|
||||
r.Use(cors.AllowAll().Handler)
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
@@ -12,37 +11,45 @@ import (
|
||||
)
|
||||
|
||||
type AlbumListController struct {
|
||||
listGen engine.ListGenerator
|
||||
listFunctions map[string]strategy
|
||||
listGen engine.ListGenerator
|
||||
}
|
||||
|
||||
func NewAlbumListController(listGen engine.ListGenerator) *AlbumListController {
|
||||
c := &AlbumListController{
|
||||
listGen: listGen,
|
||||
}
|
||||
c.listFunctions = map[string]strategy{
|
||||
"random": c.listGen.GetRandom,
|
||||
"newest": c.listGen.GetNewest,
|
||||
"recent": c.listGen.GetRecent,
|
||||
"frequent": c.listGen.GetFrequent,
|
||||
"highest": c.listGen.GetHighest,
|
||||
"alphabeticalByName": c.listGen.GetByName,
|
||||
"alphabeticalByArtist": c.listGen.GetByArtist,
|
||||
"starred": c.listGen.GetStarred,
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
type strategy func(ctx context.Context, offset int, size int) (engine.Entries, error)
|
||||
|
||||
func (c *AlbumListController) getAlbumList(r *http.Request) (engine.Entries, error) {
|
||||
func (c *AlbumListController) getNewAlbumList(r *http.Request) (engine.Entries, error) {
|
||||
typ, err := RequiredParamString(r, "type", "Required string parameter 'type' is not present")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
listFunc, found := c.listFunctions[typ]
|
||||
|
||||
if !found {
|
||||
var filter engine.ListFilter
|
||||
switch typ {
|
||||
case "newest":
|
||||
filter = engine.ByNewest()
|
||||
case "recent":
|
||||
filter = engine.ByRecent()
|
||||
case "random":
|
||||
filter = engine.ByRandom()
|
||||
case "alphabeticalByName":
|
||||
filter = engine.ByName()
|
||||
case "alphabeticalByArtist":
|
||||
filter = engine.ByArtist()
|
||||
case "frequent":
|
||||
filter = engine.ByFrequent()
|
||||
case "starred":
|
||||
filter = engine.ByStarred()
|
||||
case "highest":
|
||||
filter = engine.ByRating()
|
||||
case "byGenre":
|
||||
filter = engine.ByGenre(utils.ParamString(r, "genre"))
|
||||
case "byYear":
|
||||
filter = engine.ByYear(utils.ParamInt(r, "fromYear", 0), utils.ParamInt(r, "toYear", 0))
|
||||
default:
|
||||
log.Error(r, "albumList type not implemented", "type", typ)
|
||||
return nil, errors.New("Not implemented!")
|
||||
}
|
||||
@@ -50,7 +57,7 @@ func (c *AlbumListController) getAlbumList(r *http.Request) (engine.Entries, err
|
||||
offset := utils.ParamInt(r, "offset", 0)
|
||||
size := utils.MinInt(utils.ParamInt(r, "size", 10), 500)
|
||||
|
||||
albums, err := listFunc(r.Context(), offset, size)
|
||||
albums, err := c.listGen.GetAlbums(r.Context(), offset, size, filter)
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving albums", "error", err)
|
||||
return nil, errors.New("Internal Error")
|
||||
@@ -60,7 +67,7 @@ func (c *AlbumListController) getAlbumList(r *http.Request) (engine.Entries, err
|
||||
}
|
||||
|
||||
func (c *AlbumListController) GetAlbumList(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
albums, err := c.getAlbumList(r)
|
||||
albums, err := c.getNewAlbumList(r)
|
||||
if err != nil {
|
||||
return nil, NewError(responses.ErrorGeneric, err.Error())
|
||||
}
|
||||
@@ -71,7 +78,7 @@ func (c *AlbumListController) GetAlbumList(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
func (c *AlbumListController) GetAlbumList2(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
albums, err := c.getAlbumList(r)
|
||||
albums, err := c.getNewAlbumList(r)
|
||||
if err != nil {
|
||||
return nil, NewError(responses.ErrorGeneric, err.Error())
|
||||
}
|
||||
@@ -134,8 +141,27 @@ func (c *AlbumListController) GetNowPlaying(w http.ResponseWriter, r *http.Reque
|
||||
func (c *AlbumListController) GetRandomSongs(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
size := utils.MinInt(utils.ParamInt(r, "size", 10), 500)
|
||||
genre := utils.ParamString(r, "genre")
|
||||
fromYear := utils.ParamInt(r, "fromYear", 0)
|
||||
toYear := utils.ParamInt(r, "toYear", 0)
|
||||
|
||||
songs, err := c.listGen.GetRandomSongs(r.Context(), size, genre)
|
||||
songs, err := c.listGen.GetSongs(r.Context(), 0, size, engine.SongsByRandom(genre, fromYear, toYear))
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving random songs", "error", err)
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response.RandomSongs = &responses.Songs{}
|
||||
response.RandomSongs.Songs = ToChildren(r.Context(), songs)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *AlbumListController) GetSongsByGenre(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
count := utils.MinInt(utils.ParamInt(r, "count", 10), 500)
|
||||
offset := utils.MinInt(utils.ParamInt(r, "offset", 0), 500)
|
||||
genre := utils.ParamString(r, "genre")
|
||||
|
||||
songs, err := c.listGen.GetSongs(r.Context(), offset, count, engine.SongsByGenre(genre))
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving random songs", "error", err)
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
|
||||
@@ -18,7 +18,7 @@ type fakeListGen struct {
|
||||
recvSize int
|
||||
}
|
||||
|
||||
func (lg *fakeListGen) GetNewest(ctx context.Context, offset int, size int) (engine.Entries, error) {
|
||||
func (lg *fakeListGen) GetAlbums(ctx context.Context, offset int, size int, filter engine.ListFilter) (engine.Entries, error) {
|
||||
if lg.err != nil {
|
||||
return nil, lg.err
|
||||
}
|
||||
|
||||
@@ -5,15 +5,18 @@ import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/engine"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/server/subsonic/responses"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
)
|
||||
|
||||
const Version = "1.8.0"
|
||||
const Version = "1.10.2"
|
||||
|
||||
type Handler = func(http.ResponseWriter, *http.Request) (*responses.Subsonic, error)
|
||||
|
||||
@@ -86,6 +89,7 @@ func (api *Router) routes() http.Handler {
|
||||
H(withPlayer, "getStarred2", c.GetStarred2)
|
||||
H(withPlayer, "getNowPlaying", c.GetNowPlaying)
|
||||
H(withPlayer, "getRandomSongs", c.GetRandomSongs)
|
||||
H(withPlayer, "getSongsByGenre", c.GetSongsByGenre)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
c := initMediaAnnotationController(api)
|
||||
@@ -115,8 +119,11 @@ func (api *Router) routes() http.Handler {
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
c := initMediaRetrievalController(api)
|
||||
H(r, "getAvatar", c.GetAvatar)
|
||||
H(r, "getCoverArt", c.GetCoverArt)
|
||||
// configure request throttling
|
||||
maxRequests := utils.MaxInt(2, runtime.NumCPU())
|
||||
withThrottle := r.With(middleware.ThrottleBacklog(maxRequests, consts.RequestThrottleBacklogLimit, consts.RequestThrottleBacklogTimeout))
|
||||
H(withThrottle, "getAvatar", c.GetAvatar)
|
||||
H(withThrottle, "getCoverArt", c.GetCoverArt)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
c := initStreamController(api)
|
||||
@@ -128,6 +135,9 @@ func (api *Router) routes() http.Handler {
|
||||
// Deprecated/Out of scope endpoints
|
||||
HGone(r, "getChatMessages")
|
||||
HGone(r, "addChatMessage")
|
||||
HGone(r, "getVideos")
|
||||
HGone(r, "getVideoInfo")
|
||||
HGone(r, "getCaptions")
|
||||
return r
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ func (c *PlaylistsController) GetPlaylists(w http.ResponseWriter, r *http.Reques
|
||||
playlists[i].Duration = int(p.Duration)
|
||||
playlists[i].Owner = p.Owner
|
||||
playlists[i].Public = p.Public
|
||||
playlists[i].Created = p.CreatedAt
|
||||
playlists[i].Changed = p.UpdatedAt
|
||||
}
|
||||
response := NewResponse()
|
||||
response.Playlists = &responses.Playlists{Playlist: playlists}
|
||||
@@ -58,7 +60,7 @@ func (c *PlaylistsController) GetPlaylist(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response.Playlist = c.buildPlaylist(r.Context(), pinfo)
|
||||
response.Playlist = c.buildPlaylistWithSongs(r.Context(), pinfo)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -125,15 +127,24 @@ func (c *PlaylistsController) UpdatePlaylist(w http.ResponseWriter, r *http.Requ
|
||||
return NewResponse(), nil
|
||||
}
|
||||
|
||||
func (c *PlaylistsController) buildPlaylist(ctx context.Context, d *engine.PlaylistInfo) *responses.PlaylistWithSongs {
|
||||
pls := &responses.PlaylistWithSongs{}
|
||||
func (c *PlaylistsController) buildPlaylistWithSongs(ctx context.Context, d *engine.PlaylistInfo) *responses.PlaylistWithSongs {
|
||||
pls := &responses.PlaylistWithSongs{
|
||||
Playlist: *c.buildPlaylist(d),
|
||||
}
|
||||
pls.Entry = ToChildren(ctx, d.Entries)
|
||||
return pls
|
||||
}
|
||||
|
||||
func (c *PlaylistsController) buildPlaylist(d *engine.PlaylistInfo) *responses.Playlist {
|
||||
pls := &responses.Playlist{}
|
||||
pls.Id = d.Id
|
||||
pls.Name = d.Name
|
||||
pls.Comment = d.Comment
|
||||
pls.SongCount = d.SongCount
|
||||
pls.Owner = d.Owner
|
||||
pls.Duration = d.Duration
|
||||
pls.Public = d.Public
|
||||
|
||||
pls.Entry = ToChildren(ctx, d.Entries)
|
||||
pls.Created = d.Created
|
||||
pls.Changed = d.Changed
|
||||
return pls
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","playlists":{"playlist":[{"id":"111","name":"aaa"},{"id":"222","name":"bbb"}]}}
|
||||
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","playlists":{"playlist":[{"id":"111","name":"aaa","comment":"comment","songCount":2,"duration":120,"public":true,"owner":"admin","created":"0001-01-01T00:00:00Z","changed":"0001-01-01T00:00:00Z"},{"id":"222","name":"bbb","songCount":0,"duration":0,"created":"0001-01-01T00:00:00Z","changed":"0001-01-01T00:00:00Z"}]}}
|
||||
|
||||
@@ -1 +1 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><playlists><playlist id="111" name="aaa"></playlist><playlist id="222" name="bbb"></playlist></playlists></subsonic-response>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><playlists><playlist id="111" name="aaa" comment="comment" songCount="2" duration="120" public="true" owner="admin" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist><playlist id="222" name="bbb" songCount="0" duration="0" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist></playlists></subsonic-response>
|
||||
|
||||
@@ -28,6 +28,7 @@ type Subsonic struct {
|
||||
NowPlaying *NowPlaying `xml:"nowPlaying,omitempty" json:"nowPlaying,omitempty"`
|
||||
Song *Child `xml:"song,omitempty" json:"song,omitempty"`
|
||||
RandomSongs *Songs `xml:"randomSongs,omitempty" json:"randomSongs,omitempty"`
|
||||
SongsByGenre *Songs `xml:"songsByGenre,omitempty" json:"songsByGenre,omitempty"`
|
||||
Genres *Genres `xml:"genres,omitempty" json:"genres,omitempty"`
|
||||
|
||||
// ID3
|
||||
@@ -188,22 +189,20 @@ type AlbumList struct {
|
||||
}
|
||||
|
||||
type Playlist struct {
|
||||
Id string `xml:"id,attr" json:"id"`
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"`
|
||||
SongCount int `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
|
||||
Duration int `xml:"duration,attr,omitempty" json:"duration,omitempty"`
|
||||
Public bool `xml:"public,attr,omitempty" json:"public,omitempty"`
|
||||
Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"`
|
||||
Id string `xml:"id,attr" json:"id"`
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"`
|
||||
SongCount int `xml:"songCount,attr" json:"songCount"`
|
||||
Duration int `xml:"duration,attr" json:"duration"`
|
||||
Public bool `xml:"public,attr,omitempty" json:"public,omitempty"`
|
||||
Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"`
|
||||
Created time.Time `xml:"created,attr" json:"created"`
|
||||
Changed time.Time `xml:"changed,attr" json:"changed"`
|
||||
/*
|
||||
<xs:sequence>
|
||||
<xs:element name="allowedUser" type="xs:string" minOccurs="0" maxOccurs="unbounded"/> <!--Added in 1.8.0-->
|
||||
</xs:sequence>
|
||||
<xs:attribute name="comment" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
|
||||
<xs:attribute name="created" type="xs:dateTime" use="required"/> <!--Added in 1.8.0-->
|
||||
<xs:attribute name="changed" type="xs:dateTime" use="required"/> <!--Added in 1.13.0-->
|
||||
<xs:attribute name="coverArt" type="xs:string" use="optional"/> <!--Added in 1.11.0-->
|
||||
|
||||
*/
|
||||
}
|
||||
|
||||
|
||||
@@ -235,9 +235,20 @@ var _ = Describe("Responses", func() {
|
||||
})
|
||||
|
||||
Context("with data", func() {
|
||||
timestamp, _ := time.Parse(time.RFC3339, "2020-04-11T16:43:00Z04:00")
|
||||
BeforeEach(func() {
|
||||
pls := make([]Playlist, 2)
|
||||
pls[0] = Playlist{Id: "111", Name: "aaa"}
|
||||
pls[0] = Playlist{
|
||||
Id: "111",
|
||||
Name: "aaa",
|
||||
Comment: "comment",
|
||||
SongCount: 2,
|
||||
Duration: 120,
|
||||
Public: true,
|
||||
Owner: "admin",
|
||||
Created: timestamp,
|
||||
Changed: timestamp,
|
||||
}
|
||||
pls[1] = Playlist{Id: "222", Name: "bbb"}
|
||||
response.Playlists.Playlist = pls
|
||||
})
|
||||
|
||||
0
tests/empty_folder/not_an_audio_file.txt
Normal file
0
tests/empty_folder/not_an_audio_file.txt
Normal file
1
tests/fixtures/symlink
vendored
Symbolic link
1
tests/fixtures/symlink
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
index.html
|
||||
1
tests/fixtures/symlink2dir
vendored
Symbolic link
1
tests/fixtures/symlink2dir
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../
|
||||
1
tests/fixtures/synlink_invalid
vendored
Symbolic link
1
tests/fixtures/synlink_invalid
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
INVALID
|
||||
674
ui/package-lock.json
generated
674
ui/package-lock.json
generated
@@ -1640,9 +1640,10 @@
|
||||
}
|
||||
},
|
||||
"@jest/types": {
|
||||
"version": "25.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@jest/types/-/types-25.1.0.tgz",
|
||||
"integrity": "sha512-VpOtt7tCrgvamWZh1reVsGADujKigBUFTi19mlRjqEGsE8qH4r3s+skY33dNdXOwyZIvuftZ5tqdF1IgsMejMA==",
|
||||
"version": "25.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@jest/types/-/types-25.4.0.tgz",
|
||||
"integrity": "sha512-XBeaWNzw2PPnGW5aXvZt3+VO60M+34RY3XDsCK5tW7kyj3RK0XClRutCfjqcBuaR2aBQTbluEDME9b5MB9UAPw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/istanbul-lib-coverage": "^2.0.0",
|
||||
"@types/istanbul-reports": "^1.1.1",
|
||||
@@ -1651,19 +1652,20 @@
|
||||
}
|
||||
},
|
||||
"@material-ui/core": {
|
||||
"version": "4.9.8",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.9.8.tgz",
|
||||
"integrity": "sha512-4cslpG6oLoPWUfwPkX+hvbak4hAGiOfgXOu/UIYeeMrtsTEebC0Mirjoby7zhS4ny86YI3rXEFW6EZDmlj5n5w==",
|
||||
"version": "4.9.11",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.9.11.tgz",
|
||||
"integrity": "sha512-S2Ha9GpTxzl29XMeMc8dQX2pj97yApNzuhe/23If53fMdg5Fmd3SgbE1bMbyXeKhxwtXZjOFxd0vU+W/sez8Ew==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.4.4",
|
||||
"@material-ui/styles": "^4.9.6",
|
||||
"@material-ui/system": "^4.9.6",
|
||||
"@material-ui/types": "^5.0.0",
|
||||
"@material-ui/react-transition-group": "^4.2.0",
|
||||
"@material-ui/styles": "^4.9.10",
|
||||
"@material-ui/system": "^4.9.10",
|
||||
"@material-ui/types": "^5.0.1",
|
||||
"@material-ui/utils": "^4.9.6",
|
||||
"@types/react-transition-group": "^4.2.0",
|
||||
"clsx": "^1.0.2",
|
||||
"clsx": "^1.0.4",
|
||||
"hoist-non-react-statics": "^3.3.2",
|
||||
"popper.js": "^1.14.1",
|
||||
"popper.js": "^1.16.1-lts",
|
||||
"prop-types": "^15.7.2",
|
||||
"react-is": "^16.8.0",
|
||||
"react-transition-group": "^4.3.0"
|
||||
@@ -1687,14 +1689,25 @@
|
||||
"@babel/runtime": "^7.4.4"
|
||||
}
|
||||
},
|
||||
"@material-ui/react-transition-group": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/react-transition-group/-/react-transition-group-4.2.0.tgz",
|
||||
"integrity": "sha512-4zapZ0gW1ZTws5aH9OGy3IMvtTV/olc7YrVSkM1WFu1FsrEhL+qarEniRjx7LjHt0gukFqoINfElI8v2boVMQA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.4.5",
|
||||
"dom-helpers": "^3.4.0",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
}
|
||||
},
|
||||
"@material-ui/styles": {
|
||||
"version": "4.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.9.6.tgz",
|
||||
"integrity": "sha512-ijgwStEkw1OZ6gCz18hkjycpr/3lKs1hYPi88O/AUn4vMuuGEGAIrqKVFq/lADmZUNF3DOFIk8LDkp7zmjPxtA==",
|
||||
"version": "4.9.10",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.9.10.tgz",
|
||||
"integrity": "sha512-EXIXlqVyFDnjXF6tj72y6ZxiSy+mHtrsCo3Srkm3XUeu3Z01aftDBy7ZSr3TQ02gXHTvDSBvegp3Le6p/tl7eA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.4.4",
|
||||
"@emotion/hash": "^0.8.0",
|
||||
"@material-ui/types": "^5.0.0",
|
||||
"@material-ui/types": "^5.0.1",
|
||||
"@material-ui/utils": "^4.9.6",
|
||||
"clsx": "^1.0.2",
|
||||
"csstype": "^2.5.2",
|
||||
@@ -1721,9 +1734,9 @@
|
||||
}
|
||||
},
|
||||
"@material-ui/system": {
|
||||
"version": "4.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/system/-/system-4.9.6.tgz",
|
||||
"integrity": "sha512-QtfoAePyqXoZ2HUVSwGb1Ro0kucMCvVjbI0CdYIR21t0Opgfm1Oer6ni9P5lfeXA39xSt0wCierw37j+YES48Q==",
|
||||
"version": "4.9.10",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/system/-/system-4.9.10.tgz",
|
||||
"integrity": "sha512-E+t0baX2TBZk6ALm8twG6objpsxLdMM4MDm1++LMt2m7CetCAEc3aIAfDaprk4+tm5hFT1Cah5dRWk8EeIFQYw==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.4.4",
|
||||
"@material-ui/utils": "^4.9.6",
|
||||
@@ -1731,9 +1744,9 @@
|
||||
}
|
||||
},
|
||||
"@material-ui/types": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/types/-/types-5.0.0.tgz",
|
||||
"integrity": "sha512-UeH2BuKkwDndtMSS0qgx1kCzSMw+ydtj0xx/XbFtxNSTlXydKwzs5gVW5ZKsFlAkwoOOQ9TIsyoCC8hq18tOwg=="
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/types/-/types-5.0.1.tgz",
|
||||
"integrity": "sha512-wURPSY7/3+MAtng3i26g+WKwwNE3HEeqa/trDBR5+zWKmcjO+u9t7Npu/J1r+3dmIa/OeziN9D/18IrBKvKffw=="
|
||||
},
|
||||
"@material-ui/utils": {
|
||||
"version": "4.9.6",
|
||||
@@ -1921,32 +1934,50 @@
|
||||
}
|
||||
},
|
||||
"@testing-library/dom": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.0.4.tgz",
|
||||
"integrity": "sha512-+vrLcGDvopLPsBB7JgJhf8ZoOhBSeCsI44PKJL9YoKrP2AvCkqrTg+z77wEEZJ4tSNdxV0kymil7hSvsQQ7jMQ==",
|
||||
"version": "7.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.2.1.tgz",
|
||||
"integrity": "sha512-xIGoHlQ2ZiEL1dJIFKNmLDypzYF+4OJTTASRctl/aoIDaS5y/pRVHRigoqvPUV11mdJoR71IIgi/6UviMgyz4g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.8.4",
|
||||
"@types/testing-library__dom": "^6.12.1",
|
||||
"@babel/runtime": "^7.9.2",
|
||||
"@types/testing-library__dom": "^7.0.0",
|
||||
"aria-query": "^4.0.2",
|
||||
"dom-accessibility-api": "^0.3.0",
|
||||
"dom-accessibility-api": "^0.4.2",
|
||||
"pretty-format": "^25.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": {
|
||||
"version": "7.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.2.tgz",
|
||||
"integrity": "sha512-NE2DtOdufG7R5vnfQUTehdTfNycfUANEtCa9PssN9O/xmTzP4E08UI797ixaei6hBEVL9BI/PsdJS5x7mWoB9Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"regenerator-runtime": "^0.13.4"
|
||||
}
|
||||
},
|
||||
"aria-query": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.0.2.tgz",
|
||||
"integrity": "sha512-S1G1V790fTaigUSM/Gd0NngzEfiMy9uTUfMyHhKhVyy4cH5O/eTuR01ydhGL0z4Za1PXFTRGH3qL8VhUQuEO5w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.7.4",
|
||||
"@babel/runtime-corejs3": "^7.7.4"
|
||||
}
|
||||
},
|
||||
"regenerator-runtime": {
|
||||
"version": "0.13.5",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz",
|
||||
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@testing-library/jest-dom": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.3.0.tgz",
|
||||
"integrity": "sha512-Cdhpc3BHL888X55qBNyra9eM0UG63LCm/FqCWTa1Ou/0MpsUbQTM9vW1NU6/jBQFoSLgkFfDG5XVpm2V0dOm/A==",
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.5.0.tgz",
|
||||
"integrity": "sha512-7sWHrpxG4Yd8TmryI7Rtbx8Ff4mbs3ASye3oshQIuHvsCR+QHgr7rTR/PfeXvOmwUwR36wSTTAvrLKsPmr6VEQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.9.2",
|
||||
"@types/testing-library__jest-dom": "^5.0.2",
|
||||
@@ -1963,6 +1994,7 @@
|
||||
"version": "7.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.2.tgz",
|
||||
"integrity": "sha512-NE2DtOdufG7R5vnfQUTehdTfNycfUANEtCa9PssN9O/xmTzP4E08UI797ixaei6hBEVL9BI/PsdJS5x7mWoB9Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"regenerator-runtime": "^0.13.4"
|
||||
}
|
||||
@@ -1970,24 +2002,27 @@
|
||||
"regenerator-runtime": {
|
||||
"version": "0.13.5",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz",
|
||||
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA=="
|
||||
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@testing-library/react": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-10.0.1.tgz",
|
||||
"integrity": "sha512-sMHWud2dcymOzq2AhEniICSijEwKeTiBX+K0y36FYNY7wH2t0SIP1o732Bf5dDY0jYoMC2hj2UJSVpZC/rDsWg==",
|
||||
"version": "10.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-10.0.2.tgz",
|
||||
"integrity": "sha512-YT6Mw0oJz7R6vlEkmo1FlUD+K15FeXApOB5Ffm9zooFVnrwkt00w18dUJFMOh1yRp9wTdVRonbor7o4PIpFCmA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"@testing-library/dom": "^7.0.2",
|
||||
"@types/testing-library__react": "^9.1.3"
|
||||
"@babel/runtime": "^7.9.2",
|
||||
"@testing-library/dom": "^7.1.0",
|
||||
"@types/testing-library__react": "^10.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": {
|
||||
"version": "7.8.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.8.7.tgz",
|
||||
"integrity": "sha512-+AATMUFppJDw6aiR5NVPHqIQBlV/Pj8wY/EZH+lmvRdUo9xBaz/rF3alAwFJQavvKfeOlPE7oaaDHVbcySbCsg==",
|
||||
"version": "7.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.2.tgz",
|
||||
"integrity": "sha512-NE2DtOdufG7R5vnfQUTehdTfNycfUANEtCa9PssN9O/xmTzP4E08UI797ixaei6hBEVL9BI/PsdJS5x7mWoB9Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"regenerator-runtime": "^0.13.4"
|
||||
}
|
||||
@@ -1995,14 +2030,16 @@
|
||||
"regenerator-runtime": {
|
||||
"version": "0.13.5",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz",
|
||||
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA=="
|
||||
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@testing-library/user-event": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-10.0.1.tgz",
|
||||
"integrity": "sha512-M63ftowo1QpAGMnWyz7df0ygqnu4XyF68Sty7mivMAz2HLcY1uLoN3qcen6WMobdY0MoZUi4+BLsziSDAP62Vg=="
|
||||
"version": "10.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-10.0.2.tgz",
|
||||
"integrity": "sha512-fVeP4U37BIYdp9nBRKEITFSLPqgCSS7Og6LHvxoQ2JSOTJ1NJI4Dfesv4uNXxvNNcJgBS88V+Tc6h8vbDsa2iA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/babel__core": {
|
||||
"version": "7.1.6",
|
||||
@@ -2089,25 +2126,13 @@
|
||||
}
|
||||
},
|
||||
"@types/jest": {
|
||||
"version": "25.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-25.1.5.tgz",
|
||||
"integrity": "sha512-FBmb9YZHoEOH56Xo/PIYtfuyTL0IzJLM3Hy0Sqc82nn5eqqXgefKcl/eMgChM8eSGVfoDee8cdlj7K74T8a6Yg==",
|
||||
"version": "25.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-25.2.1.tgz",
|
||||
"integrity": "sha512-msra1bCaAeEdkSyA0CZ6gW1ukMIvZ5YoJkdXw/qhQdsuuDlFTcEUrUw8CLCPt2rVRUfXlClVvK2gvPs9IokZaA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"jest-diff": "25.1.0",
|
||||
"pretty-format": "25.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"jest-diff": {
|
||||
"version": "25.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-25.1.0.tgz",
|
||||
"integrity": "sha512-nepXgajT+h017APJTreSieh4zCqnSHEJ1iT8HDlewu630lSJ4Kjjr9KNzm+kzGwwcpsDE6Snx1GJGzzsefaEHw==",
|
||||
"requires": {
|
||||
"chalk": "^3.0.0",
|
||||
"diff-sequences": "^25.1.0",
|
||||
"jest-get-type": "^25.1.0",
|
||||
"pretty-format": "^25.1.0"
|
||||
}
|
||||
}
|
||||
"jest-diff": "^25.2.1",
|
||||
"pretty-format": "^25.2.1"
|
||||
}
|
||||
},
|
||||
"@types/json-schema": {
|
||||
@@ -2150,9 +2175,10 @@
|
||||
}
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"version": "16.9.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.9.5.tgz",
|
||||
"integrity": "sha512-BX6RQ8s9D+2/gDhxrj8OW+YD4R+8hj7FEM/OJHGNR0KipE1h1mSsf39YeyC81qafkq+N3rU3h3RFbLSwE5VqUg==",
|
||||
"version": "16.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.9.6.tgz",
|
||||
"integrity": "sha512-S6ihtlPMDotrlCJE9ST1fRmYrQNNwfgL61UB4I1W7M6kPulUKx9fXAleW5zpdIjUQ4fTaaog8uERezjsGUj9HQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
@@ -2171,82 +2197,28 @@
|
||||
"integrity": "sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw=="
|
||||
},
|
||||
"@types/testing-library__dom": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/testing-library__dom/-/testing-library__dom-6.14.0.tgz",
|
||||
"integrity": "sha512-sMl7OSv0AvMOqn1UJ6j1unPMIHRXen0Ita1ujnMX912rrOcawe4f7wu0Zt9GIQhBhJvH2BaibqFgQ3lP+Pj2hA==",
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/testing-library__dom/-/testing-library__dom-7.0.1.tgz",
|
||||
"integrity": "sha512-WokGRksRJb3Dla6h02/0/NNHTkjsj4S8aJZiwMj/5/UL8VZ1iCe3H8SHzfpmBeH8Vp4SPRT8iC2o9kYULFhDIw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"pretty-format": "^24.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jest/types": {
|
||||
"version": "24.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@jest/types/-/types-24.9.0.tgz",
|
||||
"integrity": "sha512-XKK7ze1apu5JWQ5eZjHITP66AX+QsLlbaJRBGYr8pNzwcAE2JVkwnf0yqjHTsDRcjR0mujy/NmZMXw5kl+kGBw==",
|
||||
"requires": {
|
||||
"@types/istanbul-lib-coverage": "^2.0.0",
|
||||
"@types/istanbul-reports": "^1.1.1",
|
||||
"@types/yargs": "^13.0.0"
|
||||
}
|
||||
},
|
||||
"@types/yargs": {
|
||||
"version": "13.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-13.0.8.tgz",
|
||||
"integrity": "sha512-XAvHLwG7UQ+8M4caKIH0ZozIOYay5fQkAgyIXegXT9jPtdIGdhga+sUEdAr1CiG46aB+c64xQEYyEzlwWVTNzA==",
|
||||
"requires": {
|
||||
"@types/yargs-parser": "*"
|
||||
}
|
||||
},
|
||||
"ansi-regex": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
|
||||
"integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg=="
|
||||
},
|
||||
"ansi-styles": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
|
||||
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
|
||||
"requires": {
|
||||
"color-convert": "^1.9.0"
|
||||
}
|
||||
},
|
||||
"color-convert": {
|
||||
"version": "1.9.3",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
||||
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
|
||||
"requires": {
|
||||
"color-name": "1.1.3"
|
||||
}
|
||||
},
|
||||
"color-name": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
|
||||
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
|
||||
},
|
||||
"pretty-format": {
|
||||
"version": "24.9.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-24.9.0.tgz",
|
||||
"integrity": "sha512-00ZMZUiHaJrNfk33guavqgvfJS30sLYf0f8+Srklv0AMPodGGHcoHgksZ3OThYnIvOd+8yMCn0YiEOogjlgsnA==",
|
||||
"requires": {
|
||||
"@jest/types": "^24.9.0",
|
||||
"ansi-regex": "^4.0.0",
|
||||
"ansi-styles": "^3.2.0",
|
||||
"react-is": "^16.8.4"
|
||||
}
|
||||
}
|
||||
"pretty-format": "^25.1.0"
|
||||
}
|
||||
},
|
||||
"@types/testing-library__jest-dom": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.0.2.tgz",
|
||||
"integrity": "sha512-dZP+/WHndgCSmdaImITy0KhjGAa9c0hlGGkzefbtrPFpnGEPZECDA0zyvfSp8RKhHECJJSKHFExjOwzo0rHyIA==",
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.0.3.tgz",
|
||||
"integrity": "sha512-NdbKc6yseg6uq4UJFwimPws0iwsGugVbPoOTP2EH+PJMJKiZsoSg5F2H3XYweOyytftCOuIMuXifBUrF9CSvaQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/jest": "*"
|
||||
}
|
||||
},
|
||||
"@types/testing-library__react": {
|
||||
"version": "9.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/testing-library__react/-/testing-library__react-9.1.3.tgz",
|
||||
"integrity": "sha512-iCdNPKU3IsYwRK9JieSYAiX0+aYDXOGAmrC/3/M7AqqSDKnWWVv07X+Zk1uFSL7cMTUYzv4lQRfohucEocn5/w==",
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/testing-library__react/-/testing-library__react-10.0.1.tgz",
|
||||
"integrity": "sha512-RbDwmActAckbujLZeVO/daSfdL1pnjVqas25UueOkAY5r7vriavWf0Zqg7ghXMHa8ycD/kLkv8QOj31LmSYwww==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/react-dom": "*",
|
||||
"@types/testing-library__dom": "*",
|
||||
@@ -2257,6 +2229,7 @@
|
||||
"version": "15.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.4.tgz",
|
||||
"integrity": "sha512-9T1auFmbPZoxHz0enUFlUuKRy3it01R+hlggyVUMtnCTQRunsQYifnSGb8hET4Xo8yiC0o0r1paW3ud5+rbURg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/yargs-parser": "*"
|
||||
}
|
||||
@@ -4548,11 +4521,11 @@
|
||||
}
|
||||
},
|
||||
"css-vendor": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.7.tgz",
|
||||
"integrity": "sha512-VS9Rjt79+p7M0WkPqcAza4Yq1ZHrsHrwf7hPL/bjQB+c1lwmAI+1FXxYTYt818D/50fFVflw0XKleiBN5RITkg==",
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz",
|
||||
"integrity": "sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.6.2",
|
||||
"@babel/runtime": "^7.8.3",
|
||||
"is-in-browser": "^1.0.2"
|
||||
}
|
||||
},
|
||||
@@ -4564,7 +4537,8 @@
|
||||
"css.escape": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||
"integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s="
|
||||
"integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=",
|
||||
"dev": true
|
||||
},
|
||||
"cssdb": {
|
||||
"version": "4.4.0",
|
||||
@@ -4938,7 +4912,8 @@
|
||||
"diff-sequences": {
|
||||
"version": "25.2.6",
|
||||
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.2.6.tgz",
|
||||
"integrity": "sha512-Hq8o7+6GaZeoFjtpgvRBUknSXNeJiCx7V9Fr94ZMljNiCr9n9L8H8aJqgWOQiDDGdyn29fRNcDdRVJ5fdyihfg=="
|
||||
"integrity": "sha512-Hq8o7+6GaZeoFjtpgvRBUknSXNeJiCx7V9Fr94ZMljNiCr9n9L8H8aJqgWOQiDDGdyn29fRNcDdRVJ5fdyihfg==",
|
||||
"dev": true
|
||||
},
|
||||
"diffie-hellman": {
|
||||
"version": "5.0.3",
|
||||
@@ -4990,14 +4965,15 @@
|
||||
}
|
||||
},
|
||||
"dom-accessibility-api": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.3.0.tgz",
|
||||
"integrity": "sha512-PzwHEmsRP3IGY4gv/Ug+rMeaTIyTJvadCb+ujYXYeIylbHJezIyNToe8KfEgHTCEYyC+/bUghYOGg8yMGlZ6vA=="
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.4.3.tgz",
|
||||
"integrity": "sha512-JZ8iPuEHDQzq6q0k7PKMGbrIdsgBB7TRrtVOUm4nSMCExlg5qQG4KXWTH2k90yggjM4tTumRGwTKJSldMzKyLA==",
|
||||
"dev": true
|
||||
},
|
||||
"dom-align": {
|
||||
"version": "1.10.4",
|
||||
"resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.10.4.tgz",
|
||||
"integrity": "sha512-wytDzaru67AmqFOY4B9GUb/hrwWagezoYYK97D/vpK+ezg+cnuZO0Q2gltUPa7KfNmIqfRIYVCF8UhRDEHAmgQ=="
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.11.1.tgz",
|
||||
"integrity": "sha512-hN42DmUgtweBx0iBjDLO4WtKOMcK8yBmPx/fgdsgQadLuzPu/8co3oLdK5yMmeM/vnUd3yDyV6qV8/NzxBexQg=="
|
||||
},
|
||||
"dom-converter": {
|
||||
"version": "0.2.0",
|
||||
@@ -5008,27 +4984,11 @@
|
||||
}
|
||||
},
|
||||
"dom-helpers": {
|
||||
"version": "5.1.4",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.1.4.tgz",
|
||||
"integrity": "sha512-TjMyeVUvNEnOnhzs6uAn9Ya47GmMo3qq7m+Lr/3ON0Rs5kHvb8I+SQYjLUSYn7qhEm0QjW0yrBkvz9yOrwwz1A==",
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz",
|
||||
"integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^2.6.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": {
|
||||
"version": "7.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.2.tgz",
|
||||
"integrity": "sha512-NE2DtOdufG7R5vnfQUTehdTfNycfUANEtCa9PssN9O/xmTzP4E08UI797ixaei6hBEVL9BI/PsdJS5x7mWoB9Q==",
|
||||
"requires": {
|
||||
"regenerator-runtime": "^0.13.4"
|
||||
}
|
||||
},
|
||||
"regenerator-runtime": {
|
||||
"version": "0.13.5",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz",
|
||||
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA=="
|
||||
}
|
||||
"@babel/runtime": "^7.1.2"
|
||||
}
|
||||
},
|
||||
"dom-serializer": {
|
||||
@@ -8118,38 +8078,15 @@
|
||||
}
|
||||
},
|
||||
"jest-diff": {
|
||||
"version": "25.2.6",
|
||||
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-25.2.6.tgz",
|
||||
"integrity": "sha512-KuadXImtRghTFga+/adnNrv9s61HudRMR7gVSbP35UKZdn4IK2/0N0PpGZIqtmllK9aUyye54I3nu28OYSnqOg==",
|
||||
"version": "25.4.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-25.4.0.tgz",
|
||||
"integrity": "sha512-kklLbJVXW0y8UKOWOdYhI6TH5MG6QAxrWiBMgQaPIuhj3dNFGirKCd+/xfplBXICQ7fI+3QcqHm9p9lWu1N6ug==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"chalk": "^3.0.0",
|
||||
"diff-sequences": "^25.2.6",
|
||||
"jest-get-type": "^25.2.6",
|
||||
"pretty-format": "^25.2.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jest/types": {
|
||||
"version": "25.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@jest/types/-/types-25.2.6.tgz",
|
||||
"integrity": "sha512-myJTTV37bxK7+3NgKc4Y/DlQ5q92/NOwZsZ+Uch7OXdElxOg61QYc72fPYNAjlvbnJ2YvbXLamIsa9tj48BmyQ==",
|
||||
"requires": {
|
||||
"@types/istanbul-lib-coverage": "^2.0.0",
|
||||
"@types/istanbul-reports": "^1.1.1",
|
||||
"@types/yargs": "^15.0.0",
|
||||
"chalk": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"pretty-format": {
|
||||
"version": "25.2.6",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-25.2.6.tgz",
|
||||
"integrity": "sha512-DEiWxLBaCHneffrIT4B+TpMvkV9RNvvJrd3lY9ew1CEQobDzEXmYT1mg0hJhljZty7kCc10z13ohOFAE8jrUDg==",
|
||||
"requires": {
|
||||
"@jest/types": "^25.2.6",
|
||||
"ansi-regex": "^5.0.0",
|
||||
"ansi-styles": "^4.0.0",
|
||||
"react-is": "^16.12.0"
|
||||
}
|
||||
}
|
||||
"pretty-format": "^25.4.0"
|
||||
}
|
||||
},
|
||||
"jest-docblock": {
|
||||
@@ -8419,7 +8356,8 @@
|
||||
"jest-get-type": {
|
||||
"version": "25.2.6",
|
||||
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-25.2.6.tgz",
|
||||
"integrity": "sha512-DxjtyzOHjObRM+sM1knti6or+eOgcGU4xVSb2HNP1TqO4ahsT+rqZg+nyqHWJSvWgKC5cG3QjGFBqxLghiF/Ig=="
|
||||
"integrity": "sha512-DxjtyzOHjObRM+sM1knti6or+eOgcGU4xVSb2HNP1TqO4ahsT+rqZg+nyqHWJSvWgKC5cG3QjGFBqxLghiF/Ig==",
|
||||
"dev": true
|
||||
},
|
||||
"jest-haste-map": {
|
||||
"version": "24.9.0",
|
||||
@@ -9151,38 +9089,15 @@
|
||||
}
|
||||
},
|
||||
"jest-matcher-utils": {
|
||||
"version": "25.2.6",
|
||||
"resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-25.2.6.tgz",
|
||||
"integrity": "sha512-+6IbC98ZBw3X7hsfUvt+7VIYBdI0FEvhSBjWo9XTHOc1KAAHDsrSHdeyHH/Su0r/pf4OEGuWRRLPnjkhS2S19A==",
|
||||
"version": "25.4.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-25.4.0.tgz",
|
||||
"integrity": "sha512-yPMdtj7YDgXhnGbc66bowk8AkQ0YwClbbwk3Kzhn5GVDrciiCr27U4NJRbrqXbTdtxjImONITg2LiRIw650k5A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"chalk": "^3.0.0",
|
||||
"jest-diff": "^25.2.6",
|
||||
"jest-diff": "^25.4.0",
|
||||
"jest-get-type": "^25.2.6",
|
||||
"pretty-format": "^25.2.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jest/types": {
|
||||
"version": "25.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@jest/types/-/types-25.2.6.tgz",
|
||||
"integrity": "sha512-myJTTV37bxK7+3NgKc4Y/DlQ5q92/NOwZsZ+Uch7OXdElxOg61QYc72fPYNAjlvbnJ2YvbXLamIsa9tj48BmyQ==",
|
||||
"requires": {
|
||||
"@types/istanbul-lib-coverage": "^2.0.0",
|
||||
"@types/istanbul-reports": "^1.1.1",
|
||||
"@types/yargs": "^15.0.0",
|
||||
"chalk": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"pretty-format": {
|
||||
"version": "25.2.6",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-25.2.6.tgz",
|
||||
"integrity": "sha512-DEiWxLBaCHneffrIT4B+TpMvkV9RNvvJrd3lY9ew1CEQobDzEXmYT1mg0hJhljZty7kCc10z13ohOFAE8jrUDg==",
|
||||
"requires": {
|
||||
"@jest/types": "^25.2.6",
|
||||
"ansi-regex": "^5.0.0",
|
||||
"ansi-styles": "^4.0.0",
|
||||
"react-is": "^16.12.0"
|
||||
}
|
||||
}
|
||||
"pretty-format": "^25.4.0"
|
||||
}
|
||||
},
|
||||
"jest-message-util": {
|
||||
@@ -10800,7 +10715,8 @@
|
||||
"min-indent": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.0.tgz",
|
||||
"integrity": "sha1-z8RcN+nsDY8KDsPdTvf3w6vjklY="
|
||||
"integrity": "sha1-z8RcN+nsDY8KDsPdTvf3w6vjklY=",
|
||||
"dev": true
|
||||
},
|
||||
"mini-create-react-context": {
|
||||
"version": "0.3.2",
|
||||
@@ -12825,9 +12741,9 @@
|
||||
"integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw="
|
||||
},
|
||||
"prettier": {
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz",
|
||||
"integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.0.4.tgz",
|
||||
"integrity": "sha512-SVJIQ51spzFDvh4fIbCLvciiDMCrRhlN3mbZvv/+ycjvmF5E73bKdGfU8QDLNmjYJf+lsGnDBC4UUnvTe5OO0w==",
|
||||
"dev": true
|
||||
},
|
||||
"pretty-bytes": {
|
||||
@@ -12845,11 +12761,12 @@
|
||||
}
|
||||
},
|
||||
"pretty-format": {
|
||||
"version": "25.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-25.1.0.tgz",
|
||||
"integrity": "sha512-46zLRSGLd02Rp+Lhad9zzuNZ+swunitn8zIpfD2B4OPCRLXbM87RJT2aBLBWYOznNUML/2l/ReMyWNC80PJBUQ==",
|
||||
"version": "25.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-25.4.0.tgz",
|
||||
"integrity": "sha512-PI/2dpGjXK5HyXexLPZU/jw5T9Q6S1YVXxxVxco+LIqzUFHXIbKZKdUVt7GcX7QUCr31+3fzhi4gN4/wUYPVxQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@jest/types": "^25.1.0",
|
||||
"@jest/types": "^25.4.0",
|
||||
"ansi-regex": "^5.0.0",
|
||||
"ansi-styles": "^4.0.0",
|
||||
"react-is": "^16.12.0"
|
||||
@@ -13010,9 +12927,9 @@
|
||||
"integrity": "sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA=="
|
||||
},
|
||||
"ra-core": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/ra-core/-/ra-core-3.3.3.tgz",
|
||||
"integrity": "sha512-SwbKf/qnYfCSTrbjnRo0w6PM3cHcyA6iKNElSqf0OlV6FeXxVrTjuxE5lAbjRaxBKZBE62h7LtBj48z2TjYr/g==",
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/ra-core/-/ra-core-3.4.1.tgz",
|
||||
"integrity": "sha512-XHcYAU36aMhIAmspdQ299SLCclu7qMmPS/IXN1VPVlRJHAIurK5tgRUMStAgDRO05qIkU5Xnb9C9PA63VmlPwA==",
|
||||
"requires": {
|
||||
"@testing-library/react": "^8.0.7",
|
||||
"classnames": "~2.2.5",
|
||||
@@ -13105,32 +13022,153 @@
|
||||
}
|
||||
},
|
||||
"ra-data-json-server": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/ra-data-json-server/-/ra-data-json-server-3.3.3.tgz",
|
||||
"integrity": "sha512-iOUbrU5bhOa3iEldyRFgk2HarX0h9qgzts7F/zA2UWYKKhpSBVHVI9X3VvYU+lhIJXll1+OjqpEJft5cXQnLRg==",
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/ra-data-json-server/-/ra-data-json-server-3.4.1.tgz",
|
||||
"integrity": "sha512-jRTALjGy4tUMYZL6MvrYNvDa+7O0YHEv7aFoc/r4nMX2G1+H/oH3H2BBWKhJeSMz828rhLPAaYTPj2F2NOYmRQ==",
|
||||
"requires": {
|
||||
"query-string": "^5.1.1",
|
||||
"ra-core": "^3.3.3"
|
||||
"ra-core": "^3.4.1"
|
||||
}
|
||||
},
|
||||
"ra-i18n-polyglot": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/ra-i18n-polyglot/-/ra-i18n-polyglot-3.3.3.tgz",
|
||||
"integrity": "sha512-dV00IZ5/gLLhTAbcmKeb4F5BsDE1anQMYRR1y6DeZobW4uMDjIX23HPUPGUGi4Cj6Na3M+j+lXqKsjpuQu6ZVg==",
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/ra-i18n-polyglot/-/ra-i18n-polyglot-3.4.1.tgz",
|
||||
"integrity": "sha512-i13N/wGi7aOxKeABdJ54wwYJZPUeLLUQ23C1TAbNynFro1pIHl2j4lxRBhRIlYPwNKdsUjuLzt30fdcVIGH+FQ==",
|
||||
"requires": {
|
||||
"node-polyglot": "^2.2.2",
|
||||
"ra-core": "^3.3.3"
|
||||
"ra-core": "^3.4.1"
|
||||
}
|
||||
},
|
||||
"ra-language-chinese": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ra-language-chinese/-/ra-language-chinese-2.0.5.tgz",
|
||||
"integrity": "sha512-BwaqQWDNhQX/Ufe5Ki2GrJ3k5OGmH8dKrQn/npvRik80+tpN4Ew4vbyS8o4E74B4UfSJ8Sj10YdB0bA6FZnAOA=="
|
||||
},
|
||||
"ra-language-english": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ra-language-english/-/ra-language-english-3.2.0.tgz",
|
||||
"integrity": "sha512-/XmwYWoQoB4MBkkzBCbg/ykCuRGjHQOHLk2ik6n1aM10AWHxiiJNyRw2aoLzH7Vc5rcp4BBJQCuhT+DgfYIJ2Q=="
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/ra-language-english/-/ra-language-english-3.4.1.tgz",
|
||||
"integrity": "sha512-bAoJyIGL3LJ/8hIvQ+gsHYlFKwgpOalQb3ZUdJReU+vAt52nQqN/3BxknxSMRFOxH0iMQk9k3B+EakDtiesH3Q==",
|
||||
"requires": {
|
||||
"ra-core": "^3.4.1"
|
||||
}
|
||||
},
|
||||
"ra-language-french": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/ra-language-french/-/ra-language-french-3.4.1.tgz",
|
||||
"integrity": "sha512-PZh9+n0FDw2VTNQR/H+Q3FXD49J4FAynTxZ1Zp8z3uCVHtlUySJwPQDp/pTbSmLIfGZ5DWfJltK4IqUkikY+0w==",
|
||||
"requires": {
|
||||
"ra-core": "^3.4.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jest/types": {
|
||||
"version": "24.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@jest/types/-/types-24.9.0.tgz",
|
||||
"integrity": "sha512-XKK7ze1apu5JWQ5eZjHITP66AX+QsLlbaJRBGYr8pNzwcAE2JVkwnf0yqjHTsDRcjR0mujy/NmZMXw5kl+kGBw==",
|
||||
"requires": {
|
||||
"@types/istanbul-lib-coverage": "^2.0.0",
|
||||
"@types/istanbul-reports": "^1.1.1",
|
||||
"@types/yargs": "^13.0.0"
|
||||
}
|
||||
},
|
||||
"@testing-library/dom": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-5.6.1.tgz",
|
||||
"integrity": "sha512-Y1T2bjtvQMewffn1CJ28kpgnuvPYKsBcZMagEH0ppfEMZPDc8AkkEnTk4smrGZKw0cblNB3lhM2FMnpfLExlHg==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"@sheerun/mutationobserver-shim": "^0.3.2",
|
||||
"aria-query": "3.0.0",
|
||||
"pretty-format": "^24.8.0",
|
||||
"wait-for-expect": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"@testing-library/react": {
|
||||
"version": "8.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-8.0.9.tgz",
|
||||
"integrity": "sha512-I7zd+MW5wk8rQA5VopZgBfxGKUd91jgZ6Vzj2gMqFf2iGGtKwvI5SVTrIJcSFaOXK88T2EUsbsIKugDtoqOcZQ==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"@testing-library/dom": "^5.6.1"
|
||||
}
|
||||
},
|
||||
"@types/yargs": {
|
||||
"version": "13.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-13.0.8.tgz",
|
||||
"integrity": "sha512-XAvHLwG7UQ+8M4caKIH0ZozIOYay5fQkAgyIXegXT9jPtdIGdhga+sUEdAr1CiG46aB+c64xQEYyEzlwWVTNzA==",
|
||||
"requires": {
|
||||
"@types/yargs-parser": "*"
|
||||
}
|
||||
},
|
||||
"ansi-regex": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
|
||||
"integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg=="
|
||||
},
|
||||
"ansi-styles": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
|
||||
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
|
||||
"requires": {
|
||||
"color-convert": "^1.9.0"
|
||||
}
|
||||
},
|
||||
"color-convert": {
|
||||
"version": "1.9.3",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
||||
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
|
||||
"requires": {
|
||||
"color-name": "1.1.3"
|
||||
}
|
||||
},
|
||||
"color-name": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
|
||||
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
|
||||
},
|
||||
"pretty-format": {
|
||||
"version": "24.9.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-24.9.0.tgz",
|
||||
"integrity": "sha512-00ZMZUiHaJrNfk33guavqgvfJS30sLYf0f8+Srklv0AMPodGGHcoHgksZ3OThYnIvOd+8yMCn0YiEOogjlgsnA==",
|
||||
"requires": {
|
||||
"@jest/types": "^24.9.0",
|
||||
"ansi-regex": "^4.0.0",
|
||||
"ansi-styles": "^3.2.0",
|
||||
"react-is": "^16.8.4"
|
||||
}
|
||||
},
|
||||
"ra-core": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/ra-core/-/ra-core-3.4.1.tgz",
|
||||
"integrity": "sha512-XHcYAU36aMhIAmspdQ299SLCclu7qMmPS/IXN1VPVlRJHAIurK5tgRUMStAgDRO05qIkU5Xnb9C9PA63VmlPwA==",
|
||||
"requires": {
|
||||
"@testing-library/react": "^8.0.7",
|
||||
"classnames": "~2.2.5",
|
||||
"date-fns": "^1.29.0",
|
||||
"eventemitter3": "^3.0.0",
|
||||
"inflection": "~1.12.0",
|
||||
"lodash": "~4.17.5",
|
||||
"prop-types": "^15.6.1",
|
||||
"query-string": "^5.1.1",
|
||||
"recompose": "~0.26.0",
|
||||
"reselect": "~3.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra-language-italian": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ra-language-italian/-/ra-language-italian-3.0.0.tgz",
|
||||
"integrity": "sha512-DUl1BTwYn06ype4ttUmnCfhq0BrrKKx+XuBJkjeIoesKjnT72iccF+/Dq+KUtJhzExVmeWQBA8DzI+0Z3QF+bA=="
|
||||
},
|
||||
"ra-language-portuguese": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/ra-language-portuguese/-/ra-language-portuguese-1.6.0.tgz",
|
||||
"integrity": "sha512-9PAxgrisjmDOTRefjCe2y2ruYQw/iqXnXgUt09vOYUcjY4J0ctabJ4+joGI0jV/x9icF9c7Pui2USc5QDRTktQ=="
|
||||
},
|
||||
"ra-ui-materialui": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/ra-ui-materialui/-/ra-ui-materialui-3.3.3.tgz",
|
||||
"integrity": "sha512-qtJH16NQl+ebyNIyrCtYNHiR2IwyZx9XSyRILoJgPdPITiAr+j/cuz7DB6o1D5HQUl5/VOSu4IQIM3jlXjrYFQ==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/ra-ui-materialui/-/ra-ui-materialui-3.4.2.tgz",
|
||||
"integrity": "sha512-P1WigQJzIGeMy2jpAoMF0PlkDKZuufUEp/J0y9YFPumHvd5Dvzzz4XCytlL+362XkzgtxVWWH+WlX8CoYD3Y2w==",
|
||||
"requires": {
|
||||
"autosuggest-highlight": "^3.1.1",
|
||||
"classnames": "~2.2.5",
|
||||
@@ -13143,29 +13181,8 @@
|
||||
"prop-types": "^15.7.0",
|
||||
"query-string": "^5.1.1",
|
||||
"react-dropzone": "^10.1.7",
|
||||
"react-transition-group": "^2.2.1",
|
||||
"react-transition-group": "^4.3.0",
|
||||
"recompose": "~0.26.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"dom-helpers": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz",
|
||||
"integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.1.2"
|
||||
}
|
||||
},
|
||||
"react-transition-group": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz",
|
||||
"integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==",
|
||||
"requires": {
|
||||
"dom-helpers": "^3.4.0",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2",
|
||||
"react-lifecycles-compat": "^3.0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"raf": {
|
||||
@@ -13217,14 +13234,14 @@
|
||||
}
|
||||
},
|
||||
"rc-align": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/rc-align/-/rc-align-2.4.5.tgz",
|
||||
"integrity": "sha512-nv9wYUYdfyfK+qskThf4BQUSIadeI/dCsfaMZfNEoxm9HwOIioQ+LyqmMK6jWHAZQgOzMLaqawhuBXlF63vgjw==",
|
||||
"version": "3.0.0-rc.1",
|
||||
"resolved": "https://registry.npmjs.org/rc-align/-/rc-align-3.0.0-rc.1.tgz",
|
||||
"integrity": "sha512-GbofumhCUb7SxP410j/fbtR2M9Zml+eoZSdaliZh6R3NhfEj5zP4jcO3HG3S9C9KIcXQQtd/cwVHkb9Y0KU7Hg==",
|
||||
"requires": {
|
||||
"babel-runtime": "^6.26.0",
|
||||
"classnames": "2.x",
|
||||
"dom-align": "^1.7.0",
|
||||
"prop-types": "^15.5.8",
|
||||
"rc-util": "^4.0.4"
|
||||
"rc-util": "^4.12.0",
|
||||
"resize-observer-polyfill": "^1.5.1"
|
||||
}
|
||||
},
|
||||
"rc-animate": {
|
||||
@@ -13242,16 +13259,14 @@
|
||||
}
|
||||
},
|
||||
"rc-slider": {
|
||||
"version": "8.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-8.7.1.tgz",
|
||||
"integrity": "sha512-WMT5mRFUEcrLWwTxsyS8jYmlaMsTVCZIGENLikHsNv+tE8ThU2lCoPfi/xFNUfJFNFSBFP3MwPez9ZsJmNp13g==",
|
||||
"version": "9.2.4",
|
||||
"resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-9.2.4.tgz",
|
||||
"integrity": "sha512-wSr7vz+WtzzGqsGU2rTQ4mmLz9fkuIDMPYMYm8ygYFvxQ2Rh4uRhOWHYI0R8krNK5k1bGycckYxmQqUIvLAh3w==",
|
||||
"requires": {
|
||||
"babel-runtime": "6.x",
|
||||
"classnames": "^2.2.5",
|
||||
"prop-types": "^15.5.4",
|
||||
"rc-tooltip": "^3.7.0",
|
||||
"rc-tooltip": "^4.0.0",
|
||||
"rc-util": "^4.0.4",
|
||||
"react-lifecycles-compat": "^3.0.4",
|
||||
"shallowequal": "^1.1.0",
|
||||
"warning": "^4.0.3"
|
||||
}
|
||||
@@ -13267,36 +13282,32 @@
|
||||
}
|
||||
},
|
||||
"rc-tooltip": {
|
||||
"version": "3.7.3",
|
||||
"resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-3.7.3.tgz",
|
||||
"integrity": "sha512-dE2ibukxxkrde7wH9W8ozHKUO4aQnPZ6qBHtrTH9LoO836PjDdiaWO73fgPB05VfJs9FbZdmGPVEbXCeOP99Ww==",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-4.0.3.tgz",
|
||||
"integrity": "sha512-HNyBh9/fPdds0DXja8JQX0XTIHmZapB3lLzbdn74aNSxXG1KUkt+GK4X1aOTRY5X9mqm4uUKdeFrn7j273H8gw==",
|
||||
"requires": {
|
||||
"babel-runtime": "6.x",
|
||||
"prop-types": "^15.5.8",
|
||||
"rc-trigger": "^2.2.2"
|
||||
"rc-trigger": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"rc-trigger": {
|
||||
"version": "2.6.5",
|
||||
"resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-2.6.5.tgz",
|
||||
"integrity": "sha512-m6Cts9hLeZWsTvWnuMm7oElhf+03GOjOLfTuU0QmdB9ZrW7jR2IpI5rpNM7i9MvAAlMAmTx5Zr7g3uu/aMvZAw==",
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-4.0.2.tgz",
|
||||
"integrity": "sha512-to5S1NhK10rWHIgQpoQdwIhuDc2Ok4R4/dh5NLrDt6C+gqkohsdBCYiPk97Z+NwGhRU8N+dbf251bivX8DkzQg==",
|
||||
"requires": {
|
||||
"babel-runtime": "6.x",
|
||||
"classnames": "^2.2.6",
|
||||
"prop-types": "15.x",
|
||||
"rc-align": "^2.4.0",
|
||||
"rc-animate": "2.x",
|
||||
"rc-util": "^4.4.0",
|
||||
"react-lifecycles-compat": "^3.0.4"
|
||||
"raf": "^3.4.1",
|
||||
"rc-align": "^3.0.0-rc.0",
|
||||
"rc-animate": "^2.10.2",
|
||||
"rc-util": "^4.20.0"
|
||||
}
|
||||
},
|
||||
"rc-util": {
|
||||
"version": "4.20.0",
|
||||
"resolved": "https://registry.npmjs.org/rc-util/-/rc-util-4.20.0.tgz",
|
||||
"integrity": "sha512-rUqk4RqtDe4OfTsSk2GpbvIQNVtfmmebw4Rn7ZAA1TO1zLMLfyOF78ZyrEKqs8RDwoE3S1aXp0AX0ogLfSxXrQ==",
|
||||
"version": "4.20.3",
|
||||
"resolved": "https://registry.npmjs.org/rc-util/-/rc-util-4.20.3.tgz",
|
||||
"integrity": "sha512-NBBc9Ad5yGAVTp4jV+pD7tXQGqHxGM2onPSZFyVoJ5fuvRF+ZgzSjZ6RXLPE0pVVISRJ07h+APgLJPBcAeZQlg==",
|
||||
"requires": {
|
||||
"add-dom-event-listener": "^1.1.0",
|
||||
"babel-runtime": "6.x",
|
||||
"prop-types": "^15.5.10",
|
||||
"react-is": "^16.12.0",
|
||||
"react-lifecycles-compat": "^3.0.4",
|
||||
@@ -13314,9 +13325,9 @@
|
||||
}
|
||||
},
|
||||
"react-admin": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/react-admin/-/react-admin-3.3.3.tgz",
|
||||
"integrity": "sha512-sUiwC/jaL+0RvJFuA/8dsKB7brmno0+d+++Y52G9coBeJceEmY41gEh9Q9w/GUQb4+9VstyJj9Aoq1ns2Qnteg==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/react-admin/-/react-admin-3.4.2.tgz",
|
||||
"integrity": "sha512-C0YeuWgd1fskhOk9oG6uyZidw7IEd5MkQWBKahDOY4PW59dZk8hFSuRzkVkaF8B/c/2oM1kCGuVR5likYPrJEA==",
|
||||
"requires": {
|
||||
"@material-ui/core": "^4.3.3",
|
||||
"@material-ui/icons": "^4.2.1",
|
||||
@@ -13324,10 +13335,10 @@
|
||||
"connected-react-router": "^6.5.2",
|
||||
"final-form": "^4.18.5",
|
||||
"final-form-arrays": "^3.0.1",
|
||||
"ra-core": "^3.3.3",
|
||||
"ra-i18n-polyglot": "^3.3.3",
|
||||
"ra-language-english": "^3.2.0",
|
||||
"ra-ui-materialui": "^3.3.3",
|
||||
"ra-core": "^3.4.1",
|
||||
"ra-i18n-polyglot": "^3.4.1",
|
||||
"ra-language-english": "^3.4.1",
|
||||
"ra-ui-materialui": "^3.4.2",
|
||||
"react-final-form": "^6.3.3",
|
||||
"react-final-form-arrays": "^3.1.1",
|
||||
"react-redux": "^7.1.0",
|
||||
@@ -13624,9 +13635,9 @@
|
||||
}
|
||||
},
|
||||
"react-draggable": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-3.3.2.tgz",
|
||||
"integrity": "sha512-oaz8a6enjbPtx5qb0oDWxtDNuybOylvto1QLydsXgKmwT7e3GXC2eMVDwEMIUYJIFqVG72XpOv673UuuAq6LhA==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.3.1.tgz",
|
||||
"integrity": "sha512-m8QeV+eIi7LhD5mXoLqDzLbokc6Ncwa0T34fF6uJzWSs4vc4fdZI/XGqHYoEn91T8S6qO+BSXslONh7Jz9VPQQ==",
|
||||
"requires": {
|
||||
"classnames": "^2.2.5",
|
||||
"prop-types": "^15.6.0"
|
||||
@@ -13698,18 +13709,18 @@
|
||||
"integrity": "sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q=="
|
||||
},
|
||||
"react-jinke-music-player": {
|
||||
"version": "4.10.1",
|
||||
"resolved": "https://registry.npmjs.org/react-jinke-music-player/-/react-jinke-music-player-4.10.1.tgz",
|
||||
"integrity": "sha512-5ji5OnIOf/3vHi5AL9QpQvuVTIf4kH/PoRNdHIzGN8OKC11nEvBR7PWSTXSdYRL/A4OwfcUEMW9yZr/hTm36Og==",
|
||||
"version": "4.11.2",
|
||||
"resolved": "https://registry.npmjs.org/react-jinke-music-player/-/react-jinke-music-player-4.11.2.tgz",
|
||||
"integrity": "sha512-AVUFRdva5vByBXy1VezsQG5Cr00fY956Rhe4cSNdPbs7JaFtJ/mDddgz2h75qmC6TJTumDSh52NBWhiiywN1Tw==",
|
||||
"requires": {
|
||||
"classnames": "^2.2.6",
|
||||
"downloadjs": "^1.4.7",
|
||||
"is-mobile": "^2.1.0",
|
||||
"is-mobile": "^2.2.1",
|
||||
"prop-types": "^15.7.2",
|
||||
"rc-slider": "^8.7.1",
|
||||
"rc-slider": "^9.2.4",
|
||||
"rc-switch": "^1.9.0",
|
||||
"react-drag-listview": "^0.1.6",
|
||||
"react-draggable": "^3.3.2",
|
||||
"react-draggable": "^4.2.0",
|
||||
"react-icons": "^2.2.5"
|
||||
}
|
||||
},
|
||||
@@ -13850,6 +13861,32 @@
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"dom-helpers": {
|
||||
"version": "5.1.4",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.1.4.tgz",
|
||||
"integrity": "sha512-TjMyeVUvNEnOnhzs6uAn9Ya47GmMo3qq7m+Lr/3ON0Rs5kHvb8I+SQYjLUSYn7qhEm0QjW0yrBkvz9yOrwwz1A==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^2.6.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": {
|
||||
"version": "7.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.2.tgz",
|
||||
"integrity": "sha512-NE2DtOdufG7R5vnfQUTehdTfNycfUANEtCa9PssN9O/xmTzP4E08UI797ixaei6hBEVL9BI/PsdJS5x7mWoB9Q==",
|
||||
"requires": {
|
||||
"regenerator-runtime": "^0.13.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"regenerator-runtime": {
|
||||
"version": "0.13.5",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz",
|
||||
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"read-pkg": {
|
||||
@@ -13920,6 +13957,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"indent-string": "^4.0.0",
|
||||
"strip-indent": "^3.0.0"
|
||||
@@ -14167,6 +14205,11 @@
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-3.0.1.tgz",
|
||||
"integrity": "sha1-79qpjqdFEyTQkrKyFjpqHXqaIUc="
|
||||
},
|
||||
"resize-observer-polyfill": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
|
||||
},
|
||||
"resolve": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.15.0.tgz",
|
||||
@@ -15391,6 +15434,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"min-indent": "^1.0.0"
|
||||
}
|
||||
|
||||
@@ -3,27 +3,35 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^5.3.0",
|
||||
"@testing-library/react": "^10.0.1",
|
||||
"@testing-library/user-event": "^10.0.1",
|
||||
"deepmerge": "^4.2.2",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"md5-hex": "^3.0.1",
|
||||
"prop-types": "^15.7.2",
|
||||
"ra-data-json-server": "^3.3.3",
|
||||
"ra-data-json-server": "^3.4.1",
|
||||
"ra-language-chinese": "^2.0.5",
|
||||
"ra-language-french": "^3.4.1",
|
||||
"ra-language-italian": "^3.0.0",
|
||||
"ra-language-portuguese": "^1.6.0",
|
||||
"react": "^16.13.1",
|
||||
"react-admin": "^3.3.3",
|
||||
"react-admin": "^3.4.2",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-jinke-music-player": "^4.10.1",
|
||||
"react-jinke-music-player": "^4.11.2",
|
||||
"react-redux": "^7.2.0",
|
||||
"react-scripts": "^3.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^5.5.0",
|
||||
"@testing-library/react": "^10.0.2",
|
||||
"@testing-library/user-event": "^10.0.2",
|
||||
"prettier": "^2.0.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
"eject": "react-scripts eject",
|
||||
"prettier": "prettier --write src/**/*.js"
|
||||
},
|
||||
"homepage": ".",
|
||||
"proxy": "http://localhost:4633/",
|
||||
@@ -41,8 +49,5 @@
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^1.19.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { Provider } from 'react-redux'
|
||||
import { createHashHistory } from 'history'
|
||||
import { Admin, resolveBrowserLocale, Resource } from 'react-admin'
|
||||
import { Admin, Resource } from 'react-admin'
|
||||
import dataProvider from './dataProvider'
|
||||
import authProvider from './authProvider'
|
||||
import polyglotI18nProvider from 'ra-i18n-polyglot'
|
||||
@@ -21,7 +21,7 @@ import createAdminStore from './store/createAdminStore'
|
||||
|
||||
const i18nProvider = polyglotI18nProvider(
|
||||
(locale) => (messages[locale] ? messages[locale] : messages.en),
|
||||
resolveBrowserLocale()
|
||||
localStorage.getItem('locale') || 'en'
|
||||
)
|
||||
|
||||
const history = createHashHistory()
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
Button,
|
||||
sanitizeListRestProps,
|
||||
TopToolbar,
|
||||
useTranslate
|
||||
useTranslate,
|
||||
} from 'react-admin'
|
||||
import PlayArrowIcon from '@material-ui/icons/PlayArrow'
|
||||
import ShuffleIcon from '@material-ui/icons/Shuffle'
|
||||
@@ -65,5 +65,5 @@ export const AlbumActions = ({
|
||||
|
||||
AlbumActions.defaultProps = {
|
||||
selectedIds: [],
|
||||
onUnselectItems: () => null
|
||||
onUnselectItems: () => null,
|
||||
}
|
||||
|
||||
@@ -3,35 +3,38 @@ import { GridList, GridListTile, GridListTileBar } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import withWidth from '@material-ui/core/withWidth'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { linkToRecord } from 'ra-core'
|
||||
import { Loading } from 'react-admin'
|
||||
import { linkToRecord, Loading } from 'react-admin'
|
||||
import subsonic from '../subsonic'
|
||||
import { ArtistLinkField } from './ArtistLinkField'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
margin: '20px'
|
||||
margin: '20px',
|
||||
},
|
||||
gridListTile: {
|
||||
minHeight: '180px',
|
||||
minWidth: '180px'
|
||||
minWidth: '180px',
|
||||
},
|
||||
cover: {
|
||||
display: 'inline-block',
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
height: '100%',
|
||||
},
|
||||
tileBar: {
|
||||
textAlign: 'center',
|
||||
background:
|
||||
'linear-gradient(to top, rgba(0,0,0,0.8) 0%,rgba(0,0,0,0.4) 70%,rgba(0,0,0,0) 100%)'
|
||||
'linear-gradient(to top, rgba(0,0,0,1) 0%,rgba(0,0,0,0.4) 70%,rgba(0,0,0,0) 100%)',
|
||||
},
|
||||
albumArtistName: {
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
textAlign: 'center',
|
||||
fontSize: '1em'
|
||||
}
|
||||
fontSize: '1em',
|
||||
},
|
||||
artistLink: {
|
||||
color: theme.palette.primary.light,
|
||||
},
|
||||
}))
|
||||
|
||||
const getColsForWidth = (width) => {
|
||||
@@ -69,7 +72,12 @@ const LoadedAlbumGrid = ({ ids, data, basePath, width }) => {
|
||||
title={data[id].name}
|
||||
subtitle={
|
||||
<div className={classes.albumArtistName}>
|
||||
{data[id].albumArtist}
|
||||
<ArtistLinkField
|
||||
record={data[id]}
|
||||
className={classes.artistLink}
|
||||
>
|
||||
{data[id].albumArtist}
|
||||
</ArtistLinkField>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
NumberInput,
|
||||
ReferenceInput,
|
||||
SearchInput,
|
||||
Pagination
|
||||
Pagination,
|
||||
useTranslate,
|
||||
} from 'react-admin'
|
||||
import { Title } from '../common'
|
||||
import { withWidth } from '@material-ui/core'
|
||||
@@ -17,21 +18,25 @@ import AlbumListView from './AlbumListView'
|
||||
import AlbumGridView from './AlbumGridView'
|
||||
import { ALBUM_MODE_LIST } from './albumState'
|
||||
|
||||
const AlbumFilter = (props) => (
|
||||
<Filter {...props}>
|
||||
<SearchInput source="name" alwaysOn />
|
||||
<ReferenceInput
|
||||
source="artist_id"
|
||||
reference="artist"
|
||||
sort={{ field: 'name', order: 'ASC' }}
|
||||
filterToQuery={(searchText) => ({ name: [searchText] })}
|
||||
>
|
||||
<AutocompleteInput emptyText="-- None --" />
|
||||
</ReferenceInput>
|
||||
<NullableBooleanInput source="compilation" />
|
||||
<NumberInput source="year" />
|
||||
</Filter>
|
||||
)
|
||||
const AlbumFilter = (props) => {
|
||||
const translate = useTranslate()
|
||||
return (
|
||||
<Filter {...props}>
|
||||
<SearchInput source="name" alwaysOn />
|
||||
<ReferenceInput
|
||||
label={translate('resources.album.fields.artist')}
|
||||
source="artist_id"
|
||||
reference="artist"
|
||||
sort={{ field: 'name', order: 'ASC' }}
|
||||
filterToQuery={(searchText) => ({ name: [searchText] })}
|
||||
>
|
||||
<AutocompleteInput emptyText="-- None --" />
|
||||
</ReferenceInput>
|
||||
<NullableBooleanInput source="compilation" />
|
||||
<NumberInput source="year" />
|
||||
</Filter>
|
||||
)
|
||||
}
|
||||
|
||||
const getPerPage = (width) => {
|
||||
if (width === 'xs') return 12
|
||||
@@ -53,7 +58,12 @@ const getPerPageOptions = (width) => {
|
||||
const AlbumList = (props) => {
|
||||
const { width } = props
|
||||
const albumView = useSelector((state) => state.albumView)
|
||||
|
||||
let sort
|
||||
if (albumView.mode === ALBUM_MODE_LIST) {
|
||||
sort = { field: 'name', order: 'ASC' }
|
||||
} else {
|
||||
sort = { field: 'created_at', order: 'DESC' }
|
||||
}
|
||||
return (
|
||||
<List
|
||||
{...props}
|
||||
@@ -61,6 +71,7 @@ const AlbumList = (props) => {
|
||||
exporter={false}
|
||||
bulkActionButtons={false}
|
||||
actions={<AlbumListActions />}
|
||||
sort={sort}
|
||||
filters={<AlbumFilter />}
|
||||
perPage={getPerPage(width)}
|
||||
pagination={<Pagination rowsPerPageOptions={getPerPageOptions(width)} />}
|
||||
|
||||
@@ -35,7 +35,7 @@ const AlbumListActions = ({
|
||||
showFilter,
|
||||
displayedFilters,
|
||||
filterValues,
|
||||
context: 'button'
|
||||
context: 'button',
|
||||
})}
|
||||
<ButtonGroup
|
||||
variant="text"
|
||||
@@ -63,7 +63,7 @@ const AlbumListActions = ({
|
||||
|
||||
AlbumListActions.defaultProps = {
|
||||
selectedIds: [],
|
||||
onUnselectItems: () => null
|
||||
onUnselectItems: () => null,
|
||||
}
|
||||
|
||||
export default AlbumListActions
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
FunctionField,
|
||||
Show,
|
||||
SimpleShowLayout,
|
||||
TextField
|
||||
TextField,
|
||||
} from 'react-admin'
|
||||
import { DurationField, RangeField } from '../common'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ListToolbar,
|
||||
TextField,
|
||||
useListController,
|
||||
DatagridLoading
|
||||
DatagridLoading,
|
||||
} from 'react-admin'
|
||||
import classnames from 'classnames'
|
||||
import { useDispatch } from 'react-redux'
|
||||
@@ -14,12 +14,13 @@ import { Card, useMediaQuery } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { setTrack } from '../audioplayer'
|
||||
import { DurationField } from '../common'
|
||||
import { SongDetails } from '../common'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
root: {},
|
||||
main: {
|
||||
display: 'flex'
|
||||
display: 'flex',
|
||||
},
|
||||
content: {
|
||||
marginTop: 0,
|
||||
@@ -27,25 +28,31 @@ const useStyles = makeStyles(
|
||||
position: 'relative',
|
||||
flex: '1 1 auto',
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
boxShadow: 'none'
|
||||
boxShadow: 'none',
|
||||
},
|
||||
overflow: 'inherit'
|
||||
overflow: 'inherit',
|
||||
},
|
||||
bulkActionsDisplayed: {
|
||||
marginTop: -theme.spacing(8),
|
||||
transition: theme.transitions.create('margin-top')
|
||||
transition: theme.transitions.create('margin-top'),
|
||||
},
|
||||
actions: {
|
||||
zIndex: 2,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
flexWrap: 'wrap'
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
noResults: { padding: 20 }
|
||||
noResults: { padding: 20 },
|
||||
}),
|
||||
{ name: 'RaList' }
|
||||
)
|
||||
|
||||
const useStylesListToolbar = makeStyles({
|
||||
toolbar: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
})
|
||||
|
||||
const trackName = (r) => {
|
||||
const name = r.title
|
||||
if (r.trackNumber) {
|
||||
@@ -56,7 +63,9 @@ const trackName = (r) => {
|
||||
|
||||
const AlbumSongs = (props) => {
|
||||
const classes = useStyles(props)
|
||||
const classesToolbar = useStylesListToolbar(props)
|
||||
const dispatch = useDispatch()
|
||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
|
||||
const controllerProps = useListController(props)
|
||||
const { bulkActionButtons, albumId, expand, className } = props
|
||||
@@ -69,6 +78,7 @@ const AlbumSongs = (props) => {
|
||||
return (
|
||||
<>
|
||||
<ListToolbar
|
||||
classes={classesToolbar}
|
||||
filters={props.filters}
|
||||
{...controllerProps}
|
||||
actions={props.actions}
|
||||
@@ -78,7 +88,7 @@ const AlbumSongs = (props) => {
|
||||
<Card
|
||||
className={classnames(classes.content, {
|
||||
[classes.bulkActionsDisplayed]:
|
||||
controllerProps.selectedIds.length > 0
|
||||
controllerProps.selectedIds.length > 0,
|
||||
})}
|
||||
key={version}
|
||||
>
|
||||
@@ -98,6 +108,7 @@ const AlbumSongs = (props) => {
|
||||
/>
|
||||
) : (
|
||||
<Datagrid
|
||||
expand={!isXsmall && <SongDetails />}
|
||||
rowClick={(id, basePath, record) => dispatch(setTrack(record))}
|
||||
{...controllerProps}
|
||||
hasBulkActions={hasBulkActions}
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { Link } from 'react-admin'
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Link } from 'react-admin'
|
||||
|
||||
export const ArtistLinkField = (props) => {
|
||||
const filter = { artist_id: props.record.albumArtistId }
|
||||
export const ArtistLinkField = ({ record, className }) => {
|
||||
const filter = { artist_id: record.albumArtistId }
|
||||
const url = `/album?filter=${JSON.stringify(
|
||||
filter
|
||||
)}&order=ASC&sort=maxYear&displayedFilters={"compilation":true}`
|
||||
return (
|
||||
<Link to={url} onClick={(e) => e.stopPropagation()}>
|
||||
{props.record.albumArtist}
|
||||
<Link to={url} onClick={(e) => e.stopPropagation()} className={className}>
|
||||
{record.albumArtist}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
ArtistLinkField.propTypes = {
|
||||
className: PropTypes.string,
|
||||
source: PropTypes.string,
|
||||
}
|
||||
|
||||
ArtistLinkField.defaultProps = {
|
||||
source: 'artistId',
|
||||
addLabel: true
|
||||
addLabel: true,
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ const albumListParams = {
|
||||
ALBUM_LIST_NEWEST: { sort: { field: 'created_at', order: 'DESC' } },
|
||||
ALBUM_LIST_RECENT: {
|
||||
sort: { field: 'play_date', order: 'DESC' },
|
||||
filter: { starred: true }
|
||||
}
|
||||
filter: { starred: true },
|
||||
},
|
||||
}
|
||||
|
||||
const selectAlbumList = (mode) => ({ type: mode })
|
||||
@@ -24,7 +24,7 @@ const albumViewReducer = (
|
||||
previousState = {
|
||||
mode: ALBUM_MODE_LIST,
|
||||
list: ALBUM_LIST_ALL,
|
||||
params: { sort: {}, filter: {} }
|
||||
params: { sort: {}, filter: {} },
|
||||
},
|
||||
payload
|
||||
) => {
|
||||
@@ -54,5 +54,5 @@ export {
|
||||
ALBUM_LIST_STARRED,
|
||||
albumViewReducer,
|
||||
selectViewMode,
|
||||
selectAlbumList
|
||||
selectAlbumList,
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@ import AlbumShow from './AlbumShow'
|
||||
export default {
|
||||
list: AlbumList,
|
||||
show: AlbumShow,
|
||||
icon: AlbumIcon
|
||||
icon: AlbumIcon,
|
||||
}
|
||||
|
||||
@@ -4,44 +4,44 @@ export const useStyles = makeStyles((theme) => ({
|
||||
container: {
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
padding: '0.7em',
|
||||
minWidth: '24em'
|
||||
minWidth: '24em',
|
||||
},
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
padding: '1em',
|
||||
minWidth: '32em'
|
||||
}
|
||||
minWidth: '32em',
|
||||
},
|
||||
},
|
||||
albumCover: {
|
||||
display: 'inline-block',
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
height: '8em',
|
||||
width: '8em'
|
||||
width: '8em',
|
||||
},
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
height: '10em',
|
||||
width: '10em'
|
||||
width: '10em',
|
||||
},
|
||||
[theme.breakpoints.up('lg')]: {
|
||||
height: '15em',
|
||||
width: '15em'
|
||||
}
|
||||
width: '15em',
|
||||
},
|
||||
},
|
||||
albumDetails: {
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'top',
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
width: '14em'
|
||||
width: '14em',
|
||||
},
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
width: '26em'
|
||||
width: '26em',
|
||||
},
|
||||
[theme.breakpoints.up('lg')]: {
|
||||
width: '38em'
|
||||
}
|
||||
width: '38em',
|
||||
},
|
||||
},
|
||||
albumTitle: {
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
List,
|
||||
NumberField,
|
||||
SearchInput,
|
||||
TextField
|
||||
TextField,
|
||||
} from 'react-admin'
|
||||
import { Pagination, Title } from '../common'
|
||||
|
||||
@@ -15,7 +15,7 @@ const ArtistFilter = (props) => (
|
||||
</Filter>
|
||||
)
|
||||
|
||||
const artistRowClick = (id, basePath, record) => {
|
||||
const artistRowClick = (id) => {
|
||||
const filter = { artist_id: id }
|
||||
return `/album?filter=${JSON.stringify(
|
||||
filter
|
||||
|
||||
@@ -3,5 +3,5 @@ import ArtistList from './ArtistList'
|
||||
|
||||
export default {
|
||||
list: ArtistList,
|
||||
icon: MicIcon
|
||||
icon: MicIcon,
|
||||
}
|
||||
|
||||
@@ -4,15 +4,20 @@ import { useAuthState, useDataProvider, useTranslate } from 'react-admin'
|
||||
import ReactJkMusicPlayer from 'react-jinke-music-player'
|
||||
import 'react-jinke-music-player/assets/index.css'
|
||||
import subsonic from '../subsonic'
|
||||
import { scrobbled, syncQueue } from './queue'
|
||||
import { scrobble, syncQueue } from './queue'
|
||||
import themes from '../themes'
|
||||
|
||||
const Player = () => {
|
||||
const translate = useTranslate()
|
||||
const currentTheme = useSelector((state) => state.theme)
|
||||
const theme = themes[currentTheme] || themes.DarkTheme
|
||||
const playerTheme = (theme.player && theme.player.theme) || 'dark'
|
||||
|
||||
const defaultOptions = {
|
||||
theme: playerTheme,
|
||||
bounds: 'body',
|
||||
mode: 'full',
|
||||
autoPlay: true,
|
||||
autoPlay: false,
|
||||
preload: true,
|
||||
autoPlayInitLoadPlayList: true,
|
||||
clearPriorAudioLists: false,
|
||||
@@ -21,25 +26,46 @@ const Player = () => {
|
||||
showReload: false,
|
||||
glassBg: false,
|
||||
showThemeSwitch: false,
|
||||
playModeText: {
|
||||
order: translate('player.playModeText.order'),
|
||||
orderLoop: translate('player.playModeText.orderLoop'),
|
||||
singleLoop: translate('player.playModeText.singleLoop'),
|
||||
shufflePlay: translate('player.playModeText.shufflePlay')
|
||||
},
|
||||
panelTitle: translate('player.panelTitle'),
|
||||
showMediaSession: true,
|
||||
defaultPosition: {
|
||||
top: 300,
|
||||
left: 120
|
||||
}
|
||||
left: 120,
|
||||
},
|
||||
locale: {
|
||||
playListsText: translate('player.playListsText'),
|
||||
openText: translate('player.openText'),
|
||||
closeText: translate('player.closeText'),
|
||||
notContentText: translate('player.notContentText'),
|
||||
clickToPlayText: translate('player.clickToPlayText'),
|
||||
clickToPauseText: translate('player.clickToPauseText'),
|
||||
nextTrackText: translate('player.nextTrackText'),
|
||||
previousTrackText: translate('player.previousTrackText'),
|
||||
reloadText: translate('player.reloadText'),
|
||||
volumeText: translate('player.volumeText'),
|
||||
toggleLyricText: translate('player.toggleLyricText'),
|
||||
toggleMiniModeText: translate('player.toggleMiniModeText'),
|
||||
destroyText: translate('player.destroyText'),
|
||||
downloadText: translate('player.downloadText'),
|
||||
removeAudioListsText: translate('player.removeAudioListsText'),
|
||||
controllerTitle: translate('player.controllerTitle'),
|
||||
clickToDeleteText: (name) =>
|
||||
translate('player.clickToDeleteText', { name }),
|
||||
emptyLyricText: translate('player.emptyLyricText'),
|
||||
playModeText: {
|
||||
order: translate('player.playModeText.order'),
|
||||
orderLoop: translate('player.playModeText.orderLoop'),
|
||||
singleLoop: translate('player.playModeText.singleLoop'),
|
||||
shufflePlay: translate('player.playModeText.shufflePlay'),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const addQueueToOptions = (queue) => {
|
||||
return {
|
||||
...defaultOptions,
|
||||
autoPlay: true,
|
||||
autoPlay: queue.playing,
|
||||
clearPriorAudioLists: queue.clear,
|
||||
audioLists: queue.queue.map((item) => item)
|
||||
audioLists: queue.queue.map((item) => item),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +76,7 @@ const Player = () => {
|
||||
const { authenticated } = useAuthState()
|
||||
|
||||
const OnAudioListsChange = (currentPlayIndex, audioLists) => {
|
||||
dispatch(syncQueue(audioLists))
|
||||
dispatch(syncQueue(currentPlayIndex, audioLists))
|
||||
}
|
||||
|
||||
const OnAudioProgress = (info) => {
|
||||
@@ -60,13 +86,14 @@ const Player = () => {
|
||||
}
|
||||
const item = queue.queue.find((item) => item.trackId === info.trackId)
|
||||
if (item && !item.scrobbled) {
|
||||
dispatch(scrobbled(info.trackId))
|
||||
dispatch(scrobble(info.trackId, true))
|
||||
subsonic.scrobble(info.trackId, true)
|
||||
}
|
||||
}
|
||||
|
||||
const OnAudioPlay = (info) => {
|
||||
if (info.duration) {
|
||||
dispatch(scrobble(info.trackId, false))
|
||||
subsonic.scrobble(info.trackId, false)
|
||||
dataProvider.getOne('keepalive', { id: info.trackId })
|
||||
}
|
||||
@@ -82,7 +109,7 @@ const Player = () => {
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <div />
|
||||
return null
|
||||
}
|
||||
|
||||
export default Player
|
||||
|
||||
@@ -13,37 +13,39 @@ const mapToAudioLists = (item) => ({
|
||||
name: item.title,
|
||||
singer: item.artist,
|
||||
cover: subsonic.url('getCoverArt', item.id, { size: 300 }),
|
||||
musicSrc: subsonic.url('stream', item.id, { ts: true })
|
||||
musicSrc: subsonic.url('stream', item.id, { ts: true }),
|
||||
})
|
||||
|
||||
const addTrack = (data) => ({
|
||||
type: PLAYER_ADD_TRACK,
|
||||
data
|
||||
data,
|
||||
})
|
||||
|
||||
const setTrack = (data) => ({
|
||||
type: PLAYER_SET_TRACK,
|
||||
data
|
||||
data,
|
||||
})
|
||||
|
||||
const playAlbum = (id, data) => ({
|
||||
type: PLAYER_PLAY_ALBUM,
|
||||
id,
|
||||
data,
|
||||
id
|
||||
})
|
||||
|
||||
const syncQueue = (data) => ({
|
||||
const syncQueue = (id, data) => ({
|
||||
type: PLAYER_SYNC_QUEUE,
|
||||
data
|
||||
id,
|
||||
data,
|
||||
})
|
||||
|
||||
const scrobbled = (id) => ({
|
||||
const scrobble = (id, submit) => ({
|
||||
type: PLAYER_SCROBBLE,
|
||||
data: id
|
||||
id,
|
||||
submit,
|
||||
})
|
||||
|
||||
const playQueueReducer = (
|
||||
previousState = { queue: [], clear: true },
|
||||
previousState = { queue: [], clear: true, playing: false },
|
||||
payload
|
||||
) => {
|
||||
let queue
|
||||
@@ -52,19 +54,38 @@ const playQueueReducer = (
|
||||
case PLAYER_ADD_TRACK:
|
||||
queue = previousState.queue
|
||||
queue.push(mapToAudioLists(data))
|
||||
return { queue, clear: false }
|
||||
return { ...previousState, queue, clear: false }
|
||||
case PLAYER_SET_TRACK:
|
||||
return { queue: [mapToAudioLists(data)], clear: true }
|
||||
return {
|
||||
...previousState,
|
||||
queue: [mapToAudioLists(data)],
|
||||
clear: true,
|
||||
playing: true,
|
||||
current: data.id,
|
||||
}
|
||||
case PLAYER_SYNC_QUEUE:
|
||||
return { queue: data, clear: false }
|
||||
const currentTrack = data.find((item) => item.id === data.id) || {}
|
||||
return {
|
||||
...previousState,
|
||||
queue: data,
|
||||
clear: false,
|
||||
current: currentTrack.id,
|
||||
}
|
||||
case PLAYER_SCROBBLE:
|
||||
const newQueue = previousState.queue.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
scrobbled: item.scrobbled || item.trackId === data
|
||||
scrobbled:
|
||||
item.scrobbled || (item.trackId === payload.id && payload.submit),
|
||||
}
|
||||
})
|
||||
return { queue: newQueue, clear: false }
|
||||
return {
|
||||
...previousState,
|
||||
queue: newQueue,
|
||||
clear: false,
|
||||
playing: true,
|
||||
current: payload.id,
|
||||
}
|
||||
case PLAYER_PLAY_ALBUM:
|
||||
queue = []
|
||||
let match = false
|
||||
@@ -76,10 +97,16 @@ const playQueueReducer = (
|
||||
queue.push(mapToAudioLists(data[id]))
|
||||
}
|
||||
})
|
||||
return { queue, clear: true }
|
||||
return {
|
||||
...previousState,
|
||||
queue,
|
||||
clear: true,
|
||||
playing: true,
|
||||
current: payload.id,
|
||||
}
|
||||
default:
|
||||
return previousState
|
||||
}
|
||||
}
|
||||
|
||||
export { addTrack, setTrack, playAlbum, syncQueue, scrobbled, playQueueReducer }
|
||||
export { addTrack, setTrack, playAlbum, syncQueue, scrobble, playQueueReducer }
|
||||
|
||||
@@ -8,11 +8,11 @@ const BitrateField = ({ record = {}, source }) => {
|
||||
BitrateField.propTypes = {
|
||||
label: PropTypes.string,
|
||||
record: PropTypes.object,
|
||||
source: PropTypes.string.isRequired
|
||||
source: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
BitrateField.defaultProps = {
|
||||
addLabel: true
|
||||
addLabel: true,
|
||||
}
|
||||
|
||||
export default BitrateField
|
||||
|
||||
@@ -15,11 +15,11 @@ const format = (d) => {
|
||||
DurationField.propTypes = {
|
||||
label: PropTypes.string,
|
||||
record: PropTypes.object,
|
||||
source: PropTypes.string.isRequired
|
||||
source: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
DurationField.defaultProps = {
|
||||
addLabel: true
|
||||
addLabel: true,
|
||||
}
|
||||
|
||||
export default DurationField
|
||||
|
||||
@@ -25,6 +25,6 @@ const PlayButton = ({ icon = defaultIcon, action, ...rest }) => {
|
||||
|
||||
PlayButton.propTypes = {
|
||||
icon: PropTypes.element,
|
||||
action: PropTypes.object
|
||||
action: PropTypes.object,
|
||||
}
|
||||
export default PlayButton
|
||||
|
||||
@@ -22,11 +22,11 @@ const RangeField = ({ record = {}, source }) => {
|
||||
RangeField.propTypes = {
|
||||
label: PropTypes.string,
|
||||
record: PropTypes.object,
|
||||
source: PropTypes.string.isRequired
|
||||
source: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
RangeField.defaultProps = {
|
||||
addLabel: true
|
||||
addLabel: true,
|
||||
}
|
||||
|
||||
export { formatRange }
|
||||
|
||||
@@ -15,9 +15,9 @@ const useStyles = makeStyles(
|
||||
{
|
||||
link: {
|
||||
textDecoration: 'none',
|
||||
color: 'inherit'
|
||||
color: 'inherit',
|
||||
},
|
||||
tertiary: { float: 'right', opacity: 0.541176 }
|
||||
tertiary: { float: 'right', opacity: 0.541176 },
|
||||
},
|
||||
{ name: 'RaSimpleList' }
|
||||
)
|
||||
@@ -28,7 +28,7 @@ const LinkOrNot = ({
|
||||
basePath,
|
||||
id,
|
||||
record,
|
||||
children
|
||||
children,
|
||||
}) => {
|
||||
const classes = useStyles({ classes: classesOverride })
|
||||
return linkType === 'edit' || linkType === true ? (
|
||||
@@ -129,7 +129,7 @@ SimpleList.propTypes = {
|
||||
linkType: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.bool,
|
||||
PropTypes.func
|
||||
PropTypes.func,
|
||||
]).isRequired,
|
||||
onToggleItem: PropTypes.func,
|
||||
primaryText: PropTypes.func,
|
||||
@@ -137,13 +137,13 @@ SimpleList.propTypes = {
|
||||
rightIcon: PropTypes.func,
|
||||
secondaryText: PropTypes.func,
|
||||
selectedIds: PropTypes.arrayOf(PropTypes.any).isRequired,
|
||||
tertiaryText: PropTypes.func
|
||||
tertiaryText: PropTypes.func,
|
||||
}
|
||||
|
||||
SimpleList.defaultProps = {
|
||||
linkType: 'edit',
|
||||
hasBulkActions: false,
|
||||
selectedIds: []
|
||||
selectedIds: [],
|
||||
}
|
||||
|
||||
export default SimpleList
|
||||
|
||||
30
ui/src/common/SizeField.js
Normal file
30
ui/src/common/SizeField.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
const SizeField = ({ record = {}, source }) => {
|
||||
return <span>{formatBytes(record[source])}</span>
|
||||
}
|
||||
|
||||
function formatBytes(bytes, decimals = 2) {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
|
||||
const k = 1024
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
SizeField.propTypes = {
|
||||
label: PropTypes.string,
|
||||
record: PropTypes.object,
|
||||
source: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
SizeField.defaultProps = {
|
||||
addLabel: true,
|
||||
}
|
||||
|
||||
export default SizeField
|
||||
51
ui/src/common/SongDetails.js
Normal file
51
ui/src/common/SongDetails.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react'
|
||||
import Paper from '@material-ui/core/Paper'
|
||||
import Table from '@material-ui/core/Table'
|
||||
import TableBody from '@material-ui/core/TableBody'
|
||||
import TableCell from '@material-ui/core/TableCell'
|
||||
import TableContainer from '@material-ui/core/TableContainer'
|
||||
import TableRow from '@material-ui/core/TableRow'
|
||||
import { BooleanField, DateField, TextField, useTranslate } from 'react-admin'
|
||||
import inflection from 'inflection'
|
||||
import { BitrateField, SizeField } from './index'
|
||||
|
||||
const SongDetails = (props) => {
|
||||
const translate = useTranslate()
|
||||
const { record } = props
|
||||
const data = {
|
||||
path: <TextField record={record} source="path" />,
|
||||
albumArtist: <TextField record={record} source="albumArtist" />,
|
||||
genre: <TextField record={record} source="genre" />,
|
||||
compilation: <BooleanField record={record} source="compilation" />,
|
||||
bitRate: <BitrateField record={record} source="bitRate" />,
|
||||
size: <SizeField record={record} source="size" />,
|
||||
updatedAt: <DateField record={record} source="updatedAt" showTime />,
|
||||
playCount: <TextField record={record} source="playCount" />,
|
||||
}
|
||||
if (record.playCount > 0) {
|
||||
data.playDate = <DateField record={record} source="playDate" showTime />
|
||||
}
|
||||
return (
|
||||
<TableContainer component={Paper}>
|
||||
<Table aria-label="song details" size="small">
|
||||
<TableBody>
|
||||
{Object.keys(data).map((key) => {
|
||||
return (
|
||||
<TableRow key={record.id}>
|
||||
<TableCell component="th" scope="row">
|
||||
{translate(`resources.song.fields.${key}`, {
|
||||
_: inflection.humanize(inflection.underscore(key)),
|
||||
})}
|
||||
:
|
||||
</TableCell>
|
||||
<TableCell align="left">{data[key]}</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default SongDetails
|
||||
@@ -5,14 +5,18 @@ import Pagination from './Pagination'
|
||||
import PlayButton from './PlayButton'
|
||||
import SimpleList from './SimpleList'
|
||||
import RangeField, { formatRange } from './RangeField'
|
||||
import SongDetails from './SongDetails'
|
||||
import SizeField from './SizeField'
|
||||
|
||||
export {
|
||||
Title,
|
||||
DurationField,
|
||||
SizeField,
|
||||
BitrateField,
|
||||
Pagination,
|
||||
PlayButton,
|
||||
SimpleList,
|
||||
RangeField,
|
||||
formatRange
|
||||
SongDetails,
|
||||
formatRange,
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user