mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-06 05:48:09 -05:00
Compare commits
365 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d1d992e17 | ||
|
|
653b5ea9d3 | ||
|
|
e73b71aaf7 | ||
|
|
01919661e9 | ||
|
|
3190611ec8 | ||
|
|
6a3dabbb06 | ||
|
|
238020c839 | ||
|
|
72b2e756f7 | ||
|
|
86bc8d97a0 | ||
|
|
003b73fe1a | ||
|
|
be2afb94ae | ||
|
|
f8a18b59b0 | ||
|
|
c216b14655 | ||
|
|
4702c5abbd | ||
|
|
c742ae0843 | ||
|
|
0033966c25 | ||
|
|
f072ffd377 | ||
|
|
94d88395e7 | ||
|
|
c9bcb333ae | ||
|
|
84ed3eb427 | ||
|
|
8bd9787c51 | ||
|
|
1c466d6083 | ||
|
|
a64b15c174 | ||
|
|
7148741a4f | ||
|
|
630c71119a | ||
|
|
50f4bd86a3 | ||
|
|
44c74f42e1 | ||
|
|
29c7513879 | ||
|
|
82d437f004 | ||
|
|
b54d4c75ae | ||
|
|
b636565c62 | ||
|
|
b4e06c416d | ||
|
|
5e2d463129 | ||
|
|
12d5d9573e | ||
|
|
42ee8b64cb | ||
|
|
3908ad2681 | ||
|
|
e9115dab4c | ||
|
|
79cf33281c | ||
|
|
2adb290c34 | ||
|
|
c6f23139bc | ||
|
|
4906b816af | ||
|
|
39afe0c669 | ||
|
|
f8a7ef1e19 | ||
|
|
4776dba003 | ||
|
|
331fa1d952 | ||
|
|
b597a34cb4 | ||
|
|
51fb1d1349 | ||
|
|
8fd86def18 | ||
|
|
5d285f92f5 | ||
|
|
888151728f | ||
|
|
b836dfe7f4 | ||
|
|
ddcfc546fb | ||
|
|
86a9f9e410 | ||
|
|
14d7a69088 | ||
|
|
35e4eec293 | ||
|
|
7547888f10 | ||
|
|
fbedbb7893 | ||
|
|
a7640c9df4 | ||
|
|
8f8d992da4 | ||
|
|
3fe8b02cbd | ||
|
|
ba8c8725dd | ||
|
|
915b701e44 | ||
|
|
596100b58d | ||
|
|
d8699b03bd | ||
|
|
7b36096153 | ||
|
|
62290bca77 | ||
|
|
498e196d48 | ||
|
|
432fe10a5e | ||
|
|
7e625d68b5 | ||
|
|
50f3a2c11d | ||
|
|
9028d301f0 | ||
|
|
26dba27778 | ||
|
|
7170485d08 | ||
|
|
2c68ba3934 | ||
|
|
201a22e613 | ||
|
|
3ca295c863 | ||
|
|
be85fe3773 | ||
|
|
7c3d96cf6c | ||
|
|
50b44c1991 | ||
|
|
f9dae2dd2a | ||
|
|
00811f8000 | ||
|
|
9c940cd44f | ||
|
|
1607dc8b88 | ||
|
|
a42a16696e | ||
|
|
6db63e4dfc | ||
|
|
23bd5e1131 | ||
|
|
8973477fe5 | ||
|
|
fbd6c965b0 | ||
|
|
aaa4f1531e | ||
|
|
72e92c7318 | ||
|
|
72cb3850d1 | ||
|
|
a6cc88177c | ||
|
|
d6ad833538 | ||
|
|
eb1749ce71 | ||
|
|
acebe18c95 | ||
|
|
cac1a20ec8 | ||
|
|
ac8f92d7ac | ||
|
|
207565bde0 | ||
|
|
3ae1586e10 | ||
|
|
5c46f7822f | ||
|
|
c13766bbc3 | ||
|
|
290e8c4bf0 | ||
|
|
442671578d | ||
|
|
1bca8fca97 | ||
|
|
e811816021 | ||
|
|
9331be67a3 | ||
|
|
55ad5c9fc9 | ||
|
|
ec0002e77a | ||
|
|
3632608de0 | ||
|
|
0a3e6c66c1 | ||
|
|
52a46e61e0 | ||
|
|
de2759b3d5 | ||
|
|
978e7f2eaa | ||
|
|
ae847103a2 | ||
|
|
6f6b223453 | ||
|
|
8a68cecdb9 | ||
|
|
e21262675e | ||
|
|
a3ba05b2cc | ||
|
|
294712739a | ||
|
|
ad725ac355 | ||
|
|
17df63b550 | ||
|
|
c2d1e9df9f | ||
|
|
0e4f7036eb | ||
|
|
a4183aea8c | ||
|
|
9e845cb116 | ||
|
|
f82fefe0ab | ||
|
|
f28531b609 | ||
|
|
14f3ffbee6 | ||
|
|
94e1b1f65d | ||
|
|
274eb805f9 | ||
|
|
84ea852339 | ||
|
|
cf019849f0 | ||
|
|
76a5d1928e | ||
|
|
3dced978c7 | ||
|
|
6071ae143e | ||
|
|
05a07f31c9 | ||
|
|
1afbbbf189 | ||
|
|
308163c2e0 | ||
|
|
176bfe1506 | ||
|
|
4c3f3f3573 | ||
|
|
1aef21a4a9 | ||
|
|
d1a0ffaaee | ||
|
|
41010515ee | ||
|
|
a734a1aaa3 | ||
|
|
bf1dc33782 | ||
|
|
c43798c5dd | ||
|
|
12cf2f1104 | ||
|
|
5c95eed517 | ||
|
|
e81a9dd1b5 | ||
|
|
fd49ae319f | ||
|
|
f881e2a54b | ||
|
|
0ca79eead4 | ||
|
|
8a709c489a | ||
|
|
b1f5d35f73 | ||
|
|
5682d0e721 | ||
|
|
ab690215ef | ||
|
|
8f9601090c | ||
|
|
aebee651ac | ||
|
|
a56e588c8e | ||
|
|
27de18f8c9 | ||
|
|
5afcd0ad22 | ||
|
|
fec589dce5 | ||
|
|
4e613be960 | ||
|
|
8e2480a82d | ||
|
|
50eda78ca1 | ||
|
|
b3af0f880b | ||
|
|
9490374faa | ||
|
|
a340b62fdf | ||
|
|
0d1af8c635 | ||
|
|
377c9e6be6 | ||
|
|
b8ae5ccb02 | ||
|
|
f8362a4acb | ||
|
|
5ce3135f00 | ||
|
|
162971f7b3 | ||
|
|
49dd13002c | ||
|
|
1e5c879fc6 | ||
|
|
e369cbf493 | ||
|
|
a88270a22b | ||
|
|
4355f4fe2d | ||
|
|
0d9361734f | ||
|
|
7f75994906 | ||
|
|
e9d594ebcf | ||
|
|
0d1e2a92f6 | ||
|
|
1ed6d130b1 | ||
|
|
09267d2ffd | ||
|
|
3a6639f820 | ||
|
|
8b79b288eb | ||
|
|
a0cde80c52 | ||
|
|
458636d2b8 | ||
|
|
8b30af561e | ||
|
|
1fb2b9bf1d | ||
|
|
5c9fdb064d | ||
|
|
70047fe20e | ||
|
|
1c41582d79 | ||
|
|
9a854f6cc4 | ||
|
|
06ab88415a | ||
|
|
16f2b056ef | ||
|
|
a761e6f2d0 | ||
|
|
da7489cecd | ||
|
|
0472988645 | ||
|
|
7e0881f0ec | ||
|
|
f8fb4c8f54 | ||
|
|
ddcacbb6e5 | ||
|
|
9d7512e9ab | ||
|
|
2e31b4d046 | ||
|
|
c585ca7131 | ||
|
|
29e2ab1b4a | ||
|
|
8880294ee7 | ||
|
|
a8d3466b0e | ||
|
|
0ee000a8a0 | ||
|
|
0833d87f94 | ||
|
|
23836d7c3c | ||
|
|
5495451448 | ||
|
|
bb01c8973f | ||
|
|
2f4d4c6e38 | ||
|
|
8d99c3ab92 | ||
|
|
8f66e87099 | ||
|
|
3e778e6007 | ||
|
|
b2d6dd0254 | ||
|
|
589c4cf225 | ||
|
|
4b70cc52d6 | ||
|
|
cc1205c79d | ||
|
|
cccd0235cf | ||
|
|
17e51756ef | ||
|
|
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
|
||||
22
.github/workflows/docker-tags.sh
vendored
Executable file
22
.github/workflows/docker-tags.sh
vendored
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
|
||||
GIT_TAG="${GITHUB_REF##refs/tags/}"
|
||||
GIT_BRANCH="${GITHUB_REF##refs/heads/}"
|
||||
GIT_SHA=$(git rev-parse --short HEAD)
|
||||
PR_NUM=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
|
||||
|
||||
DOCKER_IMAGE_TAG="--tag ${DOCKER_IMAGE}:sha-${GIT_SHA}"
|
||||
|
||||
if [[ $PR_NUM != "null" ]]; then
|
||||
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:pr-${PR_NUM}"
|
||||
fi
|
||||
|
||||
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"]
|
||||
161
.github/workflows/pipeline.yml
vendored
Normal file
161
.github/workflows/pipeline.yml
vendored
Normal file
@@ -0,0 +1,161 @@
|
||||
name: Pipeline
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
jobs:
|
||||
golangci-lint:
|
||||
name: Lint Server
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v1
|
||||
with:
|
||||
version: v1.27
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
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: 14
|
||||
|
||||
- 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.3-0
|
||||
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.3-0
|
||||
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:
|
||||
buildx-version: latest
|
||||
qemu-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:14-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"]
|
||||
|
||||
45
Makefile
45
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
|
||||
@@ -26,29 +27,36 @@ watch: check_go_env
|
||||
|
||||
test: check_go_env
|
||||
go test ./... -v
|
||||
# @(cd ./ui && npm test -- --watchAll=false)
|
||||
.PHONY: test
|
||||
|
||||
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)
|
||||
update-snapshots: check_go_env
|
||||
UPDATE_SNAPSHOTS=true ginkgo ./server/subsonic/...
|
||||
.PHONY: update-snapshots
|
||||
|
||||
create-migration:
|
||||
@if [ -z "${name}" ]; then echo "Usage: make create-migration name=name_of_migration_file"; exit 1; fi
|
||||
goose -dir db/migration create ${name}
|
||||
.PHONY: create-migration
|
||||
|
||||
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 reflex || (echo "Installing Reflex" && GO111MODULE=off go get -u github.com/cespare/reflex)
|
||||
@which golangci-lint || (echo "Installing GolangCI-Lint" && cd .. && GO111MODULE=on go get github.com/golangci/golangci-lint/cmd/golangci-lint@v1.27.0)
|
||||
@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
|
||||
@@ -58,12 +66,12 @@ Jamstash-master:
|
||||
rm -rf Jamstash-master/node_modules Jamstash-master/bower_components
|
||||
|
||||
check_env: check_go_env check_node_env
|
||||
.PHONE: check_env
|
||||
.PHONY: check_env
|
||||
|
||||
check_hooks:
|
||||
@lefthook add pre-commit
|
||||
@lefthook add pre-push
|
||||
.PHONE: check_hooks
|
||||
.PHONY: check_hooks
|
||||
|
||||
check_go_env:
|
||||
@(hash go) || (echo "\nERROR: GO environment not setup properly!\n"; exit 1)
|
||||
@@ -76,13 +84,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 +104,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.3-0 goreleaser release --rm-dist --skip-publish --snapshot
|
||||
.PHONY: snapshot
|
||||
|
||||
172
README.md
172
README.md
@@ -2,6 +2,7 @@
|
||||
|
||||
[](https://github.com/deluan/navidrome/releases)
|
||||
[](https://github.com/deluan/navidrome/actions)
|
||||
[](https://github.com/deluan/navidrome/releases/latest)
|
||||
[](https://hub.docker.com/r/deluan/navidrome)
|
||||
[](https://discord.gg/xh7j7yF)
|
||||
[](https://www.reddit.com/r/navidrome/)
|
||||
@@ -12,151 +13,48 @@ music collection from any browser or mobile device. It's like your personal Spot
|
||||
__Any feedback is welcome!__ If you need/want a new feature, find a bug or think of any way to improve Navidrome,
|
||||
please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join the discussion in our
|
||||
[Subreddit](https://www.reddit.com/r/navidrome/). If you want to contribute to the project in any other way
|
||||
(ui/backend dev, translations, [themes](ui/src/themes/README.md)), please join the chat in our
|
||||
([ui/backend dev](https://www.navidrome.org/docs/developers/),
|
||||
[translations](https://www.navidrome.org/docs/developers/translations/),
|
||||
[themes](https://www.navidrome.org/docs/developers/creating-themes)), please join the chat in our
|
||||
[Discord server](https://discord.gg/xh7j7yF).
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
- Handles very large music collections
|
||||
- Streams virtually any audio format available
|
||||
- Reads and uses all your beautifully curated metadata (id3 tags)
|
||||
- Multi-user, each user has their own play counts, playlists, favourites, etc..
|
||||
- Very low resource usage: Ex: with a library of 300GB (~29000 songs), it uses less than 50MB of RAM
|
||||
- Multi-platform, runs on macOS, Linux and Windows. Docker images are also provided
|
||||
- Ready to use Raspberry Pi binaries available
|
||||
- Automatically monitors your library for changes, importing new files and reloading new metadata
|
||||
- [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
|
||||
- Transcoding/Downsampling on-the-fly. Can be set per user/player. Opus encoding is supported
|
||||
- Integrated music player (WIP)
|
||||
|
||||
Navidrome should be compatible with all Subsonic clients. The following clients are tested and confirmed to work properly:
|
||||
- Android: [DSub](https://play.google.com/store/apps/details?id=github.daneren2005.dsub),
|
||||
[Ultrasonic](https://play.google.com/store/apps/details?id=org.moire.ultrasonic) and
|
||||
[Music Stash](https://play.google.com/store/apps/details?id=com.ghenry22.mymusicstash)
|
||||
- iOS: [play:Sub](http://michaelsapps.dk/playsubapp/)
|
||||
- Web: [Jamstash](http://jamstash.com),
|
||||
[Aurial](http://shrimpza.github.io/aurial/),
|
||||
[Subfire](http://p.subfireplayer.net/) and
|
||||
[Subplayer](https://github.com/peguerosdc/subplayer)
|
||||
|
||||
For more options, look at the [list of clients](https://airsonic.github.io/docs/apps/) maintained by
|
||||
the Airsonic project. Please open an [issue](https://github.com/deluan/navidrome/issues) if you have any
|
||||
trouble with the client of your choice.
|
||||
|
||||
|
||||
## Road map
|
||||
|
||||
This project is being actively worked on. Expect a more polished experience and new features/releases
|
||||
on a frequent basis. Some upcoming features planned:
|
||||
|
||||
- Complete WebUI, to browse and listen to your library
|
||||
- Last.FM integration
|
||||
- Smart/dynamic playlists (similar to iTunes)
|
||||
- Support for audiobooks (bookmarking)
|
||||
- Jukebox mode
|
||||
- Sharing links to albums/songs/playlists
|
||||
- Podcasts
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
Various options are available:
|
||||
See instructions in the [project's website](https://www.navidrome.org/docs/installation/)
|
||||
|
||||
### Pre-built executables
|
||||
## Features
|
||||
|
||||
- Handles very **large music collections**
|
||||
- Streams virtually **any audio format** available
|
||||
- Reads and uses all your beautifully curated **metadata**
|
||||
- Great support for **Box Sets** (multi-disc albums)
|
||||
- **Multi-user**, each user has their own play counts, playlists, favourites, etc...
|
||||
- Very **low resource usage**
|
||||
- **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided
|
||||
- Ready to use **Raspberry Pi** binaries and Docker images available
|
||||
- Automatically **monitors your library** for changes, importing new files and reloading new metadata
|
||||
- **Themeable**, modern and responsive **Web interface** based on [Material UI](https://material-ui.com)
|
||||
- **Compatible** with all Subsonic/Madsonic/Airsonic [clients](https://www.navidrome.org/docs/overview/#apps)
|
||||
- **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported**
|
||||
- Translated to **various languages**
|
||||
|
||||
Just head to the [releases page](https://github.com/deluan/navidrome/releases) and download the latest version for you
|
||||
platform. There are builds available for Linux (amd64 and arm), macOS and Windows (32 and 64 bits).
|
||||
For Raspberry Pi (tested with Raspbian Buster on Pi 4), use the Linux arm builds.
|
||||
|
||||
Remember to install [ffmpeg](https://ffmpeg.org/download.html) in your system, a requirement for Navidrome to work
|
||||
properly. You may find the latest static build for your platform here: https://johnvansickle.com/ffmpeg/
|
||||
|
||||
If you have any issues with these binaries, or need a binary for a different platform, please
|
||||
[open an issue](https://github.com/deluan/navidrome/issues)
|
||||
|
||||
### Docker
|
||||
|
||||
[Docker images](https://hub.docker.com/r/deluan/navidrome) are available. They include everything needed
|
||||
to run Navidrome. Example of usage:
|
||||
|
||||
```yaml
|
||||
# This is just an example. Customize it to your needs.
|
||||
|
||||
version: "3"
|
||||
services:
|
||||
navidrome:
|
||||
image: deluan/navidrome:latest
|
||||
ports:
|
||||
- "4533:4533"
|
||||
environment:
|
||||
# All options with their default values:
|
||||
ND_MUSICFOLDER: /music
|
||||
ND_DATAFOLDER: /data
|
||||
ND_SCANINTERVAL: 1m
|
||||
ND_LOGLEVEL: info
|
||||
ND_PORT: 4533
|
||||
ND_TRANSCODINGCACHESIZE: 100MB
|
||||
ND_SESSIONTIMEOUT: 30m
|
||||
ND_BASEURL: ""
|
||||
volumes:
|
||||
- "./data:/data"
|
||||
- "/path/to/your/music/folder:/music:ro"
|
||||
```
|
||||
|
||||
To get the cutting-edge, latest version from master, use the image `deluan/navidrome:develop`
|
||||
|
||||
### Build from source
|
||||
|
||||
You will need to install [Go 1.14](https://golang.org/dl/) and [Node 13.12.0](http://nodejs.org).
|
||||
You'll also need [ffmpeg](https://ffmpeg.org) installed in your system. The setup is very strict, and
|
||||
the steps bellow only work with these specific versions (enforced in the Makefile)
|
||||
|
||||
After the prerequisites above are installed, clone this repository and build the application with:
|
||||
|
||||
```shell script
|
||||
$ git clone https://github.com/deluan/navidrome
|
||||
$ cd navidrome
|
||||
$ make setup # Install tools required for Navidrome's development
|
||||
$ make buildall # Build UI and server, generates a single executable
|
||||
```
|
||||
|
||||
This will generate the `navidrome` executable binary in the project's root folder.
|
||||
|
||||
### Running for the first time
|
||||
|
||||
Start the server with:
|
||||
```shell script
|
||||
./navidrome
|
||||
```
|
||||
The server should start listening for requests on the default port __4533__
|
||||
|
||||
After starting Navidrome for the first time, go to http://localhost:4533. It will ask you to create your first admin
|
||||
user.
|
||||
|
||||
For more options, run `navidrome --help`
|
||||
|
||||
### Running as a service
|
||||
|
||||
Check the [contrib](https://github.com/deluan/navidrome/tree/master/contrib)
|
||||
folder for startup files for your init system.
|
||||
## Documentation
|
||||
All documentation can be found in the project's website: https://www.navidrome.org/docs.
|
||||
Here are some useful direct links:
|
||||
|
||||
- [Overview](https://www.navidrome.org/docs/overview/)
|
||||
- [Installation](https://www.navidrome.org/docs/installation/)
|
||||
- [Docker](https://www.navidrome.org/docs/installation/docker/)
|
||||
- [Binaries](https://www.navidrome.org/docs/installation/pre-built-binaries/)
|
||||
- [Build from source](https://www.navidrome.org/docs/installation/build-from-source/)
|
||||
- [Development](https://www.navidrome.org/docs/developers/)
|
||||
- [Subsonic API Compatibility](https://www.navidrome.org/docs/developers/subsonic-api/)
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<p float="left">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-login.png">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-player.png">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-album-view.png">
|
||||
<img width="900" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-desktop-player.png">
|
||||
<p align="left">
|
||||
<img height="550" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-login.png">
|
||||
<img height="550" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-player.png">
|
||||
<img height="550" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-album-view.png">
|
||||
<img width="550" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-desktop-player.png">
|
||||
</p>
|
||||
</p>
|
||||
|
||||
|
||||
## Subsonic API Version Compatibility
|
||||
|
||||
Check the up to date [compatibility table](https://www.navidrome.org/docs/developers/subsonic-api)
|
||||
for the latest Subsonic features available.
|
||||
|
||||
@@ -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
|
||||
@@ -13,13 +13,14 @@ import (
|
||||
)
|
||||
|
||||
type nd struct {
|
||||
ConfigFile string `default:"./navidrome.toml"`
|
||||
Port string `default:"4533"`
|
||||
MusicFolder string `default:"./music"`
|
||||
DataFolder string `default:"./"`
|
||||
ScanInterval string `default:"1m"`
|
||||
DbPath string ``
|
||||
LogLevel string `default:"info"`
|
||||
SessionTimeout string `default:"30m"`
|
||||
SessionTimeout string `default:"24h"`
|
||||
BaseURL string `default:""`
|
||||
|
||||
UILoginBackgroundURL string `default:"https://source.unsplash.com/random/1600x900?music"`
|
||||
@@ -27,9 +28,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"`
|
||||
@@ -38,6 +40,28 @@ type nd struct {
|
||||
|
||||
var Server = &nd{}
|
||||
|
||||
// TODO refactor configuration and use something different. Maybe https://github.com/spf13/cobra
|
||||
// This function loads the whole config just to get the ConfigFile. This is very cumbersome, but doesn't
|
||||
// seem there's a simpler way to do this with multiconfig. Time to replace this library?
|
||||
func configFile() string {
|
||||
conf := &nd{}
|
||||
loader := multiconfig.MultiLoader(
|
||||
&multiconfig.TagLoader{},
|
||||
&multiconfig.EnvironmentLoader{},
|
||||
&multiconfig.FlagLoader{},
|
||||
)
|
||||
d := &multiconfig.DefaultLoader{}
|
||||
d.Loader = loader
|
||||
d.Validator = multiconfig.MultiValidator(&multiconfig.RequiredValidator{})
|
||||
if err := d.Load(conf); err != nil {
|
||||
return consts.LocalConfigFile
|
||||
}
|
||||
if _, err := os.Stat(conf.ConfigFile); err != nil {
|
||||
return consts.LocalConfigFile
|
||||
}
|
||||
return conf.ConfigFile
|
||||
}
|
||||
|
||||
func newWithPath(path string, skipFlags ...bool) *multiconfig.DefaultLoader {
|
||||
var loaders []multiconfig.Loader
|
||||
|
||||
@@ -91,9 +115,9 @@ func LoadFromFile(confFile string, skipFlags ...bool) {
|
||||
}
|
||||
log.SetLevelString(Server.LogLevel)
|
||||
log.SetLogSourceLine(Server.DevLogSourceLine)
|
||||
log.Trace("Loaded configuration", "file", confFile, "config", fmt.Sprintf("%#v", Server))
|
||||
log.Debug("Loaded configuration", "file", confFile, "config", fmt.Sprintf("%#v", Server))
|
||||
}
|
||||
|
||||
func Load() {
|
||||
LoadFromFile(consts.LocalConfigFile)
|
||||
LoadFromFile(configFile())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const (
|
||||
AppName = "navidrome"
|
||||
|
||||
LocalConfigFile = "./navidrome.toml"
|
||||
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL"
|
||||
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on"
|
||||
InitialSetupFlagKey = "InitialSetup"
|
||||
|
||||
UIAuthorizationHeader = "X-ND-Authorization"
|
||||
@@ -19,13 +19,19 @@ const (
|
||||
JWTIssuer = "ND"
|
||||
DefaultSessionTimeout = 30 * time.Minute
|
||||
|
||||
UIAssetsLocalPath = "ui/build"
|
||||
|
||||
DevInitialUserName = "admin"
|
||||
DevInitialName = "Dev Admin"
|
||||
|
||||
URLPathUI = "/app"
|
||||
URLPathSubsonicAPI = "/rest"
|
||||
|
||||
RequestThrottleBacklogLimit = 100
|
||||
RequestThrottleBacklogTimeout = time.Minute
|
||||
|
||||
I18nFolder = "i18n"
|
||||
SkipScanFile = ".ndignore"
|
||||
|
||||
PlaceholderAlbumArt = "navidrome-600x600.png"
|
||||
)
|
||||
|
||||
// Cache options
|
||||
@@ -61,5 +67,4 @@ var (
|
||||
VariousArtists = "Various Artists"
|
||||
VariousArtistsID = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(VariousArtists))))
|
||||
UnknownArtist = "[Unknown Artist]"
|
||||
UnknownArtistID = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(UnknownArtist))))
|
||||
)
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
# 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
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
[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 +32,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
|
||||
|
||||
2
db/db.go
2
db/db.go
@@ -27,7 +27,7 @@ func Db() *sql.DB {
|
||||
var err error
|
||||
Path = conf.Server.DbPath
|
||||
if Path == ":memory:" {
|
||||
Path = "file::memory:?cache=shared"
|
||||
Path = "file::memory:?cache=shared&_foreign_keys=on"
|
||||
conf.Server.DbPath = Path
|
||||
}
|
||||
log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
27
db/migration/20200508093059_add_artist_song_count.go
Normal file
27
db/migration/20200508093059_add_artist_song_count.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200508093059, Down20200508093059)
|
||||
}
|
||||
|
||||
func Up20200508093059(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table artist
|
||||
add song_count integer default 0 not null;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed to calculate artists' song counts")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200508093059(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
27
db/migration/20200512104202_add_disc_subtitle.go
Normal file
27
db/migration/20200512104202_add_disc_subtitle.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200512104202, Down20200512104202)
|
||||
}
|
||||
|
||||
func Up20200512104202(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table media_file
|
||||
add disc_subtitle varchar(255);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed to import disc subtitles")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200512104202(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
100
db/migration/20200516140647_add_playlist_tracks_table.go
Normal file
100
db/migration/20200516140647_add_playlist_tracks_table.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200516140647, Down20200516140647)
|
||||
}
|
||||
|
||||
func Up20200516140647(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table if not exists playlist_tracks
|
||||
(
|
||||
id integer default 0 not null,
|
||||
playlist_id varchar(255) not null,
|
||||
media_file_id varchar(255) not null
|
||||
);
|
||||
|
||||
create unique index if not exists playlist_tracks_pos
|
||||
on playlist_tracks (playlist_id, id);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := tx.Query("select id, tracks from playlist")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
var id, tracks string
|
||||
for rows.Next() {
|
||||
err := rows.Scan(&id, &tracks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = Up20200516140647UpdatePlaylistTracks(tx, id, tracks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
create table playlist_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar(255) default '' not null,
|
||||
comment varchar(255) default '' not null,
|
||||
duration real default 0 not null,
|
||||
song_count integer default 0 not null,
|
||||
owner varchar(255) default '' not null,
|
||||
public bool default FALSE not null,
|
||||
created_at datetime,
|
||||
updated_at datetime
|
||||
);
|
||||
|
||||
insert into playlist_dg_tmp(id, name, comment, duration, owner, public, created_at, updated_at)
|
||||
select id, name, comment, duration, owner, public, created_at, updated_at from playlist;
|
||||
|
||||
drop table playlist;
|
||||
|
||||
alter table playlist_dg_tmp rename to playlist;
|
||||
|
||||
create index playlist_name
|
||||
on playlist (name);
|
||||
|
||||
update playlist set song_count = (select count(*) from playlist_tracks where playlist_id = playlist.id)
|
||||
where id <> ''
|
||||
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Up20200516140647UpdatePlaylistTracks(tx *sql.Tx, id string, tracks string) error {
|
||||
trackList := strings.Split(tracks, ",")
|
||||
stmt, err := tx.Prepare("insert into playlist_tracks (playlist_id, media_file_id, id) values (?, ?, ?)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, trackId := range trackList {
|
||||
_, err := stmt.Exec(id, trackId, i+1)
|
||||
if err != nil {
|
||||
log.Error("Error adding track to playlist", "playlistId", id, "trackId", trackId, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down20200516140647(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
123
db/migration/20200608153717_referential_integrity.go
Normal file
123
db/migration/20200608153717_referential_integrity.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200608153717, Down20200608153717)
|
||||
}
|
||||
|
||||
func Up20200608153717(tx *sql.Tx) error {
|
||||
// First delete dangling players
|
||||
_, err := tx.Exec(`
|
||||
delete from player where user_name not in (select user_name from user)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add foreign key to player table
|
||||
err = updatePlayer_20200608153717(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add foreign key to playlist table
|
||||
err = updatePlaylist_20200608153717(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add foreign keys to playlist_tracks table
|
||||
return updatePlaylistTracks_20200608153717(tx)
|
||||
}
|
||||
|
||||
func updatePlayer_20200608153717(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table player_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar not null
|
||||
unique,
|
||||
type varchar,
|
||||
user_name varchar not null
|
||||
references user (user_name)
|
||||
on update cascade on delete cascade,
|
||||
client varchar not null,
|
||||
ip_address varchar,
|
||||
last_seen timestamp,
|
||||
max_bit_rate int default 0,
|
||||
transcoding_id varchar null
|
||||
);
|
||||
|
||||
insert into player_dg_tmp(id, name, type, user_name, client, ip_address, last_seen, max_bit_rate, transcoding_id) select id, name, type, user_name, client, ip_address, last_seen, max_bit_rate, transcoding_id from player;
|
||||
|
||||
drop table player;
|
||||
|
||||
alter table player_dg_tmp rename to player;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func updatePlaylist_20200608153717(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table playlist_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar(255) default '' not null,
|
||||
comment varchar(255) default '' not null,
|
||||
duration real default 0 not null,
|
||||
song_count integer default 0 not null,
|
||||
owner varchar(255) default '' not null
|
||||
constraint playlist_user_user_name_fk
|
||||
references user (user_name)
|
||||
on update cascade on delete cascade,
|
||||
public bool default FALSE not null,
|
||||
created_at datetime,
|
||||
updated_at datetime
|
||||
);
|
||||
|
||||
insert into playlist_dg_tmp(id, name, comment, duration, song_count, owner, public, created_at, updated_at) select id, name, comment, duration, song_count, owner, public, created_at, updated_at from playlist;
|
||||
|
||||
drop table playlist;
|
||||
|
||||
alter table playlist_dg_tmp rename to playlist;
|
||||
|
||||
create index playlist_name
|
||||
on playlist (name);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func updatePlaylistTracks_20200608153717(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table playlist_tracks_dg_tmp
|
||||
(
|
||||
id integer default 0 not null,
|
||||
playlist_id varchar(255) not null
|
||||
constraint playlist_tracks_playlist_id_fk
|
||||
references playlist
|
||||
on update cascade on delete cascade,
|
||||
media_file_id varchar(255) not null
|
||||
);
|
||||
|
||||
insert into playlist_tracks_dg_tmp(id, playlist_id, media_file_id) select id, playlist_id, media_file_id from playlist_tracks;
|
||||
|
||||
drop table playlist_tracks;
|
||||
|
||||
alter table playlist_tracks_dg_tmp rename to playlist_tracks;
|
||||
|
||||
create unique index playlist_tracks_pos
|
||||
on playlist_tracks (playlist_id, id);
|
||||
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200608153717(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)
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ type DirectoryInfo struct {
|
||||
Entries Entries
|
||||
Parent string
|
||||
Starred time.Time
|
||||
PlayCount int32
|
||||
PlayCount int64
|
||||
UserRating int
|
||||
AlbumCount int
|
||||
CoverArt string
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -144,9 +135,10 @@ func (b *browser) buildArtistDir(a *model.Artist, albums model.Albums) *Director
|
||||
}
|
||||
|
||||
dir.Entries = make(Entries, len(albums))
|
||||
for i, al := range albums {
|
||||
for i := range albums {
|
||||
al := albums[i]
|
||||
dir.Entries[i] = FromAlbum(&al)
|
||||
dir.PlayCount += int32(al.PlayCount)
|
||||
dir.PlayCount += al.PlayCount
|
||||
}
|
||||
return dir
|
||||
}
|
||||
@@ -164,7 +156,7 @@ func (b *browser) buildAlbumDir(al *model.Album, tracks model.MediaFiles) *Direc
|
||||
Year: al.MaxYear,
|
||||
Genre: al.Genre,
|
||||
CoverArt: al.CoverArtId,
|
||||
PlayCount: int32(al.PlayCount),
|
||||
PlayCount: al.PlayCount,
|
||||
UserRating: al.Rating,
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
@@ -22,7 +23,7 @@ type Entry struct {
|
||||
Starred time.Time
|
||||
Track int
|
||||
Duration int
|
||||
Size int
|
||||
Size int64
|
||||
Suffix string
|
||||
BitRate int
|
||||
ContentType string
|
||||
@@ -133,7 +134,8 @@ func realArtistName(mf *model.MediaFile) string {
|
||||
|
||||
func FromAlbums(albums model.Albums) Entries {
|
||||
entries := make(Entries, len(albums))
|
||||
for i, al := range albums {
|
||||
for i := range albums {
|
||||
al := albums[i]
|
||||
entries[i] = FromAlbum(&al)
|
||||
}
|
||||
return entries
|
||||
@@ -141,7 +143,8 @@ func FromAlbums(albums model.Albums) Entries {
|
||||
|
||||
func FromMediaFiles(mfs model.MediaFiles) Entries {
|
||||
entries := make(Entries, len(mfs))
|
||||
for i, mf := range mfs {
|
||||
for i := range mfs {
|
||||
mf := mfs[i]
|
||||
entries[i] = FromMediaFile(&mf)
|
||||
}
|
||||
return entries
|
||||
@@ -149,17 +152,17 @@ func FromMediaFiles(mfs model.MediaFiles) Entries {
|
||||
|
||||
func FromArtists(ars model.Artists) Entries {
|
||||
entries := make(Entries, len(ars))
|
||||
for i, ar := range ars {
|
||||
for i := range ars {
|
||||
ar := ars[i]
|
||||
entries[i] = FromArtist(&ar)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func userName(ctx context.Context) string {
|
||||
user := ctx.Value("user")
|
||||
if user == nil {
|
||||
if user, ok := request.UserFrom(ctx); !ok {
|
||||
return "UNKNOWN"
|
||||
} else {
|
||||
return user.UserName
|
||||
}
|
||||
usr := user.(model.User)
|
||||
return usr.UserName
|
||||
}
|
||||
|
||||
@@ -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(consts.PlaceholderAlbumArt)
|
||||
}
|
||||
}()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/deluan/navidrome/engine/transcoder"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
"github.com/djherbis/fscache"
|
||||
)
|
||||
|
||||
@@ -40,9 +41,11 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat str
|
||||
|
||||
var format string
|
||||
var bitRate int
|
||||
var cached bool
|
||||
defer func() {
|
||||
log.Info("Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format,
|
||||
"bitRate", bitRate, "user", userName(ctx), "transcoding", format != "raw", "originalFormat", mf.Suffix)
|
||||
log.Info("Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached,
|
||||
"bitRate", bitRate, "user", userName(ctx), "transcoding", format != "raw",
|
||||
"originalFormat", mf.Suffix, "originalBitRate", mf.BitRate)
|
||||
}()
|
||||
|
||||
format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate)
|
||||
@@ -75,8 +78,10 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat str
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cached = w == nil
|
||||
|
||||
// If this is a brand new transcoding request, not in the cache, start transcoding
|
||||
if w != nil {
|
||||
if !cached {
|
||||
log.Trace(ctx, "Cache miss. Starting new transcoding session", "id", mf.ID)
|
||||
t, err := ms.ds.Transcoding(ctx).FindByFormat(format)
|
||||
if err != nil {
|
||||
@@ -92,7 +97,7 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat str
|
||||
}
|
||||
|
||||
// If it is in the cache, check if the stream is done being written. If so, return a ReaderSeeker
|
||||
if w == nil {
|
||||
if cached {
|
||||
size := getFinalCachedSize(r)
|
||||
if size > 0 {
|
||||
log.Debug(ctx, "Streaming cached file", "id", mf.ID, "path", mf.Path,
|
||||
@@ -161,7 +166,7 @@ func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model
|
||||
bitRate = mf.BitRate
|
||||
return
|
||||
}
|
||||
trc, hasDefault := ctx.Value("transcoding").(model.Transcoding)
|
||||
trc, hasDefault := request.TranscodingFrom(ctx)
|
||||
var cFormat string
|
||||
var cBitRate int
|
||||
if reqFormat != "" {
|
||||
@@ -170,7 +175,7 @@ func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model
|
||||
if hasDefault {
|
||||
cFormat = trc.TargetFormat
|
||||
cBitRate = trc.DefaultBitRate
|
||||
if p, ok := ctx.Value("player").(model.Player); ok {
|
||||
if p, ok := request.PlayerFrom(ctx); ok {
|
||||
cBitRate = p.MaxBitRate
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -16,7 +17,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{}}
|
||||
@@ -101,7 +102,7 @@ var _ = Describe("MediaStreamer", func() {
|
||||
Context("player has format configured", func() {
|
||||
BeforeEach(func() {
|
||||
t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
|
||||
ctx = context.WithValue(ctx, "transcoding", t)
|
||||
ctx = request.WithTranscoding(ctx, t)
|
||||
})
|
||||
It("returns raw if raw is requested", func() {
|
||||
mf.Suffix = "flac"
|
||||
@@ -142,8 +143,8 @@ var _ = Describe("MediaStreamer", func() {
|
||||
BeforeEach(func() {
|
||||
t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
|
||||
p := model.Player{ID: "player1", TranscodingId: t.ID, MaxBitRate: 80}
|
||||
ctx = context.WithValue(ctx, "transcoding", t)
|
||||
ctx = context.WithValue(ctx, "player", p)
|
||||
ctx = request.WithTranscoding(ctx, t)
|
||||
ctx = request.WithPlayer(ctx, p)
|
||||
})
|
||||
It("returns raw if raw is requested", func() {
|
||||
mf.Suffix = "flac"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -27,7 +28,7 @@ func (p *players) Register(ctx context.Context, id, client, typ, ip string) (*mo
|
||||
var plr *model.Player
|
||||
var trc *model.Transcoding
|
||||
var err error
|
||||
userName := ctx.Value("username").(string)
|
||||
userName, _ := request.UsernameFrom(ctx)
|
||||
if id != "" {
|
||||
plr, err = p.ds.Player(ctx).Get(id)
|
||||
if err == nil && plr.Client != client {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -14,8 +15,9 @@ 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(ctx, "username", "johndoe")
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "johndoe"})
|
||||
ctx = request.WithUsername(ctx, "johndoe")
|
||||
var beforeRegister time.Time
|
||||
|
||||
BeforeEach(func() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
@@ -25,34 +26,37 @@ type playlists struct {
|
||||
}
|
||||
|
||||
func (p *playlists) Create(ctx context.Context, playlistId, name string, ids []string) error {
|
||||
owner := p.getUser(ctx)
|
||||
var pls *model.Playlist
|
||||
var err error
|
||||
// If playlistID is present, override tracks
|
||||
if playlistId != "" {
|
||||
pls, err = p.ds.Playlist(ctx).Get(playlistId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != pls.Owner {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
pls.Tracks = nil
|
||||
} else {
|
||||
pls = &model.Playlist{
|
||||
Name: name,
|
||||
Owner: owner,
|
||||
}
|
||||
}
|
||||
for _, id := range ids {
|
||||
pls.Tracks = append(pls.Tracks, model.MediaFile{ID: id})
|
||||
}
|
||||
return p.ds.WithTx(func(tx model.DataStore) error {
|
||||
owner := p.getUser(ctx)
|
||||
var pls *model.Playlist
|
||||
var err error
|
||||
|
||||
return p.ds.Playlist(ctx).Put(pls)
|
||||
// If playlistID is present, override tracks
|
||||
if playlistId != "" {
|
||||
pls, err = tx.Playlist(ctx).Get(playlistId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != pls.Owner {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
pls.Tracks = nil
|
||||
} else {
|
||||
pls = &model.Playlist{
|
||||
Name: name,
|
||||
Owner: owner,
|
||||
}
|
||||
}
|
||||
for _, id := range ids {
|
||||
pls.Tracks = append(pls.Tracks, model.MediaFile{ID: id})
|
||||
}
|
||||
|
||||
return tx.Playlist(ctx).Put(pls)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *playlists) getUser(ctx context.Context) string {
|
||||
user, ok := ctx.Value("user").(model.User)
|
||||
user, ok := request.UserFrom(ctx)
|
||||
if ok {
|
||||
return user.UserName
|
||||
}
|
||||
@@ -60,54 +64,54 @@ func (p *playlists) getUser(ctx context.Context) string {
|
||||
}
|
||||
|
||||
func (p *playlists) Delete(ctx context.Context, playlistId string) error {
|
||||
pls, err := p.ds.Playlist(ctx).Get(playlistId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.ds.WithTx(func(tx model.DataStore) error {
|
||||
pls, err := tx.Playlist(ctx).Get(playlistId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
owner := p.getUser(ctx)
|
||||
if owner != pls.Owner {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
return p.ds.Playlist(nil).Delete(playlistId)
|
||||
owner := p.getUser(ctx)
|
||||
if owner != pls.Owner {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
return tx.Playlist(ctx).Delete(playlistId)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *playlists) Update(ctx context.Context, playlistId string, name *string, idsToAdd []string, idxToRemove []int) error {
|
||||
pls, err := p.ds.Playlist(ctx).Get(playlistId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
owner := p.getUser(ctx)
|
||||
if owner != pls.Owner {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
|
||||
if name != nil {
|
||||
pls.Name = *name
|
||||
}
|
||||
newTracks := model.MediaFiles{}
|
||||
for i, t := range pls.Tracks {
|
||||
if utils.IntInSlice(i, idxToRemove) {
|
||||
continue
|
||||
return p.ds.WithTx(func(tx model.DataStore) error {
|
||||
pls, err := tx.Playlist(ctx).Get(playlistId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newTracks = append(newTracks, t)
|
||||
}
|
||||
|
||||
for _, id := range idsToAdd {
|
||||
newTracks = append(newTracks, model.MediaFile{ID: id})
|
||||
}
|
||||
pls.Tracks = newTracks
|
||||
owner := p.getUser(ctx)
|
||||
if owner != pls.Owner {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
|
||||
return p.ds.Playlist(ctx).Put(pls)
|
||||
if name != nil {
|
||||
pls.Name = *name
|
||||
}
|
||||
newTracks := model.MediaFiles{}
|
||||
for i, t := range pls.Tracks {
|
||||
if utils.IntInSlice(i, idxToRemove) {
|
||||
continue
|
||||
}
|
||||
newTracks = append(newTracks, t)
|
||||
}
|
||||
|
||||
for _, id := range idsToAdd {
|
||||
newTracks = append(newTracks, model.MediaFile{ID: id})
|
||||
}
|
||||
pls.Tracks = newTracks
|
||||
|
||||
return tx.Playlist(ctx).Put(pls)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *playlists) GetAll(ctx context.Context) (model.Playlists, error) {
|
||||
all, err := p.ds.Playlist(ctx).GetAll(model.QueryOptions{})
|
||||
for i := range all {
|
||||
all[i].Public = true
|
||||
}
|
||||
return all, err
|
||||
return p.ds.Playlist(ctx).GetAll()
|
||||
}
|
||||
|
||||
type PlaylistInfo struct {
|
||||
@@ -133,7 +137,7 @@ func (p *playlists) Get(ctx context.Context, id string) (*PlaylistInfo, error) {
|
||||
plsInfo := &PlaylistInfo{
|
||||
Id: pl.ID,
|
||||
Name: pl.Name,
|
||||
SongCount: len(pl.Tracks),
|
||||
SongCount: pl.SongCount,
|
||||
Duration: int(pl.Duration),
|
||||
Public: pl.Public,
|
||||
Owner: pl.Owner,
|
||||
|
||||
@@ -2,7 +2,6 @@ package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -44,7 +43,11 @@ func (s *scrobbler) Register(ctx context.Context, playerId int, trackId string,
|
||||
return err
|
||||
})
|
||||
|
||||
log.Info("Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", userName(ctx))
|
||||
if err != nil {
|
||||
log.Error("Error while scrobbling", "trackId", trackId, err)
|
||||
} else {
|
||||
log.Info("Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", userName(ctx))
|
||||
}
|
||||
|
||||
return mf, err
|
||||
}
|
||||
@@ -57,7 +60,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
|
||||
}
|
||||
|
||||
|
||||
24
go.mod
24
go.mod
@@ -4,22 +4,21 @@ go 1.14
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v0.3.1 // indirect
|
||||
github.com/Masterminds/squirrel v1.2.0
|
||||
github.com/Masterminds/squirrel v1.4.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.2+incompatible
|
||||
github.com/go-chi/cors v1.1.1
|
||||
github.com/go-chi/jwtauth v4.0.4+incompatible
|
||||
github.com/go-sql-driver/mysql v1.5.0 // indirect
|
||||
github.com/golang/protobuf v1.3.1 // indirect
|
||||
github.com/google/uuid v1.1.1
|
||||
github.com/google/wire v0.4.0
|
||||
github.com/kennygrant/sanitize v0.0.0-20170120101633-6a0bfdde8629
|
||||
@@ -27,18 +26,17 @@ require (
|
||||
github.com/kr/pretty v0.1.0 // indirect
|
||||
github.com/lib/pq v1.3.0 // indirect
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible
|
||||
github.com/onsi/ginkgo v1.12.0
|
||||
github.com/onsi/gomega v1.9.0
|
||||
github.com/onsi/ginkgo v1.12.3
|
||||
github.com/onsi/gomega v1.10.1
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pressly/goose v2.6.0+incompatible
|
||||
github.com/sirupsen/logrus v1.5.0
|
||||
github.com/sirupsen/logrus v1.6.0
|
||||
github.com/stretchr/testify v1.4.0 // indirect
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2 // indirect
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 // indirect
|
||||
golang.org/x/text v0.3.2 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 // indirect
|
||||
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2 // indirect
|
||||
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980 // 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/yaml.v2 v2.2.8 // indirect
|
||||
gopkg.in/djherbis/stream.v1 v1.3.1 // indirect
|
||||
)
|
||||
|
||||
replace github.com/dhowden/tag => github.com/wader/tag v0.0.0-20200426234345-d072771f6a51
|
||||
|
||||
81
go.sum
81
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.4.0 h1:he5i/EXixZxrBUWcxzDYMiju9WZ3ld/l7QBNuo/eN3w=
|
||||
github.com/Masterminds/squirrel v1.4.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=
|
||||
@@ -26,12 +26,10 @@ github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0 h1:qHX6TTBIsrpg0JkYNko
|
||||
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/dhowden/tag v0.0.0-20200412032933-5d76b8eaae27 h1:Z6xaGRBbqfLR797upHuzQ6w4zg33BLKfAKtVCcmMDgg=
|
||||
github.com/dhowden/tag v0.0.0-20200412032933-5d76b8eaae27/go.mod h1:SniNVYuaD1jmdEEvi+7ywb1QFR7agjeTdGKyFb0p7Rw=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/djherbis/fscache v0.10.0 h1:+O3s3LwKL1Jfz7txRHpgKOz8/krh9w+NzfzxtFdbsQg=
|
||||
github.com/djherbis/fscache v0.10.0/go.mod h1:yyPYtkNnnPXsW+81lAcQS6yab3G2CRfnPLotBvtbf0c=
|
||||
github.com/djherbis/fscache v0.10.1 h1:hDv+RGyvD+UDKyRYuLoVNbuRTnf2SrA2K3VyR1br9lk=
|
||||
github.com/djherbis/fscache v0.10.1/go.mod h1:yyPYtkNnnPXsW+81lAcQS6yab3G2CRfnPLotBvtbf0c=
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8=
|
||||
@@ -43,8 +41,10 @@ github.com/fatih/structs v1.0.0 h1:BrX964Rv5uQ3wwS+KRUAJCBBw5PQmgJfJ6v4yly5QwU=
|
||||
github.com/fatih/structs v1.0.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/go-chi/chi v4.1.0+incompatible h1:ETj3cggsVIY2Xao5ExCu6YhEh5MD6JTfcBzS37R260w=
|
||||
github.com/go-chi/chi v4.1.0+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec=
|
||||
github.com/go-chi/chi v4.1.2+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=
|
||||
@@ -57,12 +57,21 @@ github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LB
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db h1:woRePGFeVFfLKN/pOkfl+p/TAqKOfFu+7KPlMVpok/w=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
|
||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -80,6 +89,8 @@ github.com/koding/multiconfig v0.0.0-20170327155832-26b6dfd3a84a h1:KZAp4Cn6Wybs
|
||||
github.com/koding/multiconfig v0.0.0-20170327155832-26b6dfd3a84a/go.mod h1:Y2SaZf2Rzd0pXkLVhLlCiAXFCLSXAIbTKDivVgff/AM=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
@@ -95,12 +106,16 @@ github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.0 h1:Iw5WCbBcaAAd0fpRb1c9r5YCylv4XDoCSigm1zLevwU=
|
||||
github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
|
||||
github.com/onsi/ginkgo v1.12.1 h1:mFwc4LvZ0xpSvDZ3E+k8Yte0hLOMxXUlP+yXtJqkYfQ=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.12.3 h1:+RYp9QczoWz9zfUyLP/5SLXQVhfr6gZOoKGfQqHuLZQ=
|
||||
github.com/onsi/ginkgo v1.12.3/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.9.0 h1:R1uwffexN6Pr340GtYRIdZmAiN4J+iw6WG4wog1DUXg=
|
||||
github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
|
||||
github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
|
||||
@@ -119,8 +134,8 @@ github.com/siddontang/rdb v0.0.0-20150307021120-fc89ed2e418d h1:NVwnfyR3rENtlz62
|
||||
github.com/siddontang/rdb v0.0.0-20150307021120-fc89ed2e418d/go.mod h1:AMEsy7v5z92TR1JKMkLLoaOQk++LVnOKL3ScbJ8GNGA=
|
||||
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sirupsen/logrus v1.5.0 h1:1N5EYkVAPEywqZRJd7cwnRtCb6xJx7NH3T3WUTF980Q=
|
||||
github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo=
|
||||
github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I=
|
||||
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
|
||||
@@ -133,6 +148,8 @@ github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJy
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/syndtr/goleveldb v0.0.0-20181127023241-353a9fca669c h1:3eGShk3EQf5gJCYW+WzA0TEJQd37HLOmlYF7N0YJwv0=
|
||||
github.com/syndtr/goleveldb v0.0.0-20181127023241-353a9fca669c/go.mod h1:Z4AUp2Km+PwemOoO/VB5AOx9XSsIItzFjoJlOSiYmn0=
|
||||
github.com/wader/tag v0.0.0-20200426234345-d072771f6a51 h1:WAxntH7YQD6fIboAvewi7eU+2PQ7Y1K9OOXh67CM4bY=
|
||||
github.com/wader/tag v0.0.0-20200426234345-d072771f6a51/go.mod h1:f3YqVk9PEeVf7T4JQ2+TdRqqjTg2fkaROZv0EMQOuKo=
|
||||
github.com/wendal/errors v0.0.0-20130201093226-f66c77a7882b/go.mod h1:Q12BUT7DqIlHRmgv3RskH+UCM/4eqVMgI0EMmlSpAXc=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
|
||||
@@ -145,8 +162,10 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJV
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2 h1:CCH4IOTTfewWjGOlSp+zGcjutRKlBEZQ6wTn8ozI/nI=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7 h1:AeiKBIuRw3UomYXSbLy0Mc2dDLfdtbT/IVn4keq83P0=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2 h1:eDrdRpKgkcCqKZQwyZRyeFZgfqt37SL7Kv3tok06cKE=
|
||||
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -156,9 +175,14 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 h1:LfCXLvNmTYH9kEmVgqbnsWfruoXZIrh4YBgqVHtDvw0=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299 h1:DYfZAGf2WMFjMxbgTjaC+2HC7NkNAQs+6Q8b9WEB/F4=
|
||||
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980 h1:OjiUf46hAmXblsZdnoSXsEUSKU8r1UEzcL5RVZ4gO9Y=
|
||||
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||
@@ -170,19 +194,24 @@ golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b h1:NVD8gBK33xpdqCaZVVtd6OF
|
||||
golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20200117065230-39095c1d176c h1:FodBYPZKH5tAN2O60HlglMwXGAeV/4k+NKbli79M/2c=
|
||||
golang.org/x/tools v0.0.0-20200117065230-39095c1d176c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
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=
|
||||
@@ -192,5 +221,5 @@ gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
pre-push:
|
||||
parallel: true
|
||||
commands:
|
||||
unit-tests:
|
||||
tags: tests
|
||||
run: go test ./...
|
||||
lint:
|
||||
tags: tests
|
||||
run: golangci-lint run
|
||||
|
||||
pre-commit:
|
||||
parallel: false
|
||||
|
||||
@@ -24,6 +24,10 @@ const (
|
||||
LevelTrace = Level(logrus.TraceLevel)
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const loggerCtxKey = contextKey("logger")
|
||||
|
||||
var (
|
||||
currentLevel Level
|
||||
defaultLogger = logrus.New()
|
||||
@@ -66,7 +70,7 @@ func NewContext(ctx context.Context, keyValuePairs ...interface{}) context.Conte
|
||||
}
|
||||
|
||||
logger := addFields(createNewLogger(), keyValuePairs)
|
||||
ctx = context.WithValue(ctx, "logger", logger)
|
||||
ctx = context.WithValue(ctx, loggerCtxKey, logger)
|
||||
|
||||
return ctx
|
||||
}
|
||||
@@ -176,10 +180,11 @@ func extractLogger(ctx interface{}) (*logrus.Entry, error) {
|
||||
case *logrus.Entry:
|
||||
return ctx, nil
|
||||
case context.Context:
|
||||
logger := ctx.Value("logger")
|
||||
logger := ctx.Value(loggerCtxKey)
|
||||
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")
|
||||
@@ -136,7 +136,7 @@ var _ = Describe("Logger", func() {
|
||||
It("returns the logger from context if it has one", func() {
|
||||
logger := logrus.NewEntry(logrus.New())
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, "logger", logger)
|
||||
ctx = context.WithValue(ctx, loggerCtxKey, logger)
|
||||
|
||||
Expect(extractLogger(ctx)).To(Equal(logger))
|
||||
})
|
||||
@@ -144,7 +144,7 @@ var _ = Describe("Logger", func() {
|
||||
It("returns the logger from request's context if it has one", func() {
|
||||
logger := logrus.NewEntry(logrus.New())
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, "logger", logger)
|
||||
ctx = context.WithValue(ctx, loggerCtxKey, logger)
|
||||
req := httptest.NewRequest("get", "/", nil).WithContext(ctx)
|
||||
|
||||
Expect(extractLogger(req)).To(Equal(logger))
|
||||
|
||||
@@ -3,30 +3,30 @@ 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"`
|
||||
Annotations
|
||||
|
||||
// Annotations
|
||||
PlayCount int `json:"playCount" orm:"-"`
|
||||
PlayDate time.Time `json:"playDate" orm:"-"`
|
||||
Rating int `json:"rating" orm:"-"`
|
||||
Starred bool `json:"starred" orm:"-"`
|
||||
StarredAt time.Time `json:"starredAt" orm:"-"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type Albums []Album
|
||||
@@ -41,6 +41,9 @@ type AlbumRepository interface {
|
||||
GetStarred(options ...QueryOptions) (Albums, error)
|
||||
Search(q string, offset int, size int) (Albums, error)
|
||||
Refresh(ids ...string) error
|
||||
PurgeEmpty() error
|
||||
AnnotatedRepository
|
||||
}
|
||||
|
||||
func (a Album) GetAnnotations() Annotations {
|
||||
return a.Annotations
|
||||
}
|
||||
|
||||
@@ -2,6 +2,18 @@ package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Annotations struct {
|
||||
PlayCount int64 `json:"playCount"`
|
||||
PlayDate time.Time `json:"playDate"`
|
||||
Rating int `json:"rating"`
|
||||
Starred bool `json:"starred"`
|
||||
StarredAt time.Time `json:"starredAt"`
|
||||
}
|
||||
|
||||
type AnnotatedModel interface {
|
||||
GetAnnotations() Annotations
|
||||
}
|
||||
|
||||
type AnnotatedRepository interface {
|
||||
IncPlayCount(itemID string, ts time.Time) error
|
||||
SetStar(starred bool, itemIDs ...string) error
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
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"`
|
||||
Annotations
|
||||
|
||||
// Annotations
|
||||
PlayCount int `json:"playCount" orm:"-"`
|
||||
PlayDate time.Time `json:"playDate" orm:"-"`
|
||||
Rating int `json:"rating" orm:"-"`
|
||||
Starred bool `json:"starred" orm:"-"`
|
||||
StarredAt time.Time `json:"starredAt" orm:"-"`
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
AlbumCount int `json:"albumCount"`
|
||||
SongCount int `json:"songCount"`
|
||||
FullText string `json:"fullText"`
|
||||
SortArtistName string `json:"sortArtistName"`
|
||||
OrderArtistName string `json:"orderArtistName"`
|
||||
}
|
||||
|
||||
type Artists []Artist
|
||||
@@ -33,6 +29,9 @@ type ArtistRepository interface {
|
||||
Search(q string, offset int, size int) (Artists, error)
|
||||
Refresh(ids ...string) error
|
||||
GetIndex() (ArtistIndexes, error)
|
||||
PurgeEmpty() error
|
||||
AnnotatedRepository
|
||||
}
|
||||
|
||||
func (a Artist) GetAnnotations() Annotations {
|
||||
return a.Annotations
|
||||
}
|
||||
|
||||
@@ -6,35 +6,38 @@ 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"`
|
||||
Annotations
|
||||
|
||||
// Annotations
|
||||
PlayCount int `json:"playCount" orm:"-"`
|
||||
PlayDate time.Time `json:"playDate" orm:"-"`
|
||||
Rating int `json:"rating" orm:"-"`
|
||||
Starred bool `json:"starred" orm:"-"`
|
||||
StarredAt time.Time `json:"starredAt" orm:"-"`
|
||||
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" orm:"pk;column(album_artist_id)"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
AlbumID string `json:"albumId" orm:"pk;column(album_id)"`
|
||||
HasCoverArt bool `json:"hasCoverArt"`
|
||||
TrackNumber int `json:"trackNumber"`
|
||||
DiscNumber int `json:"discNumber"`
|
||||
DiscSubtitle string `json:"discSubtitle"`
|
||||
Year int `json:"year"`
|
||||
Size int64 `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"`
|
||||
}
|
||||
|
||||
func (mf *MediaFile) ContentType() string {
|
||||
@@ -48,8 +51,10 @@ 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)
|
||||
FindPathsRecursively(basePath string) ([]string, error)
|
||||
GetStarred(options ...QueryOptions) (MediaFiles, error)
|
||||
GetRandom(options ...QueryOptions) (MediaFiles, error)
|
||||
Search(q string, offset int, size int) (MediaFiles, error)
|
||||
@@ -58,3 +63,7 @@ type MediaFileRepository interface {
|
||||
|
||||
AnnotatedRepository
|
||||
}
|
||||
|
||||
func (mf MediaFile) GetAnnotations() Annotations {
|
||||
return mf.Annotations
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package model
|
||||
|
||||
type MediaFolder struct {
|
||||
ID string
|
||||
ID int32
|
||||
Name string
|
||||
Path string
|
||||
}
|
||||
|
||||
@@ -1,26 +1,47 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Playlist struct {
|
||||
ID string
|
||||
Name string
|
||||
Comment string
|
||||
Duration float32
|
||||
Owner string
|
||||
Public bool
|
||||
Tracks MediaFiles
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment"`
|
||||
Duration float32 `json:"duration"`
|
||||
SongCount int `json:"songCount"`
|
||||
Owner string `json:"owner"`
|
||||
Public bool `json:"public"`
|
||||
Tracks MediaFiles `json:"tracks,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Playlists []Playlist
|
||||
|
||||
type PlaylistRepository interface {
|
||||
CountAll() (int64, error)
|
||||
CountAll(options ...QueryOptions) (int64, error)
|
||||
Exists(id string) (bool, error)
|
||||
Put(pls *Playlist) error
|
||||
Get(id string) (*Playlist, error)
|
||||
GetAll(options ...QueryOptions) (Playlists, error)
|
||||
Delete(id string) error
|
||||
Tracks(playlistId string) PlaylistTrackRepository
|
||||
}
|
||||
|
||||
type Playlists []Playlist
|
||||
type PlaylistTrack struct {
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
MediaFileID string `json:"mediaFileId" orm:"column(media_file_id)"`
|
||||
PlaylistID string `json:"playlistId" orm:"column(playlist_id)"`
|
||||
MediaFile
|
||||
}
|
||||
|
||||
type PlaylistTracks []PlaylistTrack
|
||||
|
||||
type PlaylistTrackRepository interface {
|
||||
ResourceRepository
|
||||
Add(mediaFileIds []string) error
|
||||
Update(mediaFileIds []string) error
|
||||
Delete(id string) error
|
||||
Reorder(pos int, newPos int) error
|
||||
}
|
||||
|
||||
72
model/request/request.go
Normal file
72
model/request/request.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package request
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
User = contextKey("user")
|
||||
Username = contextKey("username")
|
||||
Client = contextKey("client")
|
||||
Version = contextKey("version")
|
||||
Player = contextKey("player")
|
||||
Transcoding = contextKey("transcoding")
|
||||
)
|
||||
|
||||
func WithUser(ctx context.Context, u model.User) context.Context {
|
||||
return context.WithValue(ctx, User, u)
|
||||
}
|
||||
|
||||
func WithUsername(ctx context.Context, username string) context.Context {
|
||||
return context.WithValue(ctx, Username, username)
|
||||
}
|
||||
|
||||
func WithClient(ctx context.Context, client string) context.Context {
|
||||
return context.WithValue(ctx, Client, client)
|
||||
}
|
||||
|
||||
func WithVersion(ctx context.Context, version string) context.Context {
|
||||
return context.WithValue(ctx, Version, version)
|
||||
}
|
||||
|
||||
func WithPlayer(ctx context.Context, player model.Player) context.Context {
|
||||
return context.WithValue(ctx, Player, player)
|
||||
}
|
||||
|
||||
func WithTranscoding(ctx context.Context, t model.Transcoding) context.Context {
|
||||
return context.WithValue(ctx, Transcoding, t)
|
||||
}
|
||||
|
||||
func UserFrom(ctx context.Context) (model.User, bool) {
|
||||
v, ok := ctx.Value(User).(model.User)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func UsernameFrom(ctx context.Context) (string, bool) {
|
||||
v, ok := ctx.Value(Username).(string)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func ClientFrom(ctx context.Context) (string, bool) {
|
||||
v, ok := ctx.Value(Client).(string)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func VersionFrom(ctx context.Context) (string, bool) {
|
||||
v, ok := ctx.Value(Version).(string)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func PlayerFrom(ctx context.Context) (model.Player, bool) {
|
||||
v, ok := ctx.Value(Player).(model.Player)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func TranscodingFrom(ctx context.Context) (model.Transcoding, bool) {
|
||||
v, ok := ctx.Value(Transcoding).(model.Transcoding)
|
||||
return v, ok
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
@@ -23,8 +26,10 @@ 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",
|
||||
"random": "RANDOM()",
|
||||
"name": "order_album_name",
|
||||
"artist": "compilation asc, order_album_artist_name asc, order_album_name asc",
|
||||
"random": "RANDOM()",
|
||||
"max_year": "max_year asc, name, order_album_name asc",
|
||||
}
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"name": fullTextFilter,
|
||||
@@ -48,7 +53,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},
|
||||
@@ -71,12 +76,14 @@ func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuild
|
||||
|
||||
func (r *albumRepository) Get(id string) (*model.Album, error) {
|
||||
sq := r.selectAlbum().Where(Eq{"id": id})
|
||||
var res model.Album
|
||||
err := r.queryOne(sq, &res)
|
||||
if err != nil {
|
||||
var res model.Albums
|
||||
if err := r.queryAll(sq, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &res, nil
|
||||
if len(res) == 0 {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return &res[0], nil
|
||||
}
|
||||
|
||||
func (r *albumRepository) FindByArtist(artistId string) (model.Albums, error) {
|
||||
@@ -105,15 +112,20 @@ func (r *albumRepository) GetRandom(options ...model.QueryOptions) (model.Albums
|
||||
func (r *albumRepository) Refresh(ids ...string) error {
|
||||
type refreshAlbum struct {
|
||||
model.Album
|
||||
CurrentId string
|
||||
HasCoverArt bool
|
||||
SongArtists string
|
||||
CurrentId string
|
||||
HasCoverArt bool
|
||||
SongArtists string
|
||||
Years string
|
||||
DiscSubtitles 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.disc_subtitle, ' ') as disc_subtitles,
|
||||
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 +148,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 +156,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, al.DiscSubtitles)
|
||||
_, err := r.put(al.ID, al.Album)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -158,7 +172,19 @@ func (r *albumRepository) Refresh(ids ...string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *albumRepository) PurgeEmpty() error {
|
||||
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)
|
||||
if err == nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -14,13 +15,13 @@ var _ = Describe("AlbumRepository", func() {
|
||||
var repo model.AlbumRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe"})
|
||||
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 +74,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,6 +47,31 @@ func (r *artistRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(Select().Where(Eq{"id": id}))
|
||||
}
|
||||
|
||||
func (r *artistRepository) Put(a *model.Artist) error {
|
||||
a.FullText = getFullText(a.Name, a.SortArtistName)
|
||||
_, err := r.put(a.ID, a)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *artistRepository) Get(id string) (*model.Artist, error) {
|
||||
sel := r.selectArtist().Where(Eq{"id": id})
|
||||
var res model.Artists
|
||||
if err := r.queryAll(sel, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(res) == 0 {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return &res[0], nil
|
||||
}
|
||||
|
||||
func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, error) {
|
||||
sel := r.selectArtist(options...)
|
||||
res := model.Artists{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *artistRepository) getIndexKey(a *model.Artist) string {
|
||||
name := strings.ToLower(utils.NoArticle(a.Name))
|
||||
for k, v := range r.indexGroups {
|
||||
@@ -55,38 +83,18 @@ func (r *artistRepository) getIndexKey(a *model.Artist) string {
|
||||
return "#"
|
||||
}
|
||||
|
||||
func (r *artistRepository) Put(a *model.Artist) error {
|
||||
a.FullText = r.getFullText(a.Name)
|
||||
_, err := r.put(a.ID, a)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *artistRepository) Get(id string) (*model.Artist, error) {
|
||||
sel := r.selectArtist().Where(Eq{"id": id})
|
||||
var res model.Artist
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, error) {
|
||||
sel := r.selectArtist(options...)
|
||||
res := model.Artists{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
// TODO Cache the index (recalculate when there are changes to the DB)
|
||||
func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
|
||||
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
|
||||
}
|
||||
|
||||
fullIdx := make(map[string]*model.ArtistIndex)
|
||||
for _, a := range all {
|
||||
for i := range all {
|
||||
a := all[i]
|
||||
ax := r.getIndexKey(&a)
|
||||
idx, ok := fullIdx[ax]
|
||||
if !ok {
|
||||
@@ -111,7 +119,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",
|
||||
"sum(f.song_count) as song_count").
|
||||
From("album f").
|
||||
LeftJoin("artist a on f.album_artist_id = a.id").
|
||||
Where(Eq{"f.album_artist_id": ids}).
|
||||
@@ -150,7 +160,7 @@ func (r *artistRepository) GetStarred(options ...model.QueryOptions) (model.Arti
|
||||
return starred, err
|
||||
}
|
||||
|
||||
func (r *artistRepository) PurgeEmpty() error {
|
||||
func (r *artistRepository) purgeEmpty() error {
|
||||
del := Delete(r.tableName).Where("id not in (select distinct(album_artist_id) from album)")
|
||||
c, err := r.executeSQL(del)
|
||||
if err == nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -14,7 +15,8 @@ var _ = Describe("ArtistRepository", func() {
|
||||
var repo model.ArtistRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, 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() {
|
||||
|
||||
@@ -23,7 +23,7 @@ func toSqlArgs(rec interface{}) (map[string]interface{}, error) {
|
||||
err = json.Unmarshal(b, &m)
|
||||
r := make(map[string]interface{}, len(m))
|
||||
for f, v := range m {
|
||||
if !utils.StringInSlice(f, model.AnnotationFields) {
|
||||
if !utils.StringInSlice(f, model.AnnotationFields) && v != nil {
|
||||
r[toSnakeCase(f)] = v
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,15 +1,60 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Helpers", func() {
|
||||
Describe("toSnakeCase", func() {
|
||||
It("converts camelCase", func() {
|
||||
Expect(toSnakeCase("camelCase")).To(Equal("camel_case"))
|
||||
})
|
||||
It("converts PascalCase", func() {
|
||||
Expect(toSnakeCase("PascalCase")).To(Equal("pascal_case"))
|
||||
})
|
||||
It("converts ALLCAPS", func() {
|
||||
Expect(toSnakeCase("ALLCAPS")).To(Equal("allcaps"))
|
||||
})
|
||||
It("does not converts snake_case", func() {
|
||||
Expect(toSnakeCase("snake_case")).To(Equal("snake_case"))
|
||||
})
|
||||
})
|
||||
Describe("toSqlArgs", func() {
|
||||
type Model struct {
|
||||
ID string `json:"id"`
|
||||
AlbumId string `json:"albumId"`
|
||||
PlayCount int `json:"playCount"`
|
||||
CreatedAt *time.Time
|
||||
}
|
||||
|
||||
It("returns a map with snake_case keys", func() {
|
||||
now := time.Now()
|
||||
m := &Model{ID: "123", AlbumId: "456", CreatedAt: &now, PlayCount: 2}
|
||||
args, err := toSqlArgs(m)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(args).To(HaveKeyWithValue("id", "123"))
|
||||
Expect(args).To(HaveKeyWithValue("album_id", "456"))
|
||||
Expect(args).To(HaveKey("created_at"))
|
||||
Expect(args).To(HaveLen(3))
|
||||
})
|
||||
|
||||
It("remove null fields", func() {
|
||||
m := &Model{ID: "123", AlbumId: "456"}
|
||||
args, err := toSqlArgs(m)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(args).To(HaveKey("id"))
|
||||
Expect(args).To(HaveKey("album_id"))
|
||||
Expect(args).To(HaveLen(2))
|
||||
})
|
||||
})
|
||||
|
||||
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}))
|
||||
|
||||
@@ -2,8 +2,9 @@ package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"path/filepath"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
@@ -23,17 +24,19 @@ 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",
|
||||
"random": "RANDOM()",
|
||||
}
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"title": fullTextFilter,
|
||||
"title": fullTextFilter,
|
||||
"starred": booleanFilter,
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
return r.count(Select(), options...)
|
||||
return r.count(r.newSelectWithAnnotation("media_file.id"), options...)
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) Exists(id string) (bool, error) {
|
||||
@@ -41,7 +44,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, m.DiscSubtitle)
|
||||
_, err := r.put(m.ID, m)
|
||||
return err
|
||||
}
|
||||
@@ -52,9 +56,14 @@ func (r mediaFileRepository) selectMediaFile(options ...model.QueryOptions) Sele
|
||||
|
||||
func (r mediaFileRepository) Get(id string) (*model.MediaFile, error) {
|
||||
sel := r.selectMediaFile().Where(Eq{"id": id})
|
||||
var res model.MediaFile
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
var res model.MediaFiles
|
||||
if err := r.queryAll(sel, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(res) == 0 {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return &res[0], nil
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
@@ -71,25 +80,27 @@ func (r mediaFileRepository) FindByAlbum(albumId string) (model.MediaFiles, erro
|
||||
return res, err
|
||||
}
|
||||
|
||||
// FindByPath only return mediafiles that are direct children of requested path
|
||||
func (r mediaFileRepository) FindByPath(path string) (model.MediaFiles, error) {
|
||||
sel := r.selectMediaFile().Where(Like{"path": path + "%"})
|
||||
// Query by path based on https://stackoverflow.com/a/13911906/653632
|
||||
sel0 := r.selectMediaFile().Columns(fmt.Sprintf("substr(path, %d) AS item", len(path)+2)).
|
||||
Where(Like{"path": filepath.Join(path, "%")})
|
||||
sel := r.newSelect().Columns("*", "item NOT GLOB '*"+string(os.PathSeparator)+"*' AS isLast").
|
||||
Where(Eq{"isLast": 1}).FromSelect(sel0, "sel0")
|
||||
|
||||
res := model.MediaFiles{}
|
||||
err := r.queryAll(sel, &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
// Only return mediafiles that are direct child of requested path
|
||||
filtered := model.MediaFiles{}
|
||||
path = strings.ToLower(path) + string(os.PathSeparator)
|
||||
for _, mf := range res {
|
||||
filename := strings.TrimPrefix(strings.ToLower(mf.Path), path)
|
||||
if len(strings.Split(filename, string(os.PathSeparator))) > 1 {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, mf)
|
||||
}
|
||||
return filtered, nil
|
||||
// FindPathsRecursively returns a list of all subfolders of basePath, recursively
|
||||
func (r mediaFileRepository) FindPathsRecursively(basePath string) ([]string, error) {
|
||||
// Query based on https://stackoverflow.com/a/38330814/653632
|
||||
sel := r.newSelect().Columns(fmt.Sprintf("distinct rtrim(path, replace(path, '%s', ''))", string(os.PathSeparator))).
|
||||
Where(Like{"path": filepath.Join(basePath, "%")})
|
||||
var res []string
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) GetStarred(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
@@ -112,21 +123,14 @@ func (r mediaFileRepository) Delete(id string) error {
|
||||
return r.delete(Eq{"id": id})
|
||||
}
|
||||
|
||||
// DeleteByPath delete from the DB all mediafiles that are direct children of path
|
||||
func (r mediaFileRepository) DeleteByPath(path string) error {
|
||||
filtered, err := r.FindByPath(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, len(filtered))
|
||||
for i, mf := range filtered {
|
||||
ids[i] = mf.ID
|
||||
}
|
||||
log.Debug(r.ctx, "Deleting mediafiles by path", "path", path, "totalDeleted", len(ids))
|
||||
del := Delete(r.tableName).Where(Eq{"id": ids})
|
||||
_, err = r.executeSQL(del)
|
||||
path = filepath.Clean(path)
|
||||
del := Delete(r.tableName).
|
||||
Where(And{Like{"path": filepath.Join(path, "%")},
|
||||
Eq{fmt.Sprintf("substr(path, %d) glob '*%s*'", len(path)+2, string(os.PathSeparator)): 0}})
|
||||
log.Debug(r.ctx, "Deleting mediafiles by path", "path", path)
|
||||
_, err := r.executeSQL(del)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -153,8 +157,20 @@ func (r mediaFileRepository) EntityName() string {
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) NewInstance() interface{} {
|
||||
return model.MediaFile{}
|
||||
return &model.MediaFile{}
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) Save(entity interface{}) (string, error) {
|
||||
mf := entity.(*model.MediaFile)
|
||||
err := r.Put(mf)
|
||||
return mf.ID, err
|
||||
}
|
||||
|
||||
func (r mediaFileRepository) Update(entity interface{}, cols ...string) error {
|
||||
mf := entity.(*model.MediaFile)
|
||||
return r.Put(mf)
|
||||
}
|
||||
|
||||
var _ model.MediaFileRepository = (*mediaFileRepository)(nil)
|
||||
var _ model.ResourceRepository = (*mediaFileRepository)(nil)
|
||||
var _ rest.Persistable = (*mediaFileRepository)(nil)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
"github.com/google/uuid"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -16,12 +17,13 @@ var _ = Describe("MediaRepository", func() {
|
||||
var mr model.MediaFileRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, 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 +41,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,
|
||||
}))
|
||||
@@ -99,7 +101,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix()))
|
||||
Expect(mf.PlayCount).To(Equal(1))
|
||||
Expect(mf.PlayCount).To(Equal(int64(1)))
|
||||
})
|
||||
|
||||
It("increments play count on newly starred items", func() {
|
||||
@@ -113,7 +115,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix()))
|
||||
Expect(mf.PlayCount).To(Equal(1))
|
||||
Expect(mf.PlayCount).To(Equal(int64(1)))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ func (*mediaFolderRepository) GetAll() (model.MediaFolders, error) {
|
||||
}
|
||||
|
||||
func hardCoded() model.MediaFolder {
|
||||
mediaFolder := model.MediaFolder{ID: "0", Path: conf.Server.MusicFolder}
|
||||
mediaFolder := model.MediaFolder{ID: 0, Path: conf.Server.MusicFolder}
|
||||
mediaFolder.Name = "Music Library"
|
||||
return mediaFolder
|
||||
}
|
||||
|
||||
@@ -72,6 +72,8 @@ func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRe
|
||||
return s.Album(ctx).(model.ResourceRepository)
|
||||
case model.MediaFile:
|
||||
return s.MediaFile(ctx).(model.ResourceRepository)
|
||||
case model.Playlist:
|
||||
return s.Playlist(ctx).(model.ResourceRepository)
|
||||
}
|
||||
log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name())
|
||||
return nil
|
||||
@@ -106,11 +108,11 @@ func (s *SQLStore) WithTx(block func(tx model.DataStore) error) error {
|
||||
}
|
||||
|
||||
func (s *SQLStore) GC(ctx context.Context) error {
|
||||
err := s.Album(ctx).PurgeEmpty()
|
||||
err := s.Album(ctx).(*albumRepository).purgeEmpty()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.Artist(ctx).PurgeEmpty()
|
||||
err = s.Artist(ctx).(*artistRepository).purgeEmpty()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -122,7 +124,11 @@ func (s *SQLStore) GC(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Artist(ctx).(*artistRepository).cleanAnnotations()
|
||||
err = s.Artist(ctx).(*artistRepository).cleanAnnotations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Playlist(ctx).(*playlistRepository).removeOrphans()
|
||||
}
|
||||
|
||||
func (s *SQLStore) getOrmer() orm.Ormer {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/deluan/navidrome/db"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
"github.com/deluan/navidrome/tests"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
. "github.com/onsi/ginkgo"
|
||||
@@ -20,10 +21,9 @@ func TestPersistence(t *testing.T) {
|
||||
tests.Init(t, true)
|
||||
|
||||
//os.Remove("./test-123.db")
|
||||
//conf.Server.Path = "./test-123.db"
|
||||
//conf.Server.DbPath = "./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 +31,8 @@ func TestPersistence(t *testing.T) {
|
||||
}
|
||||
|
||||
var (
|
||||
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1, FullText: "kraftwerk"}
|
||||
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2, FullText: "beatles the"}
|
||||
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1, FullText: " kraftwerk"}
|
||||
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2, FullText: " beatles the"}
|
||||
testArtists = model.Artists{
|
||||
artistKraftwerk,
|
||||
artistBeatles,
|
||||
@@ -40,9 +40,9 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
albumSgtPeppers = model.Album{ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", AlbumArtistID: "3", Genre: "Rock", CoverArtId: "1", CoverArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967, FullText: "beatles peppers sgt the"}
|
||||
albumAbbeyRoad = model.Album{ID: "2", Name: "Abbey Road", Artist: "The Beatles", AlbumArtistID: "3", Genre: "Rock", CoverArtId: "2", CoverArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969, FullText: "abbey beatles road the"}
|
||||
albumRadioactivity = model.Album{ID: "3", Name: "Radioactivity", Artist: "Kraftwerk", AlbumArtistID: "2", Genre: "Electronic", CoverArtId: "3", CoverArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2, FullText: "kraftwerk radioactivity"}
|
||||
albumSgtPeppers = model.Album{ID: "101", Name: "Sgt Peppers", Artist: "The Beatles", OrderAlbumName: "sgt peppers", 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", OrderAlbumName: "abbey road", 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", OrderAlbumName: "radioactivity", AlbumArtistID: "2", Genre: "Electronic", CoverArtId: "3", CoverArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2, FullText: " kraftwerk radioactivity"}
|
||||
testAlbums = model.Albums{
|
||||
albumSgtPeppers,
|
||||
albumAbbeyRoad,
|
||||
@@ -51,10 +51,10 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
songDayInALife = model.MediaFile{ID: "1", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "1", Album: "Sgt Peppers", Genre: "Rock", Path: P("/beatles/1/sgt/a day.mp3"), FullText: "a beatles day in life peppers sgt the"}
|
||||
songComeTogether = model.MediaFile{ID: "2", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "2", Album: "Abbey Road", Genre: "Rock", Path: P("/beatles/1/come together.mp3"), FullText: "abbey beatles come road the together"}
|
||||
songRadioactivity = model.MediaFile{ID: "3", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Album: "Radioactivity", Genre: "Electronic", Path: P("/kraft/radio/radio.mp3"), FullText: "kraftwerk radioactivity"}
|
||||
songAntenna = model.MediaFile{ID: "4", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Genre: "Electronic", Path: P("/kraft/radio/antenna.mp3"), FullText: "antenna kraftwerk"}
|
||||
songDayInALife = model.MediaFile{ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Genre: "Rock", Path: P("/beatles/1/sgt/a day.mp3"), FullText: " a beatles day in life peppers sgt the"}
|
||||
songComeTogether = model.MediaFile{ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Genre: "Rock", Path: P("/beatles/1/come together.mp3"), FullText: " abbey beatles come road the together"}
|
||||
songRadioactivity = model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Genre: "Electronic", Path: P("/kraft/radio/radio.mp3"), FullText: " kraftwerk radioactivity"}
|
||||
songAntenna = model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Genre: "Electronic", Path: P("/kraft/radio/antenna.mp3"), FullText: " antenna kraftwerk"}
|
||||
testSongs = model.MediaFiles{
|
||||
songDayInALife,
|
||||
songComeTogether,
|
||||
@@ -65,15 +65,15 @@ var (
|
||||
|
||||
var (
|
||||
plsBest = model.Playlist{
|
||||
ID: "10",
|
||||
Name: "Best",
|
||||
Comment: "No Comments",
|
||||
Owner: "userid",
|
||||
Public: true,
|
||||
Tracks: model.MediaFiles{{ID: "1"}, {ID: "3"}},
|
||||
Name: "Best",
|
||||
Comment: "No Comments",
|
||||
Owner: "userid",
|
||||
Public: true,
|
||||
SongCount: 2,
|
||||
Tracks: model.MediaFiles{{ID: "1001"}, {ID: "1003"}},
|
||||
}
|
||||
plsCool = model.Playlist{ID: "11", Name: "Cool", Tracks: model.MediaFiles{{ID: "4"}}}
|
||||
testPlaylists = model.Playlists{plsBest, plsCool}
|
||||
plsCool = model.Playlist{Name: "Cool", Owner: "userid", Tracks: model.MediaFiles{{ID: "1004"}}}
|
||||
testPlaylists = []*model.Playlist{&plsBest, &plsCool}
|
||||
)
|
||||
|
||||
func P(path string) string {
|
||||
@@ -85,9 +85,11 @@ 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 := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid"})
|
||||
mr := NewMediaFileRepository(ctx, o)
|
||||
for _, s := range testSongs {
|
||||
for i := range testSongs {
|
||||
s := testSongs[i]
|
||||
err := mr.Put(&s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -95,7 +97,8 @@ var _ = Describe("Initialize test DB", func() {
|
||||
}
|
||||
|
||||
alr := NewAlbumRepository(ctx, o).(*albumRepository)
|
||||
for _, a := range testAlbums {
|
||||
for i := range testAlbums {
|
||||
a := testAlbums[i]
|
||||
_, err := alr.put(a.ID, &a)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -103,7 +106,8 @@ var _ = Describe("Initialize test DB", func() {
|
||||
}
|
||||
|
||||
arr := NewArtistRepository(ctx, o)
|
||||
for _, a := range testArtists {
|
||||
for i := range testArtists {
|
||||
a := testArtists[i]
|
||||
err := arr.Put(&a)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -111,8 +115,8 @@ var _ = Describe("Initialize test DB", func() {
|
||||
}
|
||||
|
||||
pr := NewPlaylistRepository(ctx, o)
|
||||
for _, pls := range testPlaylists {
|
||||
err := pr.Put(&pls)
|
||||
for i := range testPlaylists {
|
||||
err := pr.Put(testPlaylists[i])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ func NewPlayerRepository(ctx context.Context, o orm.Ormer) model.PlayerRepositor
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
r.tableName = "player"
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"name": containsFilter,
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -43,11 +46,19 @@ func (r *playerRepository) FindByName(client, userName string) (*model.Player, e
|
||||
|
||||
func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBuilder {
|
||||
s := r.newSelect(options...)
|
||||
return s.Where(r.addRestriction())
|
||||
}
|
||||
|
||||
func (r *playerRepository) addRestriction(sql ...Sqlizer) Sqlizer {
|
||||
s := And{}
|
||||
if len(sql) > 0 {
|
||||
s = append(s, sql[0])
|
||||
}
|
||||
u := loggedUser(r.ctx)
|
||||
if u.IsAdmin {
|
||||
return s
|
||||
}
|
||||
return s.Where(Eq{"user_name": u.UserName})
|
||||
return append(s, Eq{"user_name": u.UserName})
|
||||
}
|
||||
|
||||
func (r *playerRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
@@ -106,7 +117,8 @@ func (r *playerRepository) Update(entity interface{}, cols ...string) error {
|
||||
}
|
||||
|
||||
func (r *playerRepository) Delete(id string) error {
|
||||
err := r.delete(And{Eq{"id": id}, Eq{"user_name": loggedUser(r.ctx).UserName}})
|
||||
filter := r.addRestriction(And{Eq{"id": id}})
|
||||
err := r.delete(filter)
|
||||
if err == model.ErrNotFound {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
|
||||
@@ -2,29 +2,18 @@ package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/rest"
|
||||
)
|
||||
|
||||
type playlist struct {
|
||||
ID string `orm:"column(id)"`
|
||||
Name string
|
||||
Comment string
|
||||
Duration float32
|
||||
Owner string
|
||||
Public bool
|
||||
Tracks string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type playlistRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
func NewPlaylistRepository(ctx context.Context, o orm.Ormer) model.PlaylistRepository {
|
||||
@@ -35,136 +24,193 @@ func NewPlaylistRepository(ctx context.Context, o orm.Ormer) model.PlaylistRepos
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *playlistRepository) CountAll() (int64, error) {
|
||||
return r.count(Select())
|
||||
func (r *playlistRepository) userFilter() Sqlizer {
|
||||
user := loggedUser(r.ctx)
|
||||
if user.IsAdmin {
|
||||
return And{}
|
||||
}
|
||||
return Or{
|
||||
Eq{"public": true},
|
||||
Eq{"owner": user.UserName},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *playlistRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
sql := Select().Where(r.userFilter())
|
||||
return r.count(sql, options...)
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(Select().Where(Eq{"id": id}))
|
||||
return r.exists(Select().Where(And{Eq{"id": id}, r.userFilter()}))
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Delete(id string) error {
|
||||
return r.delete(Eq{"id": id})
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin {
|
||||
pls, err := r.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pls.Owner != usr.UserName {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
}
|
||||
return r.delete(And{Eq{"id": id}, r.userFilter()})
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Put(p *model.Playlist) error {
|
||||
if p.ID == "" {
|
||||
p.CreatedAt = time.Now()
|
||||
} else {
|
||||
ok, err := r.Exists(p.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
}
|
||||
p.UpdatedAt = time.Now()
|
||||
pls := r.fromModel(p)
|
||||
_, err := r.put(pls.ID, pls)
|
||||
return err
|
||||
|
||||
// Save tracks for later and set it to nil, to avoid trying to save it to the DB
|
||||
tracks := p.Tracks
|
||||
p.Tracks = nil
|
||||
|
||||
id, err := r.put(p.ID, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.ID = id
|
||||
|
||||
// Only update tracks if they are specified
|
||||
if tracks != nil {
|
||||
err = r.updateTracks(id, tracks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return r.loadTracks(p)
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Get(id string) (*model.Playlist, error) {
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"id": id})
|
||||
var res playlist
|
||||
err := r.queryOne(sel, &res)
|
||||
pls := r.toModel(&res)
|
||||
sel := r.newSelect().Columns("*").Where(And{Eq{"id": id}, r.userFilter()})
|
||||
var pls model.Playlist
|
||||
err := r.queryOne(sel, &pls)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = r.loadTracks(&pls)
|
||||
return &pls, err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playlists, error) {
|
||||
sel := r.newSelect(options...).Columns("*")
|
||||
var res []playlist
|
||||
sel := r.newSelect(options...).Columns("*").Where(r.userFilter())
|
||||
res := model.Playlists{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return r.toModels(res), err
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) toModels(all []playlist) model.Playlists {
|
||||
result := make(model.Playlists, len(all))
|
||||
for i, p := range all {
|
||||
result[i] = r.toModel(&p)
|
||||
func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error {
|
||||
ids := make([]string, len(tracks))
|
||||
for i := range tracks {
|
||||
ids[i] = tracks[i].ID
|
||||
}
|
||||
return result
|
||||
return r.Tracks(id).Update(ids)
|
||||
}
|
||||
|
||||
func (r *playlistRepository) toModel(p *playlist) model.Playlist {
|
||||
pls := model.Playlist{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Comment: p.Comment,
|
||||
Duration: p.Duration,
|
||||
Owner: p.Owner,
|
||||
Public: p.Public,
|
||||
CreatedAt: p.CreatedAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
func (r *playlistRepository) loadTracks(pls *model.Playlist) error {
|
||||
tracksQuery := Select().From("playlist_tracks").
|
||||
LeftJoin("annotation on ("+
|
||||
"annotation.item_id = media_file_id"+
|
||||
" AND annotation.item_type = 'media_file'"+
|
||||
" AND annotation.user_id = '"+userId(r.ctx)+"')").
|
||||
Columns("starred", "starred_at", "play_count", "play_date", "rating", "f.*").
|
||||
Join("media_file f on f.id = media_file_id").
|
||||
Where(Eq{"playlist_id": pls.ID}).OrderBy("playlist_tracks.id")
|
||||
err := r.queryAll(tracksQuery, &pls.Tracks)
|
||||
if err != nil {
|
||||
log.Error("Error loading playlist tracks", "playlist", pls.Name, "id", pls.ID)
|
||||
}
|
||||
if strings.TrimSpace(p.Tracks) != "" {
|
||||
tracks := strings.Split(p.Tracks, ",")
|
||||
for _, t := range tracks {
|
||||
pls.Tracks = append(pls.Tracks, model.MediaFile{ID: t})
|
||||
}
|
||||
}
|
||||
pls.Tracks = r.loadTracks(&pls)
|
||||
return pls
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) fromModel(p *model.Playlist) playlist {
|
||||
pls := playlist{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Comment: p.Comment,
|
||||
Owner: p.Owner,
|
||||
Public: p.Public,
|
||||
CreatedAt: p.CreatedAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
}
|
||||
p.Tracks = r.loadTracks(p)
|
||||
var newTracks []string
|
||||
for _, t := range p.Tracks {
|
||||
newTracks = append(newTracks, t.ID)
|
||||
pls.Duration += t.Duration
|
||||
}
|
||||
pls.Tracks = strings.Join(newTracks, ",")
|
||||
return pls
|
||||
func (r *playlistRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(options...))
|
||||
}
|
||||
|
||||
// TODO: Introduce a relation table for Playlist <-> MediaFiles, and rewrite this method in pure SQL
|
||||
func (r *playlistRepository) loadTracks(p *model.Playlist) model.MediaFiles {
|
||||
if len(p.Tracks) == 0 {
|
||||
return nil
|
||||
func (r *playlistRepository) Read(id string) (interface{}, error) {
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
func (r *playlistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(options...))
|
||||
}
|
||||
|
||||
func (r *playlistRepository) EntityName() string {
|
||||
return "playlist"
|
||||
}
|
||||
|
||||
func (r *playlistRepository) NewInstance() interface{} {
|
||||
return &model.Playlist{}
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Save(entity interface{}) (string, error) {
|
||||
pls := entity.(*model.Playlist)
|
||||
pls.Owner = loggedUser(r.ctx).UserName
|
||||
err := r.Put(pls)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return pls.ID, err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Update(entity interface{}, cols ...string) error {
|
||||
pls := entity.(*model.Playlist)
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin && pls.Owner != usr.UserName {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
err := r.Put(pls)
|
||||
if err == model.ErrNotFound {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) removeOrphans() error {
|
||||
sel := Select("playlist_tracks.playlist_id as id", "p.name").From("playlist_tracks").
|
||||
Join("playlist p on playlist_tracks.playlist_id = p.id").
|
||||
LeftJoin("media_file mf on playlist_tracks.media_file_id = mf.id").
|
||||
Where(Eq{"mf.id": nil}).
|
||||
GroupBy("playlist_tracks.playlist_id")
|
||||
|
||||
var pls []struct{ Id, Name string }
|
||||
err := r.queryAll(sel, &pls)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Collect all ids
|
||||
ids := make([]string, len(p.Tracks))
|
||||
for i, t := range p.Tracks {
|
||||
ids[i] = t.ID
|
||||
}
|
||||
|
||||
// Break the list in chunks, up to 50 items, to avoid hitting SQLITE_MAX_FUNCTION_ARG limit
|
||||
const chunkSize = 50
|
||||
var chunks [][]string
|
||||
for i := 0; i < len(ids); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
for _, pl := range pls {
|
||||
log.Debug(r.ctx, "Cleaning-up orphan tracks from playlist", "id", pl.Id, "name", pl.Name)
|
||||
del := Delete("playlist_tracks").Where(And{
|
||||
ConcatExpr("media_file_id not in (select id from media_file)"),
|
||||
Eq{"playlist_id": pl.Id},
|
||||
})
|
||||
n, err := r.executeSQL(del)
|
||||
if n == 0 || err != nil {
|
||||
return err
|
||||
}
|
||||
log.Debug(r.ctx, "Deleted tracks, now reordering", "id", pl.Id, "name", pl.Name, "deleted", n)
|
||||
|
||||
chunks = append(chunks, ids[i:end])
|
||||
}
|
||||
|
||||
// Query each chunk of media_file ids and store results in a map
|
||||
mfRepo := NewMediaFileRepository(r.ctx, r.ormer)
|
||||
trackMap := map[string]model.MediaFile{}
|
||||
for i := range chunks {
|
||||
idsFilter := Eq{"id": chunks[i]}
|
||||
tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: idsFilter})
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Could not load playlist's tracks", "playlistName", p.Name, "playlistId", p.ID, err)
|
||||
}
|
||||
for _, t := range tracks {
|
||||
trackMap[t.ID] = t
|
||||
// To reorganize the playlist, just add an empty list of new tracks
|
||||
trks := r.Tracks(pl.Id)
|
||||
if err := trks.Add(nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new list of tracks with the same order as the original
|
||||
newTracks := make(model.MediaFiles, len(p.Tracks))
|
||||
for i, t := range p.Tracks {
|
||||
newTracks[i] = trackMap[t.ID]
|
||||
}
|
||||
return newTracks
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ model.PlaylistRepository = (*playlistRepository)(nil)
|
||||
var _ rest.Repository = (*playlistRepository)(nil)
|
||||
var _ rest.Persistable = (*playlistRepository)(nil)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -12,7 +15,9 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
var repo model.PlaylistRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = NewPlaylistRepository(log.NewContext(nil), orm.NewOrm())
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
|
||||
repo = NewPlaylistRepository(ctx, orm.NewOrm())
|
||||
})
|
||||
|
||||
Describe("Count", func() {
|
||||
@@ -23,7 +28,7 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
|
||||
Describe("Exists", func() {
|
||||
It("returns true for an existing playlist", func() {
|
||||
Expect(repo.Exists("11")).To(BeTrue())
|
||||
Expect(repo.Exists(plsCool.ID)).To(BeTrue())
|
||||
})
|
||||
It("returns false for a non-existing playlist", func() {
|
||||
Expect(repo.Exists("666")).To(BeFalse())
|
||||
@@ -32,7 +37,7 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
|
||||
Describe("Get", func() {
|
||||
It("returns an existing playlist", func() {
|
||||
p, err := repo.Get("10")
|
||||
p, err := repo.Get(plsBest.ID)
|
||||
Expect(err).To(BeNil())
|
||||
// Compare all but Tracks and timestamps
|
||||
p2 := *p
|
||||
@@ -50,7 +55,7 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
It("returns all tracks", func() {
|
||||
pls, err := repo.Get("10")
|
||||
pls, err := repo.Get(plsBest.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(pls.Name).To(Equal(plsBest.Name))
|
||||
Expect(pls.Tracks).To(Equal(model.MediaFiles{
|
||||
@@ -60,32 +65,31 @@ 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"}}}
|
||||
})
|
||||
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"})
|
||||
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"))
|
||||
})
|
||||
It("returns the newly created playlist", func() {
|
||||
Expect(repo.Exists("22")).To(BeTrue())
|
||||
})
|
||||
It("returns deletes the playlist", func() {
|
||||
Expect(repo.Delete("22")).To(BeNil())
|
||||
})
|
||||
It("returns error if tries to retrieve the deleted playlist", func() {
|
||||
Expect(repo.Exists("22")).To(BeFalse())
|
||||
})
|
||||
It("Put/Exists/Delete", func() {
|
||||
By("saves the playlist to the DB")
|
||||
newPls := model.Playlist{Name: "Great!", Owner: "userid",
|
||||
Tracks: model.MediaFiles{{ID: "1004"}, {ID: "1003"}}}
|
||||
|
||||
By("saves the playlist to the DB")
|
||||
Expect(repo.Put(&newPls)).To(BeNil())
|
||||
|
||||
By("adds repeated songs to a playlist and keeps the order")
|
||||
newPls.Tracks = append(newPls.Tracks, model.MediaFile{ID: "1004"})
|
||||
Expect(repo.Put(&newPls)).To(BeNil())
|
||||
saved, _ := repo.Get(newPls.ID)
|
||||
Expect(saved.Tracks).To(HaveLen(3))
|
||||
Expect(saved.Tracks[0].ID).To(Equal("1004"))
|
||||
Expect(saved.Tracks[1].ID).To(Equal("1003"))
|
||||
Expect(saved.Tracks[2].ID).To(Equal("1004"))
|
||||
|
||||
By("returns the newly created playlist")
|
||||
Expect(repo.Exists(newPls.ID)).To(BeTrue())
|
||||
|
||||
By("returns deletes the playlist")
|
||||
Expect(repo.Delete(newPls.ID)).To(BeNil())
|
||||
|
||||
By("returns error if tries to retrieve the deleted playlist")
|
||||
Expect(repo.Exists(newPls.ID)).To(BeFalse())
|
||||
})
|
||||
|
||||
Describe("GetAll", func() {
|
||||
|
||||
195
persistence/playlist_track_repository.go
Normal file
195
persistence/playlist_track_repository.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
"github.com/deluan/rest"
|
||||
)
|
||||
|
||||
type playlistTrackRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
playlistId string
|
||||
playlistRepo model.PlaylistRepository
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Tracks(playlistId string) model.PlaylistTrackRepository {
|
||||
p := &playlistTrackRepository{}
|
||||
p.playlistRepo = NewPlaylistRepository(r.ctx, r.ormer)
|
||||
p.playlistId = playlistId
|
||||
p.ctx = r.ctx
|
||||
p.ormer = r.ormer
|
||||
p.tableName = "playlist_tracks"
|
||||
p.sortMappings = map[string]string{
|
||||
"id": "playlist_tracks.id",
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.count(Select().Where(Eq{"playlist_id": r.playlistId}), r.parseRestOptions(options...))
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) Read(id string) (interface{}, error) {
|
||||
sel := r.newSelect().
|
||||
LeftJoin("annotation on ("+
|
||||
"annotation.item_id = media_file_id"+
|
||||
" AND annotation.item_type = 'media_file'"+
|
||||
" AND annotation.user_id = '"+userId(r.ctx)+"')").
|
||||
Columns("starred", "starred_at", "play_count", "play_date", "rating", "f.*", "playlist_tracks.*").
|
||||
Join("media_file f on f.id = media_file_id").
|
||||
Where(And{Eq{"playlist_id": r.playlistId}, Eq{"id": id}})
|
||||
var trk model.PlaylistTrack
|
||||
err := r.queryOne(sel, &trk)
|
||||
return &trk, err
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
sel := r.newSelect(r.parseRestOptions(options...)).
|
||||
LeftJoin("annotation on ("+
|
||||
"annotation.item_id = media_file_id"+
|
||||
" AND annotation.item_type = 'media_file'"+
|
||||
" AND annotation.user_id = '"+userId(r.ctx)+"')").
|
||||
Columns("starred", "starred_at", "play_count", "play_date", "rating", "f.*", "playlist_tracks.*").
|
||||
Join("media_file f on f.id = media_file_id").
|
||||
Where(Eq{"playlist_id": r.playlistId})
|
||||
res := model.PlaylistTracks{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) EntityName() string {
|
||||
return "playlist_tracks"
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) NewInstance() interface{} {
|
||||
return &model.PlaylistTrack{}
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) Add(mediaFileIds []string) error {
|
||||
if !r.isWritable() {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
|
||||
if len(mediaFileIds) > 0 {
|
||||
log.Debug(r.ctx, "Adding songs to playlist", "playlistId", r.playlistId, "mediaFileIds", mediaFileIds)
|
||||
}
|
||||
|
||||
ids, err := r.getTracks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Append new tracks
|
||||
ids = append(ids, mediaFileIds...)
|
||||
|
||||
// Update tracks and playlist
|
||||
return r.Update(ids)
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) getTracks() ([]string, error) {
|
||||
// Get all current tracks
|
||||
all := r.newSelect().Columns("media_file_id").Where(Eq{"playlist_id": r.playlistId}).OrderBy("id")
|
||||
var tracks model.PlaylistTracks
|
||||
err := r.queryAll(all, &tracks)
|
||||
if err != nil {
|
||||
log.Error("Error querying current tracks from playlist", "playlistId", r.playlistId, err)
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]string, len(tracks))
|
||||
for i := range tracks {
|
||||
ids[i] = tracks[i].MediaFileID
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) Update(mediaFileIds []string) error {
|
||||
if !r.isWritable() {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Remove old tracks
|
||||
del := Delete(r.tableName).Where(Eq{"playlist_id": r.playlistId})
|
||||
_, err := r.executeSQL(del)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Break the track list in chunks to avoid hitting SQLITE_MAX_FUNCTION_ARG limit
|
||||
chunks := utils.BreakUpStringSlice(mediaFileIds, 50)
|
||||
|
||||
// Add new tracks, chunk by chunk
|
||||
pos := 1
|
||||
for i := range chunks {
|
||||
ins := Insert(r.tableName).Columns("playlist_id", "media_file_id", "id")
|
||||
for _, t := range chunks[i] {
|
||||
ins = ins.Values(r.playlistId, t, pos)
|
||||
pos++
|
||||
}
|
||||
_, err = r.executeSQL(ins)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return r.updateStats()
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) updateStats() error {
|
||||
// Get total playlist duration and count
|
||||
statsSql := Select("sum(duration) as duration", "count(*) as count").From("media_file").
|
||||
Join("playlist_tracks f on f.media_file_id = media_file.id").
|
||||
Where(Eq{"playlist_id": r.playlistId})
|
||||
var res struct{ Duration, Count float32 }
|
||||
err := r.queryOne(statsSql, &res)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update playlist's total duration and count
|
||||
upd := Update("playlist").
|
||||
Set("duration", res.Duration).
|
||||
Set("song_count", res.Count).
|
||||
Set("updated_at", time.Now()).
|
||||
Where(Eq{"id": r.playlistId})
|
||||
_, err = r.executeSQL(upd)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) Delete(id string) error {
|
||||
if !r.isWritable() {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
err := r.delete(And{Eq{"playlist_id": r.playlistId}, Eq{"id": id}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.updateStats()
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) Reorder(pos int, newPos int) error {
|
||||
if !r.isWritable() {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
ids, err := r.getTracks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newOrder := utils.MoveString(ids, pos-1, newPos-1)
|
||||
return r.Update(newOrder)
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) isWritable() bool {
|
||||
usr := loggedUser(r.ctx)
|
||||
if usr.IsAdmin {
|
||||
return true
|
||||
}
|
||||
pls, err := r.playlistRepo.Get(r.playlistId)
|
||||
return err == nil && pls.Owner == usr.UserName
|
||||
}
|
||||
|
||||
var _ model.PlaylistTrackRepository = (*playlistTrackRepository)(nil)
|
||||
@@ -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() {
|
||||
|
||||
@@ -96,3 +96,12 @@ func (r sqlRepository) cleanAnnotations() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r sqlRepository) updateAnnotations(id string, m interface{}) error {
|
||||
ans := m.(model.AnnotatedModel).GetAnnotations()
|
||||
err := r.SetStar(ans.Starred, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.SetRating(ans.Rating, id)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -24,21 +25,19 @@ type sqlRepository struct {
|
||||
const invalidUserId = "-1"
|
||||
|
||||
func userId(ctx context.Context) string {
|
||||
user := ctx.Value("user")
|
||||
if user == nil {
|
||||
if user, ok := request.UserFrom(ctx); !ok {
|
||||
return invalidUserId
|
||||
} else {
|
||||
return user.ID
|
||||
}
|
||||
usr := user.(model.User)
|
||||
return usr.ID
|
||||
}
|
||||
|
||||
func loggedUser(ctx context.Context) *model.User {
|
||||
user := ctx.Value("user")
|
||||
if user == nil {
|
||||
if user, ok := request.UserFrom(ctx); !ok {
|
||||
return &model.User{}
|
||||
} else {
|
||||
return &user
|
||||
}
|
||||
u := user.(model.User)
|
||||
return &u
|
||||
}
|
||||
|
||||
func (r sqlRepository) newSelect(options ...model.QueryOptions) SelectBuilder {
|
||||
@@ -113,6 +112,8 @@ func (r sqlRepository) executeSQL(sq Sqlizer) (int64, error) {
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// Note: Due to a bug in the QueryRow, this method does not map any embedded structs (ex: annotations)
|
||||
// In this case, use the queryAll method and get the first item of the returned list
|
||||
func (r sqlRepository) queryOne(sq Sqlizer, response interface{}) error {
|
||||
query, args, err := sq.ToSql()
|
||||
if err != nil {
|
||||
@@ -160,7 +161,7 @@ func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOpt
|
||||
|
||||
func (r sqlRepository) put(id string, m interface{}) (newId string, err error) {
|
||||
values, _ := toSqlArgs(m)
|
||||
// Remove created_at from args and save it for later, if needed fo insert
|
||||
// Remove created_at from args and save it for later, if needed for insert
|
||||
createdAt := values["created_at"]
|
||||
delete(values, "created_at")
|
||||
if id != "" {
|
||||
@@ -170,16 +171,19 @@ func (r sqlRepository) put(id string, m interface{}) (newId string, err error) {
|
||||
return "", err
|
||||
}
|
||||
if count > 0 {
|
||||
return id, nil
|
||||
if _, ok := m.(model.AnnotatedModel); ok {
|
||||
err = r.updateAnnotations(id, m)
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
}
|
||||
// if does not have an id OR could not update (new record with predefined id)
|
||||
// If does not have an id OR could not update (new record with predefined id)
|
||||
if id == "" {
|
||||
rand, _ := uuid.NewRandom()
|
||||
id = rand.String()
|
||||
values["id"] = id
|
||||
}
|
||||
// It is a insert, if there was a created_at, add it back to args
|
||||
// It is a insert. if there was a created_at, add it back to args
|
||||
if createdAt != nil {
|
||||
values["created_at"] = createdAt
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/kennygrant/sanitize"
|
||||
)
|
||||
|
||||
type filterFunc = func(field string, value interface{}) Sqlizer
|
||||
@@ -53,21 +52,21 @@ func startsWithFilter(field string, value interface{}) Sqlizer {
|
||||
return Like{field: fmt.Sprintf("%s%%", value)}
|
||||
}
|
||||
|
||||
func containsFilter(field string, value interface{}) Sqlizer {
|
||||
return Like{field: fmt.Sprintf("%%%s%%", value)}
|
||||
}
|
||||
|
||||
func booleanFilter(field string, value interface{}) Sqlizer {
|
||||
v := strings.ToLower(value.(string))
|
||||
return Eq{field: strings.ToLower(v) == "true"}
|
||||
}
|
||||
|
||||
func fullTextFilter(field string, value interface{}) Sqlizer {
|
||||
q := value.(string)
|
||||
q = strings.TrimSpace(sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*"))))
|
||||
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,29 @@ 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"))
|
||||
})
|
||||
|
||||
It("remove opening brackets", func() {
|
||||
Expect(getFullText("[Five Years]")).To(Equal(" five years"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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|^db/migration)" -- 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")
|
||||
}
|
||||
283
resources/i18n/cs.json
Normal file
283
resources/i18n/cs.json
Normal file
@@ -0,0 +1,283 @@
|
||||
{
|
||||
"languageName": "Čeština",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Skladba |||| Skladby",
|
||||
"fields": {
|
||||
"albumArtist": "Interpret alba",
|
||||
"duration": "Délka",
|
||||
"trackNumber": "#",
|
||||
"playCount": "Přehrání",
|
||||
"title": "Název",
|
||||
"artist": "Interpret",
|
||||
"album": "Album",
|
||||
"path": "Cesta k souboru",
|
||||
"genre": "Žánr",
|
||||
"compilation": "Kompilace",
|
||||
"year": "Rok",
|
||||
"size": "Velikost souboru",
|
||||
"updatedAt": "Nahráno",
|
||||
"bitRate": "Přenosová rychlost",
|
||||
"discSubtitle": "Podtitul disku",
|
||||
"starred": "Hvězdičkované"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Přehrát později",
|
||||
"playNow": "Přehrát nyní",
|
||||
"addToPlaylist": "Přidat do seznamu skladeb"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Album |||| Alba",
|
||||
"fields": {
|
||||
"albumArtist": "Interpret alba",
|
||||
"artist": "Interpret",
|
||||
"duration": "Délka",
|
||||
"songCount": "Skladby",
|
||||
"playCount": "Přehrání",
|
||||
"name": "Název",
|
||||
"genre": "Žánr",
|
||||
"compilation": "Kompilace",
|
||||
"year": "Rok",
|
||||
"updatedAt": "Aktualizováno"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Přehrát",
|
||||
"playNext": "Přehrát další",
|
||||
"addToQueue": "Přehrát později",
|
||||
"shuffle": "Zamíchat"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Interpret |||| Interpreti",
|
||||
"fields": {
|
||||
"name": "Název",
|
||||
"albumCount": "Počet alb",
|
||||
"songCount": "Počet skladeb"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Uživatel |||| Uživatelé",
|
||||
"fields": {
|
||||
"userName": "Uživatelské jméno",
|
||||
"isAdmin": "Správcem",
|
||||
"lastLoginAt": "Naposledy přihlášen",
|
||||
"updatedAt": "Upraven",
|
||||
"name": "Jméno",
|
||||
"password": "Heslo",
|
||||
"createdAt": "Vytvořen"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "Přehrávač |||| Přehrávače",
|
||||
"fields": {
|
||||
"name": "Název",
|
||||
"transcodingId": "ID překódování",
|
||||
"maxBitRate": "Max. přenosová rychlost",
|
||||
"client": "Klient",
|
||||
"userName": "Uživatelské jméno",
|
||||
"lastSeen": "Naposledy spatřen"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Překódování |||| Překódování",
|
||||
"fields": {
|
||||
"name": "Název",
|
||||
"targetFormat": "Cílený formát",
|
||||
"defaultBitRate": "Výchozí přenosová rychlost",
|
||||
"command": "Příkaz"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Seznam skladeb |||| Seznamy skladeb",
|
||||
"fields": {
|
||||
"name": "Název",
|
||||
"duration": "Délka",
|
||||
"owner": "Vlastník",
|
||||
"public": "Veřejný",
|
||||
"updatedAt": "Nahrán",
|
||||
"createdAt": "Vytvořen",
|
||||
"songCount": "Skladby",
|
||||
"comment": "Komentář"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Přidat skladby do seznamu:",
|
||||
"addNewPlaylist": "Vytvořit \"%{name}\""
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Děkujeme, že jste si nainstalovali Navidrome!",
|
||||
"welcome2": "Nejdříve vytvořte účet správce",
|
||||
"confirmPassword": "Potvrďte heslo",
|
||||
"buttonCreateAdmin": "Vytvořit správce",
|
||||
"auth_check_error": "Pro pokračování se prosím přihlašte",
|
||||
"user_menu": "Profil",
|
||||
"username": "Uživatelské jméno",
|
||||
"password": "Heslo",
|
||||
"sign_in": "Přihlásit se",
|
||||
"sign_in_error": "Ověření selhalo, zkuste to znovu",
|
||||
"logout": "Odhlásit se"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Prosím, používejte pouze písmena a čísla",
|
||||
"passwordDoesNotMatch": "Hesla se neschodují",
|
||||
"required": "Povinné pole",
|
||||
"minLength": "Musí obsahovat nejméně %{min} znaků",
|
||||
"maxLength": "Může obsahovat maximálně %{max} znaků",
|
||||
"minValue": "Musí být alespoň %{min}",
|
||||
"maxValue": "Může být maximálně %{max}",
|
||||
"number": "Musí být číslo",
|
||||
"email": "Musí být platná emailová adresa",
|
||||
"oneOf": "Musí splňovat jedno z: %{options}",
|
||||
"regex": "Musí být ve specifickém formátu (regexp): %{pattern}"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Přidat filtr",
|
||||
"add": "Přidat",
|
||||
"back": "Jít zpět",
|
||||
"bulk_actions": "%{smart_count} vybráno",
|
||||
"cancel": "Zrušit",
|
||||
"clear_input_value": "Smazat hodnotu",
|
||||
"clone": "Klonovat",
|
||||
"confirm": "Potvrdit",
|
||||
"create": "Vytvořit",
|
||||
"delete": "Smazat",
|
||||
"edit": "Upravit",
|
||||
"export": "Exportovat",
|
||||
"list": "Seznam",
|
||||
"refresh": "Obnovit",
|
||||
"remove_filter": "Odstranit filtr",
|
||||
"remove": "Odstranit",
|
||||
"save": "Uložit",
|
||||
"search": "Vyhledat",
|
||||
"show": "Ukázat",
|
||||
"sort": "Seřadit",
|
||||
"undo": "Vrátit",
|
||||
"expand": "Zvětšit",
|
||||
"close": "Zavřít",
|
||||
"open_menu": "Otevřít nabídku",
|
||||
"close_menu": "Zavřít nabídku"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Ano",
|
||||
"false": "Ne"
|
||||
},
|
||||
"page": {
|
||||
"create": "Vytvořit %{name}",
|
||||
"dashboard": "Dashboard",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Něco se pokazilo",
|
||||
"list": "%{name}",
|
||||
"loading": "Načítání",
|
||||
"not_found": "Nenalezeno",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Zatím žádné %{name}",
|
||||
"invite": "Chcete jeden přidat?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Přetáhněte soubory pro nahrání nebo klikněte pro výběr",
|
||||
"upload_single": "Přetáhněte soubor pro nahrání nebo klikněte pro jeho výběr"
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Přetáhněte obrázky pro nahrání nebo klikněte pro výběr",
|
||||
"upload_single": "Přetáhněte obrázek pro nahrání nebo klikněte pro jeho výběr"
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "Nelze nalézt referencovaná data",
|
||||
"many_missing": "Minimálně jedna z referencí se nezdá být nadále dostupná",
|
||||
"single_missing": "Reference se nezdá být nadále dostupná."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "Skrýt heslo",
|
||||
"toggle_hidden": "Ukázat heslo"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "O",
|
||||
"are_you_sure": "Jste si jistý?",
|
||||
"bulk_delete_content": "Jste si jistý, že chcete smazat %{name}? |||| Jste si jistý, že chcete smazat těchto %{smart_count} položek?",
|
||||
"bulk_delete_title": "Smazat %{name} |||| Smazat %{smart_count} %{name} položek",
|
||||
"delete_content": "Jste si jistý, že chcete smazat tuto položku?",
|
||||
"delete_title": "Smazat %{name} #%{id}",
|
||||
"details": "Detaily",
|
||||
"error": "Objevila se chyba klienta a váš požadavek nemohl být splněn.",
|
||||
"invalid_form": "Formulář není platný. Prosím zkontrolujte chyby.",
|
||||
"loading": "Stránka se načítá, prosím vyčkejte",
|
||||
"no": "Ne",
|
||||
"not_found": "Napsali jste špatnou adresu URL, nebo jste následovali špatný odkaz.",
|
||||
"yes": "Ano",
|
||||
"unsaved_changes": "Některé vaše změny nebyly uloženy. Jste si jisti, že je chcete ignorovat?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Žádné výsledky nebyly nalezeny",
|
||||
"no_more_results": "Stránka číslo %{page} je mimo rozsah. Zkuste předchozí.",
|
||||
"page_out_of_boundaries": "Stránka číslo %{page} je mimo rozsah",
|
||||
"page_out_from_end": "Nelze jít za poslední stranu",
|
||||
"page_out_from_begin": "Nelze jít před první stranu",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} z %{total}",
|
||||
"page_rows_per_page": "Řádků na stránce:",
|
||||
"next": "Další",
|
||||
"prev": "Předchozí"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Prvek aktualizován |||| %{smart_count} prvků aktualizováno",
|
||||
"created": "Prvek vytvořen",
|
||||
"deleted": "Prvek smazán |||| %{smart_count} prvků smazáno",
|
||||
"bad_item": "Nesprávný prvek",
|
||||
"item_doesnt_exist": "Prvek neexistuje",
|
||||
"http_error": "Chyba komunikace serveru",
|
||||
"data_provider_error": "Chyba dataProvideru. Detaily najdete v konzoli.",
|
||||
"i18n_error": "Nelze načíst překlady pro vybraný jazyk",
|
||||
"canceled": "Akce zrušena",
|
||||
"logged_out": "Vaše relace skončila, prosím připojte se znovu."
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "POZNÁMKA",
|
||||
"transcodingDisabled": "Měnění nastavení překódování je ve webovém prostředí vypnuto kvůli bezpečnosti. Pokud by jste chtěli změnit (upravit nebo přidat) možnosti překódování, restartujte server s možností %{config}.",
|
||||
"transcodingEnabled": "Navidrome právě běží s možností %{config}, umožňující spouštění systémových příkazů z nastavení překódování pomocí webového rozhraní. Doporučujeme ji vypnout kvůli bezpečnosti a použít ji pouze pokud upravujete nastavení překódování.",
|
||||
"songsAddedToPlaylist": "1 skladba přidána na seznam skladeb ||| %{smart_count} skladeb přidáno na seznam skladeb",
|
||||
"noPlaylistsAvailable": "Žádné nejsou dostupné"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Knihovna",
|
||||
"settings": "Nastavení",
|
||||
"version": "Verze %{version}",
|
||||
"theme": "Motiv",
|
||||
"personal": {
|
||||
"name": "Osobní",
|
||||
"options": {
|
||||
"theme": "Motiv",
|
||||
"language": "Jazyk"
|
||||
}
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Fronta",
|
||||
"openText": "Otevřít",
|
||||
"closeText": "Zavřít",
|
||||
"notContentText": "Žádné skladby",
|
||||
"clickToPlayText": "Klikněte pro přehrání",
|
||||
"clickToPauseText": "Klikněte pro pozastavní",
|
||||
"nextTrackText": "Další skladba",
|
||||
"previousTrackText": "Předchozí skladba",
|
||||
"reloadText": "Znovu načíst",
|
||||
"volumeText": "Hlasitost",
|
||||
"toggleLyricText": "Přepnout text",
|
||||
"toggleMiniModeText": "Zmenšit",
|
||||
"destroyText": "Zničit",
|
||||
"downloadText": "Stáhnout",
|
||||
"removeAudioListsText": "Vymazat seznam",
|
||||
"clickToDeleteText": "Klikněte pro odstratění %{name}",
|
||||
"emptyLyricText": "Bez textu",
|
||||
"playModeText": {
|
||||
"order": "Popořadě",
|
||||
"orderLoop": "Opakovat",
|
||||
"singleLoop": "Opakovat jednou",
|
||||
"shufflePlay": "Zamíchat"
|
||||
}
|
||||
}
|
||||
}
|
||||
283
resources/i18n/de.json
Normal file
283
resources/i18n/de.json
Normal file
@@ -0,0 +1,283 @@
|
||||
{
|
||||
"languageName": "Deutsch",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Song |||| Songs",
|
||||
"fields": {
|
||||
"albumArtist": "Albuminterpret",
|
||||
"duration": "Dauer",
|
||||
"trackNumber": "Titel #",
|
||||
"playCount": "Wiedergaben",
|
||||
"title": "Titel",
|
||||
"artist": "Künstler",
|
||||
"album": "Album",
|
||||
"path": "Dateipfad",
|
||||
"genre": "Genre",
|
||||
"compilation": "Kompilation",
|
||||
"year": "Jahr",
|
||||
"size": "Dateigröße",
|
||||
"updatedAt": "Hochgeladen um",
|
||||
"bitRate": "Bitrate",
|
||||
"discSubtitle": "CD Untertitel",
|
||||
"starred": "Favorit"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Später abspielen",
|
||||
"playNow": "Jetzt abspielen",
|
||||
"addToPlaylist": "Zur Playlist hinzufügen"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Album |||| Alben",
|
||||
"fields": {
|
||||
"albumArtist": "Albuminterpret",
|
||||
"artist": "Interpret",
|
||||
"duration": "Dauer",
|
||||
"songCount": "Songanzahl",
|
||||
"playCount": "Wiedergaben",
|
||||
"name": "Name",
|
||||
"genre": "Genre",
|
||||
"compilation": "Kompilation",
|
||||
"year": "Jahr",
|
||||
"updatedAt": "Aktualisiert um"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Abspielen",
|
||||
"playNext": "Als nächstes abspielen",
|
||||
"addToQueue": "Später abspielen",
|
||||
"shuffle": "Zufallswiedergabe"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Interpret |||| Interpreten",
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"albumCount": "Albumanzahl",
|
||||
"songCount": "Songanzahl"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Nutzer |||| Nutzer",
|
||||
"fields": {
|
||||
"userName": "Nutzername",
|
||||
"isAdmin": "Ist Admin",
|
||||
"lastLoginAt": "Letzer Login um",
|
||||
"updatedAt": "Aktualisiert um",
|
||||
"name": "Name",
|
||||
"password": "Passwort",
|
||||
"createdAt": "Erstellt um"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Playlist |||| Playlists",
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"duration": "Dauer",
|
||||
"owner": "Inhaber",
|
||||
"public": "Öffentlich",
|
||||
"updatedAt": "Aktualisiert um",
|
||||
"createdAt": "Erstellt um",
|
||||
"songCount": "Songanzahl",
|
||||
"comment": "Kommentar"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Songs zur Playlist hinzufügen",
|
||||
"addNewPlaylist": "\"%{name}\" erstellen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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öchtest 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 deine 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 Element",
|
||||
"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": "Deine 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.",
|
||||
"songsAddedToPlaylist": "Einen Song zur Playlist hinzugefügt |||| %{smart_count} Songs zur Playlist hinzugefügt",
|
||||
"noPlaylistsAvailable": "Keine Playlist verfügbar"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
283
resources/i18n/fr.json
Normal file
283
resources/i18n/fr.json
Normal file
@@ -0,0 +1,283 @@
|
||||
{
|
||||
"languageName": "Français",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Piste |||| Pistes",
|
||||
"fields": {
|
||||
"albumArtist": "Artiste",
|
||||
"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",
|
||||
"bitRate": "Bitrate",
|
||||
"discSubtitle": "Sous-titre du disque",
|
||||
"starred": "Favoris"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Ajouter à la file",
|
||||
"playNow": "Lire",
|
||||
"addToPlaylist": "Ajouter à la playlist"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Album |||| Albums",
|
||||
"fields": {
|
||||
"albumArtist": "Artiste",
|
||||
"artist": "Artiste",
|
||||
"duration": "Durée",
|
||||
"songCount": "Numéro de piste",
|
||||
"playCount": "Nombre d'écoutes",
|
||||
"name": "Nom",
|
||||
"genre": "Genre",
|
||||
"compilation": "Compilation",
|
||||
"year": "Année",
|
||||
"updatedAt": "Mise à jour le"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Lire",
|
||||
"playNext": "Lire ensuite",
|
||||
"addToQueue": "Ajouter à la file",
|
||||
"shuffle": "Mélanger"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Artiste |||| Artistes",
|
||||
"fields": {
|
||||
"name": "Nom",
|
||||
"albumCount": "Nombre d'albums",
|
||||
"songCount": "Nombre de pistes"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Utilisateur |||| Utilisateurs",
|
||||
"fields": {
|
||||
"userName": "Nom d'utilisateur",
|
||||
"isAdmin": "Administrateur",
|
||||
"lastLoginAt": "Dernière connexion",
|
||||
"updatedAt": "Dernière mise à jour",
|
||||
"name": "Nom",
|
||||
"password": "Mot de passe",
|
||||
"createdAt": "Crée le"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Playlist |||| Playlists",
|
||||
"fields": {
|
||||
"name": "Nom",
|
||||
"duration": "Durée",
|
||||
"owner": "Propriétaire",
|
||||
"public": "Public",
|
||||
"updatedAt": "Mise à jour le",
|
||||
"createdAt": "Crée le",
|
||||
"songCount": "Titres",
|
||||
"comment": "Commentaire"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Ajouter les pistes à la playlist",
|
||||
"addNewPlaylist": "Créer \"%{name}\""
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "NOTE",
|
||||
"transcodingDisabled": "Le changement de paramètres depuis l'interface web est désactivé pour des raisons de sécurité. Pour changer (éditer ou supprimer) les options de transcodage, relancer le serveur avec l'option %{config} activée.",
|
||||
"transcodingEnabled": "Navidrome fonctionne actuellement avec %{config}, rendant possible l’exécution de commandes arbitraires depuis l'interface web. Il est recommandé de n'activer cette fonctionnalité uniquement lors de la configuration du Transcodage.",
|
||||
"songsAddedToPlaylist": "Une piste ajoutée à la playlist |||| %{smart_count} pistes ajoutées à la playlist",
|
||||
"noPlaylistsAvailable": "Aucune playlist"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliothèque",
|
||||
"settings": "Paramètres",
|
||||
"version": "Version%{version}",
|
||||
"theme": "Thème",
|
||||
"personal": {
|
||||
"name": "Paramètres personel",
|
||||
"options": {
|
||||
"theme": "Thème",
|
||||
"language": "Langue"
|
||||
}
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "File de lecture",
|
||||
"openText": "Ouvrir",
|
||||
"closeText": "Fermer",
|
||||
"notContentText": "Absence de musique",
|
||||
"clickToPlayText": "Cliquer pour lire",
|
||||
"clickToPauseText": "Cliquer pour mettre en pause",
|
||||
"nextTrackText": "Morceau suivant",
|
||||
"previousTrackText": "Morceau précédent",
|
||||
"reloadText": "Recharger",
|
||||
"volumeText": "Volume",
|
||||
"toggleLyricText": "Afficher/masquer les paroles",
|
||||
"toggleMiniModeText": "Minimiser",
|
||||
"destroyText": "Détruire",
|
||||
"downloadText": "Télécharger",
|
||||
"removeAudioListsText": "Vider la liste de lecture",
|
||||
"clickToDeleteText": "Cliquer pour supprimer %{name}",
|
||||
"emptyLyricText": "Absence de paroles",
|
||||
"playModeText": {
|
||||
"order": "Ordonner",
|
||||
"orderLoop": "Tout répéter",
|
||||
"singleLoop": "Repéter",
|
||||
"shufflePlay": "Aleatoire"
|
||||
}
|
||||
}
|
||||
}
|
||||
283
resources/i18n/it.json
Normal file
283
resources/i18n/it.json
Normal file
@@ -0,0 +1,283 @@
|
||||
{
|
||||
"languageName": "Italiano",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Traccia |||| Tracce",
|
||||
"fields": {
|
||||
"albumArtist": "Artista Album",
|
||||
"duration": "Durata",
|
||||
"trackNumber": "#",
|
||||
"playCount": "Riproduzioni",
|
||||
"title": "Titolo",
|
||||
"artist": "Artista",
|
||||
"album": "Album",
|
||||
"path": "Percorso",
|
||||
"genre": "Genere",
|
||||
"compilation": "Compilation",
|
||||
"year": "Anno",
|
||||
"size": "Dimensioni",
|
||||
"updatedAt": "Ultimo aggiornamento",
|
||||
"bitRate": "Bitrate",
|
||||
"discSubtitle": "Sottotitoli disco",
|
||||
"starred": "Preferita"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Aggiungi alla coda",
|
||||
"playNow": "Riproduci adesso",
|
||||
"addToPlaylist": "Aggiungi alla playlist"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Album |||| Album",
|
||||
"fields": {
|
||||
"albumArtist": "Artista Album",
|
||||
"artist": "Artista",
|
||||
"duration": "Durata",
|
||||
"songCount": "Tracce",
|
||||
"playCount": "Riproduzioni",
|
||||
"name": "Nome",
|
||||
"genre": "Genere",
|
||||
"compilation": "Compilation",
|
||||
"year": "Anno",
|
||||
"updatedAt": "Ultimo aggiornamento"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Riproduci",
|
||||
"playNext": "Riproduci come successivo",
|
||||
"addToQueue": "Aggiungi alla coda",
|
||||
"shuffle": "Riprodici casualmente"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Artista |||| Artisti",
|
||||
"fields": {
|
||||
"name": "Nome",
|
||||
"albumCount": "Album",
|
||||
"songCount": "Numero tracce"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Utente |||| Utenti",
|
||||
"fields": {
|
||||
"userName": "Nome utente",
|
||||
"isAdmin": "Amministratore",
|
||||
"lastLoginAt": "Ultimo accesso",
|
||||
"updatedAt": "Ultimo aggiornamento",
|
||||
"name": "Nome",
|
||||
"password": "Password",
|
||||
"createdAt": "Creato a"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "Client |||| Client",
|
||||
"fields": {
|
||||
"name": "Nome",
|
||||
"transcodingId": "Transcodifica",
|
||||
"maxBitRate": "Bitrate massimo",
|
||||
"client": "Applicazione",
|
||||
"userName": "Nome utente",
|
||||
"lastSeen": "Ultimo acesso"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Transcodifica |||| Transcodifiche",
|
||||
"fields": {
|
||||
"name": "Nome",
|
||||
"targetFormat": "Formato",
|
||||
"defaultBitRate": "Bitrate predefinito",
|
||||
"command": "Comando"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Playlist |||| Playlist",
|
||||
"fields": {
|
||||
"name": "Nome",
|
||||
"duration": "Durata",
|
||||
"owner": "Creatore",
|
||||
"public": "Pubblica",
|
||||
"updatedAt": "Ultimo aggiornamento",
|
||||
"createdAt": "Data creazione",
|
||||
"songCount": "Tracce",
|
||||
"comment": "Commento"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Aggiungi tracce alla playlist:",
|
||||
"addNewPlaylist": "Aggiungi \"%{name}\""
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Grazie per aver installato Navidrome!",
|
||||
"welcome2": "Per iniziare, crea un amministratore",
|
||||
"confirmPassword": "Conferma la password",
|
||||
"buttonCreateAdmin": "Crea amministratore",
|
||||
"auth_check_error": "Per favore accedi per continuare",
|
||||
"user_menu": "Profile",
|
||||
"username": "Nome utente",
|
||||
"password": "Password",
|
||||
"sign_in": "Accedi",
|
||||
"sign_in_error": "Autenticazione fallita, per favore riprova",
|
||||
"logout": "Disconnetti"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Per favore usa solo lettere e numeri",
|
||||
"passwordDoesNotMatch": "Le password non coincidono",
|
||||
"required": "Campo obbligatorio",
|
||||
"minLength": "Deve essere lungo almeno %{min} caratteri",
|
||||
"maxLength": "Deve essere lungo al massimo %{max} caratteri",
|
||||
"minValue": "Deve essere almeno %{min}",
|
||||
"maxValue": "Deve essere al massimo %{max}",
|
||||
"number": "Deve essere un numero",
|
||||
"email": "Deve essere un indirizzo email valido",
|
||||
"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": "Un elemento selezionato ||| %{smart_count} elementi selezionati",
|
||||
"cancel": "Annulla",
|
||||
"clear_input_value": "Cancella",
|
||||
"clone": "Duplica",
|
||||
"confirm": "Conferma",
|
||||
"create": "Crea",
|
||||
"delete": "Rimuovi",
|
||||
"edit": "Modifica",
|
||||
"export": "Esporta",
|
||||
"list": "Elenco",
|
||||
"refresh": "Aggiorna",
|
||||
"remove_filter": "Rimuovi questo filtro",
|
||||
"remove": "Remove",
|
||||
"save": "Salva",
|
||||
"search": "Cerca",
|
||||
"show": "Mostra",
|
||||
"sort": "Ordina",
|
||||
"undo": "Annulla",
|
||||
"expand": "Espandi",
|
||||
"close": "Chiudi",
|
||||
"open_menu": "Apri menù",
|
||||
"close_menu": "Chiudi menù"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Si",
|
||||
"false": "No"
|
||||
},
|
||||
"page": {
|
||||
"create": "Aggiungi %{name}",
|
||||
"dashboard": "Pannello di controllo",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Qualcosa è andato storto",
|
||||
"list": "%{name}",
|
||||
"loading": "Caricamento in corso",
|
||||
"not_found": "Non trovato",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Nessun %{name} per adesso.",
|
||||
"invite": "Vuoi invitare un amico?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Trascina i file da caricare, oppure clicca per selezionarli.",
|
||||
"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 sembra non essere più disponibile.",
|
||||
"single_missing": "Il riferimento associato sembra non essere più disponibile."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "Nascondi password",
|
||||
"toggle_hidden": "Mostra password"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "Informazioni",
|
||||
"are_you_sure": "Sei sicuro ?",
|
||||
"bulk_delete_content": "Sei sicuro di voler rimuovere questo %{name}? |||| Sei sicuro di voler rimuovere questi %{smart_count} elementi?",
|
||||
"bulk_delete_title": "Rimuovi %{name} |||| Rimuovi %{smart_count} %{name}",
|
||||
"delete_content": "Sei sicuro di voler eliminare questo elemento?",
|
||||
"delete_title": "Rimuovi %{name} #%{id}",
|
||||
"details": "Dettagli",
|
||||
"error": "Un errore dal lato client ha impedito il completamento della tua richiesta.",
|
||||
"invalid_form": "Il modulo non è valido. Per favore controlla la presenza di errori.",
|
||||
"loading": "La pagina si sta caricando, solo un momento per favore",
|
||||
"no": "No",
|
||||
"not_found": "Hai inserito un URL inesistente, oppure hai cliccato un link errato.",
|
||||
"yes": "Si",
|
||||
"unsaved_changes": "Alcune modifiche non sono state salvate. Vuoi ripristinarle?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Nessun risultato trovato",
|
||||
"no_more_results": "La pagina numero %{page} è fuori dall'intervallo. Prova la pagina precedente.",
|
||||
"page_out_of_boundaries": "Il numero di pagina %{page} è fuori dall’intervallo",
|
||||
"page_out_from_end": "Non è possibile andare oltre l’ultima pagina",
|
||||
"page_out_from_begin": "Non è possibile andare prima della prima pagina",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} di %{total}",
|
||||
"page_rows_per_page": "Righe per pagina:",
|
||||
"next": "Successivo",
|
||||
"prev": "Precedente"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Elemento aggiornato |||| %{smart_count} elementi aggiornati",
|
||||
"created": "Elemento creato",
|
||||
"deleted": "Elemento rimosso |||| %{smart_count} elementi rimossi",
|
||||
"bad_item": "Elemento errato",
|
||||
"item_doesnt_exist": "Elemento inesistente",
|
||||
"http_error": "Errore di comunicazione con il server",
|
||||
"data_provider_error": "Errore del dataProvider. Controlla la console per i dettagli.",
|
||||
"i18n_error": "Impossibile caricare la traduzione per la lingua selezionata",
|
||||
"canceled": "Azione annullata",
|
||||
"logged_out": "La sessione è scaduta, per favore accedi di nuovo."
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "Note",
|
||||
"transcodingDisabled": "La possibilità di modificare le opzioni di transcodifica attraverso l’interfaccia web è disabilitata per ragioni di sicurezza. Se desideri cambiare (modificare o aggiungere) opzioni di transcodifica, riavvia il server con l’opzione %{config}.",
|
||||
"transcodingEnabled": "Navidrome è al momento attivo con %{config}, rendendo possibile eseguire comandi remoti attraverso l’interfaccia web. Si raccomanda di disabilitare questa opzione per ragioni di sicurezza e di abilitarla solo per configurare le opzioni di transcodifica.",
|
||||
"songsAddedToPlaylist": "Aggiunta una traccia alla playlist |||| Aggiunte %{smart_count} tracce alla playlist",
|
||||
"noPlaylistsAvailable": "Nessuna playlist"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Libreria",
|
||||
"settings": "Impostazioni",
|
||||
"version": "Versione %{version}",
|
||||
"theme": "Tema",
|
||||
"personal": {
|
||||
"name": "Personale",
|
||||
"options": {
|
||||
"theme": "Tema",
|
||||
"language": "Lingua"
|
||||
}
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Coda",
|
||||
"openText": "Apri",
|
||||
"closeText": "Chiudi",
|
||||
"notContentText": "Nessuna traccia",
|
||||
"clickToPlayText": "Clicca per riprodurre",
|
||||
"clickToPauseText": "Clicca per mettere in pausa",
|
||||
"nextTrackText": "Traccia successiva",
|
||||
"previousTrackText": "Traccia precedente",
|
||||
"reloadText": "Ricarica",
|
||||
"volumeText": "Volume",
|
||||
"toggleLyricText": "Mostra testo",
|
||||
"toggleMiniModeText": "Minimizza",
|
||||
"destroyText": "Distruggi",
|
||||
"downloadText": "Scarica",
|
||||
"removeAudioListsText": "Cancella coda",
|
||||
"clickToDeleteText": "Clicca per rimuovere %{name}",
|
||||
"emptyLyricText": "Nessun testo",
|
||||
"playModeText": {
|
||||
"order": "In ordine",
|
||||
"orderLoop": "Ripeti",
|
||||
"singleLoop": "Ripeti una volta",
|
||||
"shufflePlay": "Casuale"
|
||||
}
|
||||
}
|
||||
}
|
||||
260
resources/i18n/nl.json
Normal file
260
resources/i18n/nl.json
Normal file
@@ -0,0 +1,260 @@
|
||||
{
|
||||
"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",
|
||||
"bitRate": "Bitrate"
|
||||
},
|
||||
"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",
|
||||
"updatedAt": "Bijgewerkt op"
|
||||
},
|
||||
"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",
|
||||
"password": "Wachtwoord",
|
||||
"createdAt": "Aangemaakt op"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
285
resources/i18n/pt.json
Normal file
285
resources/i18n/pt.json
Normal file
@@ -0,0 +1,285 @@
|
||||
{
|
||||
"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",
|
||||
"bitRate": "Bitrate",
|
||||
"discSubtitle": "Sub-título do disco"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Adicionar à fila",
|
||||
"playNow": "Tocar agora",
|
||||
"addToPlaylist": "Adicionar à playlist"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"updatedAt": "Últ. Atualização"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Tocar",
|
||||
"playNext": "Tocar em seguida",
|
||||
"addToQueue": "Adicionar à fila",
|
||||
"shuffle": "Aleatório"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Artista |||| Artistas",
|
||||
"fields": {
|
||||
"name": "Nome",
|
||||
"albumCount": "Total de Álbuns",
|
||||
"songCount": "Total de Músicas"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Usuário |||| Usuários",
|
||||
"fields": {
|
||||
"userName": "Usuário",
|
||||
"isAdmin": "Admin?",
|
||||
"lastLoginAt": "Últ. Login",
|
||||
"updatedAt": "Últ. Atualização",
|
||||
"name": "Nome",
|
||||
"password": "Senha",
|
||||
"createdAt": "Data de Criação"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Playlist |||| Playlists",
|
||||
"fields": {
|
||||
"name": "Nome",
|
||||
"comment": "Comentário",
|
||||
"duration": "Duração",
|
||||
"owner": "Dono",
|
||||
"public": "Pública",
|
||||
"updatedAt": "Últ. Atualização",
|
||||
"createdAt": "Data de Criação ",
|
||||
"songCount": "Músicas"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Selecione a playlist:",
|
||||
"addNewPlaylist": "Criar \"%{name}\""
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"unselect": "Deselecionar",
|
||||
"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": "Por questão de segurança, esta tela de configuração está desabilitada. Se você quiser alterar estas configurações, reinicie o servidor com a opção %{config}",
|
||||
"transcodingEnabled": "Navidrome está sendo executado com a opção %{config}. Isto permite que potencialmente se execute comandos do sistema pela interface Web. É recomendado que vc mantenha esta opção desabilitada, e só a habilite quando precisar configurar opções de Conversão",
|
||||
"songsAddedToPlaylist": "Música adicionada à playlist |||| %{smart_count} músicas adicionadas à playlist",
|
||||
"noPlaylistsAvailable": "Nenhuma playlist",
|
||||
"delete_user_title": "Excluir usuário '%{name}'",
|
||||
"delete_user_content": "Você tem certeza que deseja excluir o usuário e todos os seus dados (incluindo suas playlists e preferências)?"
|
||||
},
|
||||
"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": "Nenhum música",
|
||||
"clickToPlayText": "Clique para tocar",
|
||||
"clickToPauseText": "Clique para pausar",
|
||||
"nextTrackText": "Próxima faixa",
|
||||
"previousTrackText": "Faixa anterior",
|
||||
"reloadText": "Recarregar",
|
||||
"volumeText": "Volume",
|
||||
"toggleLyricText": "Letra",
|
||||
"toggleMiniModeText": "Minimizar",
|
||||
"destroyText": "Destruir",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
283
resources/i18n/tr.json
Normal file
283
resources/i18n/tr.json
Normal file
@@ -0,0 +1,283 @@
|
||||
{
|
||||
"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",
|
||||
"bitRate": "Bir sayısı",
|
||||
"discSubtitle": "Disk Altyazısı",
|
||||
"starred": "Yıldızlı"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Sonra çal",
|
||||
"playNow": "Şimdi cal",
|
||||
"addToPlaylist": "Çalma listesine ekle"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"updatedAt": "Güncellendi "
|
||||
},
|
||||
"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ı",
|
||||
"songCount": "Şarkı 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",
|
||||
"password": "Şifre",
|
||||
"createdAt": "Oluşturuldu"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Çalma listesi |||| Çalma listeler",
|
||||
"fields": {
|
||||
"name": "Isim",
|
||||
"duration": "Süre",
|
||||
"owner": "Sahibi",
|
||||
"public": "Görülebilir",
|
||||
"updatedAt": "Güncelleme tarihi:",
|
||||
"createdAt": "Oluşturma tarihi:",
|
||||
"songCount": "Şarkılar",
|
||||
"comment": "Yorum"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Bir çalma listesi seç:",
|
||||
"addNewPlaylist": "Oluştur \"%{name}\""
|
||||
}
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"songsAddedToPlaylist": "Çalma listesine 1 şarkı eklendi |||| Çalma listesine %{smart_count} şarkı eklendi",
|
||||
"noPlaylistsAvailable": "Mevcut değil"
|
||||
},
|
||||
"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": "随机播放"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
resources/navidrome-600x600.png
Normal file
BIN
resources/navidrome-600x600.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 370 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user