Compare commits

...

72 Commits

Author SHA1 Message Date
Deluan
47311d16cf Trigger pipeline on new tags 2020-04-25 12:35:36 -04:00
Deluan
ef3466787d Fix the pipeline 2020-04-25 12:12:48 -04:00
Deluan
b7fd116bd8 Only triggers the pipeline on pushes to master and PRs 2020-04-25 12:06:05 -04:00
Deluan
34ad740e07 Enable French translation 2020-04-25 11:59:37 -04:00
Deluan
79454d7a92 Fix artist link contrast in light theme 2020-04-25 11:57:52 -04:00
Deluan
87cc397bc3 Add current playing track id to the Redux store 2020-04-25 11:57:52 -04:00
jvoisin
37602a2049 Bump the french traduction 2020-04-25 11:57:22 -04:00
Deluan
56ea380bb3 Add link to artist's albums on the album cover 2020-04-25 09:47:56 -04:00
Deluan
177ace1cee Turn off autoplay when reloading the play queue from the Redux store 2020-04-25 09:30:43 -04:00
Deluan
61e3fe21ff Add 'SNAPSHOT' to version when building locally, as this is not an "official" build 2020-04-25 09:27:22 -04:00
Deluan
8dcca76ec9 Fix various small sort issues 2020-04-24 17:37:28 -04:00
Deluan
1dd3a794f8 Reduce level of "invalid year" log message 2020-04-24 16:00:14 -04:00
Deluan
6c5dd245fe Parse TSO2 (seems that ffmpeg does not process this tag in some situations) 2020-04-24 15:02:20 -04:00
Deluan
3b3ad65612 Use order fields to sort by artist and album 2020-04-24 15:02:20 -04:00
Deluan
e6f798811d Generate Artist Index using the OrderArtistName 2020-04-24 15:02:20 -04:00
Deluan
371e8ab6ca Generate Order Fields based on sanitized version of original fields 2020-04-24 15:02:20 -04:00
Deluan
69c19e946c Add sort tags and use them in search 2020-04-24 15:02:20 -04:00
Deluan
d7edbf93f0 Make test more reliable
In some systems, it was detecting the `go.mod` file as an audio file, probably because of the system's mime-type configuration
2020-04-24 11:05:17 -04:00
Deluan
fb4d920fba Small change to trigger the pipeline 2020-04-23 22:29:33 -04:00
Deluan
5a072fbd10 Follow symlinks to directories when scanning 2020-04-23 20:31:44 -04:00
Deluan
79c9d8f4f4 Parameterize docker image name 2020-04-23 19:31:24 -04:00
Deluan
871bf5a70a Rename pipeline 2020-04-23 19:31:24 -04:00
Deluan
e4af235ce9 Move chmod to copy image, make the final image smaller 2020-04-23 19:31:24 -04:00
Deluan
00384a60f3 Unify GH actions 2020-04-23 19:31:24 -04:00
Deluan
f7b3ff4b34 Build and release docker images 2020-04-23 19:31:24 -04:00
Deluan
eaa48306fc Make Dockerfile platform independent
Thanks @0xERROR: https://github.com/deluan/navidrome/issues/92#issuecomment-614630429
2020-04-23 19:31:24 -04:00
Deluan
f5572b8447 Fix git tag detection 2020-04-23 19:31:24 -04:00
Deluan
a756751cc6 Build binary artifacts 2020-04-23 19:31:24 -04:00
Deluan
b8a3af090d Add cache to build workflow 2020-04-23 19:31:24 -04:00
Deluan
d534cb96a9 Replace math.Max with utils.MaxInt 2020-04-21 08:41:04 -04:00
Dimitri Herzog
f1e1d3bc07 request throttling only for media group api 2020-04-21 08:39:14 -04:00
Deluan
694be54428 Replace math.Max with utils.MaxInt 2020-04-20 12:17:01 -04:00
Deluan
76531fb1cd Remove old pre-commit script (in favour of lefthook) 2020-04-20 11:57:38 -04:00
Dimitri Herzog
716f4c5cf7 configuration for request throttling 2020-04-20 11:51:00 -04:00
jvoisin
ba2d4b6859 Add a .git-blame-ignore-revs file 2020-04-20 10:41:41 -04:00
Deluan
2ec5e47328 Set version correctly when building locally 2020-04-20 09:47:44 -04:00
Deluan
b3f70538a9 Upgrade Prettier to 2.0.4. Reformatted all JS files 2020-04-20 09:09:29 -04:00
Deluan
de115ff466 Bump Testing Library and moved it to devDependencies 2020-04-20 09:02:08 -04:00
Deluan
129f02b36b Bump ReactAdmin to 3.4.2 2020-04-20 08:50:21 -04:00
Deluan
1a8d219197 Remove generated comments from migrations 2020-04-19 23:29:08 -04:00
Deluan
80c8d85cb9 Fine tune search functionality 2020-04-19 23:29:07 -04:00
Deluan
db02f5f07f go mod tidy 2020-04-19 14:51:16 -04:00
Deluan
579294b0f1 Make Players and Transcodings view mobile-friendly 2020-04-19 13:54:51 -04:00
Deluan
f83d0d471d Fix getRandomSongs filters 2020-04-19 13:37:25 -04:00
Deluan Quintão
3b7d7bdb04 Disable French translation 2020-04-18 14:24:27 -04:00
jvoisin
05958f5195 Add French localization 2020-04-18 14:24:27 -04:00
Deluan
6cf4b81de9 Fix year range when querying by year 2020-04-18 14:05:44 -04:00
Deluan
689449df9e Force reindex to fix album by year searches 2020-04-18 11:08:54 -04:00
Deluan
dae938de6f Don't try to install Jamstash as part of initial setup 2020-04-17 22:11:58 -04:00
Deluan
f6617ff77d Add Chinese Simplified translation 2020-04-17 21:54:41 -04:00
Deluan
defdc2ea6b Bump Subsonic API to 1.10.2 2020-04-17 21:44:34 -04:00
Deluan
1fd6571a87 Refactored getSongsByGenre 2020-04-17 21:44:34 -04:00
Deluan
4c0250f9f8 Add fromYear/toYear params to getRandomSongs 2020-04-17 21:44:34 -04:00
Deluan
0e1735e7a9 Add getSongsByGenre endpoint 2020-04-17 21:44:34 -04:00
Deluan
a698e434fd Refactor list_generator to use new filters 2020-04-17 21:44:34 -04:00
Deluan
95f658336c Implement byYear and byGenre AlbumLists 2020-04-17 21:44:34 -04:00
Deluan
69dc4d97b3 Always fill album's min_year if max_year is filled 2020-04-17 21:44:34 -04:00
jvoisin
4aeb63c16e Add a couple of patterns to .gitignore 2020-04-17 10:06:35 -04:00
dependabot-preview[bot]
e5efadf99e Bump github.com/go-chi/chi from 4.1.0+incompatible to 4.1.1+incompatible
Bumps [github.com/go-chi/chi](https://github.com/go-chi/chi) from 4.1.0+incompatible to 4.1.1+incompatible.
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v4.1.0...v4.1.1)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-04-17 08:09:06 -04:00
AlphaJack
d117d5794d Add Italian localization 2020-04-17 08:05:30 -04:00
Deluan
d09a2182e0 Lax Node version (only matches major version 13) 2020-04-17 00:21:42 -04:00
Deluan
b8b09820b1 Use deluan/ci-goreleaser 2020-04-16 17:44:12 -04:00
Deluan
2cfd7babb3 Add more Portuguese translations 2020-04-16 13:02:39 -04:00
Deluan
161a9b340c Add more Portuguese translations 2020-04-16 12:53:46 -04:00
Deluan
605253446a Fix AlbumLink label in Songs view 2020-04-16 10:26:24 -04:00
Deluan
f8d9b1508e Add prettier npm script 2020-04-15 22:11:23 -04:00
Deluan
3c4de3c8b5 Move language merge logic to i18n/index
This simplifies implementations one new languages
2020-04-15 22:11:23 -04:00
Deluan
a6c9bf1b15 Persist language selection to localStorage 2020-04-15 22:11:23 -04:00
Deluan
bf6ec67528 Add Language Selector to Personal settings 2020-04-15 22:11:23 -04:00
Deluan
289ba68824 Add Portuguese translation (incomplete) 2020-04-15 22:11:23 -04:00
Deluan
2dfe01963a Build binary for Linux MUSL (ex: Alpine). Fix #142 2020-04-15 08:49:30 -04:00
Deluan
5ed1d5c19f Upgrade github.com/djherbis/fscache to v0.10.1, tentatively fix #177 2020-04-15 08:45:10 -04:00
112 changed files with 2094 additions and 909 deletions

