mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-06 13:58:09 -05:00
Compare commits
141 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13ce21843f | ||
|
|
151f43b95f | ||
|
|
055c77b38c | ||
|
|
8dc2d7a5e0 | ||
|
|
a71d5b3954 | ||
|
|
854a923fea | ||
|
|
496b467c1d | ||
|
|
056d5e7111 | ||
|
|
e43c172d96 | ||
|
|
0b56c3f026 | ||
|
|
5445d20ecd | ||
|
|
2f7443e4bd | ||
|
|
41cf99541d | ||
|
|
1a9663d432 | ||
|
|
b7dcdedf41 | ||
|
|
bf8f9d2be8 | ||
|
|
6d20ca27f6 | ||
|
|
3bb573b45f | ||
|
|
9b2d91c0f2 | ||
|
|
b002a69bf8 | ||
|
|
e341df1e26 | ||
|
|
35e8c1c407 | ||
|
|
d1a88ed8d6 | ||
|
|
10a7dfeb15 | ||
|
|
dbde5330bd | ||
|
|
9b817edd1a | ||
|
|
261d73410a | ||
|
|
555c78f536 | ||
|
|
0270a9c924 | ||
|
|
a45e278cda | ||
|
|
bdbee7f541 | ||
|
|
b453ee6598 | ||
|
|
716de24f1e | ||
|
|
c816ca4525 | ||
|
|
eb7d2dcaa1 | ||
|
|
e6d4cfba96 | ||
|
|
2a5d2d70ba | ||
|
|
e539ddceb9 | ||
|
|
00666da9c1 | ||
|
|
7ad9c385b5 | ||
|
|
e65fb189ce | ||
|
|
1afe409a79 | ||
|
|
dbf9c8be7d | ||
|
|
26188e6d8a | ||
|
|
d6c70554b3 | ||
|
|
5990a4285f | ||
|
|
08e9ac63b1 | ||
|
|
71a1f65be2 | ||
|
|
5862157a2c | ||
|
|
d4f17f2b73 | ||
|
|
ea1d534c29 | ||
|
|
069de0f9ea | ||
|
|
e871c7daee | ||
|
|
320fe11a66 | ||
|
|
5fdc09a5b9 | ||
|
|
46f1b33812 | ||
|
|
b44218fdcc | ||
|
|
4441ae1f0b | ||
|
|
1c3ee89ab4 | ||
|
|
ebc7964157 | ||
|
|
ad6c86d78a | ||
|
|
f3097496c6 | ||
|
|
ddeefad501 | ||
|
|
5cd453afeb | ||
|
|
03c3c192ed | ||
|
|
95790b9eff | ||
|
|
6bf7c751a1 | ||
|
|
1019bb8258 | ||
|
|
531155d016 | ||
|
|
47311d16cf | ||
|
|
ef3466787d | ||
|
|
b7fd116bd8 | ||
|
|
34ad740e07 | ||
|
|
79454d7a92 | ||
|
|
87cc397bc3 | ||
|
|
37602a2049 | ||
|
|
56ea380bb3 | ||
|
|
177ace1cee | ||
|
|
61e3fe21ff | ||
|
|
8dcca76ec9 | ||
|
|
1dd3a794f8 | ||
|
|
6c5dd245fe | ||
|
|
3b3ad65612 | ||
|
|
e6f798811d | ||
|
|
371e8ab6ca | ||
|
|
69c19e946c | ||
|
|
d7edbf93f0 | ||
|
|
fb4d920fba | ||
|
|
5a072fbd10 | ||
|
|
79c9d8f4f4 | ||
|
|
871bf5a70a | ||
|
|
e4af235ce9 | ||
|
|
00384a60f3 | ||
|
|
f7b3ff4b34 | ||
|
|
eaa48306fc | ||
|
|
f5572b8447 | ||
|
|
a756751cc6 | ||
|
|
b8a3af090d | ||
|
|
d534cb96a9 | ||
|
|
f1e1d3bc07 | ||
|
|
694be54428 | ||
|
|
76531fb1cd | ||
|
|
716f4c5cf7 | ||
|
|
ba2d4b6859 | ||
|
|
2ec5e47328 | ||
|
|
b3f70538a9 | ||
|
|
de115ff466 | ||
|
|
129f02b36b | ||
|
|
1a8d219197 | ||
|
|
80c8d85cb9 | ||
|
|
db02f5f07f | ||
|
|
579294b0f1 | ||
|
|
f83d0d471d | ||
|
|
3b7d7bdb04 | ||
|
|
05958f5195 | ||
|
|
6cf4b81de9 | ||
|
|
689449df9e | ||
|
|
dae938de6f | ||
|
|
f6617ff77d | ||
|
|
defdc2ea6b | ||
|
|
1fd6571a87 | ||
|
|
4c0250f9f8 | ||
|
|
0e1735e7a9 | ||
|
|
a698e434fd | ||
|
|
95f658336c | ||
|
|
69dc4d97b3 | ||
|
|
4aeb63c16e | ||
|
|
e5efadf99e | ||
|
|
d117d5794d | ||
|
|
d09a2182e0 | ||
|
|
b8b09820b1 | ||
|
|
2cfd7babb3 | ||
|
|
161a9b340c | ||
|
|
605253446a | ||
|
|
f8d9b1508e | ||
|
|
3c4de3c8b5 | ||
|
|
a6c9bf1b15 | ||
|
|
bf6ec67528 | ||
|
|
289ba68824 | ||
|
|
2dfe01963a | ||
|
|
5ed1d5c19f |
@@ -1,6 +1,5 @@
|
||||
.DS_Store
|
||||
ui/node_modules
|
||||
ui/build
|
||||
Jamstash-master
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
@@ -11,4 +10,4 @@ navidrome
|
||||
navidrome.db
|
||||
navidrome.toml
|
||||
assets/*gen.go
|
||||
dist
|
||||
|
||||
|
||||
2
.git-blame-ignore-revs
Normal file
2
.git-blame-ignore-revs
Normal file
@@ -0,0 +1,2 @@
|
||||
# Upgrade Prettier to 2.0.4. Reformatted all JS files
|
||||
b3f70538a9138bc279a451f4f358605097210d41
|
||||
53
.github/workflows/build.yml
vendored
53
.github/workflows/build.yml
vendored
@@ -1,53 +0,0 @@
|
||||
name: Build
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
go:
|
||||
name: Test Server on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
# TODO Fix tests in Windows
|
||||
# os: [macOS-latest, ubuntu-latest, windows-latest]
|
||||
os: [macOS-latest, ubuntu-latest]
|
||||
|
||||
steps:
|
||||
- name: Set up Go 1.14
|
||||
uses: actions/setup-go@v1
|
||||
with:
|
||||
go-version: 1.14
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Test
|
||||
run: go test -cover ./... -v
|
||||
|
||||
js:
|
||||
name: Test UI
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 13
|
||||
|
||||
- name: npm install dependencies
|
||||
run: |
|
||||
cd ui
|
||||
npm ci
|
||||
|
||||
# TODO: Enable when there are tests to run
|
||||
# - name: npm test
|
||||
# run: |
|
||||
# cd ui
|
||||
# CI=test npm test
|
||||
|
||||
- name: npm build
|
||||
run: |
|
||||
cd ui
|
||||
npm run build
|
||||
17
.github/workflows/docker-tags.sh
vendored
Executable file
17
.github/workflows/docker-tags.sh
vendored
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
|
||||
GIT_TAG="${GITHUB_REF##refs/tags/}"
|
||||
GIT_BRANCH="${GITHUB_REF##refs/heads/}"
|
||||
GIT_SHA=$(git rev-parse --short HEAD)
|
||||
|
||||
DOCKER_IMAGE_TAG="--tag ${DOCKER_IMAGE}:sha-${GIT_SHA}"
|
||||
|
||||
if [[ $GITHUB_REF != $GIT_TAG ]]; then
|
||||
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:${GIT_TAG#v} --tag ${DOCKER_IMAGE}:latest"
|
||||
elif [[ $GITHUB_REF == "refs/heads/master" ]]; then
|
||||
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:develop"
|
||||
elif [[ $GIT_BRANCH = feature/* ]]; then
|
||||
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:$(echo $GIT_BRANCH | tr / -)"
|
||||
fi
|
||||
|
||||
echo ${DOCKER_IMAGE_TAG}
|
||||
40
.github/workflows/pipeline.dockerfile
vendored
Normal file
40
.github/workflows/pipeline.dockerfile
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
#####################################################
|
||||
### Copy platform specific binary
|
||||
FROM bash as copy-binary
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
RUN echo "Target Platform = ${TARGETPLATFORM}"
|
||||
|
||||
COPY dist .
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then cp navidrome_linux_musl_amd64_linux_amd64/navidrome /navidrome; fi
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then cp navidrome_linux_arm64_linux_arm64/navidrome /navidrome; fi
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then cp navidrome_linux_arm_linux_arm_6/navidrome /navidrome; fi
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then cp navidrome_linux_arm_linux_arm_7/navidrome /navidrome; fi
|
||||
RUN chmod +x /navidrome
|
||||
|
||||
|
||||
#####################################################
|
||||
### Build Final Image
|
||||
FROM alpine as release
|
||||
LABEL maintainer="deluan@navidrome.org"
|
||||
|
||||
# Install ffmpeg and output build config
|
||||
RUN apk add --no-cache ffmpeg
|
||||
RUN ffmpeg -buildconf
|
||||
|
||||
COPY --from=copy-binary /navidrome /app/
|
||||
|
||||
VOLUME ["/data", "/music"]
|
||||
ENV ND_MUSICFOLDER /music
|
||||
ENV ND_DATAFOLDER /data
|
||||
ENV ND_SCANINTERVAL 1m
|
||||
ENV ND_TRANSCODINGCACHESIZE 100MB
|
||||
ENV ND_SESSIONTIMEOUT 30m
|
||||
ENV ND_LOGLEVEL info
|
||||
ENV ND_PORT 4533
|
||||
|
||||
EXPOSE ${ND_PORT}
|
||||
HEALTHCHECK CMD wget -O- http://localhost:${ND_PORT}/ping || exit 1
|
||||
WORKDIR /app
|
||||
|
||||
ENTRYPOINT ["/app/navidrome"]
|
||||
163
.github/workflows/pipeline.yml
vendored
Normal file
163
.github/workflows/pipeline.yml
vendored
Normal file
@@ -0,0 +1,163 @@
|
||||
name: Pipeline
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
jobs:
|
||||
golangci-lint:
|
||||
name: Lint Server
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Run golangci-lint
|
||||
uses: actions-contrib/golangci-lint@v1
|
||||
with:
|
||||
golangci_lint_version: v1.25.0
|
||||
# TODO Enable github actions output format: https://github.com/actions-contrib/golangci-lint/issues/11
|
||||
# args: run --out-format github-actions
|
||||
|
||||
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 check-formatting
|
||||
run: |
|
||||
cd ui
|
||||
npm run check-formatting
|
||||
|
||||
- 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, golangci-lint]
|
||||
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
|
||||
env:
|
||||
DOCKER_IMAGE: ${{secrets.DOCKER_IMAGE}}
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: crazy-max/ghaction-docker-buildx@v1
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/checkout@v1
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
|
||||
- uses: actions/download-artifact@v1
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
with:
|
||||
name: binaries
|
||||
path: dist
|
||||
|
||||
- name: Build the Docker image and push
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
env:
|
||||
DOCKER_IMAGE: ${{secrets.DOCKER_IMAGE}}
|
||||
DOCKER_PLATFORM: linux/amd64,linux/arm/v7,linux/arm64
|
||||
run: |
|
||||
echo ${{secrets.DOCKER_PASSWORD}} | docker login -u ${{secrets.DOCKER_USERNAME}} --password-stdin
|
||||
docker buildx build --platform ${DOCKER_PLATFORM} `.github/workflows/docker-tags.sh` -f .github/workflows/pipeline.dockerfile --push .
|
||||
31
.github/workflows/release.yml
vendored
31
.github/workflows/release.yml
vendored
@@ -1,31 +0,0 @@
|
||||
name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 13.12
|
||||
- name: Build UI
|
||||
run: |
|
||||
cd ui
|
||||
npm ci
|
||||
npm run build
|
||||
- name: Fetch tags
|
||||
run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
|
||||
- name: Run GoReleaser
|
||||
uses: docker://bepsays/ci-goreleaser:1.14-1
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: goreleaser release --rm-dist
|
||||
18
.github/workflows/remove-old-artifacts.yml
vendored
Normal file
18
.github/workflows/remove-old-artifacts.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
name: Remove old artifacts
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every day at 1am
|
||||
- cron: '0 1 * * *'
|
||||
|
||||
jobs:
|
||||
remove-old-artifacts:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Remove old artifacts
|
||||
uses: c-hive/gha-remove-artifacts@v1
|
||||
with:
|
||||
age: '7 days'
|
||||
skip-tags: false
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -9,7 +9,6 @@ vendor/*/
|
||||
wiki
|
||||
TODO.md
|
||||
var
|
||||
Artwork
|
||||
navidrome.toml
|
||||
master.zip
|
||||
Jamstash-master
|
||||
@@ -20,3 +19,7 @@ navidrome.db
|
||||
dist
|
||||
music
|
||||
docker-compose.override.yml
|
||||
navidrome.db-shm
|
||||
navidrome.db-wal
|
||||
tags
|
||||
|
||||
|
||||
29
.golangci.yml
Normal file
29
.golangci.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
linters:
|
||||
enable:
|
||||
- bodyclose
|
||||
- deadcode
|
||||
- dogsled
|
||||
- errcheck
|
||||
- gocyclo
|
||||
- goimports
|
||||
- goprintffuncname
|
||||
- gosec
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- interfacer
|
||||
- misspell
|
||||
- rowserrcheck
|
||||
- staticcheck
|
||||
- structcheck
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unused
|
||||
- varcheck
|
||||
- whitespace
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
- linters:
|
||||
- gosec
|
||||
text: "(G501|G401):"
|
||||
@@ -1,12 +1,10 @@
|
||||
# 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 resources -tags embed -ignore="\\\*.go" -pkg resources -o resources/embedded_gen.go resources/...
|
||||
- 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 +19,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 +32,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 +63,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 +77,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 +92,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 +107,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 +136,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:"
|
||||
|
||||
22
Dockerfile
22
Dockerfile
@@ -1,6 +1,6 @@
|
||||
#####################################################
|
||||
### Build UI bundles
|
||||
FROM node:13.12-alpine AS jsbuilder
|
||||
FROM node:13-alpine AS jsbuilder
|
||||
WORKDIR /src
|
||||
COPY ui/package.json ui/package-lock.json ./
|
||||
RUN npm ci
|
||||
@@ -17,11 +17,6 @@ RUN mkdir -p /src/ui/build
|
||||
RUN apk add -U --no-cache build-base git
|
||||
RUN go get -u github.com/go-bindata/go-bindata/...
|
||||
|
||||
# Download and unpack static ffmpeg
|
||||
ARG FFMPEG_URL=https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
|
||||
RUN wget -O /tmp/ffmpeg.tar.xz ${FFMPEG_URL}
|
||||
RUN cd /tmp && tar xJf ffmpeg.tar.xz && rm ffmpeg.tar.xz
|
||||
|
||||
# Download project dependencies
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
@@ -40,23 +35,19 @@ RUN GIT_TAG=$(git describe --tags `git rev-list --tags --max-count=1`) && \
|
||||
GIT_TAG=${GIT_TAG#"tags/"} && \
|
||||
GIT_SHA=$(git rev-parse --short HEAD) && \
|
||||
echo "Building version: ${GIT_TAG} (${GIT_SHA})" && \
|
||||
go-bindata -fs -prefix resources -tags embed -ignore="\\\*.go" -pkg resources -o resources/embedded_gen.go resources/...
|
||||
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=${GIT_TAG}" -tags=embed
|
||||
|
||||
#####################################################
|
||||
### 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 +63,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"]
|
||||
|
||||
28
Makefile
28
Makefile
@@ -2,6 +2,7 @@ GO_VERSION=$(shell grep -e "^go " go.mod | cut -f 2 -d ' ')
|
||||
NODE_VERSION=$(shell cat .nvmrc)
|
||||
|
||||
GIT_SHA=$(shell git rev-parse --short HEAD)
|
||||
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)
|
||||
|
||||
## Default target just build the Go project.
|
||||
default:
|
||||
@@ -9,7 +10,7 @@ default:
|
||||
.PHONY: default
|
||||
|
||||
dev: check_env
|
||||
@goreman -f Procfile.dev -b 4533 start
|
||||
npx foreman -j Procfile.dev -p 4533 start
|
||||
.PHONY: dev
|
||||
|
||||
server: check_go_env
|
||||
@@ -33,22 +34,20 @@ testall: check_go_env test
|
||||
@(cd ./ui && npm test -- --watchAll=false)
|
||||
.PHONY: testall
|
||||
|
||||
setup: Jamstash-master
|
||||
@which wire || (echo "Installing Wire" && GO111MODULE=off go get -u github.com/google/wire/cmd/wire)
|
||||
setup:
|
||||
@which go-bindata || (echo "Installing BinData" && GO111MODULE=off go get -u github.com/go-bindata/go-bindata/...)
|
||||
@which reflex || (echo "Installing Reflex" && GO111MODULE=off go get -u github.com/cespare/reflex)
|
||||
@which goreman || (echo "Installing Goreman" && GO111MODULE=off go get -u github.com/mattn/goreman)
|
||||
@which ginkgo || (echo "Installing Ginkgo" && GO111MODULE=off go get -u github.com/onsi/ginkgo/ginkgo)
|
||||
@which goose || (echo "Installing Goose" && GO111MODULE=off go get -u github.com/pressly/goose/cmd/goose)
|
||||
@which lefthook || (echo "Installing Lefthook" && GO111MODULE=off go get -u github.com/Arkweid/lefthook)
|
||||
@lefthook install
|
||||
go mod download
|
||||
@(cd ./ui && npm ci)
|
||||
.PHONY: setup
|
||||
|
||||
static:
|
||||
cd static && go-bindata -fs -prefix "static" -nocompress -ignore="\\\*.go" -pkg static .
|
||||
.PHONY: static
|
||||
setup-dev: setup
|
||||
@which wire || (echo "Installing Wire" && GO111MODULE=off go get -u github.com/google/wire/cmd/wire)
|
||||
@which ginkgo || (echo "Installing Ginkgo" && GO111MODULE=off go get -u github.com/onsi/ginkgo/ginkgo)
|
||||
@which goose || (echo "Installing Goose" && GO111MODULE=off go get -u github.com/pressly/goose/cmd/goose)
|
||||
@which lefthook || (echo "Installing Lefthook" && GO111MODULE=off go get -u github.com/Arkweid/lefthook)
|
||||
@lefthook install
|
||||
.PHONY: setup
|
||||
|
||||
Jamstash-master:
|
||||
wget -N https://github.com/tsquillario/Jamstash/archive/master.zip
|
||||
@@ -76,13 +75,14 @@ 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 "resources" -tags embed -ignore="\\\*.go" -pkg resources -o resources/embedded_gen.go resources/...
|
||||
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 +95,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
|
||||
|
||||
@@ -28,7 +28,7 @@ please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join
|
||||
- Automatically monitors your library for changes, importing new files and reloading new metadata
|
||||
- [Themeable](ui/src/themes/README.md), modern and responsive Web interface based on Material UI, to manage users and
|
||||
browse your library
|
||||
- Compatible with all Subsonic/Madsonic/Airsonic clients. See bellow for a list of tested clients
|
||||
- Compatible with all Subsonic/Madsonic/Airsonic clients. See below for a list of tested clients
|
||||
- Transcoding/Downsampling on-the-fly. Can be set per user/player. Opus encoding is supported
|
||||
- Integrated music player (WIP)
|
||||
|
||||
@@ -110,9 +110,9 @@ 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)
|
||||
the steps below only work with these specific versions (enforced in the Makefile)
|
||||
|
||||
After the prerequisites above are installed, clone this repository and build the application with:
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
)
|
||||
|
||||
@@ -14,7 +13,7 @@ var once sync.Once
|
||||
|
||||
func AssetFile() http.FileSystem {
|
||||
once.Do(func() {
|
||||
log.Warn("Using external assets from " + consts.UIAssetsLocalPath)
|
||||
log.Warn("Using external assets from 'ui/build' folder")
|
||||
})
|
||||
return http.Dir(consts.UIAssetsLocalPath)
|
||||
return http.Dir("ui/build")
|
||||
}
|
||||
|
||||
14
bin/fmt.sh
14
bin/fmt.sh
@@ -1,14 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
gofmtcmd=`which goimports || echo "gofmt"`
|
||||
|
||||
gofiles=$(git diff --name-only --diff-filter=ACM | grep '.go$')
|
||||
[ -z "$gofiles" ] && exit 0
|
||||
|
||||
unformatted=`$gofmtcmd -l $gofiles`
|
||||
[ -z "$unformatted" ] && exit 0
|
||||
|
||||
for f in $unformatted; do
|
||||
$gofmtcmd -w -l "$f"
|
||||
gofmt -s -w -l "$f"
|
||||
done
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2012 The Go Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style
|
||||
# license that can be found in the LICENSE file.
|
||||
|
||||
# git gofmt pre-commit hook
|
||||
#
|
||||
# To use, store as .git/hooks/pre-commit inside your repository and make sure
|
||||
# it has execute permissions.
|
||||
#
|
||||
# This script does not handle file names that contain spaces.
|
||||
|
||||
gofmtcmd=`which goimports || echo "gofmt"`
|
||||
|
||||
gofiles=$(git diff --cached --name-only --diff-filter=ACM | grep '.go$')
|
||||
[ -z "$gofiles" ] && exit 0
|
||||
|
||||
unformatted=$($gofmtcmd -l $gofiles)
|
||||
[ -z "$unformatted" ] && exit 0
|
||||
|
||||
# Some files are not gofmt'd. Print message and fail.
|
||||
|
||||
echo >&2 "Go files must be formatted with $gofmcmd. Please run:"
|
||||
for fn in $unformatted; do
|
||||
echo >&2 " $gofmtcmd -w $PWD/$fn"
|
||||
done
|
||||
|
||||
exit 1
|
||||
@@ -27,9 +27,10 @@ type nd struct {
|
||||
IgnoredArticles string `default:"The El La Los Las Le Les Os As O A"`
|
||||
IndexGroups string `default:"A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)"`
|
||||
|
||||
TranscodingCacheSize string `default:"100MB"` // in MB
|
||||
ImageCacheSize string `default:"100MB"` // in MB
|
||||
ProbeCommand string `default:"ffmpeg %s -f ffmetadata"`
|
||||
EnableTranscodingConfig bool `default:"false"`
|
||||
TranscodingCacheSize string `default:"100MB"` // in MB
|
||||
ImageCacheSize string `default:"100MB"` // in MB
|
||||
ProbeCommand string `default:"ffmpeg %s -f ffmetadata"`
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
DevLogSourceLine bool `default:"false"`
|
||||
|
||||
@@ -5,11 +5,11 @@ import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/deluan/navidrome/static"
|
||||
"github.com/deluan/navidrome/resources"
|
||||
)
|
||||
|
||||
func getBanner() string {
|
||||
data, _ := static.Asset("banner.txt")
|
||||
data, _ := resources.Asset("banner.txt")
|
||||
return strings.TrimRightFunc(string(data), unicode.IsSpace)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,13 +19,14 @@ const (
|
||||
JWTIssuer = "ND"
|
||||
DefaultSessionTimeout = 30 * time.Minute
|
||||
|
||||
UIAssetsLocalPath = "ui/build"
|
||||
|
||||
DevInitialUserName = "admin"
|
||||
DevInitialName = "Dev Admin"
|
||||
|
||||
URLPathUI = "/app"
|
||||
URLPathSubsonicAPI = "/rest"
|
||||
|
||||
RequestThrottleBacklogLimit = 100
|
||||
RequestThrottleBacklogTimeout = time.Minute
|
||||
)
|
||||
|
||||
// Cache options
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
# This file ususaly goes in /etc/systemd/system
|
||||
|
||||
[Unit]
|
||||
Description=Navidrome Daemon
|
||||
After=network.target
|
||||
Description=Navidrome Music Server and Streamer compatible with Subsonic/Airsonic
|
||||
After=remote-fs.target network.target
|
||||
AssertPathExists=/var/lib/navidrome
|
||||
|
||||
[Service]
|
||||
User=navidrome
|
||||
Group=navidrome
|
||||
Type=simple
|
||||
ExecStart=/opt/navidrome/navidrome
|
||||
WorkingDirectory=/opt/navidrome
|
||||
ExecStart=/usr/bin/navidrome
|
||||
WorkingDirectory=/var/lib/navidrome
|
||||
TimeoutStopSec=20
|
||||
KillMode=process
|
||||
Restart=on-failure
|
||||
|
||||
EnvironmentFile=-/etc/sysconfig/navidrome
|
||||
|
||||
# See https://www.freedesktop.org/software/systemd/man/systemd.exec.html
|
||||
DevicePolicy=closed
|
||||
NoNewPrivileges=yes
|
||||
@@ -26,10 +29,17 @@ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
RestrictNamespaces=yes
|
||||
RestrictRealtime=yes
|
||||
SystemCallFilter=~@clock @debug @module @mount @obsolete @privileged @reboot @setuid @swap
|
||||
ReadWritePaths=/opt/navidrome/
|
||||
PrivateDevices=yes
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/navidrome
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
# You can uncomment the following line if you're not using the jukebox This
|
||||
# will prevent navidrome from accessing any real (physical) devices
|
||||
#PrivateDevices=yes
|
||||
|
||||
# You can change the following line to `strict` instead of `full` if you don't
|
||||
# want navidrome to be able to write anything on your filesystem outside of
|
||||
# /var/lib/navidrome.
|
||||
ProtectSystem=full
|
||||
|
||||
# You can comment the following line if you don't have any media in /home/*.
|
||||
# This will prevent navidrome from ever reading/writing anything there.
|
||||
ProtectHome=true
|
||||
|
||||
@@ -51,6 +51,5 @@ create index annotation_starred
|
||||
}
|
||||
|
||||
func Down20200208222418(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -16,6 +16,5 @@ func Up20200310171621(tx *sql.Tx) error {
|
||||
}
|
||||
|
||||
func Down20200310171621(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -37,6 +37,5 @@ drop table if exists search;
|
||||
}
|
||||
|
||||
func Down20200319211049(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
@@ -75,6 +76,5 @@ create index album_max_year
|
||||
}
|
||||
|
||||
func Down20200327193744(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -25,6 +25,5 @@ create index if not exists media_file_track_number
|
||||
}
|
||||
|
||||
func Down20200404214704(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
|
||||
20
db/migration/20200418110522_reindex_to_fix_album_years.go
Normal file
20
db/migration/20200418110522_reindex_to_fix_album_years.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200418110522, Down20200418110522)
|
||||
}
|
||||
|
||||
func Up20200418110522(tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to fix search Albums by year")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200418110522(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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
|
||||
}
|
||||
65
db/migration/20200423204116_add_sort_fields.go
Normal file
65
db/migration/20200423204116_add_sort_fields.go
Normal file
@@ -0,0 +1,65 @@
|
||||
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
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -22,7 +23,7 @@ var (
|
||||
|
||||
func InitTokenAuth(ds model.DataStore) {
|
||||
once.Do(func() {
|
||||
secret, err := ds.Property(nil).DefaultGet(consts.JWTSecretKey, "not so secret")
|
||||
secret, err := ds.Property(context.TODO()).DefaultGet(consts.JWTSecretKey, "not so secret")
|
||||
if err != nil {
|
||||
log.Error("No JWT secret found in DB. Setting a temp one, but please report this error", err)
|
||||
}
|
||||
|
||||
@@ -80,10 +80,6 @@ func (b *browser) Artist(ctx context.Context, id string) (*DirectoryInfo, error)
|
||||
return nil, err
|
||||
}
|
||||
log.Debug(ctx, "Found Artist", "id", id, "name", a.Name)
|
||||
var albumIds []string
|
||||
for _, al := range albums {
|
||||
albumIds = append(albumIds, al.ID)
|
||||
}
|
||||
return b.buildArtistDir(a, albums), nil
|
||||
}
|
||||
|
||||
@@ -93,11 +89,6 @@ func (b *browser) Album(ctx context.Context, id string) (*DirectoryInfo, error)
|
||||
return nil, err
|
||||
}
|
||||
log.Debug(ctx, "Found Album", "id", id, "name", al.Name)
|
||||
var mfIds []string
|
||||
for _, mf := range tracks {
|
||||
mfIds = append(mfIds, mf.ID)
|
||||
}
|
||||
|
||||
return b.buildAlbumDir(al, tracks), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/static"
|
||||
"github.com/deluan/navidrome/resources"
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/djherbis/fscache"
|
||||
@@ -74,7 +74,9 @@ func (c *cover) Get(ctx context.Context, id string, size int, out io.Writer) err
|
||||
log.Error(ctx, "Error loading cover art", "path", path, "size", size, err)
|
||||
return
|
||||
}
|
||||
io.Copy(w, reader)
|
||||
if _, err := io.Copy(w, reader); err != nil {
|
||||
log.Error(ctx, "Error saving covert art to cache", "path", path, "size", size, err)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
log.Trace(ctx, "Loading image from cache", "path", path, "size", size, "lastUpdate", lastUpdate)
|
||||
@@ -120,7 +122,7 @@ func (c *cover) getCover(ctx context.Context, path string, size int) (reader io.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error extracting image", "path", path, "size", size, err)
|
||||
reader, err = static.AssetFile().Open("navidrome-310x310.png")
|
||||
reader, err = resources.AssetFile().Open("navidrome-310x310.png")
|
||||
}
|
||||
}()
|
||||
var data []byte
|
||||
|
||||
@@ -2,6 +2,7 @@ package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
@@ -14,7 +15,7 @@ import (
|
||||
var _ = Describe("Cover", func() {
|
||||
var cover Cover
|
||||
var ds model.DataStore
|
||||
ctx := log.NewContext(nil)
|
||||
ctx := log.NewContext(context.TODO())
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &persistence.MockDataStore{MockedTranscoding: &mockTranscodingRepository{}}
|
||||
|
||||
@@ -9,88 +9,108 @@ 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
|
||||
}
|
||||
|
||||
func (g *listGenerator) query(ctx context.Context, qo model.QueryOptions) (Entries, error) {
|
||||
albums, err := g.ds.Album(ctx).GetAll(qo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
albumIds := make([]string, len(albums))
|
||||
for i, al := range albums {
|
||||
albumIds[i] = al.ID
|
||||
}
|
||||
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 +118,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)
|
||||
@@ -126,16 +158,6 @@ func (g *listGenerator) GetAllStarred(ctx context.Context) (artists Entries, alb
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
var mfIds []string
|
||||
for _, mf := range mfs {
|
||||
mfIds = append(mfIds, mf.ID)
|
||||
}
|
||||
|
||||
var artistIds []string
|
||||
for _, ar := range ars {
|
||||
artistIds = append(artistIds, ar.ID)
|
||||
}
|
||||
|
||||
artists = FromArtists(ars)
|
||||
albums = FromAlbums(als)
|
||||
mediaFiles = FromMediaFiles(mfs)
|
||||
@@ -156,10 +178,9 @@ func (g *listGenerator) GetNowPlaying(ctx context.Context) (Entries, error) {
|
||||
}
|
||||
entries[i] = FromMediaFile(mf)
|
||||
entries[i].UserName = np.Username
|
||||
entries[i].MinutesAgo = int(time.Now().Sub(np.Start).Minutes())
|
||||
entries[i].MinutesAgo = int(time.Since(np.Start).Minutes())
|
||||
entries[i].PlayerId = np.PlayerId
|
||||
entries[i].PlayerName = np.PlayerName
|
||||
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ var _ = Describe("MediaStreamer", func() {
|
||||
var streamer MediaStreamer
|
||||
var ds model.DataStore
|
||||
ffmpeg := &fakeFFmpeg{Data: "fake data"}
|
||||
ctx := log.NewContext(nil)
|
||||
ctx := log.NewContext(context.TODO())
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &persistence.MockDataStore{MockedTranscoding: &mockTranscodingRepository{}}
|
||||
|
||||
@@ -110,7 +110,7 @@ func checkExpired(l *list.List, f func() *list.Element) *list.Element {
|
||||
return nil
|
||||
}
|
||||
start := e.Value.(*NowPlayingInfo).Start
|
||||
if time.Now().Sub(start) < NowPlayingExpire {
|
||||
if time.Since(start) < NowPlayingExpire {
|
||||
return e
|
||||
}
|
||||
l.Remove(e)
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
var _ = Describe("Players", func() {
|
||||
var players Players
|
||||
var repo *mockPlayerRepository
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid", UserName: "johndoe"})
|
||||
ctx := context.WithValue(log.NewContext(context.TODO()), "user", model.User{ID: "userid", UserName: "johndoe"})
|
||||
ctx = context.WithValue(ctx, "username", "johndoe")
|
||||
var beforeRegister time.Time
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ func (p *playlists) Delete(ctx context.Context, playlistId string) error {
|
||||
if owner != pls.Owner {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
return p.ds.Playlist(nil).Delete(playlistId)
|
||||
return p.ds.Playlist(ctx).Delete(playlistId)
|
||||
}
|
||||
|
||||
func (p *playlists) Update(ctx context.Context, playlistId string, name *string, idsToAdd []string, idxToRemove []int) error {
|
||||
|
||||
@@ -2,7 +2,6 @@ package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -57,7 +56,7 @@ func (s *scrobbler) NowPlaying(ctx context.Context, playerId int, playerName, tr
|
||||
}
|
||||
|
||||
if mf == nil {
|
||||
return nil, errors.New(fmt.Sprintf(`ID "%s" not found`, trackId))
|
||||
return nil, fmt.Errorf(`ID "%s" not found`, trackId)
|
||||
}
|
||||
|
||||
log.Info("Now Playing", "title", mf.Title, "artist", mf.Artist, "user", userName(ctx))
|
||||
|
||||
@@ -30,7 +30,7 @@ func (ff *ffmpeg) Start(ctx context.Context, command, path string, maxBitRate in
|
||||
args := createTranscodeCommand(command, path, maxBitRate)
|
||||
|
||||
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd := exec.Command(args[0], args[1:]...) // #nosec
|
||||
cmd.Stderr = os.Stderr
|
||||
if f, err = cmd.StdoutPipe(); err != nil {
|
||||
return
|
||||
@@ -38,7 +38,9 @@ func (ff *ffmpeg) Start(ctx context.Context, command, path string, maxBitRate in
|
||||
if err = cmd.Start(); err != nil {
|
||||
return
|
||||
}
|
||||
go cmd.Wait() // prevent zombies
|
||||
|
||||
go func() { _ = cmd.Wait() }() // prevent zombies
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
8
go.mod
8
go.mod
@@ -4,18 +4,18 @@ go 1.14
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v0.3.1 // indirect
|
||||
github.com/Masterminds/squirrel v1.2.0
|
||||
github.com/Masterminds/squirrel v1.3.0
|
||||
github.com/astaxie/beego v1.12.1
|
||||
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible
|
||||
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||
github.com/dhowden/tag v0.0.0-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
|
||||
)
|
||||
|
||||
16
go.sum
16
go.sum
@@ -1,8 +1,8 @@
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/Knetic/govaluate v3.0.0+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
|
||||
github.com/Masterminds/squirrel v1.2.0 h1:K1NhbTO21BWG47IVR0OnIZuE0LZcXAYqywrC3Ko53KI=
|
||||
github.com/Masterminds/squirrel v1.2.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
|
||||
github.com/Masterminds/squirrel v1.3.0 h1:1HYpGMHYd/F3zQlbF8+G006xW8VZdiJw5U7ULvQIt5M=
|
||||
github.com/Masterminds/squirrel v1.3.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
|
||||
github.com/OwnLocal/goes v1.0.0/go.mod h1:8rIFjBGTue3lCU0wplczcUgt9Gxgrkkrw7etMIcn8TM=
|
||||
github.com/astaxie/beego v1.12.1 h1:dfpuoxpzLVgclveAXe4PyNKqkzgm5zF4tgF2B3kkM2I=
|
||||
github.com/astaxie/beego v1.12.1/go.mod h1:kPBWpSANNbSdIqOc8SUL9h+1oyBMZhROeYsXQDbidWQ=
|
||||
@@ -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=
|
||||
|
||||
@@ -180,6 +180,7 @@ func extractLogger(ctx interface{}) (*logrus.Entry, error) {
|
||||
if logger != nil {
|
||||
return logger.(*logrus.Entry), nil
|
||||
}
|
||||
return extractLogger(NewContext(ctx))
|
||||
case *http.Request:
|
||||
return extractLogger(ctx.Context())
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@ var _ = Describe("Logger", func() {
|
||||
Expect(hook.LastEntry().Data).To(BeEmpty())
|
||||
})
|
||||
|
||||
XIt("Empty context", func() {
|
||||
Error(context.Background(), "Simple Message")
|
||||
It("Empty context", func() {
|
||||
Error(context.TODO(), "Simple Message")
|
||||
Expect(hook.LastEntry().Message).To(Equal("Simple Message"))
|
||||
Expect(hook.LastEntry().Data).To(BeEmpty())
|
||||
})
|
||||
@@ -70,7 +70,7 @@ var _ = Describe("Logger", func() {
|
||||
})
|
||||
|
||||
It("can get data from the request's context", func() {
|
||||
ctx := NewContext(nil, "foo", "bar")
|
||||
ctx := NewContext(context.TODO(), "foo", "bar")
|
||||
req := httptest.NewRequest("get", "/", nil).WithContext(ctx)
|
||||
|
||||
Error(req, "Simple Message", "key1", "value1")
|
||||
|
||||
@@ -3,23 +3,28 @@ package model
|
||||
import "time"
|
||||
|
||||
type Album struct {
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
CoverArtPath string `json:"coverArtPath"`
|
||||
CoverArtId string `json:"coverArtId"`
|
||||
ArtistID string `json:"artistId" orm:"pk;column(artist_id)"`
|
||||
Artist string `json:"artist"`
|
||||
AlbumArtistID string `json:"albumArtistId" orm:"pk;column(album_artist_id)"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
MaxYear int `json:"maxYear"`
|
||||
MinYear int `json:"minYear"`
|
||||
Compilation bool `json:"compilation"`
|
||||
SongCount int `json:"songCount"`
|
||||
Duration float32 `json:"duration"`
|
||||
Genre string `json:"genre"`
|
||||
FullText string `json:"fullText"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
CoverArtPath string `json:"coverArtPath"`
|
||||
CoverArtId string `json:"coverArtId"`
|
||||
ArtistID string `json:"artistId" orm:"pk;column(artist_id)"`
|
||||
Artist string `json:"artist"`
|
||||
AlbumArtistID string `json:"albumArtistId" orm:"pk;column(album_artist_id)"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
MaxYear int `json:"maxYear"`
|
||||
MinYear int `json:"minYear"`
|
||||
Compilation bool `json:"compilation"`
|
||||
SongCount int `json:"songCount"`
|
||||
Duration float32 `json:"duration"`
|
||||
Genre string `json:"genre"`
|
||||
FullText string `json:"fullText"`
|
||||
SortAlbumName string `json:"sortAlbumName"`
|
||||
SortArtistName string `json:"sortArtistName"`
|
||||
SortAlbumArtistName string `json:"sortAlbumArtistName"`
|
||||
OrderAlbumName string `json:"orderAlbumName"`
|
||||
OrderAlbumArtistName string `json:"orderAlbumArtistName"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
// Annotations
|
||||
PlayCount int `json:"playCount" orm:"-"`
|
||||
|
||||
@@ -3,10 +3,12 @@ package model
|
||||
import "time"
|
||||
|
||||
type Artist struct {
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
AlbumCount int `json:"albumCount" orm:"column(album_count)"`
|
||||
FullText string `json:"fullText"`
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
AlbumCount int `json:"albumCount" orm:"column(album_count)"`
|
||||
FullText string `json:"fullText"`
|
||||
SortArtistName string `json:"sortArtistName"`
|
||||
OrderArtistName string `json:"orderArtistName"`
|
||||
|
||||
// Annotations
|
||||
PlayCount int `json:"playCount" orm:"-"`
|
||||
|
||||
@@ -6,28 +6,35 @@ import (
|
||||
)
|
||||
|
||||
type MediaFile struct {
|
||||
ID string `json:"id" orm:"pk;column(id)"`
|
||||
Path string `json:"path"`
|
||||
Title string `json:"title"`
|
||||
Album string `json:"album"`
|
||||
ArtistID string `json:"artistId" orm:"pk;column(artist_id)"`
|
||||
Artist string `json:"artist"`
|
||||
AlbumArtistID string `json:"albumArtistId"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
AlbumID string `json:"albumId" orm:"pk;column(album_id)"`
|
||||
HasCoverArt bool `json:"hasCoverArt"`
|
||||
TrackNumber int `json:"trackNumber"`
|
||||
DiscNumber int `json:"discNumber"`
|
||||
Year int `json:"year"`
|
||||
Size int `json:"size"`
|
||||
Suffix string `json:"suffix"`
|
||||
Duration float32 `json:"duration"`
|
||||
BitRate int `json:"bitRate"`
|
||||
Genre string `json:"genre"`
|
||||
FullText string `json:"fullText"`
|
||||
Compilation bool `json:"compilation"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id" orm:"pk;column(id)"`
|
||||
Path string `json:"path"`
|
||||
Title string `json:"title"`
|
||||
Album string `json:"album"`
|
||||
ArtistID string `json:"artistId" orm:"pk;column(artist_id)"`
|
||||
Artist string `json:"artist"`
|
||||
AlbumArtistID string `json:"albumArtistId"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
AlbumID string `json:"albumId" orm:"pk;column(album_id)"`
|
||||
HasCoverArt bool `json:"hasCoverArt"`
|
||||
TrackNumber int `json:"trackNumber"`
|
||||
DiscNumber int `json:"discNumber"`
|
||||
Year int `json:"year"`
|
||||
Size int `json:"size"`
|
||||
Suffix string `json:"suffix"`
|
||||
Duration float32 `json:"duration"`
|
||||
BitRate int `json:"bitRate"`
|
||||
Genre string `json:"genre"`
|
||||
FullText string `json:"fullText"`
|
||||
SortTitle string `json:"sortTitle"`
|
||||
SortAlbumName string `json:"sortAlbumName"`
|
||||
SortArtistName string `json:"sortArtistName"`
|
||||
SortAlbumArtistName string `json:"sortAlbumArtistName"`
|
||||
OrderAlbumName string `json:"orderAlbumName"`
|
||||
OrderArtistName string `json:"orderArtistName"`
|
||||
OrderAlbumArtistName string `json:"orderAlbumArtistName"`
|
||||
Compilation bool `json:"compilation"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
// Annotations
|
||||
PlayCount int `json:"playCount" orm:"-"`
|
||||
@@ -48,6 +55,7 @@ type MediaFileRepository interface {
|
||||
Exists(id string) (bool, error)
|
||||
Put(m *MediaFile) error
|
||||
Get(id string) (*MediaFile, error)
|
||||
GetAll(options ...QueryOptions) (MediaFiles, error)
|
||||
FindByAlbum(albumId string) (MediaFiles, error)
|
||||
FindByPath(path string) (MediaFiles, error)
|
||||
GetStarred(options ...QueryOptions) (MediaFiles, error)
|
||||
|
||||
@@ -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{
|
||||
@@ -48,7 +51,7 @@ func yearFilter(field string, value interface{}) Sqlizer {
|
||||
}
|
||||
|
||||
func artistFilter(field string, value interface{}) Sqlizer {
|
||||
return Exists("media_file", And{
|
||||
return exists("media_file", And{
|
||||
ConcatExpr("album_id=album.id"),
|
||||
Or{
|
||||
Eq{"artist_id": value},
|
||||
@@ -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)
|
||||
|
||||
@@ -14,13 +14,13 @@ var _ = Describe("AlbumRepository", func() {
|
||||
var repo model.AlbumRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
|
||||
ctx := context.WithValue(log.NewContext(context.TODO()), "user", model.User{ID: "userid"})
|
||||
repo = NewAlbumRepository(ctx, orm.NewOrm())
|
||||
})
|
||||
|
||||
Describe("Get", func() {
|
||||
It("returns an existent album", func() {
|
||||
Expect(repo.Get("3")).To(Equal(&albumRadioactivity))
|
||||
Expect(repo.Get("103")).To(Equal(&albumRadioactivity))
|
||||
})
|
||||
It("returns ErrNotFound when the album does not exist", func() {
|
||||
_, err := repo.Get("666")
|
||||
@@ -73,4 +73,16 @@ var _ = Describe("AlbumRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("getMinYear", func() {
|
||||
It("returns 0 when there's no valid year", func() {
|
||||
Expect(getMinYear("a b c")).To(Equal(0))
|
||||
Expect(getMinYear("")).To(Equal(0))
|
||||
})
|
||||
It("returns 0 when all values are 0", func() {
|
||||
Expect(getMinYear("0 0 0 ")).To(Equal(0))
|
||||
})
|
||||
It("returns the smallest value from the list", func() {
|
||||
Expect(getMinYear("2000 0 1800")).To(Equal(1800))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,9 @@ func NewArtistRepository(ctx context.Context, o orm.Ormer) model.ArtistRepositor
|
||||
r.ormer = o
|
||||
r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups)
|
||||
r.tableName = "artist"
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "order_artist_name",
|
||||
}
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"name": fullTextFilter,
|
||||
}
|
||||
@@ -44,19 +47,8 @@ func (r *artistRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(Select().Where(Eq{"id": id}))
|
||||
}
|
||||
|
||||
func (r *artistRepository) getIndexKey(a *model.Artist) string {
|
||||
name := strings.ToLower(utils.NoArticle(a.Name))
|
||||
for k, v := range r.indexGroups {
|
||||
key := strings.ToLower(k)
|
||||
if strings.HasPrefix(name, key) {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return "#"
|
||||
}
|
||||
|
||||
func (r *artistRepository) Put(a *model.Artist) error {
|
||||
a.FullText = r.getFullText(a.Name)
|
||||
a.FullText = getFullText(a.Name, a.SortArtistName)
|
||||
_, err := r.put(a.ID, a)
|
||||
return err
|
||||
}
|
||||
@@ -75,11 +67,21 @@ func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists,
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *artistRepository) getIndexKey(a *model.Artist) string {
|
||||
name := strings.ToLower(utils.NoArticle(a.Name))
|
||||
for k, v := range r.indexGroups {
|
||||
key := strings.ToLower(k)
|
||||
if strings.HasPrefix(name, key) {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return "#"
|
||||
}
|
||||
|
||||
// TODO Cache the index (recalculate when there are changes to the DB)
|
||||
func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
|
||||
sq := r.selectArtist().OrderBy("name")
|
||||
sq := r.selectArtist().OrderBy("order_artist_name")
|
||||
var all model.Artists
|
||||
// TODO Paginate
|
||||
err := r.queryAll(sq, &all)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -111,7 +113,9 @@ func (r *artistRepository) Refresh(ids ...string) error {
|
||||
CurrentId string
|
||||
}
|
||||
var artists []refreshArtist
|
||||
sel := Select("f.album_artist_id as id", "f.album_artist as name", "count(*) as album_count", "a.id as current_id").
|
||||
sel := Select("f.album_artist_id as id", "f.album_artist as name", "count(*) as album_count", "a.id as current_id",
|
||||
"f.sort_album_artist_name as sort_artist_name",
|
||||
"f.order_album_artist_name as order_artist_name").
|
||||
From("album f").
|
||||
LeftJoin("artist a on f.album_artist_id = a.id").
|
||||
Where(Eq{"f.album_artist_id": ids}).
|
||||
|
||||
@@ -14,7 +14,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
var repo model.ArtistRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
|
||||
ctx := context.WithValue(log.NewContext(context.TODO()), "user", model.User{ID: "userid"})
|
||||
repo = NewArtistRepository(ctx, orm.NewOrm())
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package persistence_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
@@ -13,7 +15,7 @@ var _ = Describe("GenreRepository", func() {
|
||||
var repo model.GenreRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = persistence.NewGenreRepository(log.NewContext(nil), orm.NewOrm())
|
||||
repo = persistence.NewGenreRepository(log.NewContext(context.TODO()), orm.NewOrm())
|
||||
})
|
||||
|
||||
It("returns all records", func() {
|
||||
|
||||
@@ -39,16 +39,16 @@ func toSnakeCase(str string) string {
|
||||
return strings.ToLower(snake)
|
||||
}
|
||||
|
||||
func Exists(subTable string, cond squirrel.Sqlizer) exists {
|
||||
return exists{subTable: subTable, cond: cond}
|
||||
func exists(subTable string, cond squirrel.Sqlizer) existsCond {
|
||||
return existsCond{subTable: subTable, cond: cond}
|
||||
}
|
||||
|
||||
type exists struct {
|
||||
type existsCond struct {
|
||||
subTable string
|
||||
cond squirrel.Sqlizer
|
||||
}
|
||||
|
||||
func (e exists) ToSql() (string, []interface{}, error) {
|
||||
func (e existsCond) ToSql() (string, []interface{}, error) {
|
||||
sql, args, err := e.cond.ToSql()
|
||||
sql = fmt.Sprintf("exists (select 1 from %s where %s)", e.subTable, sql)
|
||||
return sql, args, err
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
var _ = Describe("Helpers", func() {
|
||||
Describe("Exists", func() {
|
||||
It("constructs the correct EXISTS query", func() {
|
||||
e := Exists("album", squirrel.Eq{"id": 1})
|
||||
e := exists("album", squirrel.Eq{"id": 1})
|
||||
sql, args, err := e.ToSql()
|
||||
Expect(sql).To(Equal("exists (select 1 from album where id = ?)"))
|
||||
Expect(args).To(Equal([]interface{}{1}))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -16,12 +16,12 @@ var _ = Describe("MediaRepository", func() {
|
||||
var mr model.MediaFileRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
|
||||
ctx := context.WithValue(log.NewContext(context.TODO()), "user", model.User{ID: "userid"})
|
||||
mr = NewMediaFileRepository(ctx, orm.NewOrm())
|
||||
})
|
||||
|
||||
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,
|
||||
}))
|
||||
|
||||
@@ -22,8 +22,7 @@ func TestPersistence(t *testing.T) {
|
||||
//os.Remove("./test-123.db")
|
||||
//conf.Server.Path = "./test-123.db"
|
||||
conf.Server.DbPath = "file::memory:?cache=shared"
|
||||
orm.RegisterDataBase("default", db.Driver, conf.Server.DbPath)
|
||||
New()
|
||||
_ = orm.RegisterDataBase("default", db.Driver, conf.Server.DbPath)
|
||||
db.EnsureLatestVersion()
|
||||
log.SetLevel(log.LevelCritical)
|
||||
RegisterFailHandler(Fail)
|
||||
@@ -31,8 +30,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 +39,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 +50,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 +69,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}
|
||||
)
|
||||
|
||||
@@ -85,7 +84,7 @@ var _ = Describe("Initialize test DB", func() {
|
||||
// TODO Load this data setup from file(s)
|
||||
BeforeSuite(func() {
|
||||
o := orm.NewOrm()
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
|
||||
ctx := context.WithValue(log.NewContext(context.TODO()), "user", model.User{ID: "userid"})
|
||||
mr := NewMediaFileRepository(ctx, o)
|
||||
for _, s := range testSongs {
|
||||
err := mr.Put(&s)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
@@ -12,7 +14,7 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
var repo model.PlaylistRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = NewPlaylistRepository(log.NewContext(nil), orm.NewOrm())
|
||||
repo = NewPlaylistRepository(log.NewContext(context.TODO()), orm.NewOrm())
|
||||
})
|
||||
|
||||
Describe("Count", func() {
|
||||
@@ -63,19 +65,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())
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
. "github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -12,7 +14,7 @@ var _ = Describe("Property Repository", func() {
|
||||
var pr model.PropertyRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
pr = NewPropertyRepository(NewContext(nil), orm.NewOrm())
|
||||
pr = NewPropertyRepository(log.NewContext(context.TODO()), orm.NewOrm())
|
||||
})
|
||||
|
||||
It("saves and restore a new property", func() {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/kennygrant/sanitize"
|
||||
)
|
||||
|
||||
type filterFunc = func(field string, value interface{}) Sqlizer
|
||||
@@ -59,15 +58,11 @@ func booleanFilter(field string, value interface{}) Sqlizer {
|
||||
}
|
||||
|
||||
func fullTextFilter(field string, value interface{}) Sqlizer {
|
||||
q := value.(string)
|
||||
q = strings.TrimSpace(sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*"))))
|
||||
q := sanitizeStrings(value.(string))
|
||||
parts := strings.Split(q, " ")
|
||||
filters := And{}
|
||||
for _, part := range parts {
|
||||
filters = append(filters, Or{
|
||||
Like{"full_text": part + "%"},
|
||||
Like{"full_text": "%" + part + "%"},
|
||||
})
|
||||
filters = append(filters, Like{"full_text": "% " + part + "%"})
|
||||
}
|
||||
return filters
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -8,7 +9,14 @@ import (
|
||||
"github.com/kennygrant/sanitize"
|
||||
)
|
||||
|
||||
func (r sqlRepository) getFullText(text ...string) string {
|
||||
var quotesRegex = regexp.MustCompile("[“”‘’'\"]")
|
||||
|
||||
func getFullText(text ...string) string {
|
||||
fullText := sanitizeStrings(text...)
|
||||
return " " + fullText
|
||||
}
|
||||
|
||||
func sanitizeStrings(text ...string) string {
|
||||
sanitizedText := strings.Builder{}
|
||||
for _, txt := range text {
|
||||
sanitizedText.WriteString(strings.TrimSpace(sanitize.Accents(strings.ToLower(txt))) + " ")
|
||||
@@ -19,14 +27,18 @@ func (r sqlRepository) getFullText(text ...string) string {
|
||||
}
|
||||
var fullText []string
|
||||
for w := range words {
|
||||
fullText = append(fullText, w)
|
||||
w = quotesRegex.ReplaceAllString(w, "")
|
||||
if w != "" {
|
||||
fullText = append(fullText, w)
|
||||
}
|
||||
}
|
||||
sort.Strings(fullText)
|
||||
return strings.Join(fullText, " ")
|
||||
}
|
||||
|
||||
func (r sqlRepository) doSearch(q string, offset, size int, results interface{}, orderBys ...string) error {
|
||||
q = strings.TrimSpace(sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*"))))
|
||||
q = strings.TrimSuffix(q, "*")
|
||||
q = sanitizeStrings(q)
|
||||
if len(q) < 2 {
|
||||
return nil
|
||||
}
|
||||
@@ -37,10 +49,7 @@ func (r sqlRepository) doSearch(q string, offset, size int, results interface{},
|
||||
}
|
||||
parts := strings.Split(q, " ")
|
||||
for _, part := range parts {
|
||||
sq = sq.Where(Or{
|
||||
Like{"full_text": part + "%"},
|
||||
Like{"full_text": "%" + part + "%"},
|
||||
})
|
||||
sq = sq.Where(Like{"full_text": "% " + part + "%"})
|
||||
}
|
||||
err := r.queryAll(sq, results)
|
||||
return err
|
||||
|
||||
@@ -6,23 +6,25 @@ import (
|
||||
)
|
||||
|
||||
var _ = Describe("sqlRepository", func() {
|
||||
var sqlRepository = &sqlRepository{}
|
||||
|
||||
Describe("getFullText", func() {
|
||||
It("returns all lowercase chars", func() {
|
||||
Expect(sqlRepository.getFullText("Some Text")).To(Equal("some text"))
|
||||
Expect(getFullText("Some Text")).To(Equal(" some text"))
|
||||
})
|
||||
|
||||
It("removes accents", func() {
|
||||
Expect(sqlRepository.getFullText("Quintão")).To(Equal("quintao"))
|
||||
Expect(getFullText("Quintão")).To(Equal(" quintao"))
|
||||
})
|
||||
|
||||
It("remove extra spaces", func() {
|
||||
Expect(sqlRepository.getFullText(" some text ")).To(Equal("some text"))
|
||||
Expect(getFullText(" some text ")).To(Equal(" some text"))
|
||||
})
|
||||
|
||||
It("remove duplicated words", func() {
|
||||
Expect(sqlRepository.getFullText("legião urbana urbana legiÃo")).To(Equal("legiao urbana"))
|
||||
Expect(getFullText("legião urbana urbana legiÃo")).To(Equal(" legiao urbana"))
|
||||
})
|
||||
|
||||
It("remove symbols", func() {
|
||||
Expect(getFullText("Tom’s Diner ' “40” ‘A’")).To(Equal(" 40 a diner toms"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
@@ -12,7 +14,7 @@ var _ = Describe("UserRepository", func() {
|
||||
var repo model.UserRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = NewUserRepository(log.NewContext(nil), orm.NewOrm())
|
||||
repo = NewUserRepository(log.NewContext(context.TODO()), orm.NewOrm())
|
||||
})
|
||||
|
||||
Describe("Put/Get/FindByUsername", func() {
|
||||
|
||||
@@ -1 +1 @@
|
||||
-s -r "(\.go$$|navidrome.toml)" -R "(Jamstash-master|^ui|^data)" -- go run .
|
||||
-s -r "(\.go$$|navidrome.toml|resources)" -R "(Jamstash-master|^ui|^data)" -- go run .
|
||||
|
||||
28
resources/external.go
Normal file
28
resources/external.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// +build !embed
|
||||
|
||||
package resources
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
)
|
||||
|
||||
var once sync.Once
|
||||
|
||||
func Asset(filePath string) ([]byte, error) {
|
||||
f, err := AssetFile().Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ioutil.ReadAll(f)
|
||||
}
|
||||
|
||||
func AssetFile() http.FileSystem {
|
||||
once.Do(func() {
|
||||
log.Warn("Using external resources from 'resources' folder")
|
||||
})
|
||||
return http.Dir("resources")
|
||||
}
|
||||
256
resources/i18n/de.json
Normal file
256
resources/i18n/de.json
Normal file
@@ -0,0 +1,256 @@
|
||||
{
|
||||
"languageName": "Deutsch",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Song |||| Songs",
|
||||
"fields": {
|
||||
"albumArtist": "Albuminterpret",
|
||||
"duration": "Dauer",
|
||||
"trackNumber": "Titel #",
|
||||
"playCount": "Aufrufe",
|
||||
"title": "Titel",
|
||||
"artist": "Künstler",
|
||||
"album": "Album",
|
||||
"path": "Dateipfad",
|
||||
"genre": "Genre",
|
||||
"compilation": "Kompilation",
|
||||
"year": "Jahr",
|
||||
"size": "Dateigröße",
|
||||
"updatedAt": "Hochgeladen um"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Später abspielen",
|
||||
"playNow": "Jetzt abspielen"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Album |||| Alben",
|
||||
"fields": {
|
||||
"albumArtist": "Albuminterpret",
|
||||
"artist": "Interpret",
|
||||
"duration": "Dauer",
|
||||
"songCount": "Songanzahl",
|
||||
"playCount": "Aufrufe",
|
||||
"name": "Name",
|
||||
"genre": "Genre",
|
||||
"compilation": "Kompilation",
|
||||
"year": "Jahr"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Abspielen",
|
||||
"playNext": "Als nächstes abspielen",
|
||||
"addToQueue": "Später abspielen",
|
||||
"shuffle": "Zufallswiedergabe"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Interpret |||| Interpreten",
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"albumCount": "Albumanzahl"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Nutzer |||| Nutzer",
|
||||
"fields": {
|
||||
"userName": "Nutzername",
|
||||
"isAdmin": "Ist Admin",
|
||||
"lastLoginAt": "Letzer Login um",
|
||||
"updatedAt": "Aktualisiert um",
|
||||
"name": "Name"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "Player |||| Players",
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"transcodingId": "Transkodierungs-ID",
|
||||
"maxBitRate": "Max. Bitrate",
|
||||
"client": "Client",
|
||||
"userName": "Nutzername",
|
||||
"lastSeen": "Zuletzt gesehen um"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Transcodierung |||| Transcodierungen",
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"targetFormat": "Zielformat",
|
||||
"defaultBitRate": "Standardbitrate",
|
||||
"command": "Befehl"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Vielen Dank für die Installation von Navidrome!",
|
||||
"welcome2": "Als erstes erstelle einen Admin-Benutzer",
|
||||
"confirmPassword": "Passwort bestätigen",
|
||||
"buttonCreateAdmin": "Admin erstellen",
|
||||
"auth_check_error": "Bitte einloggen um fortzufahren",
|
||||
"user_menu": "Profil",
|
||||
"username": "Nutzername",
|
||||
"password": "Passwort",
|
||||
"sign_in": "Anmelden",
|
||||
"sign_in_error": "Fehler bei der Anmeldung",
|
||||
"logout": "Abmelden"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Bitte nur Buchstaben und Zahlen verwenden",
|
||||
"passwordDoesNotMatch": "Passwort stimmt nicht überein",
|
||||
"required": "Benötigt",
|
||||
"minLength": "Muss mindestens %{min} Zeichen lang sein",
|
||||
"maxLength": "Darf maximal %{max} Zeichen lang sein",
|
||||
"minValue": "Muss mindestens %{min} sein",
|
||||
"maxValue": "Muss %{max} oder weniger sein",
|
||||
"number": "Muss eine Nummer sein",
|
||||
"email": "Muss eine gültige E-Mail sein",
|
||||
"oneOf": "Es muss einer sein von: %{options}",
|
||||
"regex": "Es muss folgendem regulären Ausdruck entsprechen: %{pattern}"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Filter hinzufügen",
|
||||
"add": "Neu",
|
||||
"back": "Zurück",
|
||||
"bulk_actions": "Ein Element ausgewählt |||| %{smart_count} Elemente ausgewählt",
|
||||
"cancel": "Abbrechen",
|
||||
"clear_input_value": "Eingabe löschen",
|
||||
"clone": "Klonen",
|
||||
"confirm": "Bestätigen",
|
||||
"create": "Erstellen",
|
||||
"delete": "Löschen",
|
||||
"edit": "Bearbeiten",
|
||||
"export": "Exportieren",
|
||||
"list": "Liste",
|
||||
"refresh": "Aktualisieren",
|
||||
"remove_filter": "Filter entfernen",
|
||||
"remove": "Entfernen",
|
||||
"save": "Speichern",
|
||||
"search": "Suchen",
|
||||
"show": "Anzeigen",
|
||||
"sort": "Sortieren",
|
||||
"undo": "Zurücksetzen",
|
||||
"expand": "Expandieren",
|
||||
"close": "Schließen",
|
||||
"open_menu": "Menü öffnen",
|
||||
"close_menu": "Menü schließen"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Ja",
|
||||
"false": "Nein"
|
||||
},
|
||||
"page": {
|
||||
"create": "%{name} erstellen",
|
||||
"dashboard": "Dashboard",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Etwas ist schief gelaufen",
|
||||
"list": "%{name}",
|
||||
"loading": "Laden",
|
||||
"not_found": "Nicht gefunden",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Noch kein %{name}.\n",
|
||||
"invite": "Möchten du eine hinzufügen?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Zum Hochladen Dateien hineinziehen oder hier klicken, um Dateien auszuwählen.",
|
||||
"upload_single": "Zum Hochladen Datei hineinziehen oder hier klicken, um eine Datei auszuwählen."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Zum Hochladen Bilder hineinziehen oder hier klicken, um Bilder auszuwählen.",
|
||||
"upload_single": "Zum Hochladen Bild hineinziehen oder hier klicken, um ein Bild auszuwählen."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "Die zugehörigen Referenzen konnten nicht gefunden werden.",
|
||||
"many_missing": "Mindestens eine der zugehörigen Referenzen scheint nicht mehr verfügbar zu sein.",
|
||||
"single_missing": "Eine zugehörige Referenz scheint nicht mehr verfügbar zu sein."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "Passwort verbergen",
|
||||
"toggle_hidden": "Passwort anzeigen"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "Über",
|
||||
"are_you_sure": "Bist du sicher?",
|
||||
"bulk_delete_content": "Möchtest du \"%{name}\" wirklich löschen? |||| Möchtest du diese %{smart_count} Elemente wirklich löschen?",
|
||||
"bulk_delete_title": "Lösche %{name} |||| Lösche %{smart_count} %{name} Elemente",
|
||||
"delete_content": "Möchtest du diesen Inhalt wirklich löschen?",
|
||||
"delete_title": "Lösche %{name} #%{id}",
|
||||
"details": "Details",
|
||||
"error": "Ein Fehler ist aufgetreten und ihre Anfrage konnte nicht abgeschlossen werden.",
|
||||
"invalid_form": "Das Formular ist ungültig. Bitte überprüfe deine Eingaben.",
|
||||
"loading": "Die Seite wird geladen.",
|
||||
"no": "Nein",
|
||||
"not_found": "Die Seite konnte nicht gefunden werden.",
|
||||
"yes": "Ja",
|
||||
"unsaved_changes": "Einige deiner Änderungen wurden nicht gespeichert. Bist du sicher, dass du sie ignorieren möchtest?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Keine Resultate gefunden",
|
||||
"no_more_results": "Die Seite %{page} enthält keine Inhalte.",
|
||||
"page_out_of_boundaries": "Die Seite %{page} liegt ausserhalb des gültigen Bereichs",
|
||||
"page_out_from_end": "Letzte Seite",
|
||||
"page_out_from_begin": "Erste Seite",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} von %{total}",
|
||||
"page_rows_per_page": "Zeilen pro Seite:",
|
||||
"next": "Weiter",
|
||||
"prev": "Zurück"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Element wurde aktualisiert |||| %{smart_count} Elemente wurden aktualisiert",
|
||||
"created": "Element wurde erstellt",
|
||||
"deleted": "Element wurde gelöscht |||| %{smart_count} Elemente wurden gelöscht",
|
||||
"bad_item": "Fehlerhaftes Elemente",
|
||||
"item_doesnt_exist": "Das Element existiert nicht",
|
||||
"http_error": "Fehler beim Kommunizieren mit dem Server",
|
||||
"data_provider_error": "dataProvider Fehler. Prüfe die Konsole für Details.",
|
||||
"i18n_error": "Die Übersetzungen für die angegebene Sprache können nicht geladen werden",
|
||||
"canceled": "Aktion abgebrochen",
|
||||
"logged_out": "Ihr Session wurde beendet. Bitte erneut verbinden."
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "HINWEIS",
|
||||
"transcodingDisabled": "Die Änderung der Transcodierungskonfiguration über die Web-UI ist aus Sicherheitsgründen deaktiviert. Wenn du die Transcodierungsoptionen ändern (bearbeiten oder hinzufügen) möchtest, starte den Server mit der Konfigurationsoption %{config} neu.",
|
||||
"transcodingEnabled": "Navidrome läuft derzeit mit %{config}, wodurch es möglich ist, Systembefehle aus den Transkodierungseinstellungen über die Webschnittstelle auszuführen. Wir empfehlen, es aus Sicherheitsgründen zu deaktivieren und nur bei der Konfiguration von Transkodierungsoptionen zu aktivieren."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliothek",
|
||||
"settings": "Einstellungen",
|
||||
"version": "Version %{version}",
|
||||
"theme": "Design",
|
||||
"personal": {
|
||||
"name": "Persönlich",
|
||||
"options": {
|
||||
"theme": "Design",
|
||||
"language": "Sprache"
|
||||
}
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Wiedergabeliste abspielen",
|
||||
"openText": "Öffnen",
|
||||
"closeText": "Schließen",
|
||||
"notContentText": "Keine Musik",
|
||||
"clickToPlayText": "Anklicken zum Abzuspielen",
|
||||
"clickToPauseText": "Anklicken zum Pausieren",
|
||||
"nextTrackText": "Nächster Titel",
|
||||
"previousTrackText": "Vorheriger Titel",
|
||||
"reloadText": "Neu laden",
|
||||
"volumeText": "Lautstärke",
|
||||
"toggleLyricText": "Liedtext umschalten",
|
||||
"toggleMiniModeText": "Minimieren",
|
||||
"destroyText": "Zerstören",
|
||||
"downloadText": "Herunterladen",
|
||||
"removeAudioListsText": "Audiolisten löschen",
|
||||
"clickToDeleteText": "Klicken um %{Name} zu Löschen",
|
||||
"emptyLyricText": "Kein Liedtext",
|
||||
"playModeText": {
|
||||
"order": "Der Reihe nach",
|
||||
"orderLoop": "Wiederholen",
|
||||
"singleLoop": "Eins wiederholen",
|
||||
"shufflePlay": "Zufallswiedergabe"
|
||||
}
|
||||
}
|
||||
}
|
||||
256
resources/i18n/fr.json
Normal file
256
resources/i18n/fr.json
Normal file
@@ -0,0 +1,256 @@
|
||||
{
|
||||
"languageName": "Français",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Piste |||| Pistes",
|
||||
"fields": {
|
||||
"albumArtist": "",
|
||||
"duration": "Durée",
|
||||
"trackNumber": "#",
|
||||
"playCount": "Nombre d'écoutes",
|
||||
"title": "Titre",
|
||||
"artist": "Artiste",
|
||||
"album": "Album",
|
||||
"path": "Chemin",
|
||||
"genre": "Genre",
|
||||
"compilation": "Compilation",
|
||||
"year": "Année",
|
||||
"size": "Taille",
|
||||
"updatedAt": "Mise à jour"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Ajouter à la file",
|
||||
"playNow": "Lire"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Album |||| Albums",
|
||||
"fields": {
|
||||
"albumArtist": "",
|
||||
"artist": "Artiste",
|
||||
"duration": "Durée",
|
||||
"songCount": "Numéro de piste",
|
||||
"playCount": "Numbre d'écoutes",
|
||||
"name": "Nom",
|
||||
"genre": "Genre",
|
||||
"compilation": "Compilation",
|
||||
"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",
|
||||
"auth_check_error": "Merci de vous connecter pour continuer",
|
||||
"user_menu": "Profil",
|
||||
"username": "Identifiant",
|
||||
"password": "Mot de passe",
|
||||
"sign_in": "Connexion",
|
||||
"sign_in_error": "Échec de l'authentification, merci de réessayer",
|
||||
"logout": "Déconnexion"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Merci d'utiliser uniquement des chiffres et des lettres",
|
||||
"passwordDoesNotMatch": "Les mots de passes ne correspondent pas",
|
||||
"required": "Ce champ est requis",
|
||||
"minLength": "Minimum %{min} caractères",
|
||||
"maxLength": "Maximum %{max} caractères",
|
||||
"minValue": "Minimum %{min}",
|
||||
"maxValue": "Maximum %{max}",
|
||||
"number": "Doit être un nombre",
|
||||
"email": "Doit être un email",
|
||||
"oneOf": "Doit être au choix: %{options}",
|
||||
"regex": "Doit respecter un format spécifique (regexp): %{pattern}"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Ajouter un filtre",
|
||||
"add": "Ajouter",
|
||||
"back": "Retour",
|
||||
"bulk_actions": "%{smart_count} selectionné |||| %{smart_count} selectionnés",
|
||||
"cancel": "Annuler",
|
||||
"clear_input_value": "Vider le champ",
|
||||
"clone": "Dupliquer",
|
||||
"confirm": "Confirmer",
|
||||
"create": "Créer",
|
||||
"delete": "Supprimer",
|
||||
"edit": "Éditer",
|
||||
"export": "Exporter",
|
||||
"list": "Liste",
|
||||
"refresh": "Actualiser",
|
||||
"remove_filter": "Supprimer ce filtre",
|
||||
"remove": "Supprimer",
|
||||
"save": "Enregistrer",
|
||||
"search": "Rechercher",
|
||||
"show": "Afficher",
|
||||
"sort": "Trier",
|
||||
"undo": "Annuler",
|
||||
"expand": "Étendre",
|
||||
"close": "Fermer",
|
||||
"open_menu": "Ouvrir le menu",
|
||||
"close_menu": "Fermer le menu"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Oui",
|
||||
"false": "Non"
|
||||
},
|
||||
"page": {
|
||||
"create": "Créer %{name}",
|
||||
"dashboard": "Tableau de bord",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Un problème est survenu",
|
||||
"list": "%{name}",
|
||||
"loading": "Chargement",
|
||||
"not_found": "Page manquante",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Pas encore de %{name}.",
|
||||
"invite": "Voulez-vous en créer un ?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Déposez les fichiers à uploader, ou cliquez pour en sélectionner.",
|
||||
"upload_single": "Déposez le fichier à uploader, ou cliquez pour le sélectionner."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Déposez les images à uploader, ou cliquez pour en sélectionner.",
|
||||
"upload_single": "Déposez l'image à uploader, ou cliquez pour la sélectionner."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "Impossible de trouver des données de références.",
|
||||
"many_missing": "Au moins une des références associées semble ne plus être disponible.",
|
||||
"single_missing": "La référence associée ne semble plus disponible."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "Cacher le mot de passe",
|
||||
"toggle_hidden": "Montrer le mot de passe"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "Au sujet de",
|
||||
"are_you_sure": "Êtes-vous sûr ?",
|
||||
"bulk_delete_content": "Êtes-vous sûr(e) de vouloir supprimer cet élément ? |||| Êtes-vous sûr(e) de vouloir supprimer ces %{smart_count} éléments ?",
|
||||
"bulk_delete_title": "Supprimer %{name} |||| Supprimer %{smart_count} %{name}",
|
||||
"delete_content": "Êtes-vous sûr(e) de vouloir supprimer cet élément ?",
|
||||
"delete_title": "Supprimer %{name} #%{id}",
|
||||
"details": "Détails",
|
||||
"error": "En raison d'une erreur côté navigateur, votre requête n'a pas pu aboutir.",
|
||||
"invalid_form": "Le formulaire n'est pas valide.",
|
||||
"loading": "La page est en cours de chargement, merci de bien vouloir patienter.",
|
||||
"no": "Non",
|
||||
"not_found": "L'URL saisie est incorrecte, ou vous avez suivi un mauvais lien.",
|
||||
"yes": "Oui",
|
||||
"unsaved_changes": "Certains changements n'ont pas été enregistrés. Êtes-vous sûr(e) de vouloir quitter cette page ?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Aucun résultat",
|
||||
"no_more_results": "La page numéro %{page} est en dehors des limites. Essayez la page précédente.",
|
||||
"page_out_of_boundaries": "La page %{page} est en dehors des limites",
|
||||
"page_out_from_end": "Fin de la pagination",
|
||||
"page_out_from_begin": "La page doit être supérieure à 1",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} sur %{total}",
|
||||
"page_rows_per_page": "Lignes par page :",
|
||||
"next": "Suivant",
|
||||
"prev": "Précédent"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Élément mis à jour |||| %{smart_count} élements mis à jour",
|
||||
"created": "Élément créé",
|
||||
"deleted": "Élément supprimé |||| %{smart_count} élements supprimés",
|
||||
"bad_item": "Élément inconnu",
|
||||
"item_doesnt_exist": "L'élément n'existe pas",
|
||||
"http_error": "Erreur de communication avec le serveur",
|
||||
"data_provider_error": "Erreur dans le dataProvider. Plus de détails dans la console.",
|
||||
"i18n_error": "Erreur de chargement des traductions pour la langue sélectionnée",
|
||||
"canceled": "Action annulée",
|
||||
"logged_out": "Votre session a pris fin, veuillez vous reconnecter."
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "",
|
||||
"transcodingDisabled": "",
|
||||
"transcodingEnabled": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliothèque",
|
||||
"settings": "Paramètres",
|
||||
"version": "Version%{version}",
|
||||
"theme": "",
|
||||
"personal": {
|
||||
"name": "Paramètres personel",
|
||||
"options": {
|
||||
"theme": "Thème",
|
||||
"language": "Langue"
|
||||
}
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "File de lecture",
|
||||
"openText": "Ouvrir",
|
||||
"closeText": "Fermer",
|
||||
"notContentText": "",
|
||||
"clickToPlayText": "Cliquer pour lire",
|
||||
"clickToPauseText": "Cliquer pour mettre en pause",
|
||||
"nextTrackText": "Morceau suivant",
|
||||
"previousTrackText": "Morceau précédent",
|
||||
"reloadText": "",
|
||||
"volumeText": "Volume",
|
||||
"toggleLyricText": "",
|
||||
"toggleMiniModeText": "Minimiser",
|
||||
"destroyText": "",
|
||||
"downloadText": "",
|
||||
"removeAudioListsText": "Vider la liste de lecture",
|
||||
"clickToDeleteText": "Cliquer pour supprimer %{name}",
|
||||
"emptyLyricText": "",
|
||||
"playModeText": {
|
||||
"order": "Ordonner",
|
||||
"orderLoop": "Tout répéter",
|
||||
"singleLoop": "Repéter",
|
||||
"shufflePlay": "Aleatoire"
|
||||
}
|
||||
}
|
||||
}
|
||||
256
resources/i18n/it.json
Normal file
256
resources/i18n/it.json
Normal file
@@ -0,0 +1,256 @@
|
||||
{
|
||||
"languageName": "Italiano",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Traccia |||| Tracce",
|
||||
"fields": {
|
||||
"albumArtist": "",
|
||||
"duration": "Durata",
|
||||
"trackNumber": "#",
|
||||
"playCount": "Riproduzioni",
|
||||
"title": "Titolo",
|
||||
"artist": "Artista",
|
||||
"album": "Album",
|
||||
"path": "Percorso",
|
||||
"genre": "Genere",
|
||||
"compilation": "Compilation",
|
||||
"year": "Anno",
|
||||
"size": "Dimensioni",
|
||||
"updatedAt": "Ultimo aggiornamento"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Aggiungi alla coda",
|
||||
"playNow": "Riproduci"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Album |||| Album",
|
||||
"fields": {
|
||||
"albumArtist": "",
|
||||
"artist": "Artista",
|
||||
"duration": "Durata",
|
||||
"songCount": "Tracce",
|
||||
"playCount": "Riproduzioni",
|
||||
"name": "Nome",
|
||||
"genre": "Genere",
|
||||
"compilation": "Compilation",
|
||||
"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",
|
||||
"auth_check_error": "",
|
||||
"user_menu": "Profile",
|
||||
"username": "Nome utente",
|
||||
"password": "Password",
|
||||
"sign_in": "Login",
|
||||
"sign_in_error": "Autenticazione fallita, riprovare.",
|
||||
"logout": "Disconnessione"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Per favore usa solo lettere e numeri",
|
||||
"passwordDoesNotMatch": "Le password non coincidono",
|
||||
"required": "Campo obbligatorio",
|
||||
"minLength": "Deve essere lungo %{min} caratteri almeno",
|
||||
"maxLength": "Deve essere lungo %{max} caratteri al massimo",
|
||||
"minValue": "Deve essere almeno %{min}",
|
||||
"maxValue": "Deve essere al massimo %{max}",
|
||||
"number": "Deve essere un numero",
|
||||
"email": "Deve essere un valido indirizzo email",
|
||||
"oneOf": "Deve essere uno di: %{options}",
|
||||
"regex": "Deve rispettare il formato (espressione regolare): %{pattern}"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Aggiungi un filtro",
|
||||
"add": "Aggiungi",
|
||||
"back": "Indietro",
|
||||
"bulk_actions": "%{smart_count} selezionati",
|
||||
"cancel": "Annulla",
|
||||
"clear_input_value": "Svuota il modulo",
|
||||
"clone": "Duplica",
|
||||
"confirm": "Conferma",
|
||||
"create": "Crea",
|
||||
"delete": "Cancella",
|
||||
"edit": "Modifica",
|
||||
"export": "Esporta",
|
||||
"list": "Elenco",
|
||||
"refresh": "Aggiorna",
|
||||
"remove_filter": "Rimuovi questo filtro",
|
||||
"remove": "Remove",
|
||||
"save": "Salva",
|
||||
"search": "Ricerca",
|
||||
"show": "Mostra",
|
||||
"sort": "Ordina",
|
||||
"undo": "Annulla",
|
||||
"expand": "Espandi",
|
||||
"close": "Chiudi",
|
||||
"open_menu": "",
|
||||
"close_menu": ""
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Si",
|
||||
"false": "No"
|
||||
},
|
||||
"page": {
|
||||
"create": "Aggiungi %{name}",
|
||||
"dashboard": "Cruscotto",
|
||||
"edit": "%{name} %{id}",
|
||||
"error": "Qualcosa non ha funzionato",
|
||||
"list": "Lista %{name}",
|
||||
"loading": "Caricamento in corso",
|
||||
"not_found": "Non trovato",
|
||||
"show": "%{name} %{id}",
|
||||
"empty": "",
|
||||
"invite": ""
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Trascina i files da caricare, oppure clicca per selezionare.",
|
||||
"upload_single": "Trascina il file da caricare, oppure clicca per selezionarlo."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Trascina le immagini da caricare, oppure clicca per selezionarle.",
|
||||
"upload_single": "Trascina l'immagine da caricare, oppure clicca per selezionarla."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "Impossibile trovare i riferimenti associati.",
|
||||
"many_missing": "Almeno uno dei riferimenti associati non sembra più disponibile.",
|
||||
"single_missing": "Il riferimento associato non sembra più disponibile."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "",
|
||||
"toggle_hidden": ""
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "Informazioni",
|
||||
"are_you_sure": "Sei sicuro ?",
|
||||
"bulk_delete_content": "Sei sicuro di voler cancellare questo %{name}? |||| Sei sicuro di voler eliminare questi %{smart_count}?",
|
||||
"bulk_delete_title": "Delete %{name} |||| Delete %{smart_count} %{name} items",
|
||||
"delete_content": "Are you sure you want to delete this item?",
|
||||
"delete_title": "Cancella %{name} #%{id}",
|
||||
"details": "Dettagli",
|
||||
"error": "Un errore locale è occorso e la tua richiesta non è stata completata.",
|
||||
"invalid_form": "Il modulo non è valido. Si prega di verificare la presenza di errori.",
|
||||
"loading": "La pagina si sta caricando, solo un momento per favore",
|
||||
"no": "No",
|
||||
"not_found": "Hai inserito un URL errato, oppure hai cliccato un link errato",
|
||||
"yes": "Si",
|
||||
"unsaved_changes": ""
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Nessun risultato trovato",
|
||||
"no_more_results": "La pagina numero %{page} è fuori dell'intervallo. Prova la pagina precedente.",
|
||||
"page_out_of_boundaries": "Il numero di pagina %{page} è fuori dei limiti",
|
||||
"page_out_from_end": "Fine della paginazione",
|
||||
"page_out_from_begin": "Il numero di pagina deve essere maggiore di 1",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} di %{total}",
|
||||
"page_rows_per_page": "Righe per pagina",
|
||||
"next": "Successivo",
|
||||
"prev": "Precedente"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Record aggiornato |||| %{smart_count} records aggiornati",
|
||||
"created": "Record creato",
|
||||
"deleted": "Record eliminato |||| %{smart_count} records eliminati",
|
||||
"bad_item": "Record errato",
|
||||
"item_doesnt_exist": "Record inesistente",
|
||||
"http_error": "Errore di comunicazione con il server dati",
|
||||
"data_provider_error": "Errore del data provider. Controlla la console per i dettagli.",
|
||||
"i18n_error": "",
|
||||
"canceled": "Azione annullata",
|
||||
"logged_out": "La sessione è scaduta. Effettua nuovamente l'accesso."
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "",
|
||||
"transcodingDisabled": "",
|
||||
"transcodingEnabled": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Libreria",
|
||||
"settings": "Impostazioni",
|
||||
"version": "Versione %{version}",
|
||||
"theme": "",
|
||||
"personal": {
|
||||
"name": "Personale",
|
||||
"options": {
|
||||
"theme": "Tema",
|
||||
"language": "Lingua"
|
||||
}
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Coda",
|
||||
"openText": "Apri",
|
||||
"closeText": "Chiudi",
|
||||
"notContentText": "",
|
||||
"clickToPlayText": "Clicca per riprodurre",
|
||||
"clickToPauseText": "Clicca per mettere in pausa",
|
||||
"nextTrackText": "Traccia successiva",
|
||||
"previousTrackText": "Traccia precedente",
|
||||
"reloadText": "",
|
||||
"volumeText": "Volume",
|
||||
"toggleLyricText": "",
|
||||
"toggleMiniModeText": "Minimizza",
|
||||
"destroyText": "",
|
||||
"downloadText": "",
|
||||
"removeAudioListsText": "Cancella coda",
|
||||
"clickToDeleteText": "Clicca per rimuovere %{name}",
|
||||
"emptyLyricText": "",
|
||||
"playModeText": {
|
||||
"order": "In ordine",
|
||||
"orderLoop": "Ripeti",
|
||||
"singleLoop": "Ripeti una volta",
|
||||
"shufflePlay": "Casuale"
|
||||
}
|
||||
}
|
||||
}
|
||||
256
resources/i18n/nl.json
Normal file
256
resources/i18n/nl.json
Normal file
@@ -0,0 +1,256 @@
|
||||
{
|
||||
"languageName": "Nederlands",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Nummer |||| Nummers",
|
||||
"fields": {
|
||||
"albumArtist": "Album Artiest",
|
||||
"duration": "Tijd",
|
||||
"trackNumber": "Nummer #",
|
||||
"playCount": "Aantal keren afgespeeld",
|
||||
"title": "Titel",
|
||||
"artist": "Artiest",
|
||||
"album": "Album",
|
||||
"path": "Bestandspad",
|
||||
"genre": "Genre",
|
||||
"compilation": "Compilatie",
|
||||
"year": "Jaar",
|
||||
"size": "Bestandsgrootte",
|
||||
"updatedAt": "Laatst bijgewerkt op"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Toevoegen aan afspeellijst",
|
||||
"playNow": "Nu Afspelen"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Album |||| Albums",
|
||||
"fields": {
|
||||
"albumArtist": "Album Artiest",
|
||||
"artist": "Artiest",
|
||||
"duration": "Tijd",
|
||||
"songCount": "Nummerss",
|
||||
"playCount": "Aantal keren afgespeeld",
|
||||
"name": "Naam",
|
||||
"genre": "Genre",
|
||||
"compilation": "Compilatie",
|
||||
"year": "Jaar"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Afspelen",
|
||||
"playNext": "Hierna afspelen",
|
||||
"addToQueue": "Toevoegen aan afspeellijst",
|
||||
"shuffle": "Shuffle"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Artiest |||| Artiesten",
|
||||
"fields": {
|
||||
"name": "Naam",
|
||||
"albumCount": "Aantal albums"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Gebruiker |||| Gebruikers",
|
||||
"fields": {
|
||||
"userName": "Gebruikersnaam",
|
||||
"isAdmin": "Is beheerder",
|
||||
"lastLoginAt": "Laatst ingelogd op",
|
||||
"updatedAt": "Laatst gewijzigd op",
|
||||
"name": "Naam"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "Speler |||| Spelers",
|
||||
"fields": {
|
||||
"name": "Naam",
|
||||
"transcodingId": "Transcoderingsidentifier",
|
||||
"maxBitRate": "Maximale bitrate",
|
||||
"client": "Client",
|
||||
"userName": "Gebruikersnaam",
|
||||
"lastSeen": "Laatst gezien op"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Transcodering |||| Transcoderingen",
|
||||
"fields": {
|
||||
"name": "Naam",
|
||||
"targetFormat": "Doel formaat",
|
||||
"defaultBitRate": "Standaard bitrate",
|
||||
"command": "Commando"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Bedankt voor het installeren van Navidrome!",
|
||||
"welcome2": "Maak om te beginnen een beheerdersaccount",
|
||||
"confirmPassword": "Bevestig wachtwoord",
|
||||
"buttonCreateAdmin": "Beheerder maken",
|
||||
"auth_check_error": "Log in om door te gaan",
|
||||
"user_menu": "Profiel",
|
||||
"username": "Gebruikersnaam",
|
||||
"password": "Wachtwoord",
|
||||
"sign_in": "Inloggen",
|
||||
"sign_in_error": "Authenticatie mislukt, probeer opnieuw a.u.b.",
|
||||
"logout": "Uitloggen"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Gebruik alleen letters en cijfers",
|
||||
"passwordDoesNotMatch": "Wachtwoord komt niet overeen",
|
||||
"required": "Verplicht",
|
||||
"minLength": "Moet minimaal %{min} karakters bevatten",
|
||||
"maxLength": "Mag hooguit %{max} karakters bevatten",
|
||||
"minValue": "Moet groter of gelijk zijn aan %{min}",
|
||||
"maxValue": "Moet kleiner of gelijk zijn aan %{max}",
|
||||
"number": "Moet een getal zijn",
|
||||
"email": "Moet een geldig e-mailadres zijn",
|
||||
"oneOf": "Moet een zijn van: %{options}",
|
||||
"regex": "Moet overeenkomen met een specifiek format (regexp): %{pattern}"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Voeg filter toe",
|
||||
"add": "Voeg toe",
|
||||
"back": "Ga terug",
|
||||
"bulk_actions": "1 geselecteerd |||| %{smart_count} geselecteerd",
|
||||
"cancel": "Annuleer",
|
||||
"clear_input_value": "Veld wissen",
|
||||
"clone": "Kloon",
|
||||
"confirm": "Bevestig",
|
||||
"create": "Toevoegen",
|
||||
"delete": "Verwijderen",
|
||||
"edit": "Bewerk",
|
||||
"export": "Exporteer",
|
||||
"list": "Lijst",
|
||||
"refresh": "Ververs",
|
||||
"remove_filter": "Verwijder dit filter",
|
||||
"remove": "Verwijder",
|
||||
"save": "Opslaan",
|
||||
"search": "Zoek",
|
||||
"show": "Toon",
|
||||
"sort": "Sorteer",
|
||||
"undo": "Ongedaan maken",
|
||||
"expand": "Uitklappen",
|
||||
"close": "Sluiten",
|
||||
"open_menu": "Open menu",
|
||||
"close_menu": "Sluit menu"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Ja",
|
||||
"false": "Nee"
|
||||
},
|
||||
"page": {
|
||||
"create": "%{name} toevoegen",
|
||||
"dashboard": "Dashboard",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Er is iets misgegaan",
|
||||
"list": "%{name}",
|
||||
"loading": "Aan het laden",
|
||||
"not_found": "Niet gevonden",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Nog geen %{name}.",
|
||||
"invite": "Wilt u er een toevoegen?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Drag en drop bestanden om te uploaden, of klik om bestanden te selecteren.",
|
||||
"upload_single": "Drag en drop een bestand om te uploaden, of klik om een bestand te selecteren."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Drag en drop afbeeldingen om te uploaden, of klik om bestanden te selecteren.",
|
||||
"upload_single": "Drag en drop een afbeelding om te uploaden, of klik om een bestand te selecteren."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "De gerefereerde elementen konden niet gevonden worden.",
|
||||
"many_missing": "Een of meer van de gerefereerde elementen is niet meer beschikbaar.",
|
||||
"single_missing": "Een van de gerefereerde elementen is niet meer beschikbaar"
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "Verberg wachtwoord",
|
||||
"toggle_hidden": "Toon wachtwoord"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "Over",
|
||||
"are_you_sure": "Weet u het zeker?",
|
||||
"bulk_delete_content": "Weet u zeker dat u dit %{name} item wilt verwijderen? |||| Weet u zeker dat u deze %{smart_count} items wilt verwijderen?",
|
||||
"bulk_delete_title": "Verwijder %{name} |||| Verwijder %{smart_count} %{name}",
|
||||
"delete_content": "Weet u zeker dat u dit item wilt verwijderen?",
|
||||
"delete_title": "%{name} #%{id} verwijderen",
|
||||
"details": "Details",
|
||||
"error": "Er is een clientfout opgetreden en uw aanvraag kon niet worden voltooid.",
|
||||
"invalid_form": "Het formulier is ongeldig. Controleer a.u.b. de foutmeldingen",
|
||||
"loading": "De pagina is aan het laden, een moment a.u.b.",
|
||||
"no": "Nee",
|
||||
"not_found": "U heeft een verkeerde URL ingevoerd of een defecte link aangeklikt.",
|
||||
"yes": "Ja",
|
||||
"unsaved_changes": "Sommige van uw wijzigingen zijn niet opgeslagen. Weet ue zeker dat u ze wilt negeren?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Geen resultaten gevonden",
|
||||
"no_more_results": "Pagina %{page} ligt buiten het bereik. Probeer de vorige pagina.",
|
||||
"page_out_of_boundaries": "Paginanummer %{page} buiten bereik",
|
||||
"page_out_from_end": "Laatste pagina",
|
||||
"page_out_from_begin": "Eerste pagina",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} van %{total}",
|
||||
"page_rows_per_page": "Rijen per pagina:",
|
||||
"next": "Volgende",
|
||||
"prev": "Vorige"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Element bijgewerkt |||| %{smart_count} elementen bijgewerkt",
|
||||
"created": "Element toegevoegd",
|
||||
"deleted": "Element verwijderd |||| %{smart_count} elementen verwijderd",
|
||||
"bad_item": "Incorrect element",
|
||||
"item_doesnt_exist": "Element bestaat niet",
|
||||
"http_error": "Server communicatie fout",
|
||||
"data_provider_error": "dataProvider fout. Open console voor meer details.",
|
||||
"i18n_error": "Kan de vertalingen voor de opgegeven taal niet laden",
|
||||
"canceled": "Actie geannuleerd",
|
||||
"logged_out": "Uw sessie is beëindigd, maak opnieuw verbinding."
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "Notitie",
|
||||
"transcodingDisabled": "Het wijzigen van de transcoderingsconfiguratie via de web interface is om veiligheidsredenen uitgeschakeld. Als u transcoderingsopties wilt wijzigen (bewerken of toevoegen), start u de server opnieuw op met de %{config} configuratie-optie.",
|
||||
"transcodingEnabled": "Navidrome werkt momenteel met %{config}, waardoor het mogelijk is om systeemopdrachten uit te voeren vanuit de transcoderingsinstellingen via de web interface. We raden aan om het om veiligheidsredenen uit te schakelen en alleen in te schakelen bij het configureren van transcoderingsopties."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliotheek",
|
||||
"settings": "Instellingen",
|
||||
"version": "Versie %{version}",
|
||||
"theme": "Thema",
|
||||
"personal": {
|
||||
"name": "Persoonlijk",
|
||||
"options": {
|
||||
"theme": "Thema",
|
||||
"language": "Taal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Afspeellijst afspelen",
|
||||
"openText": "Openen",
|
||||
"closeText": "Sluiten",
|
||||
"notContentText": "Geen muziek",
|
||||
"clickToPlayText": "Klik om af te spelen",
|
||||
"clickToPauseText": "Klik om te pauzeren",
|
||||
"nextTrackText": "Volgende",
|
||||
"previousTrackText": "Vorige",
|
||||
"reloadText": "Herladen",
|
||||
"volumeText": "Volume",
|
||||
"toggleLyricText": "Songtekst aan/uit",
|
||||
"toggleMiniModeText": "Minimaliseren",
|
||||
"destroyText": "Vernietigen",
|
||||
"downloadText": "Downloaden",
|
||||
"removeAudioListsText": "Audiolijsten verwijderen",
|
||||
"clickToDeleteText": "Klik om %{name} te verwijderen",
|
||||
"emptyLyricText": "Geen songtekst",
|
||||
"playModeText": {
|
||||
"order": "In volgorde",
|
||||
"orderLoop": "Herhalen",
|
||||
"singleLoop": "Herhaal Eenmalig",
|
||||
"shufflePlay": "Shuffle"
|
||||
}
|
||||
}
|
||||
}
|
||||
256
resources/i18n/pt.json
Normal file
256
resources/i18n/pt.json
Normal file
@@ -0,0 +1,256 @@
|
||||
{
|
||||
"languageName": "Português",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Música |||| Músicas",
|
||||
"fields": {
|
||||
"albumArtist": "Artista",
|
||||
"duration": "Duração",
|
||||
"trackNumber": "#",
|
||||
"playCount": "Execuções",
|
||||
"title": "Título",
|
||||
"artist": "Artista",
|
||||
"album": "Álbum",
|
||||
"path": "Arquivo",
|
||||
"genre": "Gênero",
|
||||
"compilation": "Coletânea",
|
||||
"year": "Ano",
|
||||
"size": "Tamanho",
|
||||
"updatedAt": "Últ. Atualização"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Tocar por último",
|
||||
"playNow": "Tocar agora"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Álbum |||| Álbuns",
|
||||
"fields": {
|
||||
"albumArtist": "Artista",
|
||||
"artist": "Artista",
|
||||
"duration": "Duração",
|
||||
"songCount": "Músicas",
|
||||
"playCount": "Execuções",
|
||||
"name": "Nome",
|
||||
"genre": "Gênero",
|
||||
"compilation": "Coletânea",
|
||||
"year": "Ano"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Tocar",
|
||||
"playNext": "Tocar em seguida",
|
||||
"addToQueue": "Tocar no fim",
|
||||
"shuffle": "Aleatório"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"auth_check_error": "Por favor, faça login para continuar",
|
||||
"user_menu": "Perfil",
|
||||
"username": "Usuário",
|
||||
"password": "Senha",
|
||||
"sign_in": "Entrar",
|
||||
"sign_in_error": "Erro na autenticação, tente novamente.",
|
||||
"logout": "Sair"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Somente use letras e numeros",
|
||||
"passwordDoesNotMatch": "Senha não confere",
|
||||
"required": "Obrigatório",
|
||||
"minLength": "Deve ser ter no mínimo %{min} caracteres",
|
||||
"maxLength": "Deve ter no máximo %{max} caracteres",
|
||||
"minValue": "Deve ser %{min} ou maior",
|
||||
"maxValue": "Deve ser %{max} ou menor",
|
||||
"number": "Deve ser um número",
|
||||
"email": "Deve ser um email válido",
|
||||
"oneOf": "Deve ser uma das seguintes opções: %{options}",
|
||||
"regex": "Deve ter o formato específico (regexp): %{pattern}"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Adicionar Filtro",
|
||||
"add": "Adicionar",
|
||||
"back": "Voltar",
|
||||
"bulk_actions": "1 item selecionado |||| %{smart_count} itens selecionados",
|
||||
"cancel": "Cancelar",
|
||||
"clear_input_value": "Limpar campo",
|
||||
"clone": "Duplicar",
|
||||
"confirm": "Confirmar",
|
||||
"create": "Novo",
|
||||
"delete": "Deletar",
|
||||
"edit": "Editar",
|
||||
"export": "Exportar",
|
||||
"list": "Listar",
|
||||
"refresh": "Atualizar",
|
||||
"remove_filter": "Cancelar filtro",
|
||||
"remove": "Excluir",
|
||||
"save": "Salvar",
|
||||
"search": "Buscar",
|
||||
"show": "Exibir",
|
||||
"sort": "Ordenar",
|
||||
"undo": "Desfazer",
|
||||
"expand": "Expandir",
|
||||
"close": "Fechar",
|
||||
"open_menu": "Abrir menu",
|
||||
"close_menu": "Fechar menu"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Sim",
|
||||
"false": "Não"
|
||||
},
|
||||
"page": {
|
||||
"create": "Criar %{name}",
|
||||
"dashboard": "Painel de Controle",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Um erro ocorreu",
|
||||
"list": "Listar %{name}",
|
||||
"loading": "Carregando",
|
||||
"not_found": "Não encontrado",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Ainda não há nenhum registro em %{name}",
|
||||
"invite": "Gostaria de criar um novo?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Arraste alguns arquivos para fazer o upload, ou clique para selecioná-los.",
|
||||
"upload_single": "Arraste o arquivo para fazer o upload, ou clique para selecioná-lo."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Arraste algumas imagens para fazer o upload ou clique para selecioná-las",
|
||||
"upload_single": "Arraste um arquivo para upload ou clique em selecionar arquivo."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "Não foi possível encontrar os dados das referencias.",
|
||||
"many_missing": "Pelo menos uma das referências passadas não está mais disponível.",
|
||||
"single_missing": "A referência passada aparenta não estar mais disponível."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "Esconder senha",
|
||||
"toggle_hidden": "Mostrar senha"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "Sobre",
|
||||
"are_you_sure": "Tem certeza?",
|
||||
"bulk_delete_content": "Você tem certeza que deseja excluir %{name}? |||| Você tem certeza que deseja excluir estes %{smart_count} itens?",
|
||||
"bulk_delete_title": "Excluir %{name} |||| Excluir %{smart_count} %{name} itens",
|
||||
"delete_content": "Você tem certeza que deseja excluir?",
|
||||
"delete_title": "Excluir %{name} #%{id}",
|
||||
"details": "Detalhes",
|
||||
"error": "Um erro ocorreu e a sua requisição não pôde ser completada.",
|
||||
"invalid_form": "Este formulário não está valido. Certifique-se de corrigir os erros",
|
||||
"loading": "A página está carregando. Um momento, por favor",
|
||||
"no": "Não",
|
||||
"not_found": "Foi digitada uma URL inválida, ou o link pode estar quebrado.",
|
||||
"yes": "Sim",
|
||||
"unsaved_changes": "Algumas das suas mudanças não foram salvas, deseja realmente ignorá-las?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Nenhum resultado encontrado",
|
||||
"no_more_results": "A página numero %{page} está fora dos limites. Tente a página anterior.",
|
||||
"page_out_of_boundaries": "Página %{page} fora do limite",
|
||||
"page_out_from_end": "Não é possível ir após a última página",
|
||||
"page_out_from_begin": "Não é possível ir antes da primeira página",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} de %{total}",
|
||||
"page_rows_per_page": "Resultados por página:",
|
||||
"next": "Próximo",
|
||||
"prev": "Anterior"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Item atualizado com sucesso |||| %{smart_count} itens foram atualizados com sucesso",
|
||||
"created": "Item criado com sucesso",
|
||||
"deleted": "Item removido com sucesso! |||| %{smart_count} itens foram removidos com sucesso",
|
||||
"bad_item": "Item incorreto",
|
||||
"item_doesnt_exist": "Esse item não existe mais",
|
||||
"http_error": "Erro na comunicação com servidor",
|
||||
"data_provider_error": "Erro interno do servidor. Entre em contato",
|
||||
"i18n_error": "Não foi possível carregar as traduções para o idioma especificado",
|
||||
"canceled": "Ação cancelada",
|
||||
"logged_out": "Sua sessão foi encerrada. Por favor, reconecte"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "ATENÇÃO",
|
||||
"transcodingDisabled": "",
|
||||
"transcodingEnabled": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteca",
|
||||
"settings": "Configurações",
|
||||
"version": "Versão %{version}",
|
||||
"theme": "Tema",
|
||||
"personal": {
|
||||
"name": "Pessoal",
|
||||
"options": {
|
||||
"theme": "Tema",
|
||||
"language": "Língua"
|
||||
}
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Fila de Execução",
|
||||
"openText": "Abrir",
|
||||
"closeText": "Fechar",
|
||||
"notContentText": "",
|
||||
"clickToPlayText": "Clique para tocar",
|
||||
"clickToPauseText": "Clique para pausar",
|
||||
"nextTrackText": "Próxima faixa",
|
||||
"previousTrackText": "Faixa anterior",
|
||||
"reloadText": "Recarregar",
|
||||
"volumeText": "Volume",
|
||||
"toggleLyricText": "",
|
||||
"toggleMiniModeText": "Minimizar",
|
||||
"destroyText": "",
|
||||
"downloadText": "Baixar",
|
||||
"removeAudioListsText": "Limpar fila de execução",
|
||||
"clickToDeleteText": "Clique para remover %{name}",
|
||||
"emptyLyricText": "Letra não disponível",
|
||||
"playModeText": {
|
||||
"order": "Em ordem",
|
||||
"orderLoop": "Repetir tudo",
|
||||
"singleLoop": "Repetir",
|
||||
"shufflePlay": "Aleatório"
|
||||
}
|
||||
}
|
||||
}
|
||||
256
resources/i18n/tr.json
Normal file
256
resources/i18n/tr.json
Normal file
@@ -0,0 +1,256 @@
|
||||
{
|
||||
"languageName": "Türkçe",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Şarkı |||| Şarkılar",
|
||||
"fields": {
|
||||
"albumArtist": "Albüm sanatçısı",
|
||||
"duration": "Süre",
|
||||
"trackNumber": "Parça #",
|
||||
"playCount": "Oynatma",
|
||||
"title": "Isim",
|
||||
"artist": "Sanatçı",
|
||||
"album": "Albüm",
|
||||
"path": "Dosya yolu",
|
||||
"genre": "Tür",
|
||||
"compilation": "Derleme",
|
||||
"year": "Yıl",
|
||||
"size": "Dosya boyutu",
|
||||
"updatedAt": "Yüklendiği zaman"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Sonra çal",
|
||||
"playNow": "Şimdi cal"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Albüm |||| Albümler",
|
||||
"fields": {
|
||||
"albumArtist": "Albüm sanatçısı",
|
||||
"artist": "Sanatçı",
|
||||
"duration": "Süre",
|
||||
"songCount": "Şarkılar",
|
||||
"playCount": "Oynatma",
|
||||
"name": "Ad",
|
||||
"genre": "Tür",
|
||||
"compilation": "Derleme",
|
||||
"year": "Yıl"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Çaldır",
|
||||
"playNext": "Sonrakini çal",
|
||||
"addToQueue": "Sonra çal",
|
||||
"shuffle": "Karıştır"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Sanatçı |||| Sanatçılar",
|
||||
"fields": {
|
||||
"name": "Ad",
|
||||
"albumCount": "Albüm Sayısı"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Kullanıcı |||| Kullanıcılar",
|
||||
"fields": {
|
||||
"userName": "Kullanıcı adı",
|
||||
"isAdmin": "Yönetici mi",
|
||||
"lastLoginAt": "Son Giriş Tarihi",
|
||||
"updatedAt": "Güncelleme Tarihi",
|
||||
"name": "Ad"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "Çalar |||| Çalarlar",
|
||||
"fields": {
|
||||
"name": "Ad",
|
||||
"transcodingId": "Kod dönüştürme kimliği",
|
||||
"maxBitRate": "Maks. bit orani",
|
||||
"client": "Cihaz",
|
||||
"userName": "Kullanıcı adı",
|
||||
"lastSeen": "Son Görülme"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Transcoding |||| Transcodings",
|
||||
"fields": {
|
||||
"name": "Ad",
|
||||
"targetFormat": "Hedef Formatı",
|
||||
"defaultBitRate": "Varsayılan bit orani",
|
||||
"command": "komut"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Navidrome'yi yüklediğiniz için teşekkürler!",
|
||||
"welcome2": "Başlamak için bir yönetici kullanıcı oluştur",
|
||||
"confirmPassword": "Şifreyi Onayla",
|
||||
"buttonCreateAdmin": "Yönetici oluştur",
|
||||
"auth_check_error": "Devam etmek için lütfen giriş yap",
|
||||
"user_menu": "Profil",
|
||||
"username": "Kullanıcı adı",
|
||||
"password": "Parola",
|
||||
"sign_in": "Giriş yap",
|
||||
"sign_in_error": "Giriş başarısız. Lütfen tekrar deneyin",
|
||||
"logout": "Çıkış"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Lütfen sadece harf ve rakam kullan",
|
||||
"passwordDoesNotMatch": "Şifre eşleşmiyor",
|
||||
"required": "Zorunlu alan",
|
||||
"minLength": "En az %{min} karakter",
|
||||
"maxLength": "En fazla %{max} karakter",
|
||||
"minValue": "En az %{min} olmalı",
|
||||
"maxValue": "En fazla %{max} olmali",
|
||||
"number": "Sayısal bir değer olmalı",
|
||||
"email": "E-posta geçerli değil",
|
||||
"oneOf": "Bunlardan biri olmalı: %{options}",
|
||||
"regex": "Belirli bir formatla eşleşmelidir (regexp): %{pattern}"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Filtre ekle",
|
||||
"add": "Ekle",
|
||||
"back": "Geri Dön",
|
||||
"bulk_actions": "1 seçildi |||| %{smart_count} seçildi",
|
||||
"cancel": "İptal",
|
||||
"clear_input_value": "Temizle",
|
||||
"clone": "Klonla",
|
||||
"confirm": "Onayla",
|
||||
"create": "Oluştur",
|
||||
"delete": "Sil",
|
||||
"edit": "Düzenle",
|
||||
"export": "Dışa aktar",
|
||||
"list": "Listele",
|
||||
"refresh": "Yenile",
|
||||
"remove_filter": "Filtreyi kaldır",
|
||||
"remove": "Kaldır",
|
||||
"save": "Kaydet",
|
||||
"search": "Ara",
|
||||
"show": "Göster",
|
||||
"sort": "Sırala",
|
||||
"undo": "Geri al",
|
||||
"expand": "Genişlettir",
|
||||
"close": "Kapat",
|
||||
"open_menu": "Menüyü aç",
|
||||
"close_menu": "Menüyü kapat"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Evet",
|
||||
"false": "Hayır"
|
||||
},
|
||||
"page": {
|
||||
"create": "%{name} oluştur",
|
||||
"dashboard": "Ana Sayfa",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Bazı şeyler yolunda değil",
|
||||
"list": "%{name} listesi",
|
||||
"loading": "Yükleniyor",
|
||||
"not_found": "Sayfa bulunamadı",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Henüz %{name} yok.",
|
||||
"invite": "Bir tane eklemek ister misin?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Yüklemek istediğiniz dosyaları buraya sürükleyin ya da seçmek için tıklayın.",
|
||||
"upload_single": "Yüklemek istediğiniz dosyayı buraya sürükleyin ya da seçmek için tıklayın.."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Yüklemek istediğiniz resimleri buraya sürükleyin ya da seçmek için tıklayın.",
|
||||
"upload_single": "Yüklemek istediğiniz resmi buraya sürükleyin ya da seçmek için tıklayın."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "Referans verileri bulunamadı.",
|
||||
"many_missing": "İlişkilendirilmiş referanslardan en az biri artık mevcut değil.",
|
||||
"single_missing": "İlişkilendirilmiş referans artık mevcut değil."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "Şifreyi gizle",
|
||||
"toggle_hidden": "Şifreyi göster"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "Hakkında",
|
||||
"are_you_sure": "Emin misiniz?",
|
||||
"bulk_delete_content": "%{name} silmek istediğinizden emin misiniz? |||| %{smart_count} öğeyi silmek istediğinizden emin misiniz?",
|
||||
"bulk_delete_title": "%{name} sil |||| %{smart_count} %{name} öğesi sil",
|
||||
"delete_content": "Bu öğeyi silmek istediğinizden emin misiniz?",
|
||||
"delete_title": "%{name} #%{id} Sil",
|
||||
"details": "Detaylar",
|
||||
"error": "Bir istemci hatası oluştu ve isteğiniz tamamlanamadı.",
|
||||
"invalid_form": "Form geçerli değil. Lütfen hataları kontrol edin",
|
||||
"loading": "Sayfa yükleniyor, lütfen bekleyiniz",
|
||||
"no": "Hayır",
|
||||
"not_found": "Hatalı bir URL girdiniz ya da yanlış bir linke tıkladınız",
|
||||
"yes": "Evet",
|
||||
"unsaved_changes": "Yaptığın değişikliklerin bazıları kaydedilmedi. Onları yoksaymak istediğinizden emin misin?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Kayıt bulunamadı",
|
||||
"no_more_results": "%{page} sayfası mevcut değil. Önceki sayfayı deneyin.",
|
||||
"page_out_of_boundaries": "%{page} sayfası mevcut değil",
|
||||
"page_out_from_end": "Son sayfadan ileri gidemezsin",
|
||||
"page_out_from_begin": "1. sayfadan geri gidemezsin",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} of %{total}",
|
||||
"page_rows_per_page": "Sayfa başına kayıtlar",
|
||||
"next": "Sonraki",
|
||||
"prev": "Önceki"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Öğe güncellendi |||| %{smart_count} öğe güncellendi",
|
||||
"created": "Öğe oluşturuldu",
|
||||
"deleted": "Öğe silindi |||| %{smart_count} öğe silindi",
|
||||
"bad_item": "Hatalı öğe",
|
||||
"item_doesnt_exist": "Öğe bulunamadı",
|
||||
"http_error": "Sunucu iletişim hatası",
|
||||
"data_provider_error": "dataProvider hatası. Detay için konsolu gözden geçir.",
|
||||
"i18n_error": "Belirtilen dil için çeviriler yüklenemedi",
|
||||
"canceled": "Eylem iptal edildi",
|
||||
"logged_out": "Oturumunuz sona erdi, Lütfen yeniden bağlanın."
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "NOT",
|
||||
"transcodingDisabled": "Transcoding ayarlari web arayüzü üzerinden değiştirilmesi güvenlik nedeniyle devre dışı bırakılmıştır. Kod dönüştürme seçeneklerini değiştirmek (düzenlemek veya eklemek) istiyorsan, %{config} seçeneğiyle sunucuyu yeniden başlatın.",
|
||||
"transcodingEnabled": "Navidrome şu anda %{config} ile çalışıyor, web arayüzünü kullanarak kod dönüştürme ayarlarından sistem komutlarını çalıştırmayı mümkün kılıyor. Güvenlik nedeniyle devre dışı bırakmanızı ve yalnızca Kod Dönüştürme seçeneklerini yapılandırırken etkinleştirmenizi öneririz."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Müzik kütüphanesi",
|
||||
"settings": "Ayarlar",
|
||||
"version": "Sürüm %{version}",
|
||||
"theme": "Tema",
|
||||
"personal": {
|
||||
"name": "Kişisel",
|
||||
"options": {
|
||||
"theme": "Tema",
|
||||
"language": "Dil"
|
||||
}
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Oynatma Sırası",
|
||||
"openText": "Aç",
|
||||
"closeText": "Kapat",
|
||||
"notContentText": "Müzik yok",
|
||||
"clickToPlayText": "Oynatmak için tıkla",
|
||||
"clickToPauseText": "Duraklatmak için tıkla",
|
||||
"nextTrackText": "Sonraki parça",
|
||||
"previousTrackText": "Önceki parça",
|
||||
"reloadText": "Tekrar yükle",
|
||||
"volumeText": "Ses",
|
||||
"toggleLyricText": "Şarkı sözü aç/kapat",
|
||||
"toggleMiniModeText": "Küçült",
|
||||
"destroyText": "Yık",
|
||||
"downloadText": "İndir",
|
||||
"removeAudioListsText": "Ses listelerini sil",
|
||||
"clickToDeleteText": "%{name} silmek için tıkla",
|
||||
"emptyLyricText": "Şarkı sözü yok",
|
||||
"playModeText": {
|
||||
"order": "Sırayla",
|
||||
"orderLoop": "Tekrar et",
|
||||
"singleLoop": "Birini tekrarla",
|
||||
"shufflePlay": "Karıştır"
|
||||
}
|
||||
}
|
||||
}
|
||||
256
resources/i18n/zn.json
Normal file
256
resources/i18n/zn.json
Normal file
@@ -0,0 +1,256 @@
|
||||
{
|
||||
"languageName": "简体中文",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "歌曲 |||| 曲库",
|
||||
"fields": {
|
||||
"albumArtist": "专辑歌手",
|
||||
"duration": "时长",
|
||||
"trackNumber": "音轨 #",
|
||||
"playCount": "播放次数",
|
||||
"title": "",
|
||||
"artist": "",
|
||||
"album": "",
|
||||
"path": "",
|
||||
"genre": "",
|
||||
"compilation": "",
|
||||
"year": "",
|
||||
"size": "",
|
||||
"updatedAt": ""
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "稍后播放",
|
||||
"playNow": ""
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "专辑 |||| 专辑",
|
||||
"fields": {
|
||||
"albumArtist": "专辑歌手",
|
||||
"artist": "歌手",
|
||||
"duration": "时长",
|
||||
"songCount": "曲目数",
|
||||
"playCount": "播放次数",
|
||||
"name": "",
|
||||
"genre": "",
|
||||
"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": "创建管理员",
|
||||
"auth_check_error": "",
|
||||
"user_menu": "设置",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"sign_in": "登录",
|
||||
"sign_in_error": "验证失败, 请重试",
|
||||
"logout": "退出"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "请只使用字母和数字",
|
||||
"passwordDoesNotMatch": "密码不匹配",
|
||||
"required": "必填",
|
||||
"minLength": "必须不少于 %{min} 个字符",
|
||||
"maxLength": "必须不多于 %{max} 个字符",
|
||||
"minValue": "必须不小于 %{min}",
|
||||
"maxValue": "必须不大于 %{max}",
|
||||
"number": "必须为数字",
|
||||
"email": "必须是有效的邮箱",
|
||||
"oneOf": "必须为: %{options}其中一项",
|
||||
"regex": "必须符合指定的格式 (regexp): %{pattern}"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "增加检索",
|
||||
"add": "增加",
|
||||
"back": "回退",
|
||||
"bulk_actions": "选中%{smart_count}项",
|
||||
"cancel": "取消",
|
||||
"clear_input_value": "",
|
||||
"clone": "",
|
||||
"confirm": "",
|
||||
"create": "新建",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"export": "导出",
|
||||
"list": "列表",
|
||||
"refresh": "刷新",
|
||||
"remove_filter": "移除检索",
|
||||
"remove": "删除",
|
||||
"save": "保存",
|
||||
"search": "检索",
|
||||
"show": "显示",
|
||||
"sort": "排序",
|
||||
"undo": "撤销",
|
||||
"expand": "",
|
||||
"close": "",
|
||||
"open_menu": "",
|
||||
"close_menu": ""
|
||||
},
|
||||
"boolean": {
|
||||
"true": "是",
|
||||
"false": "否"
|
||||
},
|
||||
"page": {
|
||||
"create": "新建 %{name}",
|
||||
"dashboard": "概览",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "出现错误",
|
||||
"list": "%{name} 列表",
|
||||
"loading": "加载中",
|
||||
"not_found": "未发现",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "",
|
||||
"invite": ""
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "将文件集合拖拽到这里, 或点击这里选择文件集合.",
|
||||
"upload_single": "将文件拖拽到这里, 或点击这里选择文件."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "将图片文件集合拖拽到这里, 或点击这里选择图片文件集合.",
|
||||
"upload_single": "将图片文件拖拽到这里, 或点击这里选择图片文件."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "未找到参考数据.",
|
||||
"many_missing": "至少有一条参考数据不再可用.",
|
||||
"single_missing": "关联的参考数据不再可用."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "",
|
||||
"toggle_hidden": ""
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "关于",
|
||||
"are_you_sure": "您确定操作?",
|
||||
"bulk_delete_content": "您确定要删除 %{name}? |||| 您确定要删除 %{smart_count} 项?",
|
||||
"bulk_delete_title": "删除 %{name} |||| 删除 %{smart_count}项 %{name} ",
|
||||
"delete_content": "您确定要删除该条目?",
|
||||
"delete_title": "删除 %{name} #%{id}",
|
||||
"details": "",
|
||||
"error": "",
|
||||
"invalid_form": "表单输入无效. 请检查错误提示",
|
||||
"loading": "正在加载页面, 请稍候",
|
||||
"no": "否",
|
||||
"not_found": "您输入了错误的URL或者错误的链接.",
|
||||
"yes": "是",
|
||||
"unsaved_changes": ""
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "结果为空",
|
||||
"no_more_results": "页码 %{page} 超出边界. 试试上一页.",
|
||||
"page_out_of_boundaries": "页码 %{page} 超出边界",
|
||||
"page_out_from_end": "已到最末页",
|
||||
"page_out_from_begin": "已到最前页",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}",
|
||||
"page_rows_per_page": "每页行数:",
|
||||
"next": "向后",
|
||||
"prev": "向前"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "条目已更新 |||| %{smart_count} 项条目已更新",
|
||||
"created": "条目已新建",
|
||||
"deleted": "条目已删除 |||| %{smart_count} 项条目已删除",
|
||||
"bad_item": "不正确的条目",
|
||||
"item_doesnt_exist": "条目不存在",
|
||||
"http_error": "与服务通信出错",
|
||||
"data_provider_error": "dataProvider错误. 请检查console的详细信息.",
|
||||
"i18n_error": "",
|
||||
"canceled": "取消动作",
|
||||
"logged_out": ""
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "",
|
||||
"transcodingDisabled": "",
|
||||
"transcodingEnabled": ""
|
||||
},
|
||||
"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": "清空播放列表",
|
||||
"clickToDeleteText": "点击删除 %{name}",
|
||||
"emptyLyricText": "无歌词",
|
||||
"playModeText": {
|
||||
"order": "顺序播放",
|
||||
"orderLoop": "列表循环",
|
||||
"singleLoop": "单曲循环",
|
||||
"shufflePlay": "随机播放"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
@@ -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 {
|
||||
|
||||
@@ -103,13 +103,32 @@ var _ = Describe("ChangeDetector", func() {
|
||||
Expect(changed).To(BeEmpty())
|
||||
Expect(changed).To(BeEmpty())
|
||||
|
||||
f, err := os.Create(filepath.Join(testFolder, "a", "b", "new.txt"))
|
||||
f.Close()
|
||||
f, _ := os.Create(filepath.Join(testFolder, "a", "b", "new.txt"))
|
||||
_ = f.Close()
|
||||
changed, deleted, err = newScanner.Scan(lastModifiedSince)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(deleted).To(BeEmpty())
|
||||
Expect(changed).To(ConsistOf(P("a/b")))
|
||||
})
|
||||
|
||||
Describe("IsDirOrSymlinkToDir", func() {
|
||||
It("returns true for normal dirs", func() {
|
||||
dir, _ := os.Stat("tests/fixtures")
|
||||
Expect(IsDirOrSymlinkToDir("tests", dir)).To(BeTrue())
|
||||
})
|
||||
It("returns true for symlinks to dirs", func() {
|
||||
dir, _ := os.Stat("tests/fixtures/symlink2dir")
|
||||
Expect(IsDirOrSymlinkToDir("tests/fixtures", dir)).To(BeTrue())
|
||||
})
|
||||
It("returns false for files", func() {
|
||||
dir, _ := os.Stat("tests/fixtures/test.mp3")
|
||||
Expect(IsDirOrSymlinkToDir("tests/fixtures", dir)).To(BeFalse())
|
||||
})
|
||||
It("returns false for symlinks to files", func() {
|
||||
dir, _ := os.Stat("tests/fixtures/symlink")
|
||||
Expect(IsDirOrSymlinkToDir("tests/fixtures", dir)).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// I hate time-based tests....
|
||||
|
||||
@@ -3,6 +3,7 @@ package scanner
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -24,10 +25,16 @@ type Metadata struct {
|
||||
tags map[string]string
|
||||
}
|
||||
|
||||
func (m *Metadata) Title() string { return m.getTag("title", "sort_name") }
|
||||
func (m *Metadata) Album() string { return m.getTag("album", "sort_album") }
|
||||
func (m *Metadata) Artist() string { return m.getTag("artist", "sort_artist") }
|
||||
func (m *Metadata) AlbumArtist() string { return m.getTag("album_artist") }
|
||||
func (m *Metadata) Title() string { return m.getTag("title", "sort_name") }
|
||||
func (m *Metadata) Album() string { return m.getTag("album", "sort_album") }
|
||||
func (m *Metadata) Artist() string { return m.getTag("artist", "sort_artist") }
|
||||
func (m *Metadata) AlbumArtist() string { return m.getTag("album_artist", "albumartist") }
|
||||
func (m *Metadata) SortTitle() string { return m.getSortTag("", "title", "name") }
|
||||
func (m *Metadata) SortAlbum() string { return m.getSortTag("", "album") }
|
||||
func (m *Metadata) SortArtist() string { return m.getSortTag("", "artist") }
|
||||
func (m *Metadata) SortAlbumArtist() string {
|
||||
return m.getSortTag("tso2", "albumartist", "album_artist")
|
||||
}
|
||||
func (m *Metadata) Composer() string { return m.getTag("composer", "tcm", "sort_composer") }
|
||||
func (m *Metadata) Genre() string { return m.getTag("genre") }
|
||||
func (m *Metadata) Year() int { return m.parseYear("date") }
|
||||
@@ -77,7 +84,7 @@ func ExtractAllMetadata(inputs []string) (map[string]*Metadata, error) {
|
||||
args := createProbeCommand(inputs)
|
||||
|
||||
log.Trace("Executing command", "args", args)
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd := exec.Command(args[0], args[1:]...) // #nosec
|
||||
output, _ := cmd.CombinedOutput()
|
||||
mds := map[string]*Metadata{}
|
||||
if len(output) == 0 {
|
||||
@@ -99,7 +106,7 @@ var (
|
||||
inputRegex = regexp.MustCompile(`(?m)^Input #\d+,.*,\sfrom\s'(.*)'`)
|
||||
|
||||
// TITLE : Back In Black
|
||||
tagsRx = regexp.MustCompile(`(?i)^\s{4,6}(\w+)\s*:(.*)`)
|
||||
tagsRx = regexp.MustCompile(`(?i)^\s{4,6}([\w-]+)\s*:(.*)`)
|
||||
|
||||
// Duration: 00:04:16.00, start: 0.000000, bitrate: 995 kb/s`
|
||||
durationRx = regexp.MustCompile(`^\s\sDuration: ([\d.:]+).*bitrate: (\d+)`)
|
||||
@@ -212,7 +219,7 @@ func (m *Metadata) parseYear(tagName string) int {
|
||||
if v, ok := m.tags[tagName]; ok {
|
||||
match := dateRegex.FindStringSubmatch(v)
|
||||
if len(match) == 0 {
|
||||
log.Error("Error parsing year from ffmpeg date field. Please report this issue", "file", m.filePath, "date", v)
|
||||
log.Warn("Error parsing year from ffmpeg date field", "file", m.filePath, "date", v)
|
||||
return 0
|
||||
}
|
||||
year, _ := strconv.Atoi(match[1])
|
||||
@@ -230,6 +237,18 @@ func (m *Metadata) getTag(tags ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *Metadata) getSortTag(originalTag string, tags ...string) string {
|
||||
formats := []string{"sort%s", "sort_%s", "sort-%s", "%ssort", "%s_sort", "%s-sort"}
|
||||
all := []string{originalTag}
|
||||
for _, tag := range tags {
|
||||
for _, format := range formats {
|
||||
name := fmt.Sprintf(format, tag)
|
||||
all = append(all, name)
|
||||
}
|
||||
}
|
||||
return m.getTag(all...)
|
||||
}
|
||||
|
||||
func (m *Metadata) parseTuple(tags ...string) (int, int) {
|
||||
for _, tagName := range tags {
|
||||
if v, ok := m.tags[tagName]; ok {
|
||||
|
||||
@@ -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/fixtures/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"}))
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@ func (s *Scanner) Rescan(mediaFolder string, fullRescan bool) error {
|
||||
log.Debug("Scanning folder (full scan)", "folder", mediaFolder)
|
||||
}
|
||||
|
||||
err := folderScanner.Scan(log.NewContext(nil), lastModifiedSince)
|
||||
err := folderScanner.Scan(log.NewContext(context.TODO()), lastModifiedSince)
|
||||
if err != nil {
|
||||
log.Error("Error importing MediaFolder", "folder", mediaFolder, err)
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func (s *Scanner) RescanAll(fullRescan bool) error {
|
||||
func (s *Scanner) Status() []StatusInfo { return nil }
|
||||
|
||||
func (s *Scanner) getLastModifiedSince(folder string) time.Time {
|
||||
ms, err := s.ds.Property(nil).Get(model.PropLastScan + "-" + folder)
|
||||
ms, err := s.ds.Property(context.TODO()).Get(model.PropLastScan + "-" + folder)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
@@ -72,11 +72,13 @@ func (s *Scanner) getLastModifiedSince(folder string) time.Time {
|
||||
|
||||
func (s *Scanner) updateLastModifiedSince(folder string, t time.Time) {
|
||||
millis := t.UnixNano() / int64(time.Millisecond)
|
||||
s.ds.Property(nil).Put(model.PropLastScan+"-"+folder, fmt.Sprint(millis))
|
||||
if err := s.ds.Property(context.TODO()).Put(model.PropLastScan+"-"+folder, fmt.Sprint(millis)); err != nil {
|
||||
log.Error("Error updating DB after scan", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scanner) loadFolders() {
|
||||
fs, _ := s.ds.MediaFolder(nil).GetAll()
|
||||
fs, _ := s.ds.MediaFolder(context.TODO()).GetAll()
|
||||
for _, f := range fs {
|
||||
log.Info("Configuring Media Folder", "name", f.Name, "path", f.Path)
|
||||
s.folders[f.Path] = NewTagScanner(f.Path, s.ds)
|
||||
@@ -85,12 +87,6 @@ func (s *Scanner) loadFolders() {
|
||||
|
||||
type Status int
|
||||
|
||||
const (
|
||||
StatusComplete Status = iota
|
||||
StatusInProgress
|
||||
StatusError
|
||||
)
|
||||
|
||||
type StatusInfo struct {
|
||||
MediaFolder string
|
||||
Status Status
|
||||
|
||||
@@ -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 {
|
||||
@@ -111,7 +113,7 @@ func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time) erro
|
||||
return err
|
||||
}
|
||||
|
||||
err = s.ds.GC(log.NewContext(nil))
|
||||
err = s.ds.GC(log.NewContext(context.TODO()))
|
||||
log.Info("Finished Music Folder", "folder", s.rootFolder, "elapsed", time.Since(start))
|
||||
|
||||
return err
|
||||
@@ -241,7 +243,7 @@ func (s *TagScanner) loadTracks(filePaths []string) (model.MediaFiles, error) {
|
||||
}
|
||||
|
||||
func (s *TagScanner) toMediaFile(md *Metadata) model.MediaFile {
|
||||
mf := model.MediaFile{}
|
||||
mf := &model.MediaFile{}
|
||||
mf.ID = s.trackID(md)
|
||||
mf.Title = s.mapTrackTitle(md)
|
||||
mf.Album = md.Album()
|
||||
@@ -262,12 +264,25 @@ func (s *TagScanner) toMediaFile(md *Metadata) model.MediaFile {
|
||||
mf.Suffix = md.Suffix()
|
||||
mf.Size = md.Size()
|
||||
mf.HasCoverArt = md.HasPicture()
|
||||
mf.SortTitle = md.SortTitle()
|
||||
mf.SortAlbumName = md.SortAlbum()
|
||||
mf.SortArtistName = md.SortArtist()
|
||||
mf.SortAlbumArtistName = md.SortAlbumArtist()
|
||||
mf.OrderAlbumName = sanitizeFieldForSorting(mf.Album)
|
||||
mf.OrderArtistName = sanitizeFieldForSorting(mf.Artist)
|
||||
mf.OrderAlbumArtistName = sanitizeFieldForSorting(mf.AlbumArtist)
|
||||
|
||||
// TODO Get Creation time. https://github.com/djherbis/times ?
|
||||
mf.CreatedAt = md.ModificationTime()
|
||||
mf.UpdatedAt = md.ModificationTime()
|
||||
|
||||
return mf
|
||||
return *mf
|
||||
}
|
||||
|
||||
func sanitizeFieldForSorting(originalValue string) string {
|
||||
v := utils.NoArticle(originalValue)
|
||||
v = strings.TrimSpace(sanitize.Accents(v))
|
||||
return utils.NoArticle(v)
|
||||
}
|
||||
|
||||
func (s *TagScanner) mapTrackTitle(md *Metadata) string {
|
||||
|
||||
21
scanner/tag_scanner_test.go
Normal file
21
scanner/tag_scanner_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"github.com/deluan/navidrome/conf"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("TagScanner", func() {
|
||||
Describe("sanitizeFieldForSorting", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.IgnoredArticles = "The"
|
||||
})
|
||||
It("sanitize accents", func() {
|
||||
Expect(sanitizeFieldForSorting("Céu")).To(Equal("Ceu"))
|
||||
})
|
||||
It("removes articles", func() {
|
||||
Expect(sanitizeFieldForSorting("The Beatles")).To(Equal("Beatles"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/assets"
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/engine/auth"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/rest"
|
||||
@@ -41,15 +42,16 @@ func (app *Router) routes(path string) http.Handler {
|
||||
r.Use(mapAuthHeader())
|
||||
r.Use(jwtauth.Verifier(auth.TokenAuth))
|
||||
r.Use(authenticator(app.ds))
|
||||
app.R(r, "/user", model.User{})
|
||||
app.R(r, "/song", model.MediaFile{})
|
||||
app.R(r, "/album", model.Album{})
|
||||
app.R(r, "/artist", model.Artist{})
|
||||
app.R(r, "/transcoding", model.Transcoding{})
|
||||
app.R(r, "/player", model.Player{})
|
||||
app.R(r, "/user", model.User{}, true)
|
||||
app.R(r, "/song", model.MediaFile{}, true)
|
||||
app.R(r, "/album", model.Album{}, true)
|
||||
app.R(r, "/artist", model.Artist{}, true)
|
||||
app.R(r, "/player", model.Player{}, true)
|
||||
app.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
|
||||
app.addResource(r, "/translation", newTranslationRepository, false)
|
||||
|
||||
// Keepalive endpoint to be used to keep the session valid (ex: while playing songs)
|
||||
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"response":"ok"}`)) })
|
||||
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"response":"ok"}`)) })
|
||||
})
|
||||
|
||||
// Serve UI app assets
|
||||
@@ -59,18 +61,26 @@ func (app *Router) routes(path string) http.Handler {
|
||||
return r
|
||||
}
|
||||
|
||||
func (app *Router) R(r chi.Router, pathPrefix string, model interface{}) {
|
||||
func (app *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
return app.ds.Resource(ctx, model)
|
||||
}
|
||||
app.addResource(r, pathPrefix, constructor, persistable)
|
||||
}
|
||||
|
||||
func (app *Router) addResource(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) {
|
||||
r.Route(pathPrefix, func(r chi.Router) {
|
||||
r.Get("/", rest.GetAll(constructor))
|
||||
r.Post("/", rest.Post(constructor))
|
||||
r.Route("/{id:[0-9a-f\\-]+}", func(r chi.Router) {
|
||||
if persistable {
|
||||
r.Post("/", rest.Post(constructor))
|
||||
}
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Use(UrlParams)
|
||||
r.Get("/", rest.Get(constructor))
|
||||
r.Put("/", rest.Put(constructor))
|
||||
r.Delete("/", rest.Delete(constructor))
|
||||
if persistable {
|
||||
r.Put("/", rest.Put(constructor))
|
||||
r.Delete("/", rest.Delete(constructor))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
@@ -20,7 +19,6 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
ErrFirstTime = errors.New("no users created")
|
||||
)
|
||||
|
||||
@@ -31,7 +29,7 @@ func Login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, err := getCredentialsFromBody(r)
|
||||
if err != nil {
|
||||
log.Error(r, "Parsing request body", err)
|
||||
rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error())
|
||||
_ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -42,21 +40,21 @@ func Login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) {
|
||||
func handleLogin(ds model.DataStore, username string, password string, w http.ResponseWriter, r *http.Request) {
|
||||
user, err := validateLogin(ds.User(r.Context()), username, password)
|
||||
if err != nil {
|
||||
rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authentication user. Please try again")
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authentication user. Please try again")
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
log.Warn(r, "Unsuccessful login", "username", username, "request", r.Header)
|
||||
rest.RespondWithError(w, http.StatusUnauthorized, "Invalid username or password")
|
||||
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Invalid username or password")
|
||||
return
|
||||
}
|
||||
|
||||
tokenString, err := auth.CreateToken(user)
|
||||
if err != nil {
|
||||
rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authenticating user. Please try again")
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authenticating user. Please try again")
|
||||
return
|
||||
}
|
||||
rest.RespondWithJSON(w, http.StatusOK,
|
||||
_ = rest.RespondWithJSON(w, http.StatusOK,
|
||||
map[string]interface{}{
|
||||
"message": "User '" + username + "' authenticated successfully",
|
||||
"token": tokenString,
|
||||
@@ -71,7 +69,7 @@ func getCredentialsFromBody(r *http.Request) (username string, password string,
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err = decoder.Decode(&data); err != nil {
|
||||
log.Error(r, "parsing request body", err)
|
||||
err = errors.New("Invalid request payload")
|
||||
err = errors.New("invalid request payload")
|
||||
return
|
||||
}
|
||||
username = data["username"]
|
||||
@@ -86,21 +84,21 @@ func CreateAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request
|
||||
username, password, err := getCredentialsFromBody(r)
|
||||
if err != nil {
|
||||
log.Error(r, "parsing request body", err)
|
||||
rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error())
|
||||
_ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
c, err := ds.User(r.Context()).CountAll()
|
||||
if err != nil {
|
||||
rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if c > 0 {
|
||||
rest.RespondWithError(w, http.StatusForbidden, "Cannot create another first admin")
|
||||
_ = rest.RespondWithError(w, http.StatusForbidden, "Cannot create another first admin")
|
||||
return
|
||||
}
|
||||
err = createDefaultUser(r.Context(), ds, username, password)
|
||||
if err != nil {
|
||||
rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
handleLogin(ds, username, password, w, r)
|
||||
@@ -186,11 +184,11 @@ func authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, err := getToken(ds, r.Context())
|
||||
if err == ErrFirstTime {
|
||||
rest.RespondWithJSON(w, http.StatusUnauthorized, map[string]string{"message": ErrFirstTime.Error()})
|
||||
_ = rest.RespondWithJSON(w, http.StatusUnauthorized, map[string]string{"message": ErrFirstTime.Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
|
||||
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -200,7 +198,7 @@ func authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
newTokenString, err := auth.TouchToken(token)
|
||||
if err != nil {
|
||||
log.Error(r, "signing new token", err)
|
||||
rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
|
||||
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
// Injects the `firstTime` config in the `index.html` template
|
||||
// Injects the config in the `index.html` template
|
||||
func ServeIndex(ds model.DataStore, fs http.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := ds.User(r.Context()).CountAll()
|
||||
@@ -21,13 +21,22 @@ func ServeIndex(ds model.DataStore, fs http.FileSystem) http.HandlerFunc {
|
||||
|
||||
t := getIndexTemplate(r, fs)
|
||||
|
||||
appConfig := map[string]interface{}{
|
||||
"version": consts.Version(),
|
||||
"firstTime": firstTime,
|
||||
"baseURL": strings.TrimSuffix(conf.Server.BaseURL, "/"),
|
||||
"loginBackgroundURL": conf.Server.UILoginBackgroundURL,
|
||||
if err != nil {
|
||||
log.Error("Error loading default English translation file", err)
|
||||
}
|
||||
appConfig := map[string]interface{}{
|
||||
"version": consts.Version(),
|
||||
"firstTime": firstTime,
|
||||
"baseURL": strings.TrimSuffix(conf.Server.BaseURL, "/"),
|
||||
"loginBackgroundURL": conf.Server.UILoginBackgroundURL,
|
||||
"enableTranscodingConfig": conf.Server.EnableTranscodingConfig,
|
||||
}
|
||||
j, err := json.Marshal(appConfig)
|
||||
if err != nil {
|
||||
log.Error(r, "Error converting config to JSON", "config", appConfig, err)
|
||||
} else {
|
||||
log.Trace(r, "Injecting config in index.html", "config", string(j))
|
||||
}
|
||||
j, _ := json.Marshal(appConfig)
|
||||
|
||||
data := map[string]interface{}{
|
||||
"AppConfig": string(j),
|
||||
|
||||
124
server/app/translations.go
Normal file
124
server/app/translations.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/resources"
|
||||
"github.com/deluan/rest"
|
||||
)
|
||||
|
||||
const i18nFolder = "i18n"
|
||||
|
||||
type translation struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
translations map[string]translation
|
||||
)
|
||||
|
||||
func newTranslationRepository(context.Context) rest.Repository {
|
||||
if err := loadTranslations(); err != nil {
|
||||
log.Error("Error loading translation files", err)
|
||||
}
|
||||
return &translationRepository{}
|
||||
}
|
||||
|
||||
type translationRepository struct{}
|
||||
|
||||
func (r *translationRepository) Read(id string) (interface{}, error) {
|
||||
if t, ok := translations[id]; ok {
|
||||
return t, nil
|
||||
}
|
||||
return nil, rest.ErrNotFound
|
||||
}
|
||||
|
||||
// Simple Count implementation. Does not support any `options`
|
||||
func (r *translationRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return int64(len(translations)), nil
|
||||
}
|
||||
|
||||
// Simple ReadAll implementation, only returns IDs. Does not support any `options`
|
||||
func (r *translationRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
var result []translation
|
||||
for _, t := range translations {
|
||||
t.Data = ""
|
||||
result = append(result, t)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *translationRepository) EntityName() string {
|
||||
return "translation"
|
||||
}
|
||||
|
||||
func (r *translationRepository) NewInstance() interface{} {
|
||||
return &translation{}
|
||||
}
|
||||
|
||||
func loadTranslations() (loadError error) {
|
||||
once.Do(func() {
|
||||
translations = make(map[string]translation)
|
||||
dir, err := resources.AssetFile().Open(i18nFolder)
|
||||
if err != nil {
|
||||
loadError = err
|
||||
return
|
||||
}
|
||||
files, err := dir.Readdir(0)
|
||||
if err != nil {
|
||||
loadError = err
|
||||
return
|
||||
}
|
||||
var languages []string
|
||||
for _, f := range files {
|
||||
t, err := loadTranslation(f.Name())
|
||||
if err != nil {
|
||||
log.Error("Error loading translation file", "file", f.Name(), err)
|
||||
continue
|
||||
}
|
||||
translations[t.ID] = t
|
||||
languages = append(languages, t.ID)
|
||||
}
|
||||
log.Info("Loading translations", "languages", languages)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func loadTranslation(fileName string) (translation translation, err error) {
|
||||
// Get id and full path
|
||||
name := filepath.Base(fileName)
|
||||
id := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
filePath := filepath.Join(i18nFolder, name)
|
||||
|
||||
// Load translation from json file
|
||||
data, err := resources.Asset(filePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err = json.Unmarshal(data, &out); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Compress JSON
|
||||
buf := new(bytes.Buffer)
|
||||
if err = json.Compact(buf, data); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
translation.Data = buf.String()
|
||||
translation.Name = out["languageName"].(string)
|
||||
translation.ID = id
|
||||
return
|
||||
}
|
||||
|
||||
var _ rest.Repository = (*translationRepository)(nil)
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -18,7 +19,8 @@ func initialSetup(ds model.DataStore) {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := ds.Property(nil).Get(consts.InitialSetupFlagKey)
|
||||
properties := ds.Property(context.TODO())
|
||||
_, err := properties.Get(consts.InitialSetupFlagKey)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -33,13 +35,14 @@ func initialSetup(ds model.DataStore) {
|
||||
}
|
||||
}
|
||||
|
||||
err = ds.Property(nil).Put(consts.InitialSetupFlagKey, time.Now().String())
|
||||
err = properties.Put(consts.InitialSetupFlagKey, time.Now().String())
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func createInitialAdminUser(ds model.DataStore) error {
|
||||
c, err := ds.User(nil).CountAll()
|
||||
users := ds.User(context.TODO())
|
||||
c, err := users.CountAll()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Could not access User table: %s", err))
|
||||
}
|
||||
@@ -59,7 +62,7 @@ func createInitialAdminUser(ds model.DataStore) error {
|
||||
Password: initialPassword,
|
||||
IsAdmin: true,
|
||||
}
|
||||
err := ds.User(nil).Put(&initialUser)
|
||||
err := users.Put(&initialUser)
|
||||
if err != nil {
|
||||
log.Error("Could not create initial admin user", "user", initialUser, err)
|
||||
}
|
||||
@@ -68,13 +71,14 @@ func createInitialAdminUser(ds model.DataStore) error {
|
||||
}
|
||||
|
||||
func createJWTSecret(ds model.DataStore) error {
|
||||
_, err := ds.Property(nil).Get(consts.JWTSecretKey)
|
||||
properties := ds.Property(context.TODO())
|
||||
_, err := properties.Get(consts.JWTSecretKey)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
jwtSecret, _ := uuid.NewRandom()
|
||||
log.Warn("Creating JWT secret, used for encrypting UI sessions")
|
||||
err = ds.Property(nil).Put(consts.JWTSecretKey, jwtSecret.String())
|
||||
err = properties.Put(consts.JWTSecretKey, jwtSecret.String())
|
||||
if err != nil {
|
||||
log.Error("Could not save JWT secret in DB", err)
|
||||
}
|
||||
@@ -82,8 +86,8 @@ func createJWTSecret(ds model.DataStore) error {
|
||||
}
|
||||
|
||||
func createDefaultTranscodings(ds model.DataStore) error {
|
||||
repo := ds.Transcoding(nil)
|
||||
c, _ := repo.CountAll()
|
||||
transcodings := ds.Transcoding(context.TODO())
|
||||
c, _ := transcodings.CountAll()
|
||||
if c != 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -98,7 +102,7 @@ func createDefaultTranscodings(ds model.DataStore) error {
|
||||
return err
|
||||
}
|
||||
log.Info("Creating default transcoding config", "name", t.Name)
|
||||
if err = repo.Put(&t); err != nil {
|
||||
if err = transcodings.Put(&t); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
@@ -12,37 +11,45 @@ import (
|
||||
)
|
||||
|
||||
type AlbumListController struct {
|
||||
listGen engine.ListGenerator
|
||||
listFunctions map[string]strategy
|
||||
listGen engine.ListGenerator
|
||||
}
|
||||
|
||||
func NewAlbumListController(listGen engine.ListGenerator) *AlbumListController {
|
||||
c := &AlbumListController{
|
||||
listGen: listGen,
|
||||
}
|
||||
c.listFunctions = map[string]strategy{
|
||||
"random": c.listGen.GetRandom,
|
||||
"newest": c.listGen.GetNewest,
|
||||
"recent": c.listGen.GetRecent,
|
||||
"frequent": c.listGen.GetFrequent,
|
||||
"highest": c.listGen.GetHighest,
|
||||
"alphabeticalByName": c.listGen.GetByName,
|
||||
"alphabeticalByArtist": c.listGen.GetByArtist,
|
||||
"starred": c.listGen.GetStarred,
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
type strategy func(ctx context.Context, offset int, size int) (engine.Entries, error)
|
||||
|
||||
func (c *AlbumListController) getAlbumList(r *http.Request) (engine.Entries, error) {
|
||||
func (c *AlbumListController) getNewAlbumList(r *http.Request) (engine.Entries, error) {
|
||||
typ, err := RequiredParamString(r, "type", "Required string parameter 'type' is not present")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
listFunc, found := c.listFunctions[typ]
|
||||
|
||||
if !found {
|
||||
var filter engine.ListFilter
|
||||
switch typ {
|
||||
case "newest":
|
||||
filter = engine.ByNewest()
|
||||
case "recent":
|
||||
filter = engine.ByRecent()
|
||||
case "random":
|
||||
filter = engine.ByRandom()
|
||||
case "alphabeticalByName":
|
||||
filter = engine.ByName()
|
||||
case "alphabeticalByArtist":
|
||||
filter = engine.ByArtist()
|
||||
case "frequent":
|
||||
filter = engine.ByFrequent()
|
||||
case "starred":
|
||||
filter = engine.ByStarred()
|
||||
case "highest":
|
||||
filter = engine.ByRating()
|
||||
case "byGenre":
|
||||
filter = engine.ByGenre(utils.ParamString(r, "genre"))
|
||||
case "byYear":
|
||||
filter = engine.ByYear(utils.ParamInt(r, "fromYear", 0), utils.ParamInt(r, "toYear", 0))
|
||||
default:
|
||||
log.Error(r, "albumList type not implemented", "type", typ)
|
||||
return nil, errors.New("Not implemented!")
|
||||
}
|
||||
@@ -50,7 +57,7 @@ func (c *AlbumListController) getAlbumList(r *http.Request) (engine.Entries, err
|
||||
offset := utils.ParamInt(r, "offset", 0)
|
||||
size := utils.MinInt(utils.ParamInt(r, "size", 10), 500)
|
||||
|
||||
albums, err := listFunc(r.Context(), offset, size)
|
||||
albums, err := c.listGen.GetAlbums(r.Context(), offset, size, filter)
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving albums", "error", err)
|
||||
return nil, errors.New("Internal Error")
|
||||
@@ -60,7 +67,7 @@ func (c *AlbumListController) getAlbumList(r *http.Request) (engine.Entries, err
|
||||
}
|
||||
|
||||
func (c *AlbumListController) GetAlbumList(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
albums, err := c.getAlbumList(r)
|
||||
albums, err := c.getNewAlbumList(r)
|
||||
if err != nil {
|
||||
return nil, NewError(responses.ErrorGeneric, err.Error())
|
||||
}
|
||||
@@ -71,7 +78,7 @@ func (c *AlbumListController) GetAlbumList(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
func (c *AlbumListController) GetAlbumList2(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
albums, err := c.getAlbumList(r)
|
||||
albums, err := c.getNewAlbumList(r)
|
||||
if err != nil {
|
||||
return nil, NewError(responses.ErrorGeneric, err.Error())
|
||||
}
|
||||
@@ -134,8 +141,27 @@ func (c *AlbumListController) GetNowPlaying(w http.ResponseWriter, r *http.Reque
|
||||
func (c *AlbumListController) GetRandomSongs(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
size := utils.MinInt(utils.ParamInt(r, "size", 10), 500)
|
||||
genre := utils.ParamString(r, "genre")
|
||||
fromYear := utils.ParamInt(r, "fromYear", 0)
|
||||
toYear := utils.ParamInt(r, "toYear", 0)
|
||||
|
||||
songs, err := c.listGen.GetRandomSongs(r.Context(), size, genre)
|
||||
songs, err := c.listGen.GetSongs(r.Context(), 0, size, engine.SongsByRandom(genre, fromYear, toYear))
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving random songs", "error", err)
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
}
|
||||
|
||||
response := NewResponse()
|
||||
response.RandomSongs = &responses.Songs{}
|
||||
response.RandomSongs.Songs = ToChildren(r.Context(), songs)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *AlbumListController) GetSongsByGenre(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
count := utils.MinInt(utils.ParamInt(r, "count", 10), 500)
|
||||
offset := utils.MinInt(utils.ParamInt(r, "offset", 0), 500)
|
||||
genre := utils.ParamString(r, "genre")
|
||||
|
||||
songs, err := c.listGen.GetSongs(r.Context(), offset, count, engine.SongsByGenre(genre))
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving random songs", "error", err)
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal Error")
|
||||
|
||||
@@ -18,7 +18,7 @@ type fakeListGen struct {
|
||||
recvSize int
|
||||
}
|
||||
|
||||
func (lg *fakeListGen) GetNewest(ctx context.Context, offset int, size int) (engine.Entries, error) {
|
||||
func (lg *fakeListGen) GetAlbums(ctx context.Context, offset int, size int, filter engine.ListFilter) (engine.Entries, error) {
|
||||
if lg.err != nil {
|
||||
return nil, lg.err
|
||||
}
|
||||
|
||||
@@ -5,15 +5,18 @@ import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/engine"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/server/subsonic/responses"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
)
|
||||
|
||||
const Version = "1.8.0"
|
||||
const Version = "1.10.2"
|
||||
|
||||
type Handler = func(http.ResponseWriter, *http.Request) (*responses.Subsonic, error)
|
||||
|
||||
@@ -35,7 +38,6 @@ type Router struct {
|
||||
func New(browser engine.Browser, cover engine.Cover, listGenerator engine.ListGenerator, users engine.Users,
|
||||
playlists engine.Playlists, ratings engine.Ratings, scrobbler engine.Scrobbler, search engine.Search,
|
||||
streamer engine.MediaStreamer, players engine.Players) *Router {
|
||||
|
||||
r := &Router{Browser: browser, Cover: cover, ListGenerator: listGenerator, Playlists: playlists,
|
||||
Ratings: ratings, Scrobbler: scrobbler, Search: search, Users: users, Streamer: streamer, Players: players}
|
||||
r.mux = r.routes()
|
||||
@@ -86,6 +88,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 +118,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)
|
||||
@@ -155,7 +161,7 @@ func H(r chi.Router, path string, f Handler) {
|
||||
func HGone(r chi.Router, path string) {
|
||||
handle := func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(410)
|
||||
w.Write([]byte("This endpoint will not be implemented"))
|
||||
_, _ = w.Write([]byte("This endpoint will not be implemented"))
|
||||
}
|
||||
r.HandleFunc("/"+path, handle)
|
||||
r.HandleFunc("/"+path+".view", handle)
|
||||
@@ -200,5 +206,7 @@ func SendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub
|
||||
} else {
|
||||
log.Warn(r.Context(), "API: Failed response", "error", payload.Error.Code, "message", payload.Error.Message)
|
||||
}
|
||||
w.Write(response)
|
||||
if _, err := w.Write(response); err != nil {
|
||||
log.Error(r, "Error sending response to client", "payload", string(response), err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"github.com/deluan/navidrome/engine"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/resources"
|
||||
"github.com/deluan/navidrome/server/subsonic/responses"
|
||||
"github.com/deluan/navidrome/static"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
@@ -21,13 +21,13 @@ func NewMediaRetrievalController(cover engine.Cover) *MediaRetrievalController {
|
||||
}
|
||||
|
||||
func (c *MediaRetrievalController) GetAvatar(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
f, err := static.AssetFile().Open("navidrome-310x310.png")
|
||||
f, err := resources.AssetFile().Open("navidrome-310x310.png")
|
||||
if err != nil {
|
||||
log.Error(r, "Image not found", err)
|
||||
return nil, NewError(responses.ErrorDataNotFound, "Avatar image not found")
|
||||
}
|
||||
defer f.Close()
|
||||
io.Copy(w, f)
|
||||
_, _ = io.Copy(w, f)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ func (c *fakeCover) Get(ctx context.Context, id string, size int, out io.Writer)
|
||||
}
|
||||
c.recvId = id
|
||||
c.recvSize = size
|
||||
out.Write([]byte(c.data))
|
||||
return nil
|
||||
_, err := out.Write([]byte(c.data))
|
||||
return err
|
||||
}
|
||||
|
||||
var _ = Describe("MediaRetrievalController", func() {
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
0
tests/fixtures/empty_folder/not_an_audio_file.txt
vendored
Normal file
0
tests/fixtures/empty_folder/not_an_audio_file.txt
vendored
Normal file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user