mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-06 05:48:09 -05:00
Compare commits
395 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb4c0f0b84 | ||
|
|
c507e344ff | ||
|
|
a6af46dbad | ||
|
|
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 | ||
|
|
db4479e720 | ||
|
|
66275d3b94 | ||
|
|
57f2c3f823 | ||
|
|
afba4c9915 | ||
|
|
f0d18d2cb3 | ||
|
|
da45bcf448 | ||
|
|
3a54246b15 | ||
|
|
2b06f20f41 | ||
|
|
88f44b2e77 | ||
|
|
4dff067e0b | ||
|
|
d81bf8a518 | ||
|
|
adfaf39489 | ||
|
|
f6a15905d7 | ||
|
|
52b8c5f151 | ||
|
|
c4eab5db86 | ||
|
|
4b1c76e307 | ||
|
|
e476a5f6f1 | ||
|
|
9fb4f5ef52 | ||
|
|
e232c5c561 | ||
|
|
803a5776ae | ||
|
|
a6dfcafdab | ||
|
|
8f2c7b7913 | ||
|
|
2ab647efe1 | ||
|
|
04eb421186 | ||
|
|
6a3a66975c | ||
|
|
1ef4fa970f | ||
|
|
b34523e196 |
@@ -1,6 +1,5 @@
|
||||
.DS_Store
|
||||
ui/node_modules
|
||||
ui/build
|
||||
Jamstash-master
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
@@ -11,4 +10,4 @@ navidrome
|
||||
navidrome.db
|
||||
navidrome.toml
|
||||
assets/*gen.go
|
||||
dist
|
||||
|
||||
|
||||
2
.git-blame-ignore-revs
Normal file
2
.git-blame-ignore-revs
Normal file
@@ -0,0 +1,2 @@
|
||||
# Upgrade Prettier to 2.0.4. Reformatted all JS files
|
||||
b3f70538a9138bc279a451f4f358605097210d41
|
||||
53
.github/workflows/build.yml
vendored
53
.github/workflows/build.yml
vendored
@@ -1,53 +0,0 @@
|
||||
name: Build
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
go:
|
||||
name: Test Server on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
# TODO Fix tests in Windows
|
||||
# os: [macOS-latest, ubuntu-latest, windows-latest]
|
||||
os: [macOS-latest, ubuntu-latest]
|
||||
|
||||
steps:
|
||||
- name: Set up Go 1.14
|
||||
uses: actions/setup-go@v1
|
||||
with:
|
||||
go-version: 1.14
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Test
|
||||
run: go test -cover ./... -v
|
||||
|
||||
js:
|
||||
name: Test UI
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 13
|
||||
|
||||
- name: npm install dependencies
|
||||
run: |
|
||||
cd ui
|
||||
npm ci
|
||||
|
||||
# TODO: Enable when there are tests to run
|
||||
# - name: npm test
|
||||
# run: |
|
||||
# cd ui
|
||||
# CI=test npm test
|
||||
|
||||
- name: npm build
|
||||
run: |
|
||||
cd ui
|
||||
npm run build
|
||||
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:"
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
|
||||
### Supported Subsonic API endpoints
|
||||
|
||||
Navidrome is currently compatible with [Subsonic API](http://www.subsonic.org/pages/api.jsp) v1.8.0, with some exceptions.
|
||||
|
||||
This is an (almost) up to date list of all Subsonic API endpoints implemented by Navidrome.
|
||||
Check the "Notes" column for limitations/missing behaviour. Also keep in mind these differences between
|
||||
Navidrome and Subsonic:
|
||||
|
||||
* Right now, Navidrome only works with a single Music Library (Music Folder)
|
||||
* Navidrome does not mark songs as played by calls to `stream`, only when
|
||||
`scrobble` is called with `submission=true`
|
||||
* Next features to be implemented: Last.FM integration, Jukebox, Sharing, Bookmarks, Podcasts, Internet Radio.
|
||||
|
||||
Navidrome is actively being tested with:
|
||||
[DSub](http://www.subsonic.org/pages/apps.jsp#dsub),
|
||||
[Music Stash](https://play.google.com/store/apps/details?id=com.ghenry22.mymusicstash) and
|
||||
[Jamstash](http://www.subsonic.org/pages/apps.jsp#jamstash))
|
||||
|
||||
|
||||
| ENDPOINT | NOTES |
|
||||
|------------------------|-------|
|
||||
| _SYSTEM_ ||
|
||||
| `ping` | |
|
||||
| `getLicense` | Always valid ;) |
|
||||
| ||
|
||||
| _BROWSING_ ||
|
||||
| `getMusicFolders` | Hardcoded to just one, set with ND_MUSICFOLDER configuration |
|
||||
| `getIndexes` | Doesn't support shortcuts, nor direct children |
|
||||
| `getMusicDirectory` | |
|
||||
| `getSong` | |
|
||||
| `getArtists` | |
|
||||
| `getArtist` | |
|
||||
| `getAlbum` | |
|
||||
| `getGenres` | |
|
||||
| ||
|
||||
| _ALBUM/SONGS LISTS_ ||
|
||||
| `getAlbumList` | `byYear` and `byGenre` are not implemented |
|
||||
| `getAlbumList2` | `byYear` and `byGenre` are not implemented |
|
||||
| `getStarred` | |
|
||||
| `getStarred2` | |
|
||||
| `getNowPlaying` | |
|
||||
| `getRandomSongs` | Ignores `fromYear` and `toYear` parameters |
|
||||
| ||
|
||||
| _SEARCHING_ ||
|
||||
| `search2` | Doesn't support Lucene queries, only simple auto complete queries |
|
||||
| `search3` | Doesn't support Lucene queries, only simple auto complete queries |
|
||||
| ||
|
||||
| _PLAYLISTS_ ||
|
||||
| `getPlaylists` | `username` parameter is not implemented |
|
||||
| `getPlaylist` | |
|
||||
| `createPlaylist` | Return empty response on success |
|
||||
| `updatePlaylist` | `comment` and `public` are not implemented. All playlists are public |
|
||||
| `deletePlaylist` | |
|
||||
| ||
|
||||
| _MEDIA RETRIEVAL_ ||
|
||||
| `stream` | |
|
||||
| `download` | |
|
||||
| `getCoverArt` | Only gets embedded artwork |
|
||||
| `getAvatar` | Always returns the same image |
|
||||
| ||
|
||||
| _MEDIA ANNOTATION_ ||
|
||||
| `star` | |
|
||||
| `unstar` | |
|
||||
| `setRating` | |
|
||||
| `scrobble` | No Last.FM support yet. It is used to update play count and last played |
|
||||
| ||
|
||||
| _USER MANAGEMENT_ ||
|
||||
| `getUser` | Hardcoded all roles, ignores `username` parameter|
|
||||
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://github.com/deluan/navidrome/blob/master/API_COMPATIBILITY.md)
|
||||
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 -i %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)
|
||||
|
||||
@@ -37,7 +37,7 @@ update annotation set item_type = 'media_file' where item_type = 'mediaFile';
|
||||
}
|
||||
|
||||
func Down20200131183653(tx *sql.Tx) error {
|
||||
tx.Exec(`
|
||||
_, err := tx.Exec(`
|
||||
create table search_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
@@ -59,5 +59,5 @@ create index search_table
|
||||
|
||||
update annotation set item_type = 'mediaFile' where item_type = 'media_file';
|
||||
`)
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -51,6 +51,5 @@ create index annotation_starred
|
||||
}
|
||||
|
||||
func Down20200208222418(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -16,6 +16,5 @@ func Up20200310171621(tx *sql.Tx) error {
|
||||
}
|
||||
|
||||
func Down20200310171621(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -37,6 +37,5 @@ drop table if exists search;
|
||||
}
|
||||
|
||||
func Down20200319211049(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200411164603, Down20200411164603)
|
||||
}
|
||||
|
||||
func Up20200411164603(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table playlist
|
||||
add created_at datetime;
|
||||
alter table playlist
|
||||
add updated_at datetime;
|
||||
update playlist
|
||||
set created_at = datetime('now'), updated_at = datetime('now');
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200411164603(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
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"
|
||||
@@ -46,6 +46,18 @@ func (c *cover) Get(ctx context.Context, id string, size int, out io.Writer) err
|
||||
return err
|
||||
}
|
||||
|
||||
// If cache is disabled, just read the coverart directly from file
|
||||
if c.cache == nil {
|
||||
log.Trace(ctx, "Retrieving cover art from file", "path", path, "size", size, err)
|
||||
reader, err := c.getCover(ctx, path, size)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading cover art", "path", path, "size", size, err)
|
||||
} else {
|
||||
_, err = io.Copy(out, reader)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
cacheKey := imageCacheKey(path, size, lastUpdate)
|
||||
r, w, err := c.cache.Get(cacheKey)
|
||||
if err != nil {
|
||||
@@ -62,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)
|
||||
@@ -108,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
|
||||
@@ -158,5 +172,5 @@ func readFromTag(path string) ([]byte, error) {
|
||||
}
|
||||
|
||||
func NewImageCache() (ImageCache, error) {
|
||||
return newFileCache("image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems)
|
||||
return newFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
@@ -14,92 +15,112 @@ 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{}}
|
||||
ds.Album(ctx).(*persistence.MockAlbum).SetData(`[{"id": "222", "coverArtId": "123"}, {"id": "333", "coverArtId": ""}]`)
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "hasCoverArt": true, "updatedAt":"2020-04-02T21:29:31.6377Z"}]`)
|
||||
cover = NewCover(ds, testCache)
|
||||
})
|
||||
|
||||
It("retrieves the original cover art from an album", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "222", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
|
||||
It("accepts albumIds with 'al-' prefix", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "al-222", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
|
||||
It("returns the default cover if album does not have cover", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "333", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("png"))
|
||||
})
|
||||
|
||||
It("returns the default cover if album is not found", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "444", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("png"))
|
||||
})
|
||||
|
||||
It("retrieves the original cover art from a media_file", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 0, buf)).To(BeNil())
|
||||
|
||||
img, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(600))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(600))
|
||||
})
|
||||
|
||||
It("resized cover art as requested", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 200, buf)).To(BeNil())
|
||||
|
||||
img, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
|
||||
Context("Errors", func() {
|
||||
It("returns err if gets error from album table", func() {
|
||||
ds.Album(ctx).(*persistence.MockAlbum).SetError(true)
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "222", 0, buf)).To(MatchError("Error!"))
|
||||
Context("Cache is configured", func() {
|
||||
BeforeEach(func() {
|
||||
cover = NewCover(ds, testCache)
|
||||
})
|
||||
|
||||
It("returns err if gets error from media_file table", func() {
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetError(true)
|
||||
It("retrieves the original cover art from an album", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 0, buf)).To(MatchError("Error!"))
|
||||
Expect(cover.Get(ctx, "222", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
|
||||
It("accepts albumIds with 'al-' prefix", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "al-222", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
|
||||
It("returns the default cover if album does not have cover", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "333", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("png"))
|
||||
})
|
||||
|
||||
It("returns the default cover if album is not found", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "444", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("png"))
|
||||
})
|
||||
|
||||
It("retrieves the original cover art from a media_file", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 0, buf)).To(BeNil())
|
||||
|
||||
img, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(600))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(600))
|
||||
})
|
||||
|
||||
It("resized cover art as requested", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 200, buf)).To(BeNil())
|
||||
|
||||
img, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
|
||||
Context("Errors", func() {
|
||||
It("returns err if gets error from album table", func() {
|
||||
ds.Album(ctx).(*persistence.MockAlbum).SetError(true)
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "222", 0, buf)).To(MatchError("Error!"))
|
||||
})
|
||||
|
||||
It("returns err if gets error from media_file table", func() {
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetError(true)
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 0, buf)).To(MatchError("Error!"))
|
||||
})
|
||||
})
|
||||
})
|
||||
Context("Cache is NOT configured", func() {
|
||||
BeforeEach(func() {
|
||||
cover = NewCover(ds, nil)
|
||||
})
|
||||
|
||||
It("retrieves the original cover art from an album", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "222", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,10 @@ import (
|
||||
)
|
||||
|
||||
func newFileCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cache, error) {
|
||||
if cacheSize == "0" {
|
||||
log.Warn(fmt.Sprintf("%s cache disabled", name))
|
||||
return nil, nil
|
||||
}
|
||||
size, err := humanize.ParseBytes(cacheSize)
|
||||
if err != nil {
|
||||
size = consts.DefaultCacheSize
|
||||
|
||||
@@ -29,5 +29,9 @@ var _ = Describe("File Caches", func() {
|
||||
It("creates the cache folder with invalid size", func() {
|
||||
Expect(newFileCache("test", "abc", "test", 10)).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("returns empty if cache size is '0'", func() {
|
||||
Expect(newFileCache("test", "0", "test", 10)).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,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,15 +78,17 @@ 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 {
|
||||
log.Error(ctx, "Error loading transcoding command", "format", format, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
out, err := ms.ffm.Start(ctx, t.Command, mf.Path, bitRate, format)
|
||||
out, err := ms.ffm.Start(ctx, t.Command, mf.Path, bitRate)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error starting transcoder", "id", mf.ID, err)
|
||||
return nil, os.ErrInvalid
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -213,5 +218,5 @@ func getFinalCachedSize(r fscache.ReadAtCloser) int64 {
|
||||
}
|
||||
|
||||
func NewTranscodingCache() (TranscodingCache, error) {
|
||||
return newFileCache("transcoding", conf.Server.TranscodingCacheSize, consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems)
|
||||
return newFileCache("Transcoding", conf.Server.TranscodingCacheSize, consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -182,7 +183,7 @@ type fakeFFmpeg struct {
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (ff *fakeFFmpeg) Start(ctx context.Context, cmd, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
|
||||
func (ff *fakeFFmpeg) Start(ctx context.Context, cmd, path string, maxBitRate int) (f io.ReadCloser, err error) {
|
||||
ff.r = strings.NewReader(ff.Data)
|
||||
return ff, nil
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -37,7 +38,7 @@ func (p *players) Register(ctx context.Context, id, client, typ, ip string) (*mo
|
||||
if err != nil || id == "" {
|
||||
plr, err = p.ds.Player(ctx).FindByName(client, userName)
|
||||
if err == nil {
|
||||
log.Debug("Found player by name", "id", plr.ID, "client", client, "userName", userName)
|
||||
log.Debug("Found player by name", "id", plr.ID, "client", client, "username", userName)
|
||||
} else {
|
||||
r, _ := uuid.NewRandom()
|
||||
plr = &model.Player{
|
||||
@@ -46,7 +47,7 @@ func (p *players) Register(ctx context.Context, id, client, typ, ip string) (*mo
|
||||
UserName: userName,
|
||||
Client: client,
|
||||
}
|
||||
log.Info("Registering new player", "id", plr.ID, "client", client, "userName", userName)
|
||||
log.Info("Registering new player", "id", plr.ID, "client", client, "username", userName)
|
||||
}
|
||||
}
|
||||
plr.LastSeen = time.Now()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -2,8 +2,10 @@ package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
@@ -24,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
|
||||
}
|
||||
@@ -59,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 {
|
||||
@@ -118,10 +123,12 @@ type PlaylistInfo struct {
|
||||
Public bool
|
||||
Owner string
|
||||
Comment string
|
||||
Created time.Time
|
||||
Changed time.Time
|
||||
}
|
||||
|
||||
func (p *playlists) Get(ctx context.Context, id string) (*PlaylistInfo, error) {
|
||||
pl, err := p.ds.Playlist(ctx).GetWithTracks(id)
|
||||
pl, err := p.ds.Playlist(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -130,11 +137,13 @@ 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,
|
||||
Comment: pl.Comment,
|
||||
Changed: pl.UpdatedAt,
|
||||
Created: pl.CreatedAt,
|
||||
}
|
||||
|
||||
plsInfo.Entries = FromMediaFiles(pl.Tracks)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -12,20 +12,25 @@ import (
|
||||
)
|
||||
|
||||
type Transcoder interface {
|
||||
Start(ctx context.Context, command, path string, maxBitRate int, format string) (f io.ReadCloser, err error)
|
||||
Start(ctx context.Context, command, path string, maxBitRate int) (f io.ReadCloser, err error)
|
||||
}
|
||||
|
||||
func New() Transcoder {
|
||||
path, err := exec.LookPath("ffmpeg")
|
||||
if err != nil {
|
||||
log.Error("Unable to find ffmpeg", err)
|
||||
}
|
||||
log.Debug("Found ffmpeg", "path", path)
|
||||
return &ffmpeg{}
|
||||
}
|
||||
|
||||
type ffmpeg struct{}
|
||||
|
||||
func (ff *ffmpeg) Start(ctx context.Context, command, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
|
||||
arg0, args := createTranscodeCommand(command, path, maxBitRate, format)
|
||||
func (ff *ffmpeg) Start(ctx context.Context, command, path string, maxBitRate int) (f io.ReadCloser, err error) {
|
||||
args := createTranscodeCommand(command, path, maxBitRate)
|
||||
|
||||
log.Trace(ctx, "Executing ffmpeg command", "cmd", arg0, "args", args)
|
||||
cmd := exec.Command(arg0, args...)
|
||||
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
|
||||
cmd := exec.Command(args[0], args[1:]...) // #nosec
|
||||
cmd.Stderr = os.Stderr
|
||||
if f, err = cmd.StdoutPipe(); err != nil {
|
||||
return
|
||||
@@ -33,12 +38,14 @@ 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
|
||||
}
|
||||
|
||||
// Path will always be an absolute path
|
||||
func createTranscodeCommand(cmd, path string, maxBitRate int, format string) (string, []string) {
|
||||
func createTranscodeCommand(cmd, path string, maxBitRate int) []string {
|
||||
split := strings.Split(cmd, " ")
|
||||
for i, s := range split {
|
||||
s = strings.Replace(s, "%s", path, -1)
|
||||
@@ -46,5 +53,5 @@ func createTranscodeCommand(cmd, path string, maxBitRate int, format string) (st
|
||||
split[i] = s
|
||||
}
|
||||
|
||||
return split[0], split[1:]
|
||||
return split
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@ func TestTranscoder(t *testing.T) {
|
||||
|
||||
var _ = Describe("createTranscodeCommand", func() {
|
||||
It("creates a valid command line", func() {
|
||||
cmd, args := createTranscodeCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, "")
|
||||
Expect(cmd).To(Equal("ffmpeg"))
|
||||
Expect(args).To(Equal([]string{"-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
args := createTranscodeCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
})
|
||||
})
|
||||
|
||||
28
go.mod
28
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-20191122115059-7e5c04feccd8
|
||||
github.com/dhowden/tag v0.0.0-20200412032933-5d76b8eaae27
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/djherbis/fscache v0.10.0
|
||||
github.com/djherbis/fscache v0.10.1
|
||||
github.com/dustin/go-humanize v1.0.0
|
||||
github.com/fatih/camelcase v0.0.0-20160318181535-f6a740d52f96 // indirect
|
||||
github.com/fatih/structs v1.0.0 // indirect
|
||||
github.com/go-chi/chi v4.1.0+incompatible
|
||||
github.com/go-chi/cors v1.0.1
|
||||
github.com/go-chi/chi v4.1.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.13.0
|
||||
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
|
||||
|
||||
86
go.sum
86
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-20191122115059-7e5c04feccd8 h1:nmFnmD8VZXkjDHIb1Gnfz50cgzUvGN72zLjPRXBW/hU=
|
||||
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8/go.mod h1:SniNVYuaD1jmdEEvi+7ywb1QFR7agjeTdGKyFb0p7Rw=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/djherbis/fscache v0.10.0 h1:+O3s3LwKL1Jfz7txRHpgKOz8/krh9w+NzfzxtFdbsQg=
|
||||
github.com/djherbis/fscache v0.10.0/go.mod h1:yyPYtkNnnPXsW+81lAcQS6yab3G2CRfnPLotBvtbf0c=
|
||||
github.com/djherbis/fscache v0.10.1 h1:hDv+RGyvD+UDKyRYuLoVNbuRTnf2SrA2K3VyR1br9lk=
|
||||
github.com/djherbis/fscache v0.10.1/go.mod h1:yyPYtkNnnPXsW+81lAcQS6yab3G2CRfnPLotBvtbf0c=
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8=
|
||||
@@ -43,10 +41,12 @@ github.com/fatih/structs v1.0.0 h1:BrX964Rv5uQ3wwS+KRUAJCBBw5PQmgJfJ6v4yly5QwU=
|
||||
github.com/fatih/structs v1.0.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/go-chi/chi v4.1.0+incompatible h1:ETj3cggsVIY2Xao5ExCu6YhEh5MD6JTfcBzS37R260w=
|
||||
github.com/go-chi/chi v4.1.0+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||
github.com/go-chi/cors v1.0.1 h1:56TT/uWGoLWZpnMI/AwAmCneikXr5eLsiIq27wrKecw=
|
||||
github.com/go-chi/cors v1.0.1/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
|
||||
github.com/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=
|
||||
github.com/go-chi/jwtauth v4.0.4+incompatible/go.mod h1:Q5EIArY/QnD6BdS+IyDw7B2m6iNbnPxtfd6/BcmtWbs=
|
||||
github.com/go-redis/redis v6.14.2+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
|
||||
@@ -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.13.0 h1:M76yO2HkZASFjXL0HSoZJ1AYEmQxNJmY41Jx1zNUq1Y=
|
||||
github.com/onsi/ginkgo v1.13.0/go.mod h1:+REjRxOmWfHCjfv9TTWB1jD1Frx4XydAD3zm1lskyM0=
|
||||
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=
|
||||
@@ -111,6 +126,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose v2.6.0+incompatible h1:3f8zIQ8rfgP9tyI0Hmcs2YNAqUCL1c+diLe3iU8Qd/k=
|
||||
github.com/pressly/goose v2.6.0+incompatible/go.mod h1:m+QHWCqxR3k8D9l7qfzuC/djtlfzxr34mozWDYEu1z8=
|
||||
github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
|
||||
github.com/siddontang/go v0.0.0-20180604090527-bdc77568d726 h1:xT+JlYxNGqyT+XcU8iUrN18JYed2TvG9yN5ULG2jATM=
|
||||
github.com/siddontang/go v0.0.0-20180604090527-bdc77568d726/go.mod h1:3yhqj7WBBfRhbBlzyOC3gUxftwsU0u8gqevxwIHQpMw=
|
||||
github.com/siddontang/ledisdb v0.0.0-20181029004158-becf5f38d373 h1:p6IxqQMjab30l4lb9mmkIkkcE1yv6o0SKbPhW5pxqHI=
|
||||
@@ -119,8 +135,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 +149,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 +163,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 +176,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 +195,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 +222,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,23 +1,47 @@
|
||||
package model
|
||||
|
||||
type Playlist struct {
|
||||
ID string
|
||||
Name string
|
||||
Comment string
|
||||
Duration float32
|
||||
Owner string
|
||||
Public bool
|
||||
Tracks MediaFiles
|
||||
}
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type PlaylistRepository interface {
|
||||
CountAll() (int64, error)
|
||||
Exists(id string) (bool, error)
|
||||
Put(pls *Playlist) error
|
||||
Get(id string) (*Playlist, error)
|
||||
GetWithTracks(id string) (*Playlist, error)
|
||||
GetAll(options ...QueryOptions) (Playlists, error)
|
||||
Delete(id string) error
|
||||
type Playlist struct {
|
||||
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(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 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
|
||||
}
|
||||
|
||||
73
model/request/request.go
Normal file
73
model/request/request.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package request
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"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, strings.ToLower(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,16 +65,15 @@ var (
|
||||
|
||||
var (
|
||||
plsBest = model.Playlist{
|
||||
ID: "10",
|
||||
Name: "Best",
|
||||
Comment: "No Comments",
|
||||
Duration: 10,
|
||||
Owner: "userid",
|
||||
Public: true,
|
||||
Tracks: model.MediaFiles{{ID: "1"}, {ID: "3"}},
|
||||
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 {
|
||||
@@ -86,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)
|
||||
@@ -96,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)
|
||||
@@ -104,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)
|
||||
@@ -112,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,25 +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
|
||||
}
|
||||
|
||||
type playlistRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
func NewPlaylistRepository(ctx context.Context, o orm.Ormer) model.PlaylistRepository {
|
||||
@@ -31,100 +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 {
|
||||
pls := r.fromModel(p)
|
||||
_, err := r.put(pls.ID, pls)
|
||||
return err
|
||||
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()
|
||||
|
||||
// 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)
|
||||
return &pls, err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) GetWithTracks(id string) (*model.Playlist, error) {
|
||||
pls, err := r.Get(id)
|
||||
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
|
||||
}
|
||||
mfRepo := NewMediaFileRepository(r.ctx, r.ormer)
|
||||
pls.Duration = 0
|
||||
newTracks := model.MediaFiles{}
|
||||
for _, t := range pls.Tracks {
|
||||
mf, err := mfRepo.Get(t.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
pls.Duration += mf.Duration
|
||||
newTracks = append(newTracks, *mf)
|
||||
}
|
||||
pls.Tracks = newTracks
|
||||
return pls, err
|
||||
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,
|
||||
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})
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(options...))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
return pls
|
||||
}
|
||||
|
||||
func (r *playlistRepository) fromModel(p *model.Playlist) playlist {
|
||||
pls := playlist{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Comment: p.Comment,
|
||||
Duration: p.Duration,
|
||||
Owner: p.Owner,
|
||||
Public: p.Public,
|
||||
}
|
||||
var newTracks []string
|
||||
for _, t := range p.Tracks {
|
||||
newTracks = append(newTracks, t.ID)
|
||||
}
|
||||
pls.Tracks = strings.Join(newTracks, ",")
|
||||
return pls
|
||||
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,34 +37,25 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
|
||||
Describe("Get", func() {
|
||||
It("returns an existing playlist", func() {
|
||||
Expect(repo.Get("10")).To(Equal(&plsBest))
|
||||
p, err := repo.Get(plsBest.ID)
|
||||
Expect(err).To(BeNil())
|
||||
// Compare all but Tracks and timestamps
|
||||
p2 := *p
|
||||
p2.Tracks = plsBest.Tracks
|
||||
p2.UpdatedAt = plsBest.UpdatedAt
|
||||
p2.CreatedAt = plsBest.CreatedAt
|
||||
Expect(p2).To(Equal(plsBest))
|
||||
// Compare tracks
|
||||
for i := range p.Tracks {
|
||||
Expect(p.Tracks[i].ID).To(Equal(plsBest.Tracks[i].ID))
|
||||
}
|
||||
})
|
||||
It("returns ErrNotFound for a non-existing playlist", func() {
|
||||
_, err := repo.Get("666")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Put/Get/Delete", func() {
|
||||
newPls := model.Playlist{ID: "22", Name: "Great!", Tracks: model.MediaFiles{{ID: "4"}}}
|
||||
It("saves the playlist to the DB", func() {
|
||||
Expect(repo.Put(&newPls)).To(BeNil())
|
||||
})
|
||||
It("returns the newly created playlist", func() {
|
||||
Expect(repo.Get("22")).To(Equal(&newPls))
|
||||
})
|
||||
It("returns deletes the playlist", func() {
|
||||
Expect(repo.Delete("22")).To(BeNil())
|
||||
})
|
||||
It("returns error if tries to retrieve the deleted playlist", func() {
|
||||
_, err := repo.Get("22")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetWithTracks", func() {
|
||||
It("returns an existing playlist", func() {
|
||||
pls, err := repo.GetWithTracks("10")
|
||||
It("returns all tracks", func() {
|
||||
pls, err := repo.Get(plsBest.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(pls.Name).To(Equal(plsBest.Name))
|
||||
Expect(pls.Tracks).To(Equal(model.MediaFiles{
|
||||
@@ -69,9 +65,39 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
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() {
|
||||
It("returns all playlists from DB", func() {
|
||||
Expect(repo.GetAll()).To(Equal(model.Playlists{plsBest, plsCool}))
|
||||
all, err := repo.GetAll()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(all[0].ID).To(Equal(plsBest.ID))
|
||||
Expect(all[1].ID).To(Equal(plsCool.ID))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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,6 +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 for insert
|
||||
createdAt := values["created_at"]
|
||||
delete(values, "created_at")
|
||||
if id != "" {
|
||||
@@ -169,15 +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
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user