View File

@@ -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
View File

@@ -0,0 +1,2 @@
# Upgrade Prettier to 2.0.4. Reformatted all JS files
b3f70538a9138bc279a451f4f358605097210d41

View File

@@ -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
View 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
View 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
View 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 .

View File

@@ -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
View File

@@ -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

View File

@@ -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:"

2
.nvmrc
View File

@@ -1 +1 @@
v13.12.0
v13

View File

@@ -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"]

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -26,6 +26,9 @@ const (
URLPathUI = "/app"
URLPathSubsonicAPI = "/rest"
RequestThrottleBacklogLimit = 100
RequestThrottleBacklogTimeout = time.Minute
)
// Cache options

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View 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
}

View File

@@ -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
}

View 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
}

View File

@@ -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)

6
go.mod
View File

@@ -11,11 +11,11 @@ require (
github.com/dgrijalva/jwt-go v3.2.0+incompatible
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/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
@@ -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
)

12
go.sum
View File

@@ -30,8 +30,8 @@ github.com/dhowden/tag v0.0.0-20200412032933-5d76b8eaae27 h1:Z6xaGRBbqfLR797upHu
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,8 +43,8 @@ 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/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=
@@ -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=

View File

@@ -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:"-"`

View File

@@ -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:"-"`

View File

@@ -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)

View File

@@ -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)

View File

@@ -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))
})
})
})

View File

@@ -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}).

View File

@@ -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
}

View File

@@ -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,
}))

View File

@@ -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,
@@ -70,9 +70,9 @@ var (
Comment: "No Comments",
Owner: "userid",
Public: true,
Tracks: model.MediaFiles{{ID: "1"}, {ID: "3"}},
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}
)

View File

@@ -63,19 +63,19 @@ 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: "4"}, {ID: "3"}}}
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: "4"})
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("4"))
Expect(saved.Tracks[1].ID).To(Equal("3"))
Expect(saved.Tracks[2].ID).To(Equal("4"))
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())

View File

@@ -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
}

View File

@@ -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

View File

@@ -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("Toms Diner ' “40” A")).To(Equal(" 40 a diner toms"))
})
})
})

View File

@@ -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 {

View File

@@ -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....

View File

@@ -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") }
@@ -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 {

View File

@@ -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() {
@@ -231,7 +255,7 @@ Tracklist:
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" }))
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/one.mp3", "-i", "/music library/two.mp3", "-f", "ffmetadata"}))
})
})

View File

@@ -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 {

View 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"))
})
})
})

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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

View File

1
tests/fixtures/symlink vendored Symbolic link
View File

@@ -0,0 +1 @@
index.html

1
tests/fixtures/symlink2dir vendored Symbolic link
View File

@@ -0,0 +1 @@
../

1
tests/fixtures/synlink_invalid vendored Symbolic link
View File

@@ -0,0 +1 @@
INVALID

591
ui/package-lock.json generated
View File

@@ -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,9 +4965,10 @@
}
},
"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.11.1",
@@ -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": {
@@ -13308,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",
@@ -13318,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",
@@ -13844,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": {
@@ -13914,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"
@@ -15390,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"
}

View File

@@ -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.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"
}
}

View File

@@ -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()

View File

@@ -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,
}

View File

@@ -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>
}
/>

View File

@@ -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

View File

@@ -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

View File

@@ -7,7 +7,7 @@ import {
FunctionField,
Show,
SimpleShowLayout,
TextField
TextField,
} from 'react-admin'
import { DurationField, RangeField } from '../common'
import { useMediaQuery } from '@material-ui/core'

View File

@@ -6,7 +6,7 @@ import {
ListToolbar,
TextField,
useListController,
DatagridLoading
DatagridLoading,
} from 'react-admin'
import classnames from 'classnames'
import { useDispatch } from 'react-redux'
@@ -20,7 +20,7 @@ const useStyles = makeStyles(
(theme) => ({
root: {},
main: {
display: 'flex'
display: 'flex',
},
content: {
marginTop: 0,
@@ -28,29 +28,29 @@ 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'
}
justifyContent: 'flex-start',
},
})
const trackName = (r) => {
@@ -88,7 +88,7 @@ const AlbumSongs = (props) => {
<Card
className={classnames(classes.content, {
[classes.bulkActionsDisplayed]:
controllerProps.selectedIds.length > 0
controllerProps.selectedIds.length > 0,
})}
key={version}
>

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -5,5 +5,5 @@ import AlbumShow from './AlbumShow'
export default {
list: AlbumList,
show: AlbumShow,
icon: AlbumIcon
icon: AlbumIcon,
}

View File

@@ -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',
},
}))

View File

@@ -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

View File

@@ -3,5 +3,5 @@ import ArtistList from './ArtistList'
export default {
list: ArtistList,
icon: MicIcon
icon: MicIcon,
}

View File

@@ -4,7 +4,7 @@ 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 = () => {
@@ -17,7 +17,7 @@ const Player = () => {
theme: playerTheme,
bounds: 'body',
mode: 'full',
autoPlay: true,
autoPlay: false,
preload: true,
autoPlayInitLoadPlayList: true,
clearPriorAudioLists: false,
@@ -27,27 +27,45 @@ const Player = () => {
glassBg: false,
showThemeSwitch: false,
showMediaSession: true,
panelTitle: translate('player.panelTitle'),
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')
}
}
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),
}
}
@@ -58,7 +76,7 @@ const Player = () => {
const { authenticated } = useAuthState()
const OnAudioListsChange = (currentPlayIndex, audioLists) => {
dispatch(syncQueue(audioLists))
dispatch(syncQueue(currentPlayIndex, audioLists))
}
const OnAudioProgress = (info) => {
@@ -68,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 })
}
@@ -90,7 +109,7 @@ const Player = () => {
/>
)
}
return <div />
return null
}
export default Player

View File

@@ -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 }

View File

@@ -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

View File

@@ -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

View File

@@ -25,6 +25,6 @@ const PlayButton = ({ icon = defaultIcon, action, ...rest }) => {
PlayButton.propTypes = {
icon: PropTypes.element,
action: PropTypes.object
action: PropTypes.object,
}
export default PlayButton

View File

@@ -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 }

View File

@@ -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

View File

@@ -20,11 +20,11 @@ function formatBytes(bytes, decimals = 2) {
SizeField.propTypes = {
label: PropTypes.string,
record: PropTypes.object,
source: PropTypes.string.isRequired
source: PropTypes.string.isRequired,
}
SizeField.defaultProps = {
addLabel: true
addLabel: true,
}
export default SizeField

View File

@@ -20,7 +20,7 @@ const SongDetails = (props) => {
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" />
playCount: <TextField record={record} source="playCount" />,
}
if (record.playCount > 0) {
data.playDate = <DateField record={record} source="playDate" showTime />
@@ -34,7 +34,7 @@ const SongDetails = (props) => {
<TableRow key={record.id}>
<TableCell component="th" scope="row">
{translate(`resources.song.fields.${key}`, {
_: inflection.humanize(inflection.underscore(key))
_: inflection.humanize(inflection.underscore(key)),
})}
:
</TableCell>

View File

@@ -18,5 +18,5 @@ export {
SimpleList,
RangeField,
SongDetails,
formatRange
formatRange,
}

136
ui/src/i18n/cn.js Normal file
View File

@@ -0,0 +1,136 @@
import deepmerge from 'deepmerge'
import chineseMessages from 'ra-language-chinese'
export default deepmerge(chineseMessages, {
languageName: '简体中文',
resources: {
song: {
name: '歌曲 |||| 曲库',
fields: {
title: '标题',
artist: '歌手',
album: '专辑',
path: '路径',
genre: '类型',
compilation: '收录',
albumArtist: '专辑歌手',
duration: '时长',
year: '年份',
playCount: '播放次数',
trackNumber: '音轨 #',
size: '大小',
updatedAt: '上次更新',
},
bulk: {
addToQueue: '稍后播放',
},
},
album: {
name: '专辑 |||| 专辑',
fields: {
name: '名称',
albumArtist: '专辑歌手',
artist: '歌手',
duration: '时长',
songCount: '曲目数',
playCount: '播放次数',
compilation: '合辑',
year: '年份',
},
actions: {
playAll: '播放',
playNext: '播放下一首',
addToQueue: '稍后播放',
shuffle: '刷新',
},
},
artist: {
name: '歌手 |||| 歌手',
fields: {
name: '名称',
albumCount: '歌手数',
},
},
user: {
name: '用户 |||| 用户',
fields: {
userName: '用户名',
isAdmin: '管理员',
lastLoginAt: '最后一次访问',
updatedAt: '上次修改',
name: '名称',
},
},
player: {
name: '用户 |||| 用户',
fields: {
name: '名称',
transcodingId: '转码',
maxBitRate: '最大比特率',
client: '应用程序',
userName: '用户',
lastSeen: '最后一次访问',
},
},
transcoding: {
name: '转码 |||| 转码',
fields: {
name: '名称',
targetFormat: '格式',
defaultBitRate: '默认比特率',
command: '命令',
},
},
},
ra: {
auth: {
welcome1: '感谢您安装Navidrome!',
welcome2: '为了开始使用,请创建一个管理员账户',
confirmPassword: '确认密码',
buttonCreateAdmin: '创建管理员',
},
validation: {
invalidChars: '请只使用字母和数字',
passwordDoesNotMatch: '密码不匹配',
},
},
menu: {
library: '曲库',
settings: '设置',
version: '版本 %{version}',
theme: '主题',
personal: {
name: '个性化',
options: {
theme: '主题',
language: '语言',
},
},
},
player: {
playListsText: '播放队列',
openText: '打开',
closeText: '关闭',
notContentText: '无音乐',
clickToPlayText: '点击播放',
clickToPauseText: '点击暂停',
nextTrackText: '下一首',
previousTrackText: '上一首',
reloadText: 'Reload',
volumeText: '音量',
toggleLyricText: '切换歌词',
toggleMiniModeText: '最小化',
destroyText: '损坏',
downloadText: '下载',
removeAudioListsText: '清空播放列表',
controllerTitle: '',
clickToDeleteText: `点击删除 %{name}`,
emptyLyricText: '无歌词',
playModeText: {
order: '顺序播放',
orderLoop: '列表循环',
singleLoop: '单曲循环',
shufflePlay: '随机播放',
},
},
})

View File

@@ -2,6 +2,7 @@ import deepmerge from 'deepmerge'
import englishMessages from 'ra-language-english'
export default deepmerge(englishMessages, {
languageName: 'English',
resources: {
song: {
name: 'Song |||| Songs',
@@ -9,53 +10,77 @@ export default deepmerge(englishMessages, {
albumArtist: 'Album Artist',
duration: 'Time',
trackNumber: 'Track #',
playCount: 'Plays'
playCount: 'Plays',
},
bulk: {
addToQueue: 'Play Later'
}
addToQueue: 'Play Later',
},
},
album: {
fields: {
albumArtist: 'Album Artist',
artist: 'Artist',
duration: 'Time',
songCount: 'Songs',
playCount: 'Plays'
playCount: 'Plays',
},
actions: {
playAll: 'Play',
playNext: 'Play Next',
addToQueue: 'Play Later',
shuffle: 'Shuffle'
}
}
shuffle: 'Shuffle',
},
},
},
ra: {
auth: {
welcome1: 'Thanks for installing Navidrome!',
welcome2: 'To start, create an admin user',
confirmPassword: 'Confirm Password',
buttonCreateAdmin: 'Create Admin'
buttonCreateAdmin: 'Create Admin',
},
validation: {
invalidChars: 'Please only use letter and numbers',
passwordDoesNotMatch: 'Password does not match'
}
passwordDoesNotMatch: 'Password does not match',
},
},
menu: {
library: 'Library',
settings: 'Settings',
personal: 'Personal',
version: 'Version %{version}',
theme: 'Theme'
theme: 'Theme',
personal: {
name: 'Personal',
options: {
theme: 'Theme',
language: 'Language',
},
},
},
player: {
panelTitle: 'Play Queue',
playListsText: 'Play Queue',
openText: 'Open',
closeText: 'Close',
notContentText: 'No music',
clickToPlayText: 'Click to play',
clickToPauseText: 'Click to pause',
nextTrackText: 'Next track',
previousTrackText: 'Previous track',
reloadText: 'Reload',
volumeText: 'Volume',
toggleLyricText: 'Toggle lyric',
toggleMiniModeText: 'Minimize',
destroyText: 'Destroy',
downloadText: 'Download',
removeAudioListsText: 'Delete audio lists',
controllerTitle: '',
clickToDeleteText: `Click to delete %{name}`,
emptyLyricText: 'No lyric',
playModeText: {
order: 'In order',
orderLoop: 'Repeat',
singleLoop: 'Repeat One',
shufflePlay: 'Shuffle'
}
}
shufflePlay: 'Shuffle',
},
},
})

127
ui/src/i18n/fr.js Normal file
View File

@@ -0,0 +1,127 @@
import deepmerge from 'deepmerge'
import frenchMessages from 'ra-language-french'
export default deepmerge(frenchMessages, {
languageName: 'Français',
resources: {
song: {
name: 'Piste |||| Pistes',
fields: {
title: 'Titre',
artist: 'Artiste',
album: 'Album',
path: 'Chemin',
genre: 'Genre',
compilation: 'Compilation',
duration: 'Durée',
year: 'Année',
playCount: "Nombre d'écoutes",
trackNumber: '#',
size: 'Taille',
updatedAt: 'Mise à jour',
},
bulk: {
addToQueue: 'Ajouter à la file',
},
},
album: {
name: 'Album |||| Albums',
fields: {
name: 'Nom',
artist: 'Artiste',
songCount: 'Numéro de piste',
genre: 'Genre',
playCount: "Numbre d'écoutes",
compilation: 'Compilation',
duration: 'Durée',
year: 'Année',
},
actions: {
playAll: 'Lire',
playNext: 'Lire ensuite',
addToQueue: 'Ajouter à la file',
shuffle: 'Mélanger',
},
},
artist: {
name: 'Artiste |||| Artistes',
fields: {
name: 'Nom',
albumCount: "Nombre d'albums",
},
},
user: {
name: 'Utilisateur |||| Utilisateurs',
fields: {
userName: 'Nom d\'utilisateur',
isAdmin: 'Administrateur',
lastLoginAt: 'Dernière connexion',
updatedAt: 'Dernière mise à jour',
name: 'Nom',
},
},
player: {
name: 'Lecteur |||| Lecteurs',
fields: {
name: 'Nom',
transcodingId: 'Transcodage',
maxBitRate: 'Bitrate maximum',
client: 'Client',
userName: 'Nom d\'utilisateur',
lastSeen: 'Vu pour la dernière fois',
},
},
transcoding: {
name: 'Conversion |||| Conversions',
fields: {
name: 'Nom',
targetFormat: 'Format',
defaultBitRate: 'Bitrate par défaut',
command: 'Commande',
},
},
},
ra: {
auth: {
welcome1: "Merci d'avoir installé Navidrome !",
welcome2: 'Pour commencer, créez un compte administrateur',
confirmPassword: 'Confirmer votre mot de passe',
buttonCreateAdmin: 'Créer un compte administrateur',
},
validation: {
invalidChars: "Merci d'utiliser uniquement des chiffres et des lettres",
passwordDoesNotMatch: 'Les mots de passes ne correspondent pas',
},
},
menu: {
library: 'Bibliothèque',
settings: 'Paramètres',
version: 'Version%{version}',
personal: {
name: 'Paramètres personel',
options: {
theme: 'Thème',
language: 'Langue',
},
},
},
player: {
playListsText: 'File de lecture',
openText: 'Ouvrir',
closeText: 'Fermer',
clickToPlayText: 'Cliquer pour lire',
clickToPauseText: 'Cliquer pour mettre en pause',
nextTrackText: 'Morceau suivant',
previousTrackText: 'Morceau précédent',
volumeText: 'Volume',
toggleMiniModeText: 'Minimiser',
removeAudioListsText: 'Vider la liste de lecture',
clickToDeleteText: `Cliquer pour supprimer %{name}`,
playModeText: {
order: 'Ordonner',
orderLoop: 'Tout répéter',
singleLoop: 'Repéter',
shufflePlay: 'Aleatoire',
},
},
})

View File

@@ -1,3 +1,21 @@
import deepmerge from 'deepmerge'
import en from './en'
import fr from './fr'
import it from './it'
import pt from './pt'
import cn from './cn'
export default { en }
const addLanguages = (lang) => {
Object.keys(lang).forEach((l) => (languages[l] = deepmerge(en, lang[l])))
}
const languages = { en }
// Add new languages to the object bellow (please keep alphabetic sort)
addLanguages({ cn, fr, it, pt })
// "Hack" to make "albumSongs" resource use the same translations as "song"
Object.keys(languages).forEach(
(k) => (languages[k].resources.albumSong = languages[k].resources.song)
)
export default languages

127
ui/src/i18n/it.js Normal file
View File

@@ -0,0 +1,127 @@
import deepmerge from 'deepmerge'
import italianMessages from 'ra-language-italian'
export default deepmerge(italianMessages, {
languageName: 'Italiano',
resources: {
song: {
name: 'Traccia |||| Tracce',
fields: {
title: 'Titolo',
artist: 'Artista',
album: 'Album',
path: 'Percorso',
genre: 'Genere',
compilation: 'Compilation',
duration: 'Durata',
year: 'Anno',
playCount: 'Riproduzioni',
trackNumber: '#',
size: 'Dimensioni',
updatedAt: 'Ultimo aggiornamento',
},
bulk: {
addToQueue: 'Aggiungi alla coda',
},
},
album: {
name: 'Album |||| Album',
fields: {
name: 'Nome',
artist: 'Artista',
songCount: 'Tracce',
genre: 'Genere',
playCount: 'Riproduzioni',
compilation: 'Compilation',
duration: 'Durata',
year: 'Anno',
},
actions: {
playAll: 'Riproduci',
playNext: 'Riproduci come successivo',
addToQueue: 'Aggiungi alla coda',
shuffle: 'Riprodici casualmente',
},
},
artist: {
name: 'Artista |||| Artisti',
fields: {
name: 'Nome',
albumCount: 'Album',
},
},
user: {
name: 'Utente |||| Utenti',
fields: {
userName: 'Utente',
isAdmin: 'Amministratore',
lastLoginAt: 'Ultimo accesso',
updatedAt: 'Ultima modifica',
name: 'Nome',
},
},
player: {
name: 'Client |||| Client',
fields: {
name: 'Nome',
transcodingId: 'Transcodifica',
maxBitRate: 'Bitrate massimo',
client: 'Applicazione',
userName: 'Utente',
lastSeen: 'Ultimo acesso',
},
},
transcoding: {
name: 'Transcodifica |||| Transcodifiche',
fields: {
name: 'Nome',
targetFormat: 'Formato',
defaultBitRate: 'Bitrate predefinito',
command: 'Comando',
},
},
},
ra: {
auth: {
welcome1: 'Grazie per aver installato Navidrome!',
welcome2: 'Per iniziare, crea un amministratore',
confirmPassword: 'Conferma la password',
buttonCreateAdmin: 'Crea amministratore',
},
validation: {
invalidChars: 'Per favore usa solo lettere e numeri',
passwordDoesNotMatch: 'Le password non coincidono',
},
},
menu: {
library: 'Libreria',
settings: 'Impostazioni',
version: 'Versione %{version}',
personal: {
name: 'Personale',
options: {
theme: 'Tema',
language: 'Lingua',
},
},
},
player: {
playListsText: 'Coda',
openText: 'Apri',
closeText: 'Chiudi',
clickToPlayText: 'Clicca per riprodurre',
clickToPauseText: 'Clicca per mettere in pausa',
nextTrackText: 'Traccia successiva',
previousTrackText: 'Traccia precedente',
volumeText: 'Volume',
toggleMiniModeText: 'Minimizza',
removeAudioListsText: 'Cancella coda',
clickToDeleteText: `Clicca per rimuovere %{name}`,
playModeText: {
order: 'In ordine',
orderLoop: 'Ripeti',
singleLoop: 'Ripeti una volta',
shufflePlay: 'Casuale',
},
},
})

127
ui/src/i18n/pt.js Normal file
View File

@@ -0,0 +1,127 @@
import deepmerge from 'deepmerge'
import portugueseMessages from 'ra-language-portuguese'
export default deepmerge(portugueseMessages, {
languageName: 'Português',
resources: {
song: {
name: 'Música |||| Músicas',
fields: {
title: 'Título',
artist: 'Artista',
album: 'Álbum',
path: 'Arquivo',
genre: 'Gênero',
compilation: 'Coletânea',
duration: 'Duração',
year: 'Ano',
playCount: 'Execuções',
trackNumber: '#',
size: 'Tamanho',
updatedAt: 'Últ. Atualização',
},
bulk: {
addToQueue: 'Play Later',
},
},
album: {
name: 'Álbum |||| Álbuns',
fields: {
name: 'Nome',
artist: 'Artista',
songCount: 'Músicas',
genre: 'Gênero',
playCount: 'Execuções',
compilation: 'Coletânea',
duration: 'Duração',
year: 'Ano',
},
actions: {
playAll: 'Play',
playNext: 'Play Next',
addToQueue: 'Play Later',
shuffle: 'Shuffle',
},
},
artist: {
name: 'Artista |||| Artistas',
fields: {
name: 'Nome',
albumCount: 'Total de Álbuns',
},
},
user: {
name: 'Usuário |||| Usuários',
fields: {
userName: 'Usuário',
isAdmin: 'Admin?',
lastLoginAt: 'Últ. Login',
updatedAt: 'Últ. Atualização',
name: 'Nome',
},
},
player: {
name: 'Tocador |||| Tocadores',
fields: {
name: 'Nome',
transcodingId: 'Conversão',
maxBitRate: 'Bitrate máx',
client: 'Cliente',
userName: 'Usuário',
lastSeen: 'Últ. acesso',
},
},
transcoding: {
name: 'Conversão |||| Conversões',
fields: {
name: 'Nome',
targetFormat: 'Formato',
defaultBitRate: 'Bitrate padrão',
command: 'Comando',
},
},
},
ra: {
auth: {
welcome1: 'Obrigado por instalar Navidrome!',
welcome2: 'Para iniciar, crie um usuário admin',
confirmPassword: 'Confirme a senha',
buttonCreateAdmin: 'Criar Admin',
},
validation: {
invalidChars: 'Somente use letras e numeros',
passwordDoesNotMatch: 'Senha não confere',
},
},
menu: {
library: 'Biblioteca',
settings: 'Configurações',
version: 'Versão %{version}',
personal: {
name: 'Pessoal',
options: {
theme: 'Tema',
language: 'Língua',
},
},
},
player: {
playListsText: 'Fila de Execução',
openText: 'Abrir',
closeText: 'Fechar',
clickToPlayText: 'Clique para tocar',
clickToPauseText: 'Clique para pausar',
nextTrackText: 'Próxima faixa',
previousTrackText: 'Faixa anterior',
volumeText: 'Volume',
toggleMiniModeText: 'Minimizar',
removeAudioListsText: 'Limpar fila de execução',
clickToDeleteText: `Clique para remover %{name}`,
playModeText: {
order: 'Em ordem',
orderLoop: 'Repetir tudo',
singleLoop: 'Repetir',
shufflePlay: 'Aleatório',
},
},
})

View File

@@ -3,7 +3,7 @@ import {
AppBar as RAAppBar,
MenuItemLink,
UserMenu,
useTranslate
useTranslate,
} from 'react-admin'
import { makeStyles } from '@material-ui/core'
import InfoIcon from '@material-ui/icons/Info'
@@ -11,8 +11,8 @@ import config from '../config'
const useStyles = makeStyles((theme) => ({
menuItem: {
color: theme.palette.text.secondary
}
color: theme.palette.text.secondary,
},
}))
const VersionMenu = forwardRef((props, ref) => {
@@ -23,7 +23,7 @@ const VersionMenu = forwardRef((props, ref) => {
ref={ref}
to="#"
primaryText={translate('menu.version', {
version: config.version
version: config.version,
})}
leftIcon={<InfoIcon />}
className={classes.menuItem}

View File

@@ -7,7 +7,7 @@ import AppBar from './AppBar'
import themes from '../themes'
const useStyles = makeStyles({
root: { paddingBottom: (props) => (props.addPadding ? '80px' : 0) }
root: { paddingBottom: (props) => (props.addPadding ? '80px' : 0) },
})
export default (props) => {

View File

@@ -27,35 +27,35 @@ const useStyles = makeStyles((theme) => ({
background: `url(${config.loginBackgroundURL})`,
backgroundRepeat: 'no-repeat',
backgroundSize: 'cover',
backgroundPosition: 'center'
backgroundPosition: 'center',
},
card: {
minWidth: 300,
marginTop: '6em'
marginTop: '6em',
},
avatar: {
margin: '1em',
display: 'flex',
justifyContent: 'center'
justifyContent: 'center',
},
icon: {
backgroundColor: theme.palette.secondary.main
backgroundColor: theme.palette.secondary.main,
},
systemName: {
marginTop: '1em',
display: 'flex',
justifyContent: 'center',
color: 'blue' //theme.palette.grey[500]
color: 'blue', //theme.palette.grey[500]
},
form: {
padding: '0 1em 1em 1em'
padding: '0 1em 1em 1em',
},
input: {
marginTop: '1em'
marginTop: '1em',
},
actions: {
padding: '0 1em 1em 1em'
}
padding: '0 1em 1em 1em',
},
}))
const renderInput = ({
@@ -274,7 +274,7 @@ const Login = ({ location }) => {
Login.propTypes = {
authProvider: PropTypes.func,
previousRoute: PropTypes.string
previousRoute: PropTypes.string,
}
// We need to put the ThemeProvider decoration in another component

View File

@@ -17,9 +17,9 @@ const translatedResourceName = (resource, translate) =>
resource.options && resource.options.label
? translate(resource.options.label, {
smart_count: 2,
_: resource.options.label
_: resource.options.label,
})
: inflection.humanize(inflection.pluralize(resource.name))
: inflection.humanize(inflection.pluralize(resource.name)),
})
const Menu = ({ onMenuClick, dense, logout }) => {
@@ -31,7 +31,7 @@ const Menu = ({ onMenuClick, dense, logout }) => {
// TODO State is not persisted in mobile when you close the sidebar menu. Move to redux?
const [state, setState] = useState({
menuLibrary: true,
menuSettings: false
menuSettings: false,
})
const handleToggle = (menu) => {

View File

@@ -5,8 +5,8 @@ import TuneIcon from '@material-ui/icons/Tune'
const useStyles = makeStyles((theme) => ({
menuItem: {
color: theme.palette.text.secondary
}
color: theme.palette.text.secondary,
},
}))
const PersonalMenu = forwardRef(({ onClick, sidebarIsOpen, dense }, ref) => {
@@ -16,7 +16,7 @@ const PersonalMenu = forwardRef(({ onClick, sidebarIsOpen, dense }, ref) => {
<MenuItemLink
ref={ref}
to="/personal"
primaryText={translate('menu.personal')}
primaryText={translate('menu.personal.name')}
leftIcon={<TuneIcon />}
onClick={onClick}
className={classes.menuItem}

View File

@@ -14,12 +14,12 @@ const useStyles = makeStyles((theme) => ({
icon: { minWidth: theme.spacing(5) },
sidebarIsOpen: {
paddingLeft: 25,
transition: 'padding-left 195ms cubic-bezier(0.4, 0, 0.6, 1) 0ms'
transition: 'padding-left 195ms cubic-bezier(0.4, 0, 0.6, 1) 0ms',
},
sidebarIsClosed: {
paddingLeft: 0,
transition: 'padding-left 195ms cubic-bezier(0.4, 0, 0.6, 1) 0ms'
}
transition: 'padding-left 195ms cubic-bezier(0.4, 0, 0.6, 1) 0ms',
},
}))
const SubMenu = ({
@@ -29,7 +29,7 @@ const SubMenu = ({
name,
icon,
children,
dense
dense,
}) => {
const translate = useTranslate()
const classes = useStyles()

View File

@@ -1,36 +1,75 @@
import React from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { Card } from '@material-ui/core'
import { Title, SimpleForm, SelectInput, useTranslate } from 'react-admin'
import {
Title,
SimpleForm,
SelectInput,
useTranslate,
useSetLocale,
useLocale,
} from 'react-admin'
import { makeStyles } from '@material-ui/core/styles'
import { changeTheme } from './actions'
import themes from '../themes'
import i18n from '../i18n'
const useStyles = makeStyles({
root: { marginTop: '1em' }
root: { marginTop: '1em' },
})
const Personal = () => {
const SelectLanguage = (props) => {
const translate = useTranslate()
const locale = useLocale()
const setLocale = useSetLocale()
const langChoices = Object.keys(i18n).map((key) => {
return { id: key, name: i18n[key].languageName }
})
return (
<SelectInput
{...props}
source="lamguage"
label={translate('menu.personal.options.language')}
defaultValue={locale}
choices={langChoices}
onChange={(event) => {
setLocale(event.target.value)
localStorage.setItem('locale', event.target.value)
}}
/>
)
}
const SelectTheme = (props) => {
const translate = useTranslate()
const classes = useStyles()
const currentTheme = useSelector((state) => state.theme)
const dispatch = useDispatch()
const currentTheme = useSelector((state) => state.theme)
const themeChoices = Object.keys(themes).map((key) => {
return { id: key, name: themes[key].themeName }
})
return (
<SelectInput
{...props}
source="theme"
label={translate('menu.personal.options.theme')}
defaultValue={currentTheme}
choices={themeChoices}
onChange={(event) => {
dispatch(changeTheme(event.target.value))
}}
/>
)
}
const Personal = () => {
const translate = useTranslate()
const classes = useStyles()
return (
<Card className={classes.root}>
<Title title={'Navidrome - ' + translate('menu.personal')} />
<Title title={'Navidrome - ' + translate('menu.personal.name')} />
<SimpleForm toolbar={null}>
<SelectInput
source="theme"
defaultValue={currentTheme}
choices={themeChoices}
onChange={(event) => {
dispatch(changeTheme(event.target.value))
}}
/>
<SelectTheme />
<SelectLanguage />
</SimpleForm>
</Card>
)

View File

@@ -2,5 +2,5 @@ export const CHANGE_THEME = 'CHANGE_THEME'
export const changeTheme = (theme) => ({
type: CHANGE_THEME,
payload: theme
payload: theme,
})

View File

@@ -6,7 +6,7 @@ import {
required,
SimpleForm,
SelectInput,
ReferenceInput
ReferenceInput,
} from 'react-admin'
import { Title } from '../common'
@@ -19,7 +19,6 @@ const PlayerEdit = (props) => (
<SimpleForm>
<TextInput source="name" validate={[required()]} />
<ReferenceInput
label="Transcoding"
source="transcodingId"
reference="transcoding"
sort={{ field: 'name', order: 'ASC' }}
@@ -40,7 +39,7 @@ const PlayerEdit = (props) => (
{ id: 192, name: '192' },
{ id: 256, name: '256' },
{ id: 320, name: '320' },
{ id: 0, name: 'Unlimited' }
{ id: 0, name: 'Unlimited' },
]}
/>
<TextField source="client" />

View File

@@ -5,29 +5,36 @@ import {
TextField,
DateField,
FunctionField,
ReferenceField
ReferenceField,
} from 'react-admin'
import { Title } from '../common'
import { useMediaQuery } from '@material-ui/core'
import { SimpleList, Title } from '../common'
const PlayerList = (props) => (
<List title={<Title subTitle={'Players'} />} exporter={false} {...props}>
<Datagrid rowClick="edit">
<TextField source="name" />
<ReferenceField
label="Transcoding"
source="transcodingId"
reference="transcoding"
>
<TextField source="name" />
</ReferenceField>
<FunctionField
label="MaxBitRate"
source="maxBitRate"
render={(r) => (r.maxBitRate ? r.maxBitRate : 'Unlimited')}
/>
<DateField source="lastSeen" showTime />
</Datagrid>
</List>
)
const PlayerList = (props) => {
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
return (
<List title={<Title subTitle={'Players'} />} exporter={false} {...props}>
{isXsmall ? (
<SimpleList
primaryText={(r) => r.client}
secondaryText={(r) => r.userName}
tertiaryText={(r) => (r.maxBitRate ? r.maxBitRate : 'Unlimited')}
/>
) : (
<Datagrid rowClick="edit">
<TextField source="name" />
<ReferenceField source="transcodingId" reference="transcoding">
<TextField source="name" />
</ReferenceField>
<FunctionField
source="maxBitRate"
render={(r) => (r.maxBitRate ? r.maxBitRate : 'Unlimited')}
/>
<DateField source="lastSeen" showTime />
</Datagrid>
)}
</List>
)
}
export default PlayerList

View File

@@ -5,5 +5,5 @@ import PlayerEdit from './PlayerEdit'
export default {
list: PlayerList,
edit: PlayerEdit,
icon: RadioIcon
icon: RadioIcon,
}

View File

@@ -3,7 +3,7 @@ import {
Button,
useDataProvider,
useTranslate,
useUnselectAll
useUnselectAll,
} from 'react-admin'
import { useDispatch } from 'react-redux'
import { addTrack } from '../audioplayer'

View File

@@ -11,6 +11,5 @@ export const AlbumLinkField = (props) => (
)
AlbumLinkField.defaultProps = {
source: 'albumId',
addLabel: true
addLabel: true,
}

View File

@@ -6,7 +6,7 @@ import {
List,
NumberField,
SearchInput,
TextField
TextField,
} from 'react-admin'
import { useMediaQuery } from '@material-ui/core'
import {
@@ -14,7 +14,7 @@ import {
Pagination,
PlayButton,
SimpleList,
Title
Title,
} from '../common'
import { useDispatch } from 'react-redux'
import { addTrack, setTrack } from '../audioplayer'
@@ -63,7 +63,7 @@ const SongList = (props) => {
rowClick={(id, basePath, record) => dispatch(setTrack(record))}
>
<TextField source="title" />
{isDesktop && <AlbumLinkField source="albumId" sortBy="album" />}
{isDesktop && <AlbumLinkField source="album" />}
<TextField source="artist" />
{isDesktop && <NumberField source="trackNumber" />}
{isDesktop && <NumberField source="playCount" />}

View File

@@ -3,5 +3,5 @@ import SongList from './SongList'
export default {
list: SongList,
icon: MusicNoteIcon
icon: MusicNoteIcon,
}

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