Compare commits

...

141 Commits

Author SHA1 Message Date
Deluan
13ce21843f go mod tidy 2020-05-02 18:00:18 -04:00
Deluan
151f43b95f Refactor i18n functions a bit 2020-05-02 17:44:24 -04:00
Deluan
055c77b38c Remove "default" from Dark theme name 2020-05-02 14:50:46 -04:00
Deluan
8dc2d7a5e0 Make context menu icon smaller 2020-05-02 14:50:15 -04:00
Deluan
a71d5b3954 Add remaining languages 2020-05-02 14:19:01 -04:00
Deluan
854a923fea Don't sort ReadAll translations, as it will be sorted in the UI 2020-05-02 14:19:01 -04:00
Deluan
496b467c1d Cater for differences when loading embedded Assets and in dev mode 2020-05-02 14:19:01 -04:00
Deluan
056d5e7111 Remove empty keys to allow English fallback 2020-05-02 14:19:01 -04:00
Deluan Quintão
e43c172d96 Update de.json (POEditor.com) 2020-05-02 14:19:01 -04:00
Deluan Quintão
0b56c3f026 Update pt.json (POEditor.com) 2020-05-02 14:19:01 -04:00
Deluan Quintão
5445d20ecd Update en.json (POEditor.com) 2020-05-02 14:19:01 -04:00
Deluan
2f7443e4bd Use English as fallback language 2020-05-02 14:19:01 -04:00
Deluan
41cf99541d Move translations to server 2020-05-02 14:19:01 -04:00
Deluan
1a9663d432 Move static to resources. Embed them at build time 2020-05-02 14:19:01 -04:00
Deluan
b7dcdedf41 More error handling 2020-05-02 14:19:01 -04:00
Deluan
bf8f9d2be8 Fix context menu icon color on Light theme 2020-05-01 12:08:32 -04:00
Deluan
6d20ca27f6 Add mobile album list view 2020-05-01 11:50:07 -04:00
Deluan
3bb573b45f Add AlbumContextMenu to AlbumListView 2020-05-01 11:27:09 -04:00
Deluan
9b2d91c0f2 Fix songs pagination param in AlbumContextMenu 2020-05-01 11:05:36 -04:00
Deluan
b002a69bf8 Fix language sorting 2020-05-01 10:48:28 -04:00
Deluan
e341df1e26 Rename Chinese translation file to zh 2020-05-01 10:43:49 -04:00
Deluan
35e8c1c407 Add all translation keys to English 2020-05-01 10:41:47 -04:00
Deluan
d1a88ed8d6 Remove duplicated translation key 2020-05-01 10:28:31 -04:00
Deluan
10a7dfeb15 Add SongContextMenu 2020-05-01 10:22:24 -04:00
Deluan
dbde5330bd Mark helper function as unexported 2020-05-01 09:17:21 -04:00
Deluan
9b817edd1a go mod tidy 2020-05-01 09:08:35 -04:00
dependabot-preview[bot]
261d73410a Bump github.com/Masterminds/squirrel from 1.2.0 to 1.3.0
Bumps [github.com/Masterminds/squirrel](https://github.com/Masterminds/squirrel) from 1.2.0 to 1.3.0.
- [Release notes](https://github.com/Masterminds/squirrel/releases)
- [Commits](https://github.com/Masterminds/squirrel/compare/v1.2.0...v1.3.0)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-05-01 09:01:49 -04:00
Deluan
555c78f536 Reduce flickering of album covers 2020-05-01 09:00:00 -04:00
Deluan
0270a9c924 Remove dangling create-react-app README 2020-04-30 15:19:55 -04:00
Deluan
a45e278cda Bum react-music-player version to 4.12.0 2020-04-30 14:18:05 -04:00
stncrn
bdbee7f541 Add setup step: download node dependencies 2020-04-30 09:54:15 -04:00
Deluan
b453ee6598 Fix color of album context menu when in Light mode.
Fix is to make it always white
2020-04-29 22:46:34 -04:00
Deluan
716de24f1e Localize translation config notice 2020-04-29 21:59:05 -04:00
Deluan
c816ca4525 Add config option to enable/disable Transcoding configuration 2020-04-29 21:59:05 -04:00
Srihari Chandana
eb7d2dcaa1 fixed compile errors 2020-04-29 21:51:44 -04:00
Srihari Chandana
e6d4cfba96 cleaned up logic 2020-04-29 21:51:44 -04:00
Srihari Chandana
2a5d2d70ba replaced GridButton with GridMenu 2020-04-29 21:51:44 -04:00
Srihari Chandana
e539ddceb9 fixed code to remove warnings 2020-04-29 21:51:44 -04:00
Srihari Chandana
00666da9c1 added grid play button 2020-04-29 21:51:44 -04:00
Deluan
7ad9c385b5 Fix typo 2020-04-29 17:38:03 -04:00
Sumner Evans
e65fb189ce Added back configs that I totally missed because I was tired 2020-04-29 17:18:44 -04:00
Sumner Evans
1afe409a79 Update the sample navidrome.service for use in Arch Linux 2020-04-29 17:18:44 -04:00
jvoisin
dbf9c8be7d An other batch of linters 2020-04-29 14:09:45 -04:00
jvoisin
26188e6d8a Enable a couple of linters 2020-04-29 09:03:07 -04:00
Brian Pierson
d6c70554b3 Fixing 50 shades of blue 2020-04-29 08:15:28 -04:00
Deluan
5990a4285f Replace goreman with node-foreman 2020-04-28 23:24:57 -04:00
Deluan
08e9ac63b1 Add cron workflow to remove old pipeline artifacts 2020-04-28 14:13:34 -04:00
Deluan
71a1f65be2 Bump @testing-library dependencies 2020-04-28 12:06:05 -04:00
Deluan
5862157a2c Move test file to fixtures folder 2020-04-28 11:59:47 -04:00
Deluan
d4f17f2b73 Fix username English translation (fix #231) 2020-04-27 23:23:03 -04:00
Deluan
ea1d534c29 Fix NavBar title translations 2020-04-27 23:22:17 -04:00
Deluan
069de0f9ea Add a try catch to display the record when DurationField fails 2020-04-27 22:46:40 -04:00
Deluan
e871c7daee Add links to documentation on how to contribute with themes and translations 2020-04-27 20:43:58 -04:00
dependabot-preview[bot]
320fe11a66 Bump prettier from 2.0.4 to 2.0.5 in /ui
Bumps [prettier](https://github.com/prettier/prettier) from 2.0.4 to 2.0.5.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/2.0.4...2.0.5)

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

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

View File

@@ -1,6 +1,5 @@
.DS_Store
ui/node_modules
ui/build
Jamstash-master
Dockerfile
docker-compose*.yml
@@ -11,4 +10,4 @@ navidrome
navidrome.db
navidrome.toml
assets/*gen.go
dist

2
.git-blame-ignore-revs Normal file
View File

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

View File

@@ -1,53 +0,0 @@
name: Build
on: [push, pull_request]
jobs:
go:
name: Test Server on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
# TODO Fix tests in Windows
# os: [macOS-latest, ubuntu-latest, windows-latest]
os: [macOS-latest, ubuntu-latest]
steps:
- name: Set up Go 1.14
uses: actions/setup-go@v1
with:
go-version: 1.14
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v1
- name: Download dependencies
run: go mod download
- name: Test
run: go test -cover ./... -v
js:
name: Test UI
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/setup-node@v1
with:
node-version: 13
- name: npm install dependencies
run: |
cd ui
npm ci
# TODO: Enable when there are tests to run
# - name: npm test
# run: |
# cd ui
# CI=test npm test
- name: npm build
run: |
cd ui
npm run build

17
.github/workflows/docker-tags.sh vendored Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/bash
GIT_TAG="${GITHUB_REF##refs/tags/}"
GIT_BRANCH="${GITHUB_REF##refs/heads/}"
GIT_SHA=$(git rev-parse --short HEAD)
DOCKER_IMAGE_TAG="--tag ${DOCKER_IMAGE}:sha-${GIT_SHA}"
if [[ $GITHUB_REF != $GIT_TAG ]]; then
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:${GIT_TAG#v} --tag ${DOCKER_IMAGE}:latest"
elif [[ $GITHUB_REF == "refs/heads/master" ]]; then
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:develop"
elif [[ $GIT_BRANCH = feature/* ]]; then
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:$(echo $GIT_BRANCH | tr / -)"
fi
echo ${DOCKER_IMAGE_TAG}

40
.github/workflows/pipeline.dockerfile vendored Normal file
View File

@@ -0,0 +1,40 @@
#####################################################
### Copy platform specific binary
FROM bash as copy-binary
ARG TARGETPLATFORM
RUN echo "Target Platform = ${TARGETPLATFORM}"
COPY dist .
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then cp navidrome_linux_musl_amd64_linux_amd64/navidrome /navidrome; fi
RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then cp navidrome_linux_arm64_linux_arm64/navidrome /navidrome; fi
RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then cp navidrome_linux_arm_linux_arm_6/navidrome /navidrome; fi
RUN if [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then cp navidrome_linux_arm_linux_arm_7/navidrome /navidrome; fi
RUN chmod +x /navidrome
#####################################################
### Build Final Image
FROM alpine as release
LABEL maintainer="deluan@navidrome.org"
# Install ffmpeg and output build config
RUN apk add --no-cache ffmpeg
RUN ffmpeg -buildconf
COPY --from=copy-binary /navidrome /app/
VOLUME ["/data", "/music"]
ENV ND_MUSICFOLDER /music
ENV ND_DATAFOLDER /data
ENV ND_SCANINTERVAL 1m
ENV ND_TRANSCODINGCACHESIZE 100MB
ENV ND_SESSIONTIMEOUT 30m
ENV ND_LOGLEVEL info
ENV ND_PORT 4533
EXPOSE ${ND_PORT}
HEALTHCHECK CMD wget -O- http://localhost:${ND_PORT}/ping || exit 1
WORKDIR /app
ENTRYPOINT ["/app/navidrome"]

163
.github/workflows/pipeline.yml vendored Normal file
View File

@@ -0,0 +1,163 @@
name: Pipeline
on:
push:
branches:
- master
tags:
- "v*"
pull_request:
branches:
- master
jobs:
golangci-lint:
name: Lint Server
runs-on: ubuntu-latest
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Run golangci-lint
uses: actions-contrib/golangci-lint@v1
with:
golangci_lint_version: v1.25.0
# TODO Enable github actions output format: https://github.com/actions-contrib/golangci-lint/issues/11
# args: run --out-format github-actions
go:
name: Test Server on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
# TODO Fix tests in Windows
# os: [macOS-latest, ubuntu-latest, windows-latest]
os: [macOS-latest, ubuntu-latest]
steps:
- name: Set up Go 1.14
uses: actions/setup-go@v1
with:
go-version: 1.14
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- uses: actions/cache@v1
id: cache-go
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Download dependencies
if: steps.cache-go.outputs.cache-hit != 'true'
run: go mod download
- name: Test
run: go test -cover ./... -v
js:
name: Build JS bundle
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: 13
- uses: actions/cache@v1
id: cache-npm
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('ui/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: npm install dependencies
run: |
cd ui
npm ci
- name: npm check-formatting
run: |
cd ui
npm run check-formatting
- name: npm build
run: |
cd ui
npm run build
- uses: actions/upload-artifact@v1
with:
name: js-bundle
path: ui/build
binaries:
name: Binaries
needs: [js, go, golangci-lint]
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Unshallow
run: git fetch --prune --unshallow
- uses: actions/download-artifact@v1
with:
name: js-bundle
path: ui/build
- name: Run GoReleaser - SNAPSHOT
if: startsWith(github.ref, 'refs/tags/') != true
uses: docker://deluan/ci-goreleaser:1.14.1-1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: goreleaser release --rm-dist --skip-publish --snapshot
- name: Run GoReleaser - RELEASE
if: startsWith(github.ref, 'refs/tags/')
uses: docker://deluan/ci-goreleaser:1.14.1-1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: goreleaser release --rm-dist
- uses: actions/upload-artifact@v1
with:
name: binaries
path: dist
docker:
name: Docker images
needs: [binaries]
runs-on: ubuntu-latest
env:
DOCKER_IMAGE: ${{secrets.DOCKER_IMAGE}}
steps:
- name: Set up Docker Buildx
id: buildx
uses: crazy-max/ghaction-docker-buildx@v1
if: env.DOCKER_IMAGE != ''
with:
version: latest
- uses: actions/checkout@v1
if: env.DOCKER_IMAGE != ''
- uses: actions/download-artifact@v1
if: env.DOCKER_IMAGE != ''
with:
name: binaries
path: dist
- name: Build the Docker image and push
if: env.DOCKER_IMAGE != ''
env:
DOCKER_IMAGE: ${{secrets.DOCKER_IMAGE}}
DOCKER_PLATFORM: linux/amd64,linux/arm/v7,linux/arm64
run: |
echo ${{secrets.DOCKER_PASSWORD}} | docker login -u ${{secrets.DOCKER_USERNAME}} --password-stdin
docker buildx build --platform ${DOCKER_PLATFORM} `.github/workflows/docker-tags.sh` -f .github/workflows/pipeline.dockerfile --push .

View File

@@ -1,31 +0,0 @@
name: Release
on:
push:
tags:
- '*'
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v1
with:
fetch-depth: 0
- uses: actions/setup-node@v1
with:
node-version: 13.12
- name: Build UI
run: |
cd ui
npm ci
npm run build
- name: Fetch tags
run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
- name: Run GoReleaser
uses: docker://bepsays/ci-goreleaser:1.14-1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: goreleaser release --rm-dist

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

@@ -9,7 +9,6 @@ vendor/*/
wiki
TODO.md
var
Artwork
navidrome.toml
master.zip
Jamstash-master
@@ -20,3 +19,7 @@ navidrome.db
dist
music
docker-compose.override.yml
navidrome.db-shm
navidrome.db-wal
tags

29
.golangci.yml Normal file
View 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):"

View File

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

2
.nvmrc
View File

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

View File

@@ -1,6 +1,6 @@
#####################################################
### Build UI bundles
FROM node:13.12-alpine AS jsbuilder
FROM node:13-alpine AS jsbuilder
WORKDIR /src
COPY ui/package.json ui/package-lock.json ./
RUN npm ci
@@ -17,11 +17,6 @@ RUN mkdir -p /src/ui/build
RUN apk add -U --no-cache build-base git
RUN go get -u github.com/go-bindata/go-bindata/...
# Download and unpack static ffmpeg
ARG FFMPEG_URL=https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
RUN wget -O /tmp/ffmpeg.tar.xz ${FFMPEG_URL}
RUN cd /tmp && tar xJf ffmpeg.tar.xz && rm ffmpeg.tar.xz
# Download project dependencies
WORKDIR /src
COPY go.mod go.sum ./
@@ -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"]

View File

@@ -2,6 +2,7 @@ GO_VERSION=$(shell grep -e "^go " go.mod | cut -f 2 -d ' ')
NODE_VERSION=$(shell cat .nvmrc)
GIT_SHA=$(shell git rev-parse --short HEAD)
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)
## Default target just build the Go project.
default:
@@ -9,7 +10,7 @@ default:
.PHONY: default
dev: check_env
@goreman -f Procfile.dev -b 4533 start
npx foreman -j Procfile.dev -p 4533 start
.PHONY: dev
server: check_go_env
@@ -33,22 +34,20 @@ testall: check_go_env test
@(cd ./ui && npm test -- --watchAll=false)
.PHONY: testall
setup: Jamstash-master
@which wire || (echo "Installing Wire" && GO111MODULE=off go get -u github.com/google/wire/cmd/wire)
setup:
@which go-bindata || (echo "Installing BinData" && GO111MODULE=off go get -u github.com/go-bindata/go-bindata/...)
@which reflex || (echo "Installing Reflex" && GO111MODULE=off go get -u github.com/cespare/reflex)
@which goreman || (echo "Installing Goreman" && GO111MODULE=off go get -u github.com/mattn/goreman)
@which ginkgo || (echo "Installing Ginkgo" && GO111MODULE=off go get -u github.com/onsi/ginkgo/ginkgo)
@which goose || (echo "Installing Goose" && GO111MODULE=off go get -u github.com/pressly/goose/cmd/goose)
@which lefthook || (echo "Installing Lefthook" && GO111MODULE=off go get -u github.com/Arkweid/lefthook)
@lefthook install
go mod download
@(cd ./ui && npm ci)
.PHONY: setup
static:
cd static && go-bindata -fs -prefix "static" -nocompress -ignore="\\\*.go" -pkg static .
.PHONY: static
setup-dev: setup
@which wire || (echo "Installing Wire" && GO111MODULE=off go get -u github.com/google/wire/cmd/wire)
@which ginkgo || (echo "Installing Ginkgo" && GO111MODULE=off go get -u github.com/onsi/ginkgo/ginkgo)
@which goose || (echo "Installing Goose" && GO111MODULE=off go get -u github.com/pressly/goose/cmd/goose)
@which lefthook || (echo "Installing Lefthook" && GO111MODULE=off go get -u github.com/Arkweid/lefthook)
@lefthook install
.PHONY: setup
Jamstash-master:
wget -N https://github.com/tsquillario/Jamstash/archive/master.zip
@@ -76,13 +75,14 @@ check_node_env:
.PHONY: check_node_env
build: check_go_env
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=master"
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT"
.PHONY: build
buildall: check_env
@(cd ./ui && npm run build)
go-bindata -fs -prefix "resources" -tags embed -ignore="\\\*.go" -pkg resources -o resources/embedded_gen.go resources/...
go-bindata -fs -prefix "ui/build" -tags embed -nocompress -pkg assets -o assets/embedded_gen.go ui/build/...
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=master" -tags=embed
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT" -tags=embed
.PHONY: buildall
release:
@@ -95,5 +95,5 @@ release:
.PHONY: release
snapshot:
docker run -it -v $(PWD):/workspace -w /workspace bepsays/ci-goreleaser:1.14-1 goreleaser release --rm-dist --skip-publish --snapshot
docker run -it -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:1.14.1-1 goreleaser release --rm-dist --skip-publish --snapshot
.PHONY: snapshot

View File

@@ -28,7 +28,7 @@ please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join
- Automatically monitors your library for changes, importing new files and reloading new metadata
- [Themeable](ui/src/themes/README.md), modern and responsive Web interface based on Material UI, to manage users and
browse your library
- Compatible with all Subsonic/Madsonic/Airsonic clients. See bellow for a list of tested clients
- Compatible with all Subsonic/Madsonic/Airsonic clients. See below for a list of tested clients
- Transcoding/Downsampling on-the-fly. Can be set per user/player. Opus encoding is supported
- Integrated music player (WIP)
@@ -110,9 +110,9 @@ To get the cutting-edge, latest version from master, use the image `deluan/navid
### Build from source
You will need to install [Go 1.14](https://golang.org/dl/) and [Node 13.12.0](http://nodejs.org).
You will need to install [Go 1.14](https://golang.org/dl/) and [Node 13](http://nodejs.org).
You'll also need [ffmpeg](https://ffmpeg.org) installed in your system. The setup is very strict, and
the steps bellow only work with these specific versions (enforced in the Makefile)
the steps below only work with these specific versions (enforced in the Makefile)
After the prerequisites above are installed, clone this repository and build the application with:

View File

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

View File

@@ -1,14 +0,0 @@
#!/usr/bin/env bash
gofmtcmd=`which goimports || echo "gofmt"`
gofiles=$(git diff --name-only --diff-filter=ACM | grep '.go$')
[ -z "$gofiles" ] && exit 0
unformatted=`$gofmtcmd -l $gofiles`
[ -z "$unformatted" ] && exit 0
for f in $unformatted; do
$gofmtcmd -w -l "$f"
gofmt -s -w -l "$f"
done

View File

@@ -1,28 +0,0 @@
#!/bin/sh
# Copyright 2012 The Go Authors. All rights reserved.
# Use of this source code is governed by a BSD-style
# license that can be found in the LICENSE file.
# git gofmt pre-commit hook
#
# To use, store as .git/hooks/pre-commit inside your repository and make sure
# it has execute permissions.
#
# This script does not handle file names that contain spaces.
gofmtcmd=`which goimports || echo "gofmt"`
gofiles=$(git diff --cached --name-only --diff-filter=ACM | grep '.go$')
[ -z "$gofiles" ] && exit 0
unformatted=$($gofmtcmd -l $gofiles)
[ -z "$unformatted" ] && exit 0
# Some files are not gofmt'd. Print message and fail.
echo >&2 "Go files must be formatted with $gofmcmd. Please run:"
for fn in $unformatted; do
echo >&2 " $gofmtcmd -w $PWD/$fn"
done
exit 1

View File

@@ -27,9 +27,10 @@ type nd struct {
IgnoredArticles string `default:"The El La Los Las Le Les Os As O A"`
IndexGroups string `default:"A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)"`
TranscodingCacheSize string `default:"100MB"` // in MB
ImageCacheSize string `default:"100MB"` // in MB
ProbeCommand string `default:"ffmpeg %s -f ffmetadata"`
EnableTranscodingConfig bool `default:"false"`
TranscodingCacheSize string `default:"100MB"` // in MB
ImageCacheSize string `default:"100MB"` // in MB
ProbeCommand string `default:"ffmpeg %s -f ffmetadata"`
// DevFlags. These are used to enable/disable debugging and incomplete features
DevLogSourceLine bool `default:"false"`

View File

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

View File

@@ -19,13 +19,14 @@ const (
JWTIssuer = "ND"
DefaultSessionTimeout = 30 * time.Minute
UIAssetsLocalPath = "ui/build"
DevInitialUserName = "admin"
DevInitialName = "Dev Admin"
URLPathUI = "/app"
URLPathSubsonicAPI = "/rest"
RequestThrottleBacklogLimit = 100
RequestThrottleBacklogTimeout = time.Minute
)
// Cache options

View File

@@ -1,19 +1,22 @@
# This file ususaly goes in /etc/systemd/system
[Unit]
Description=Navidrome Daemon
After=network.target
Description=Navidrome Music Server and Streamer compatible with Subsonic/Airsonic
After=remote-fs.target network.target
AssertPathExists=/var/lib/navidrome
[Service]
User=navidrome
Group=navidrome
Type=simple
ExecStart=/opt/navidrome/navidrome
WorkingDirectory=/opt/navidrome
ExecStart=/usr/bin/navidrome
WorkingDirectory=/var/lib/navidrome
TimeoutStopSec=20
KillMode=process
Restart=on-failure
EnvironmentFile=-/etc/sysconfig/navidrome
# See https://www.freedesktop.org/software/systemd/man/systemd.exec.html
DevicePolicy=closed
NoNewPrivileges=yes
@@ -26,10 +29,17 @@ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
SystemCallFilter=~@clock @debug @module @mount @obsolete @privileged @reboot @setuid @swap
ReadWritePaths=/opt/navidrome/
PrivateDevices=yes
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/var/lib/navidrome
[Install]
WantedBy=multi-user.target
# You can uncomment the following line if you're not using the jukebox This
# will prevent navidrome from accessing any real (physical) devices
#PrivateDevices=yes
# You can change the following line to `strict` instead of `full` if you don't
# want navidrome to be able to write anything on your filesystem outside of
# /var/lib/navidrome.
ProtectSystem=full
# You can comment the following line if you don't have any media in /home/*.
# This will prevent navidrome from ever reading/writing anything there.
ProtectHome=true

View File

@@ -51,6 +51,5 @@ create index annotation_starred
}
func Down20200208222418(tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@@ -16,6 +16,5 @@ func Up20200310171621(tx *sql.Tx) error {
}
func Down20200310171621(tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@@ -37,6 +37,5 @@ drop table if exists search;
}
func Down20200319211049(tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@@ -2,6 +2,7 @@ package migration
import (
"database/sql"
"github.com/pressly/goose"
)

View File

@@ -2,6 +2,7 @@ package migration
import (
"database/sql"
"github.com/pressly/goose"
)

View File

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

View File

@@ -25,6 +25,5 @@ create index if not exists media_file_track_number
}
func Down20200404214704(tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@@ -2,6 +2,7 @@ package migration
import (
"database/sql"
"github.com/pressly/goose"
)

View File

@@ -2,6 +2,7 @@ package migration
import (
"database/sql"
"github.com/pressly/goose"
)

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

View File

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

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

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ import (
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/static"
"github.com/deluan/navidrome/resources"
"github.com/dhowden/tag"
"github.com/disintegration/imaging"
"github.com/djherbis/fscache"
@@ -74,7 +74,9 @@ func (c *cover) Get(ctx context.Context, id string, size int, out io.Writer) err
log.Error(ctx, "Error loading cover art", "path", path, "size", size, err)
return
}
io.Copy(w, reader)
if _, err := io.Copy(w, reader); err != nil {
log.Error(ctx, "Error saving covert art to cache", "path", path, "size", size, err)
}
}()
} else {
log.Trace(ctx, "Loading image from cache", "path", path, "size", size, "lastUpdate", lastUpdate)
@@ -120,7 +122,7 @@ func (c *cover) getCover(ctx context.Context, path string, size int) (reader io.
defer func() {
if err != nil {
log.Warn(ctx, "Error extracting image", "path", path, "size", size, err)
reader, err = static.AssetFile().Open("navidrome-310x310.png")
reader, err = resources.AssetFile().Open("navidrome-310x310.png")
}
}()
var data []byte

View File

@@ -2,6 +2,7 @@ package engine
import (
"bytes"
"context"
"image"
"github.com/deluan/navidrome/log"
@@ -14,7 +15,7 @@ import (
var _ = Describe("Cover", func() {
var cover Cover
var ds model.DataStore
ctx := log.NewContext(nil)
ctx := log.NewContext(context.TODO())
BeforeEach(func() {
ds = &persistence.MockDataStore{MockedTranscoding: &mockTranscodingRepository{}}

View File

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

View File

@@ -16,7 +16,7 @@ var _ = Describe("MediaStreamer", func() {
var streamer MediaStreamer
var ds model.DataStore
ffmpeg := &fakeFFmpeg{Data: "fake data"}
ctx := log.NewContext(nil)
ctx := log.NewContext(context.TODO())
BeforeEach(func() {
ds = &persistence.MockDataStore{MockedTranscoding: &mockTranscodingRepository{}}

View File

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

View File

@@ -14,7 +14,7 @@ import (
var _ = Describe("Players", func() {
var players Players
var repo *mockPlayerRepository
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid", UserName: "johndoe"})
ctx := context.WithValue(log.NewContext(context.TODO()), "user", model.User{ID: "userid", UserName: "johndoe"})
ctx = context.WithValue(ctx, "username", "johndoe")
var beforeRegister time.Time

View File

@@ -69,7 +69,7 @@ func (p *playlists) Delete(ctx context.Context, playlistId string) error {
if owner != pls.Owner {
return model.ErrNotAuthorized
}
return p.ds.Playlist(nil).Delete(playlistId)
return p.ds.Playlist(ctx).Delete(playlistId)
}
func (p *playlists) Update(ctx context.Context, playlistId string, name *string, idsToAdd []string, idxToRemove []int) error {

View File

@@ -2,7 +2,6 @@ package engine
import (
"context"
"errors"
"fmt"
"time"
@@ -57,7 +56,7 @@ func (s *scrobbler) NowPlaying(ctx context.Context, playerId int, playerName, tr
}
if mf == nil {
return nil, errors.New(fmt.Sprintf(`ID "%s" not found`, trackId))
return nil, fmt.Errorf(`ID "%s" not found`, trackId)
}
log.Info("Now Playing", "title", mf.Title, "artist", mf.Artist, "user", userName(ctx))

View File

@@ -30,7 +30,7 @@ func (ff *ffmpeg) Start(ctx context.Context, command, path string, maxBitRate in
args := createTranscodeCommand(command, path, maxBitRate)
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
cmd := exec.Command(args[0], args[1:]...)
cmd := exec.Command(args[0], args[1:]...) // #nosec
cmd.Stderr = os.Stderr
if f, err = cmd.StdoutPipe(); err != nil {
return
@@ -38,7 +38,9 @@ func (ff *ffmpeg) Start(ctx context.Context, command, path string, maxBitRate in
if err = cmd.Start(); err != nil {
return
}
go cmd.Wait() // prevent zombies
go func() { _ = cmd.Wait() }() // prevent zombies
return
}

8
go.mod
View File

@@ -4,18 +4,18 @@ go 1.14
require (
github.com/BurntSushi/toml v0.3.1 // indirect
github.com/Masterminds/squirrel v1.2.0
github.com/Masterminds/squirrel v1.3.0
github.com/astaxie/beego v1.12.1
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/dhowden/tag v0.0.0-20200412032933-5d76b8eaae27
github.com/disintegration/imaging v1.6.2
github.com/djherbis/fscache v0.10.0
github.com/djherbis/fscache v0.10.1
github.com/dustin/go-humanize v1.0.0
github.com/fatih/camelcase v0.0.0-20160318181535-f6a740d52f96 // indirect
github.com/fatih/structs v1.0.0 // indirect
github.com/go-chi/chi v4.1.0+incompatible
github.com/go-chi/chi v4.1.1+incompatible
github.com/go-chi/cors v1.1.1
github.com/go-chi/jwtauth v4.0.4+incompatible
github.com/go-sql-driver/mysql v1.5.0 // indirect
@@ -39,6 +39,6 @@ require (
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
gopkg.in/djherbis/atime.v1 v1.0.0 // indirect
gopkg.in/djherbis/stream.v1 v1.2.0 // indirect
gopkg.in/djherbis/stream.v1 v1.3.1 // indirect
gopkg.in/yaml.v2 v2.2.8 // indirect
)

16
go.sum
View File

@@ -1,8 +1,8 @@
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Knetic/govaluate v3.0.0+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
github.com/Masterminds/squirrel v1.2.0 h1:K1NhbTO21BWG47IVR0OnIZuE0LZcXAYqywrC3Ko53KI=
github.com/Masterminds/squirrel v1.2.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
github.com/Masterminds/squirrel v1.3.0 h1:1HYpGMHYd/F3zQlbF8+G006xW8VZdiJw5U7ULvQIt5M=
github.com/Masterminds/squirrel v1.3.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
github.com/OwnLocal/goes v1.0.0/go.mod h1:8rIFjBGTue3lCU0wplczcUgt9Gxgrkkrw7etMIcn8TM=
github.com/astaxie/beego v1.12.1 h1:dfpuoxpzLVgclveAXe4PyNKqkzgm5zF4tgF2B3kkM2I=
github.com/astaxie/beego v1.12.1/go.mod h1:kPBWpSANNbSdIqOc8SUL9h+1oyBMZhROeYsXQDbidWQ=
@@ -30,8 +30,8 @@ github.com/dhowden/tag v0.0.0-20200412032933-5d76b8eaae27 h1:Z6xaGRBbqfLR797upHu
github.com/dhowden/tag v0.0.0-20200412032933-5d76b8eaae27/go.mod h1:SniNVYuaD1jmdEEvi+7ywb1QFR7agjeTdGKyFb0p7Rw=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/djherbis/fscache v0.10.0 h1:+O3s3LwKL1Jfz7txRHpgKOz8/krh9w+NzfzxtFdbsQg=
github.com/djherbis/fscache v0.10.0/go.mod h1:yyPYtkNnnPXsW+81lAcQS6yab3G2CRfnPLotBvtbf0c=
github.com/djherbis/fscache v0.10.1 h1:hDv+RGyvD+UDKyRYuLoVNbuRTnf2SrA2K3VyR1br9lk=
github.com/djherbis/fscache v0.10.1/go.mod h1:yyPYtkNnnPXsW+81lAcQS6yab3G2CRfnPLotBvtbf0c=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8=
@@ -43,8 +43,8 @@ github.com/fatih/structs v1.0.0 h1:BrX964Rv5uQ3wwS+KRUAJCBBw5PQmgJfJ6v4yly5QwU=
github.com/fatih/structs v1.0.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/go-chi/chi v4.1.0+incompatible h1:ETj3cggsVIY2Xao5ExCu6YhEh5MD6JTfcBzS37R260w=
github.com/go-chi/chi v4.1.0+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/chi v4.1.1+incompatible h1:MmTgB0R8Bt/jccxp+t6S/1VGIKdJw5J74CK/c9tTfA4=
github.com/go-chi/chi v4.1.1+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/cors v1.1.1 h1:eHuqxsIw89iXcWnWUN8R72JMibABJTN/4IOYI5WERvw=
github.com/go-chi/cors v1.1.1/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
github.com/go-chi/jwtauth v4.0.4+incompatible h1:LGIxg6YfvSBzxU2BljXbrzVc1fMlgqSKBQgKOGAVtPY=
@@ -181,8 +181,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/djherbis/atime.v1 v1.0.0 h1:eMRqB/JrLKocla2PBPKgQYg/p5UG4L6AUAs92aP7F60=
gopkg.in/djherbis/atime.v1 v1.0.0/go.mod h1:hQIUStKmJfvf7xdh/wtK84qe+DsTV5LnA9lzxxtPpJ8=
gopkg.in/djherbis/stream.v1 v1.2.0 h1:3tZuXO+RK8opjw8/BJr780h+eAPwOFfLHCKRKyYxk3s=
gopkg.in/djherbis/stream.v1 v1.2.0/go.mod h1:aEV8CBVRmSpLamVJfM903Npic1IKmb2qS30VAZ+sssg=
gopkg.in/djherbis/stream.v1 v1.3.1 h1:uGfmsOY1qqMjQQphhRBSGLyA9qumJ56exkRu9ASTjCw=
gopkg.in/djherbis/stream.v1 v1.3.1/go.mod h1:aEV8CBVRmSpLamVJfM903Npic1IKmb2qS30VAZ+sssg=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=

View File

@@ -180,6 +180,7 @@ func extractLogger(ctx interface{}) (*logrus.Entry, error) {
if logger != nil {
return logger.(*logrus.Entry), nil
}
return extractLogger(NewContext(ctx))
case *http.Request:
return extractLogger(ctx.Context())
}

View File

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

View File

@@ -3,23 +3,28 @@ package model
import "time"
type Album struct {
ID string `json:"id" orm:"column(id)"`
Name string `json:"name"`
CoverArtPath string `json:"coverArtPath"`
CoverArtId string `json:"coverArtId"`
ArtistID string `json:"artistId" orm:"pk;column(artist_id)"`
Artist string `json:"artist"`
AlbumArtistID string `json:"albumArtistId" orm:"pk;column(album_artist_id)"`
AlbumArtist string `json:"albumArtist"`
MaxYear int `json:"maxYear"`
MinYear int `json:"minYear"`
Compilation bool `json:"compilation"`
SongCount int `json:"songCount"`
Duration float32 `json:"duration"`
Genre string `json:"genre"`
FullText string `json:"fullText"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id" orm:"column(id)"`
Name string `json:"name"`
CoverArtPath string `json:"coverArtPath"`
CoverArtId string `json:"coverArtId"`
ArtistID string `json:"artistId" orm:"pk;column(artist_id)"`
Artist string `json:"artist"`
AlbumArtistID string `json:"albumArtistId" orm:"pk;column(album_artist_id)"`
AlbumArtist string `json:"albumArtist"`
MaxYear int `json:"maxYear"`
MinYear int `json:"minYear"`
Compilation bool `json:"compilation"`
SongCount int `json:"songCount"`
Duration float32 `json:"duration"`
Genre string `json:"genre"`
FullText string `json:"fullText"`
SortAlbumName string `json:"sortAlbumName"`
SortArtistName string `json:"sortArtistName"`
SortAlbumArtistName string `json:"sortAlbumArtistName"`
OrderAlbumName string `json:"orderAlbumName"`
OrderAlbumArtistName string `json:"orderAlbumArtistName"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// Annotations
PlayCount int `json:"playCount" orm:"-"`

View File

@@ -3,10 +3,12 @@ package model
import "time"
type Artist struct {
ID string `json:"id" orm:"column(id)"`
Name string `json:"name"`
AlbumCount int `json:"albumCount" orm:"column(album_count)"`
FullText string `json:"fullText"`
ID string `json:"id" orm:"column(id)"`
Name string `json:"name"`
AlbumCount int `json:"albumCount" orm:"column(album_count)"`
FullText string `json:"fullText"`
SortArtistName string `json:"sortArtistName"`
OrderArtistName string `json:"orderArtistName"`
// Annotations
PlayCount int `json:"playCount" orm:"-"`

View File

@@ -6,28 +6,35 @@ import (
)
type MediaFile struct {
ID string `json:"id" orm:"pk;column(id)"`
Path string `json:"path"`
Title string `json:"title"`
Album string `json:"album"`
ArtistID string `json:"artistId" orm:"pk;column(artist_id)"`
Artist string `json:"artist"`
AlbumArtistID string `json:"albumArtistId"`
AlbumArtist string `json:"albumArtist"`
AlbumID string `json:"albumId" orm:"pk;column(album_id)"`
HasCoverArt bool `json:"hasCoverArt"`
TrackNumber int `json:"trackNumber"`
DiscNumber int `json:"discNumber"`
Year int `json:"year"`
Size int `json:"size"`
Suffix string `json:"suffix"`
Duration float32 `json:"duration"`
BitRate int `json:"bitRate"`
Genre string `json:"genre"`
FullText string `json:"fullText"`
Compilation bool `json:"compilation"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id" orm:"pk;column(id)"`
Path string `json:"path"`
Title string `json:"title"`
Album string `json:"album"`
ArtistID string `json:"artistId" orm:"pk;column(artist_id)"`
Artist string `json:"artist"`
AlbumArtistID string `json:"albumArtistId"`
AlbumArtist string `json:"albumArtist"`
AlbumID string `json:"albumId" orm:"pk;column(album_id)"`
HasCoverArt bool `json:"hasCoverArt"`
TrackNumber int `json:"trackNumber"`
DiscNumber int `json:"discNumber"`
Year int `json:"year"`
Size int `json:"size"`
Suffix string `json:"suffix"`
Duration float32 `json:"duration"`
BitRate int `json:"bitRate"`
Genre string `json:"genre"`
FullText string `json:"fullText"`
SortTitle string `json:"sortTitle"`
SortAlbumName string `json:"sortAlbumName"`
SortArtistName string `json:"sortArtistName"`
SortAlbumArtistName string `json:"sortAlbumArtistName"`
OrderAlbumName string `json:"orderAlbumName"`
OrderArtistName string `json:"orderArtistName"`
OrderAlbumArtistName string `json:"orderAlbumArtistName"`
Compilation bool `json:"compilation"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// Annotations
PlayCount int `json:"playCount" orm:"-"`
@@ -48,6 +55,7 @@ type MediaFileRepository interface {
Exists(id string) (bool, error)
Put(m *MediaFile) error
Get(id string) (*MediaFile, error)
GetAll(options ...QueryOptions) (MediaFiles, error)
FindByAlbum(albumId string) (MediaFiles, error)
FindByPath(path string) (MediaFiles, error)
GetStarred(options ...QueryOptions) (MediaFiles, error)

View File

@@ -2,6 +2,9 @@ package persistence
import (
"context"
"sort"
"strconv"
"strings"
"time"
. "github.com/Masterminds/squirrel"
@@ -23,7 +26,7 @@ func NewAlbumRepository(ctx context.Context, o orm.Ormer) model.AlbumRepository
r.ormer = o
r.tableName = "album"
r.sortMappings = map[string]string{
"artist": "compilation asc, album_artist asc, name asc",
"artist": "compilation asc, order_album_artist_name asc, order_album_name asc",
"random": "RANDOM()",
}
r.filterMappings = map[string]filterFunc{
@@ -48,7 +51,7 @@ func yearFilter(field string, value interface{}) Sqlizer {
}
func artistFilter(field string, value interface{}) Sqlizer {
return Exists("media_file", And{
return exists("media_file", And{
ConcatExpr("album_id=album.id"),
Or{
Eq{"artist_id": value},
@@ -108,12 +111,15 @@ func (r *albumRepository) Refresh(ids ...string) error {
CurrentId string
HasCoverArt bool
SongArtists string
Years string
}
var albums []refreshAlbum
sel := Select(`album_id as id, album as name, f.artist, f.album_artist, f.artist_id, f.album_artist_id,
f.compilation, f.genre, max(f.year) as max_year, min(f.year) as min_year, sum(f.duration) as duration,
count(*) as song_count, a.id as current_id, f.id as cover_art_id, f.path as cover_art_path,
group_concat(f.artist, ' ') as song_artists, f.has_cover_art`).
f.sort_album_name, f.sort_artist_name, f.sort_album_artist_name,
f.order_album_name, f.order_album_artist_name,
f.compilation, f.genre, max(f.year) as max_year, sum(f.duration) as duration,
count(*) as song_count, a.id as current_id, f.id as cover_art_id, f.path as cover_art_path, f.has_cover_art,
group_concat(f.artist, ' ') as song_artists, group_concat(f.year, ' ') as years`).
From("media_file f").
LeftJoin("album a on f.album_id = a.id").
Where(Eq{"f.album_id": ids}).GroupBy("album_id").OrderBy("f.id")
@@ -136,6 +142,7 @@ func (r *albumRepository) Refresh(ids ...string) error {
al.AlbumArtist = al.Artist
al.AlbumArtistID = al.ArtistID
}
al.MinYear = getMinYear(al.Years)
al.UpdatedAt = time.Now()
if al.CurrentId != "" {
toUpdate++
@@ -143,7 +150,8 @@ func (r *albumRepository) Refresh(ids ...string) error {
toInsert++
al.CreatedAt = time.Now()
}
al.FullText = r.getFullText(al.Name, al.Artist, al.AlbumArtist, al.SongArtists)
al.FullText = getFullText(al.Name, al.Artist, al.AlbumArtist, al.SongArtists,
al.SortAlbumName, al.SortArtistName, al.SortAlbumArtistName)
_, err := r.put(al.ID, al.Album)
if err != nil {
return err
@@ -158,6 +166,18 @@ func (r *albumRepository) Refresh(ids ...string) error {
return err
}
func getMinYear(years string) int {
ys := strings.Fields(years)
sort.Strings(ys)
for _, y := range ys {
if y != "0" {
r, _ := strconv.Atoi(y)
return r
}
}
return 0
}
func (r *albumRepository) PurgeEmpty() error {
del := Delete(r.tableName).Where("id not in (select distinct(album_id) from media_file)")
c, err := r.executeSQL(del)

View File

@@ -14,13 +14,13 @@ var _ = Describe("AlbumRepository", func() {
var repo model.AlbumRepository
BeforeEach(func() {
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
ctx := context.WithValue(log.NewContext(context.TODO()), "user", model.User{ID: "userid"})
repo = NewAlbumRepository(ctx, orm.NewOrm())
})
Describe("Get", func() {
It("returns an existent album", func() {
Expect(repo.Get("3")).To(Equal(&albumRadioactivity))
Expect(repo.Get("103")).To(Equal(&albumRadioactivity))
})
It("returns ErrNotFound when the album does not exist", func() {
_, err := repo.Get("666")
@@ -73,4 +73,16 @@ var _ = Describe("AlbumRepository", func() {
})
})
Describe("getMinYear", func() {
It("returns 0 when there's no valid year", func() {
Expect(getMinYear("a b c")).To(Equal(0))
Expect(getMinYear("")).To(Equal(0))
})
It("returns 0 when all values are 0", func() {
Expect(getMinYear("0 0 0 ")).To(Equal(0))
})
It("returns the smallest value from the list", func() {
Expect(getMinYear("2000 0 1800")).To(Equal(1800))
})
})
})

View File

@@ -26,6 +26,9 @@ func NewArtistRepository(ctx context.Context, o orm.Ormer) model.ArtistRepositor
r.ormer = o
r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups)
r.tableName = "artist"
r.sortMappings = map[string]string{
"name": "order_artist_name",
}
r.filterMappings = map[string]filterFunc{
"name": fullTextFilter,
}
@@ -44,19 +47,8 @@ func (r *artistRepository) Exists(id string) (bool, error) {
return r.exists(Select().Where(Eq{"id": id}))
}
func (r *artistRepository) getIndexKey(a *model.Artist) string {
name := strings.ToLower(utils.NoArticle(a.Name))
for k, v := range r.indexGroups {
key := strings.ToLower(k)
if strings.HasPrefix(name, key) {
return v
}
}
return "#"
}
func (r *artistRepository) Put(a *model.Artist) error {
a.FullText = r.getFullText(a.Name)
a.FullText = getFullText(a.Name, a.SortArtistName)
_, err := r.put(a.ID, a)
return err
}
@@ -75,11 +67,21 @@ func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists,
return res, err
}
func (r *artistRepository) getIndexKey(a *model.Artist) string {
name := strings.ToLower(utils.NoArticle(a.Name))
for k, v := range r.indexGroups {
key := strings.ToLower(k)
if strings.HasPrefix(name, key) {
return v
}
}
return "#"
}
// TODO Cache the index (recalculate when there are changes to the DB)
func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
sq := r.selectArtist().OrderBy("name")
sq := r.selectArtist().OrderBy("order_artist_name")
var all model.Artists
// TODO Paginate
err := r.queryAll(sq, &all)
if err != nil {
return nil, err
@@ -111,7 +113,9 @@ func (r *artistRepository) Refresh(ids ...string) error {
CurrentId string
}
var artists []refreshArtist
sel := Select("f.album_artist_id as id", "f.album_artist as name", "count(*) as album_count", "a.id as current_id").
sel := Select("f.album_artist_id as id", "f.album_artist as name", "count(*) as album_count", "a.id as current_id",
"f.sort_album_artist_name as sort_artist_name",
"f.order_album_artist_name as order_artist_name").
From("album f").
LeftJoin("artist a on f.album_artist_id = a.id").
Where(Eq{"f.album_artist_id": ids}).

View File

@@ -14,7 +14,7 @@ var _ = Describe("ArtistRepository", func() {
var repo model.ArtistRepository
BeforeEach(func() {
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
ctx := context.WithValue(log.NewContext(context.TODO()), "user", model.User{ID: "userid"})
repo = NewArtistRepository(ctx, orm.NewOrm())
})

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ import (
var _ = Describe("Helpers", func() {
Describe("Exists", func() {
It("constructs the correct EXISTS query", func() {
e := Exists("album", squirrel.Eq{"id": 1})
e := exists("album", squirrel.Eq{"id": 1})
sql, args, err := e.ToSql()
Expect(sql).To(Equal("exists (select 1 from album where id = ?)"))
Expect(args).To(Equal([]interface{}{1}))

View File

@@ -23,8 +23,8 @@ func NewMediaFileRepository(ctx context.Context, o orm.Ormer) *mediaFileReposito
r.ormer = o
r.tableName = "media_file"
r.sortMappings = map[string]string{
"artist": "artist asc, album asc, disc_number asc, track_number asc",
"album": "album asc, disc_number asc, track_number asc",
"artist": "order_artist_name asc, album asc, disc_number asc, track_number asc",
"album": "order_album_name asc, disc_number asc, track_number asc",
}
r.filterMappings = map[string]filterFunc{
"title": fullTextFilter,
@@ -41,7 +41,8 @@ func (r mediaFileRepository) Exists(id string) (bool, error) {
}
func (r mediaFileRepository) Put(m *model.MediaFile) error {
m.FullText = r.getFullText(m.Title, m.Album, m.Artist, m.AlbumArtist)
m.FullText = getFullText(m.Title, m.Album, m.Artist, m.AlbumArtist,
m.SortTitle, m.SortAlbumName, m.SortArtistName, m.SortAlbumArtistName)
_, err := r.put(m.ID, m)
return err
}

View File

@@ -16,12 +16,12 @@ var _ = Describe("MediaRepository", func() {
var mr model.MediaFileRepository
BeforeEach(func() {
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
ctx := context.WithValue(log.NewContext(context.TODO()), "user", model.User{ID: "userid"})
mr = NewMediaFileRepository(ctx, orm.NewOrm())
})
It("gets mediafile from the DB", func() {
Expect(mr.Get("4")).To(Equal(&songAntenna))
Expect(mr.Get("1004")).To(Equal(&songAntenna))
})
It("returns ErrNotFound", func() {
@@ -39,7 +39,7 @@ var _ = Describe("MediaRepository", func() {
})
It("find mediafiles by album", func() {
Expect(mr.FindByAlbum("3")).To(Equal(model.MediaFiles{
Expect(mr.FindByAlbum("103")).To(Equal(model.MediaFiles{
songRadioactivity,
songAntenna,
}))

View File

@@ -22,8 +22,7 @@ func TestPersistence(t *testing.T) {
//os.Remove("./test-123.db")
//conf.Server.Path = "./test-123.db"
conf.Server.DbPath = "file::memory:?cache=shared"
orm.RegisterDataBase("default", db.Driver, conf.Server.DbPath)
New()
_ = orm.RegisterDataBase("default", db.Driver, conf.Server.DbPath)
db.EnsureLatestVersion()
log.SetLevel(log.LevelCritical)
RegisterFailHandler(Fail)
@@ -31,8 +30,8 @@ func TestPersistence(t *testing.T) {
}
var (
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1, FullText: "kraftwerk"}
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2, FullText: "beatles the"}
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1, FullText: " kraftwerk"}
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2, FullText: " beatles the"}
testArtists = model.Artists{
artistKraftwerk,
artistBeatles,
@@ -40,9 +39,9 @@ var (
)
var (
albumSgtPeppers = model.Album{ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", AlbumArtistID: "3", Genre: "Rock", CoverArtId: "1", CoverArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967, FullText: "beatles peppers sgt the"}
albumAbbeyRoad = model.Album{ID: "2", Name: "Abbey Road", Artist: "The Beatles", AlbumArtistID: "3", Genre: "Rock", CoverArtId: "2", CoverArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969, FullText: "abbey beatles road the"}
albumRadioactivity = model.Album{ID: "3", Name: "Radioactivity", Artist: "Kraftwerk", AlbumArtistID: "2", Genre: "Electronic", CoverArtId: "3", CoverArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2, FullText: "kraftwerk radioactivity"}
albumSgtPeppers = model.Album{ID: "101", Name: "Sgt Peppers", Artist: "The Beatles", AlbumArtistID: "3", Genre: "Rock", CoverArtId: "1", CoverArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967, FullText: " beatles peppers sgt the"}
albumAbbeyRoad = model.Album{ID: "102", Name: "Abbey Road", Artist: "The Beatles", AlbumArtistID: "3", Genre: "Rock", CoverArtId: "2", CoverArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969, FullText: " abbey beatles road the"}
albumRadioactivity = model.Album{ID: "103", Name: "Radioactivity", Artist: "Kraftwerk", AlbumArtistID: "2", Genre: "Electronic", CoverArtId: "3", CoverArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2, FullText: " kraftwerk radioactivity"}
testAlbums = model.Albums{
albumSgtPeppers,
albumAbbeyRoad,
@@ -51,10 +50,10 @@ var (
)
var (
songDayInALife = model.MediaFile{ID: "1", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "1", Album: "Sgt Peppers", Genre: "Rock", Path: P("/beatles/1/sgt/a day.mp3"), FullText: "a beatles day in life peppers sgt the"}
songComeTogether = model.MediaFile{ID: "2", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "2", Album: "Abbey Road", Genre: "Rock", Path: P("/beatles/1/come together.mp3"), FullText: "abbey beatles come road the together"}
songRadioactivity = model.MediaFile{ID: "3", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Album: "Radioactivity", Genre: "Electronic", Path: P("/kraft/radio/radio.mp3"), FullText: "kraftwerk radioactivity"}
songAntenna = model.MediaFile{ID: "4", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Genre: "Electronic", Path: P("/kraft/radio/antenna.mp3"), FullText: "antenna kraftwerk"}
songDayInALife = model.MediaFile{ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Genre: "Rock", Path: P("/beatles/1/sgt/a day.mp3"), FullText: " a beatles day in life peppers sgt the"}
songComeTogether = model.MediaFile{ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Genre: "Rock", Path: P("/beatles/1/come together.mp3"), FullText: " abbey beatles come road the together"}
songRadioactivity = model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Genre: "Electronic", Path: P("/kraft/radio/radio.mp3"), FullText: " kraftwerk radioactivity"}
songAntenna = model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Genre: "Electronic", Path: P("/kraft/radio/antenna.mp3"), FullText: " antenna kraftwerk"}
testSongs = model.MediaFiles{
songDayInALife,
songComeTogether,
@@ -70,9 +69,9 @@ var (
Comment: "No Comments",
Owner: "userid",
Public: true,
Tracks: model.MediaFiles{{ID: "1"}, {ID: "3"}},
Tracks: model.MediaFiles{{ID: "1001"}, {ID: "1003"}},
}
plsCool = model.Playlist{ID: "11", Name: "Cool", Tracks: model.MediaFiles{{ID: "4"}}}
plsCool = model.Playlist{ID: "11", Name: "Cool", Tracks: model.MediaFiles{{ID: "1004"}}}
testPlaylists = model.Playlists{plsBest, plsCool}
)
@@ -85,7 +84,7 @@ var _ = Describe("Initialize test DB", func() {
// TODO Load this data setup from file(s)
BeforeSuite(func() {
o := orm.NewOrm()
ctx := context.WithValue(log.NewContext(nil), "user", model.User{ID: "userid"})
ctx := context.WithValue(log.NewContext(context.TODO()), "user", model.User{ID: "userid"})
mr := NewMediaFileRepository(ctx, o)
for _, s := range testSongs {
err := mr.Put(&s)

View File

@@ -1,6 +1,8 @@
package persistence
import (
"context"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
@@ -12,7 +14,7 @@ var _ = Describe("PlaylistRepository", func() {
var repo model.PlaylistRepository
BeforeEach(func() {
repo = NewPlaylistRepository(log.NewContext(nil), orm.NewOrm())
repo = NewPlaylistRepository(log.NewContext(context.TODO()), orm.NewOrm())
})
Describe("Count", func() {
@@ -63,19 +65,19 @@ var _ = Describe("PlaylistRepository", func() {
Describe("Put/Exists/Delete", func() {
var newPls model.Playlist
BeforeEach(func() {
newPls = model.Playlist{ID: "22", Name: "Great!", Tracks: model.MediaFiles{{ID: "4"}, {ID: "3"}}}
newPls = model.Playlist{ID: "22", Name: "Great!", Tracks: model.MediaFiles{{ID: "1004"}, {ID: "1003"}}}
})
It("saves the playlist to the DB", func() {
Expect(repo.Put(&newPls)).To(BeNil())
})
It("adds repeated songs to a playlist and keeps the order", func() {
newPls.Tracks = append(newPls.Tracks, model.MediaFile{ID: "4"})
newPls.Tracks = append(newPls.Tracks, model.MediaFile{ID: "1004"})
Expect(repo.Put(&newPls)).To(BeNil())
saved, _ := repo.Get("22")
Expect(saved.Tracks).To(HaveLen(3))
Expect(saved.Tracks[0].ID).To(Equal("4"))
Expect(saved.Tracks[1].ID).To(Equal("3"))
Expect(saved.Tracks[2].ID).To(Equal("4"))
Expect(saved.Tracks[0].ID).To(Equal("1004"))
Expect(saved.Tracks[1].ID).To(Equal("1003"))
Expect(saved.Tracks[2].ID).To(Equal("1004"))
})
It("returns the newly created playlist", func() {
Expect(repo.Exists("22")).To(BeTrue())

View File

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

View File

@@ -7,7 +7,6 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/deluan/navidrome/model"
"github.com/deluan/rest"
"github.com/kennygrant/sanitize"
)
type filterFunc = func(field string, value interface{}) Sqlizer
@@ -59,15 +58,11 @@ func booleanFilter(field string, value interface{}) Sqlizer {
}
func fullTextFilter(field string, value interface{}) Sqlizer {
q := value.(string)
q = strings.TrimSpace(sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*"))))
q := sanitizeStrings(value.(string))
parts := strings.Split(q, " ")
filters := And{}
for _, part := range parts {
filters = append(filters, Or{
Like{"full_text": part + "%"},
Like{"full_text": "%" + part + "%"},
})
filters = append(filters, Like{"full_text": "% " + part + "%"})
}
return filters
}

View File

@@ -1,6 +1,7 @@
package persistence
import (
"regexp"
"sort"
"strings"
@@ -8,7 +9,14 @@ import (
"github.com/kennygrant/sanitize"
)
func (r sqlRepository) getFullText(text ...string) string {
var quotesRegex = regexp.MustCompile("[“”‘’'\"]")
func getFullText(text ...string) string {
fullText := sanitizeStrings(text...)
return " " + fullText
}
func sanitizeStrings(text ...string) string {
sanitizedText := strings.Builder{}
for _, txt := range text {
sanitizedText.WriteString(strings.TrimSpace(sanitize.Accents(strings.ToLower(txt))) + " ")
@@ -19,14 +27,18 @@ func (r sqlRepository) getFullText(text ...string) string {
}
var fullText []string
for w := range words {
fullText = append(fullText, w)
w = quotesRegex.ReplaceAllString(w, "")
if w != "" {
fullText = append(fullText, w)
}
}
sort.Strings(fullText)
return strings.Join(fullText, " ")
}
func (r sqlRepository) doSearch(q string, offset, size int, results interface{}, orderBys ...string) error {
q = strings.TrimSpace(sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*"))))
q = strings.TrimSuffix(q, "*")
q = sanitizeStrings(q)
if len(q) < 2 {
return nil
}
@@ -37,10 +49,7 @@ func (r sqlRepository) doSearch(q string, offset, size int, results interface{},
}
parts := strings.Split(q, " ")
for _, part := range parts {
sq = sq.Where(Or{
Like{"full_text": part + "%"},
Like{"full_text": "%" + part + "%"},
})
sq = sq.Where(Like{"full_text": "% " + part + "%"})
}
err := r.queryAll(sq, results)
return err

View File

@@ -6,23 +6,25 @@ import (
)
var _ = Describe("sqlRepository", func() {
var sqlRepository = &sqlRepository{}
Describe("getFullText", func() {
It("returns all lowercase chars", func() {
Expect(sqlRepository.getFullText("Some Text")).To(Equal("some text"))
Expect(getFullText("Some Text")).To(Equal(" some text"))
})
It("removes accents", func() {
Expect(sqlRepository.getFullText("Quintão")).To(Equal("quintao"))
Expect(getFullText("Quintão")).To(Equal(" quintao"))
})
It("remove extra spaces", func() {
Expect(sqlRepository.getFullText(" some text ")).To(Equal("some text"))
Expect(getFullText(" some text ")).To(Equal(" some text"))
})
It("remove duplicated words", func() {
Expect(sqlRepository.getFullText("legião urbana urbana legiÃo")).To(Equal("legiao urbana"))
Expect(getFullText("legião urbana urbana legiÃo")).To(Equal(" legiao urbana"))
})
It("remove symbols", func() {
Expect(getFullText("Toms Diner ' “40” A")).To(Equal(" 40 a diner toms"))
})
})
})

View File

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

View File

@@ -1 +1 @@
-s -r "(\.go$$|navidrome.toml)" -R "(Jamstash-master|^ui|^data)" -- go run .
-s -r "(\.go$$|navidrome.toml|resources)" -R "(Jamstash-master|^ui|^data)" -- go run .

View File

28
resources/external.go Normal file
View File

@@ -0,0 +1,28 @@
// +build !embed
package resources
import (
"io/ioutil"
"net/http"
"sync"
"github.com/deluan/navidrome/log"
)
var once sync.Once
func Asset(filePath string) ([]byte, error) {
f, err := AssetFile().Open(filePath)
if err != nil {
return nil, err
}
return ioutil.ReadAll(f)
}
func AssetFile() http.FileSystem {
once.Do(func() {
log.Warn("Using external resources from 'resources' folder")
})
return http.Dir("resources")
}

256
resources/i18n/de.json Normal file
View File

@@ -0,0 +1,256 @@
{
"languageName": "Deutsch",
"resources": {
"song": {
"name": "Song |||| Songs",
"fields": {
"albumArtist": "Albuminterpret",
"duration": "Dauer",
"trackNumber": "Titel #",
"playCount": "Aufrufe",
"title": "Titel",
"artist": "Künstler",
"album": "Album",
"path": "Dateipfad",
"genre": "Genre",
"compilation": "Kompilation",
"year": "Jahr",
"size": "Dateigröße",
"updatedAt": "Hochgeladen um"
},
"actions": {
"addToQueue": "Später abspielen",
"playNow": "Jetzt abspielen"
}
},
"album": {
"name": "Album |||| Alben",
"fields": {
"albumArtist": "Albuminterpret",
"artist": "Interpret",
"duration": "Dauer",
"songCount": "Songanzahl",
"playCount": "Aufrufe",
"name": "Name",
"genre": "Genre",
"compilation": "Kompilation",
"year": "Jahr"
},
"actions": {
"playAll": "Abspielen",
"playNext": "Als nächstes abspielen",
"addToQueue": "Später abspielen",
"shuffle": "Zufallswiedergabe"
}
},
"artist": {
"name": "Interpret |||| Interpreten",
"fields": {
"name": "Name",
"albumCount": "Albumanzahl"
}
},
"user": {
"name": "Nutzer |||| Nutzer",
"fields": {
"userName": "Nutzername",
"isAdmin": "Ist Admin",
"lastLoginAt": "Letzer Login um",
"updatedAt": "Aktualisiert um",
"name": "Name"
}
},
"player": {
"name": "Player |||| Players",
"fields": {
"name": "Name",
"transcodingId": "Transkodierungs-ID",
"maxBitRate": "Max. Bitrate",
"client": "Client",
"userName": "Nutzername",
"lastSeen": "Zuletzt gesehen um"
}
},
"transcoding": {
"name": "Transcodierung |||| Transcodierungen",
"fields": {
"name": "Name",
"targetFormat": "Zielformat",
"defaultBitRate": "Standardbitrate",
"command": "Befehl"
}
}
},
"ra": {
"auth": {
"welcome1": "Vielen Dank für die Installation von Navidrome!",
"welcome2": "Als erstes erstelle einen Admin-Benutzer",
"confirmPassword": "Passwort bestätigen",
"buttonCreateAdmin": "Admin erstellen",
"auth_check_error": "Bitte einloggen um fortzufahren",
"user_menu": "Profil",
"username": "Nutzername",
"password": "Passwort",
"sign_in": "Anmelden",
"sign_in_error": "Fehler bei der Anmeldung",
"logout": "Abmelden"
},
"validation": {
"invalidChars": "Bitte nur Buchstaben und Zahlen verwenden",
"passwordDoesNotMatch": "Passwort stimmt nicht überein",
"required": "Benötigt",
"minLength": "Muss mindestens %{min} Zeichen lang sein",
"maxLength": "Darf maximal %{max} Zeichen lang sein",
"minValue": "Muss mindestens %{min} sein",
"maxValue": "Muss %{max} oder weniger sein",
"number": "Muss eine Nummer sein",
"email": "Muss eine gültige E-Mail sein",
"oneOf": "Es muss einer sein von: %{options}",
"regex": "Es muss folgendem regulären Ausdruck entsprechen: %{pattern}"
},
"action": {
"add_filter": "Filter hinzufügen",
"add": "Neu",
"back": "Zurück",
"bulk_actions": "Ein Element ausgewählt |||| %{smart_count} Elemente ausgewählt",
"cancel": "Abbrechen",
"clear_input_value": "Eingabe löschen",
"clone": "Klonen",
"confirm": "Bestätigen",
"create": "Erstellen",
"delete": "Löschen",
"edit": "Bearbeiten",
"export": "Exportieren",
"list": "Liste",
"refresh": "Aktualisieren",
"remove_filter": "Filter entfernen",
"remove": "Entfernen",
"save": "Speichern",
"search": "Suchen",
"show": "Anzeigen",
"sort": "Sortieren",
"undo": "Zurücksetzen",
"expand": "Expandieren",
"close": "Schließen",
"open_menu": "Menü öffnen",
"close_menu": "Menü schließen"
},
"boolean": {
"true": "Ja",
"false": "Nein"
},
"page": {
"create": "%{name} erstellen",
"dashboard": "Dashboard",
"edit": "%{name} #%{id}",
"error": "Etwas ist schief gelaufen",
"list": "%{name}",
"loading": "Laden",
"not_found": "Nicht gefunden",
"show": "%{name} #%{id}",
"empty": "Noch kein %{name}.\n",
"invite": "Möchten du eine hinzufügen?"
},
"input": {
"file": {
"upload_several": "Zum Hochladen Dateien hineinziehen oder hier klicken, um Dateien auszuwählen.",
"upload_single": "Zum Hochladen Datei hineinziehen oder hier klicken, um eine Datei auszuwählen."
},
"image": {
"upload_several": "Zum Hochladen Bilder hineinziehen oder hier klicken, um Bilder auszuwählen.",
"upload_single": "Zum Hochladen Bild hineinziehen oder hier klicken, um ein Bild auszuwählen."
},
"references": {
"all_missing": "Die zugehörigen Referenzen konnten nicht gefunden werden.",
"many_missing": "Mindestens eine der zugehörigen Referenzen scheint nicht mehr verfügbar zu sein.",
"single_missing": "Eine zugehörige Referenz scheint nicht mehr verfügbar zu sein."
},
"password": {
"toggle_visible": "Passwort verbergen",
"toggle_hidden": "Passwort anzeigen"
}
},
"message": {
"about": "Über",
"are_you_sure": "Bist du sicher?",
"bulk_delete_content": "Möchtest du \"%{name}\" wirklich löschen? |||| Möchtest du diese %{smart_count} Elemente wirklich löschen?",
"bulk_delete_title": "Lösche %{name} |||| Lösche %{smart_count} %{name} Elemente",
"delete_content": "Möchtest du diesen Inhalt wirklich löschen?",
"delete_title": "Lösche %{name} #%{id}",
"details": "Details",
"error": "Ein Fehler ist aufgetreten und ihre Anfrage konnte nicht abgeschlossen werden.",
"invalid_form": "Das Formular ist ungültig. Bitte überprüfe deine Eingaben.",
"loading": "Die Seite wird geladen.",
"no": "Nein",
"not_found": "Die Seite konnte nicht gefunden werden.",
"yes": "Ja",
"unsaved_changes": "Einige deiner Änderungen wurden nicht gespeichert. Bist du sicher, dass du sie ignorieren möchtest?"
},
"navigation": {
"no_results": "Keine Resultate gefunden",
"no_more_results": "Die Seite %{page} enthält keine Inhalte.",
"page_out_of_boundaries": "Die Seite %{page} liegt ausserhalb des gültigen Bereichs",
"page_out_from_end": "Letzte Seite",
"page_out_from_begin": "Erste Seite",
"page_range_info": "%{offsetBegin}-%{offsetEnd} von %{total}",
"page_rows_per_page": "Zeilen pro Seite:",
"next": "Weiter",
"prev": "Zurück"
},
"notification": {
"updated": "Element wurde aktualisiert |||| %{smart_count} Elemente wurden aktualisiert",
"created": "Element wurde erstellt",
"deleted": "Element wurde gelöscht |||| %{smart_count} Elemente wurden gelöscht",
"bad_item": "Fehlerhaftes Elemente",
"item_doesnt_exist": "Das Element existiert nicht",
"http_error": "Fehler beim Kommunizieren mit dem Server",
"data_provider_error": "dataProvider Fehler. Prüfe die Konsole für Details.",
"i18n_error": "Die Übersetzungen für die angegebene Sprache können nicht geladen werden",
"canceled": "Aktion abgebrochen",
"logged_out": "Ihr Session wurde beendet. Bitte erneut verbinden."
}
},
"message": {
"note": "HINWEIS",
"transcodingDisabled": "Die Änderung der Transcodierungskonfiguration über die Web-UI ist aus Sicherheitsgründen deaktiviert. Wenn du die Transcodierungsoptionen ändern (bearbeiten oder hinzufügen) möchtest, starte den Server mit der Konfigurationsoption %{config} neu.",
"transcodingEnabled": "Navidrome läuft derzeit mit %{config}, wodurch es möglich ist, Systembefehle aus den Transkodierungseinstellungen über die Webschnittstelle auszuführen. Wir empfehlen, es aus Sicherheitsgründen zu deaktivieren und nur bei der Konfiguration von Transkodierungsoptionen zu aktivieren."
},
"menu": {
"library": "Bibliothek",
"settings": "Einstellungen",
"version": "Version %{version}",
"theme": "Design",
"personal": {
"name": "Persönlich",
"options": {
"theme": "Design",
"language": "Sprache"
}
}
},
"player": {
"playListsText": "Wiedergabeliste abspielen",
"openText": "Öffnen",
"closeText": "Schließen",
"notContentText": "Keine Musik",
"clickToPlayText": "Anklicken zum Abzuspielen",
"clickToPauseText": "Anklicken zum Pausieren",
"nextTrackText": "Nächster Titel",
"previousTrackText": "Vorheriger Titel",
"reloadText": "Neu laden",
"volumeText": "Lautstärke",
"toggleLyricText": "Liedtext umschalten",
"toggleMiniModeText": "Minimieren",
"destroyText": "Zerstören",
"downloadText": "Herunterladen",
"removeAudioListsText": "Audiolisten löschen",
"clickToDeleteText": "Klicken um %{Name} zu Löschen",
"emptyLyricText": "Kein Liedtext",
"playModeText": {
"order": "Der Reihe nach",
"orderLoop": "Wiederholen",
"singleLoop": "Eins wiederholen",
"shufflePlay": "Zufallswiedergabe"
}
}
}

256
resources/i18n/fr.json Normal file
View File

@@ -0,0 +1,256 @@
{
"languageName": "Français",
"resources": {
"song": {
"name": "Piste |||| Pistes",
"fields": {
"albumArtist": "",
"duration": "Durée",
"trackNumber": "#",
"playCount": "Nombre d'écoutes",
"title": "Titre",
"artist": "Artiste",
"album": "Album",
"path": "Chemin",
"genre": "Genre",
"compilation": "Compilation",
"year": "Année",
"size": "Taille",
"updatedAt": "Mise à jour"
},
"actions": {
"addToQueue": "Ajouter à la file",
"playNow": "Lire"
}
},
"album": {
"name": "Album |||| Albums",
"fields": {
"albumArtist": "",
"artist": "Artiste",
"duration": "Durée",
"songCount": "Numéro de piste",
"playCount": "Numbre d'écoutes",
"name": "Nom",
"genre": "Genre",
"compilation": "Compilation",
"year": "Année"
},
"actions": {
"playAll": "Lire",
"playNext": "Lire ensuite",
"addToQueue": "Ajouter à la file",
"shuffle": "Mélanger"
}
},
"artist": {
"name": "Artiste |||| Artistes",
"fields": {
"name": "Nom",
"albumCount": "Nombre d'albums"
}
},
"user": {
"name": "Utilisateur |||| Utilisateurs",
"fields": {
"userName": "Nom d'utilisateur",
"isAdmin": "Administrateur",
"lastLoginAt": "Dernière connexion",
"updatedAt": "Dernière mise à jour",
"name": "Nom"
}
},
"player": {
"name": "Lecteur |||| Lecteurs",
"fields": {
"name": "Nom",
"transcodingId": "Transcodage",
"maxBitRate": "Bitrate maximum",
"client": "Client",
"userName": "Nom d'utilisateur",
"lastSeen": "Vu pour la dernière fois"
}
},
"transcoding": {
"name": "Conversion |||| Conversions",
"fields": {
"name": "Nom",
"targetFormat": "Format",
"defaultBitRate": "Bitrate par défaut",
"command": "Commande"
}
}
},
"ra": {
"auth": {
"welcome1": "Merci d'avoir installé Navidrome !",
"welcome2": "Pour commencer, créez un compte administrateur",
"confirmPassword": "Confirmer votre mot de passe",
"buttonCreateAdmin": "Créer un compte administrateur",
"auth_check_error": "Merci de vous connecter pour continuer",
"user_menu": "Profil",
"username": "Identifiant",
"password": "Mot de passe",
"sign_in": "Connexion",
"sign_in_error": "Échec de l'authentification, merci de réessayer",
"logout": "Déconnexion"
},
"validation": {
"invalidChars": "Merci d'utiliser uniquement des chiffres et des lettres",
"passwordDoesNotMatch": "Les mots de passes ne correspondent pas",
"required": "Ce champ est requis",
"minLength": "Minimum %{min} caractères",
"maxLength": "Maximum %{max} caractères",
"minValue": "Minimum %{min}",
"maxValue": "Maximum %{max}",
"number": "Doit être un nombre",
"email": "Doit être un email",
"oneOf": "Doit être au choix: %{options}",
"regex": "Doit respecter un format spécifique (regexp): %{pattern}"
},
"action": {
"add_filter": "Ajouter un filtre",
"add": "Ajouter",
"back": "Retour",
"bulk_actions": "%{smart_count} selectionné |||| %{smart_count} selectionnés",
"cancel": "Annuler",
"clear_input_value": "Vider le champ",
"clone": "Dupliquer",
"confirm": "Confirmer",
"create": "Créer",
"delete": "Supprimer",
"edit": "Éditer",
"export": "Exporter",
"list": "Liste",
"refresh": "Actualiser",
"remove_filter": "Supprimer ce filtre",
"remove": "Supprimer",
"save": "Enregistrer",
"search": "Rechercher",
"show": "Afficher",
"sort": "Trier",
"undo": "Annuler",
"expand": "Étendre",
"close": "Fermer",
"open_menu": "Ouvrir le menu",
"close_menu": "Fermer le menu"
},
"boolean": {
"true": "Oui",
"false": "Non"
},
"page": {
"create": "Créer %{name}",
"dashboard": "Tableau de bord",
"edit": "%{name} #%{id}",
"error": "Un problème est survenu",
"list": "%{name}",
"loading": "Chargement",
"not_found": "Page manquante",
"show": "%{name} #%{id}",
"empty": "Pas encore de %{name}.",
"invite": "Voulez-vous en créer un ?"
},
"input": {
"file": {
"upload_several": "Déposez les fichiers à uploader, ou cliquez pour en sélectionner.",
"upload_single": "Déposez le fichier à uploader, ou cliquez pour le sélectionner."
},
"image": {
"upload_several": "Déposez les images à uploader, ou cliquez pour en sélectionner.",
"upload_single": "Déposez l'image à uploader, ou cliquez pour la sélectionner."
},
"references": {
"all_missing": "Impossible de trouver des données de références.",
"many_missing": "Au moins une des références associées semble ne plus être disponible.",
"single_missing": "La référence associée ne semble plus disponible."
},
"password": {
"toggle_visible": "Cacher le mot de passe",
"toggle_hidden": "Montrer le mot de passe"
}
},
"message": {
"about": "Au sujet de",
"are_you_sure": "Êtes-vous sûr ?",
"bulk_delete_content": "Êtes-vous sûr(e) de vouloir supprimer cet élément ? |||| Êtes-vous sûr(e) de vouloir supprimer ces %{smart_count} éléments ?",
"bulk_delete_title": "Supprimer %{name} |||| Supprimer %{smart_count} %{name}",
"delete_content": "Êtes-vous sûr(e) de vouloir supprimer cet élément ?",
"delete_title": "Supprimer %{name} #%{id}",
"details": "Détails",
"error": "En raison d'une erreur côté navigateur, votre requête n'a pas pu aboutir.",
"invalid_form": "Le formulaire n'est pas valide.",
"loading": "La page est en cours de chargement, merci de bien vouloir patienter.",
"no": "Non",
"not_found": "L'URL saisie est incorrecte, ou vous avez suivi un mauvais lien.",
"yes": "Oui",
"unsaved_changes": "Certains changements n'ont pas été enregistrés. Êtes-vous sûr(e) de vouloir quitter cette page ?"
},
"navigation": {
"no_results": "Aucun résultat",
"no_more_results": "La page numéro %{page} est en dehors des limites. Essayez la page précédente.",
"page_out_of_boundaries": "La page %{page} est en dehors des limites",
"page_out_from_end": "Fin de la pagination",
"page_out_from_begin": "La page doit être supérieure à 1",
"page_range_info": "%{offsetBegin}-%{offsetEnd} sur %{total}",
"page_rows_per_page": "Lignes par page :",
"next": "Suivant",
"prev": "Précédent"
},
"notification": {
"updated": "Élément mis à jour |||| %{smart_count} élements mis à jour",
"created": "Élément créé",
"deleted": "Élément supprimé |||| %{smart_count} élements supprimés",
"bad_item": "Élément inconnu",
"item_doesnt_exist": "L'élément n'existe pas",
"http_error": "Erreur de communication avec le serveur",
"data_provider_error": "Erreur dans le dataProvider. Plus de détails dans la console.",
"i18n_error": "Erreur de chargement des traductions pour la langue sélectionnée",
"canceled": "Action annulée",
"logged_out": "Votre session a pris fin, veuillez vous reconnecter."
}
},
"message": {
"note": "",
"transcodingDisabled": "",
"transcodingEnabled": ""
},
"menu": {
"library": "Bibliothèque",
"settings": "Paramètres",
"version": "Version%{version}",
"theme": "",
"personal": {
"name": "Paramètres personel",
"options": {
"theme": "Thème",
"language": "Langue"
}
}
},
"player": {
"playListsText": "File de lecture",
"openText": "Ouvrir",
"closeText": "Fermer",
"notContentText": "",
"clickToPlayText": "Cliquer pour lire",
"clickToPauseText": "Cliquer pour mettre en pause",
"nextTrackText": "Morceau suivant",
"previousTrackText": "Morceau précédent",
"reloadText": "",
"volumeText": "Volume",
"toggleLyricText": "",
"toggleMiniModeText": "Minimiser",
"destroyText": "",
"downloadText": "",
"removeAudioListsText": "Vider la liste de lecture",
"clickToDeleteText": "Cliquer pour supprimer %{name}",
"emptyLyricText": "",
"playModeText": {
"order": "Ordonner",
"orderLoop": "Tout répéter",
"singleLoop": "Repéter",
"shufflePlay": "Aleatoire"
}
}
}

256
resources/i18n/it.json Normal file
View File

@@ -0,0 +1,256 @@
{
"languageName": "Italiano",
"resources": {
"song": {
"name": "Traccia |||| Tracce",
"fields": {
"albumArtist": "",
"duration": "Durata",
"trackNumber": "#",
"playCount": "Riproduzioni",
"title": "Titolo",
"artist": "Artista",
"album": "Album",
"path": "Percorso",
"genre": "Genere",
"compilation": "Compilation",
"year": "Anno",
"size": "Dimensioni",
"updatedAt": "Ultimo aggiornamento"
},
"actions": {
"addToQueue": "Aggiungi alla coda",
"playNow": "Riproduci"
}
},
"album": {
"name": "Album |||| Album",
"fields": {
"albumArtist": "",
"artist": "Artista",
"duration": "Durata",
"songCount": "Tracce",
"playCount": "Riproduzioni",
"name": "Nome",
"genre": "Genere",
"compilation": "Compilation",
"year": "Anno"
},
"actions": {
"playAll": "Riproduci",
"playNext": "Riproduci come successivo",
"addToQueue": "Aggiungi alla coda",
"shuffle": "Riprodici casualmente"
}
},
"artist": {
"name": "Artista |||| Artisti",
"fields": {
"name": "Nome",
"albumCount": "Album"
}
},
"user": {
"name": "Utente |||| Utenti",
"fields": {
"userName": "Utente",
"isAdmin": "Amministratore",
"lastLoginAt": "Ultimo accesso",
"updatedAt": "Ultima modifica",
"name": "Nome"
}
},
"player": {
"name": "Client |||| Client",
"fields": {
"name": "Nome",
"transcodingId": "Transcodifica",
"maxBitRate": "Bitrate massimo",
"client": "Applicazione",
"userName": "Utente",
"lastSeen": "Ultimo acesso"
}
},
"transcoding": {
"name": "Transcodifica |||| Transcodifiche",
"fields": {
"name": "Nome",
"targetFormat": "Formato",
"defaultBitRate": "Bitrate predefinito",
"command": "Comando"
}
}
},
"ra": {
"auth": {
"welcome1": "Grazie per aver installato Navidrome!",
"welcome2": "Per iniziare, crea un amministratore",
"confirmPassword": "Conferma la password",
"buttonCreateAdmin": "Crea amministratore",
"auth_check_error": "",
"user_menu": "Profile",
"username": "Nome utente",
"password": "Password",
"sign_in": "Login",
"sign_in_error": "Autenticazione fallita, riprovare.",
"logout": "Disconnessione"
},
"validation": {
"invalidChars": "Per favore usa solo lettere e numeri",
"passwordDoesNotMatch": "Le password non coincidono",
"required": "Campo obbligatorio",
"minLength": "Deve essere lungo %{min} caratteri almeno",
"maxLength": "Deve essere lungo %{max} caratteri al massimo",
"minValue": "Deve essere almeno %{min}",
"maxValue": "Deve essere al massimo %{max}",
"number": "Deve essere un numero",
"email": "Deve essere un valido indirizzo email",
"oneOf": "Deve essere uno di: %{options}",
"regex": "Deve rispettare il formato (espressione regolare): %{pattern}"
},
"action": {
"add_filter": "Aggiungi un filtro",
"add": "Aggiungi",
"back": "Indietro",
"bulk_actions": "%{smart_count} selezionati",
"cancel": "Annulla",
"clear_input_value": "Svuota il modulo",
"clone": "Duplica",
"confirm": "Conferma",
"create": "Crea",
"delete": "Cancella",
"edit": "Modifica",
"export": "Esporta",
"list": "Elenco",
"refresh": "Aggiorna",
"remove_filter": "Rimuovi questo filtro",
"remove": "Remove",
"save": "Salva",
"search": "Ricerca",
"show": "Mostra",
"sort": "Ordina",
"undo": "Annulla",
"expand": "Espandi",
"close": "Chiudi",
"open_menu": "",
"close_menu": ""
},
"boolean": {
"true": "Si",
"false": "No"
},
"page": {
"create": "Aggiungi %{name}",
"dashboard": "Cruscotto",
"edit": "%{name} %{id}",
"error": "Qualcosa non ha funzionato",
"list": "Lista %{name}",
"loading": "Caricamento in corso",
"not_found": "Non trovato",
"show": "%{name} %{id}",
"empty": "",
"invite": ""
},
"input": {
"file": {
"upload_several": "Trascina i files da caricare, oppure clicca per selezionare.",
"upload_single": "Trascina il file da caricare, oppure clicca per selezionarlo."
},
"image": {
"upload_several": "Trascina le immagini da caricare, oppure clicca per selezionarle.",
"upload_single": "Trascina l'immagine da caricare, oppure clicca per selezionarla."
},
"references": {
"all_missing": "Impossibile trovare i riferimenti associati.",
"many_missing": "Almeno uno dei riferimenti associati non sembra più disponibile.",
"single_missing": "Il riferimento associato non sembra più disponibile."
},
"password": {
"toggle_visible": "",
"toggle_hidden": ""
}
},
"message": {
"about": "Informazioni",
"are_you_sure": "Sei sicuro ?",
"bulk_delete_content": "Sei sicuro di voler cancellare questo %{name}? |||| Sei sicuro di voler eliminare questi %{smart_count}?",
"bulk_delete_title": "Delete %{name} |||| Delete %{smart_count} %{name} items",
"delete_content": "Are you sure you want to delete this item?",
"delete_title": "Cancella %{name} #%{id}",
"details": "Dettagli",
"error": "Un errore locale è occorso e la tua richiesta non è stata completata.",
"invalid_form": "Il modulo non è valido. Si prega di verificare la presenza di errori.",
"loading": "La pagina si sta caricando, solo un momento per favore",
"no": "No",
"not_found": "Hai inserito un URL errato, oppure hai cliccato un link errato",
"yes": "Si",
"unsaved_changes": ""
},
"navigation": {
"no_results": "Nessun risultato trovato",
"no_more_results": "La pagina numero %{page} è fuori dell'intervallo. Prova la pagina precedente.",
"page_out_of_boundaries": "Il numero di pagina %{page} è fuori dei limiti",
"page_out_from_end": "Fine della paginazione",
"page_out_from_begin": "Il numero di pagina deve essere maggiore di 1",
"page_range_info": "%{offsetBegin}-%{offsetEnd} di %{total}",
"page_rows_per_page": "Righe per pagina",
"next": "Successivo",
"prev": "Precedente"
},
"notification": {
"updated": "Record aggiornato |||| %{smart_count} records aggiornati",
"created": "Record creato",
"deleted": "Record eliminato |||| %{smart_count} records eliminati",
"bad_item": "Record errato",
"item_doesnt_exist": "Record inesistente",
"http_error": "Errore di comunicazione con il server dati",
"data_provider_error": "Errore del data provider. Controlla la console per i dettagli.",
"i18n_error": "",
"canceled": "Azione annullata",
"logged_out": "La sessione è scaduta. Effettua nuovamente l'accesso."
}
},
"message": {
"note": "",
"transcodingDisabled": "",
"transcodingEnabled": ""
},
"menu": {
"library": "Libreria",
"settings": "Impostazioni",
"version": "Versione %{version}",
"theme": "",
"personal": {
"name": "Personale",
"options": {
"theme": "Tema",
"language": "Lingua"
}
}
},
"player": {
"playListsText": "Coda",
"openText": "Apri",
"closeText": "Chiudi",
"notContentText": "",
"clickToPlayText": "Clicca per riprodurre",
"clickToPauseText": "Clicca per mettere in pausa",
"nextTrackText": "Traccia successiva",
"previousTrackText": "Traccia precedente",
"reloadText": "",
"volumeText": "Volume",
"toggleLyricText": "",
"toggleMiniModeText": "Minimizza",
"destroyText": "",
"downloadText": "",
"removeAudioListsText": "Cancella coda",
"clickToDeleteText": "Clicca per rimuovere %{name}",
"emptyLyricText": "",
"playModeText": {
"order": "In ordine",
"orderLoop": "Ripeti",
"singleLoop": "Ripeti una volta",
"shufflePlay": "Casuale"
}
}
}

256
resources/i18n/nl.json Normal file
View File

@@ -0,0 +1,256 @@
{
"languageName": "Nederlands",
"resources": {
"song": {
"name": "Nummer |||| Nummers",
"fields": {
"albumArtist": "Album Artiest",
"duration": "Tijd",
"trackNumber": "Nummer #",
"playCount": "Aantal keren afgespeeld",
"title": "Titel",
"artist": "Artiest",
"album": "Album",
"path": "Bestandspad",
"genre": "Genre",
"compilation": "Compilatie",
"year": "Jaar",
"size": "Bestandsgrootte",
"updatedAt": "Laatst bijgewerkt op"
},
"actions": {
"addToQueue": "Toevoegen aan afspeellijst",
"playNow": "Nu Afspelen"
}
},
"album": {
"name": "Album |||| Albums",
"fields": {
"albumArtist": "Album Artiest",
"artist": "Artiest",
"duration": "Tijd",
"songCount": "Nummerss",
"playCount": "Aantal keren afgespeeld",
"name": "Naam",
"genre": "Genre",
"compilation": "Compilatie",
"year": "Jaar"
},
"actions": {
"playAll": "Afspelen",
"playNext": "Hierna afspelen",
"addToQueue": "Toevoegen aan afspeellijst",
"shuffle": "Shuffle"
}
},
"artist": {
"name": "Artiest |||| Artiesten",
"fields": {
"name": "Naam",
"albumCount": "Aantal albums"
}
},
"user": {
"name": "Gebruiker |||| Gebruikers",
"fields": {
"userName": "Gebruikersnaam",
"isAdmin": "Is beheerder",
"lastLoginAt": "Laatst ingelogd op",
"updatedAt": "Laatst gewijzigd op",
"name": "Naam"
}
},
"player": {
"name": "Speler |||| Spelers",
"fields": {
"name": "Naam",
"transcodingId": "Transcoderingsidentifier",
"maxBitRate": "Maximale bitrate",
"client": "Client",
"userName": "Gebruikersnaam",
"lastSeen": "Laatst gezien op"
}
},
"transcoding": {
"name": "Transcodering |||| Transcoderingen",
"fields": {
"name": "Naam",
"targetFormat": "Doel formaat",
"defaultBitRate": "Standaard bitrate",
"command": "Commando"
}
}
},
"ra": {
"auth": {
"welcome1": "Bedankt voor het installeren van Navidrome!",
"welcome2": "Maak om te beginnen een beheerdersaccount",
"confirmPassword": "Bevestig wachtwoord",
"buttonCreateAdmin": "Beheerder maken",
"auth_check_error": "Log in om door te gaan",
"user_menu": "Profiel",
"username": "Gebruikersnaam",
"password": "Wachtwoord",
"sign_in": "Inloggen",
"sign_in_error": "Authenticatie mislukt, probeer opnieuw a.u.b.",
"logout": "Uitloggen"
},
"validation": {
"invalidChars": "Gebruik alleen letters en cijfers",
"passwordDoesNotMatch": "Wachtwoord komt niet overeen",
"required": "Verplicht",
"minLength": "Moet minimaal %{min} karakters bevatten",
"maxLength": "Mag hooguit %{max} karakters bevatten",
"minValue": "Moet groter of gelijk zijn aan %{min}",
"maxValue": "Moet kleiner of gelijk zijn aan %{max}",
"number": "Moet een getal zijn",
"email": "Moet een geldig e-mailadres zijn",
"oneOf": "Moet een zijn van: %{options}",
"regex": "Moet overeenkomen met een specifiek format (regexp): %{pattern}"
},
"action": {
"add_filter": "Voeg filter toe",
"add": "Voeg toe",
"back": "Ga terug",
"bulk_actions": "1 geselecteerd |||| %{smart_count} geselecteerd",
"cancel": "Annuleer",
"clear_input_value": "Veld wissen",
"clone": "Kloon",
"confirm": "Bevestig",
"create": "Toevoegen",
"delete": "Verwijderen",
"edit": "Bewerk",
"export": "Exporteer",
"list": "Lijst",
"refresh": "Ververs",
"remove_filter": "Verwijder dit filter",
"remove": "Verwijder",
"save": "Opslaan",
"search": "Zoek",
"show": "Toon",
"sort": "Sorteer",
"undo": "Ongedaan maken",
"expand": "Uitklappen",
"close": "Sluiten",
"open_menu": "Open menu",
"close_menu": "Sluit menu"
},
"boolean": {
"true": "Ja",
"false": "Nee"
},
"page": {
"create": "%{name} toevoegen",
"dashboard": "Dashboard",
"edit": "%{name} #%{id}",
"error": "Er is iets misgegaan",
"list": "%{name}",
"loading": "Aan het laden",
"not_found": "Niet gevonden",
"show": "%{name} #%{id}",
"empty": "Nog geen %{name}.",
"invite": "Wilt u er een toevoegen?"
},
"input": {
"file": {
"upload_several": "Drag en drop bestanden om te uploaden, of klik om bestanden te selecteren.",
"upload_single": "Drag en drop een bestand om te uploaden, of klik om een bestand te selecteren."
},
"image": {
"upload_several": "Drag en drop afbeeldingen om te uploaden, of klik om bestanden te selecteren.",
"upload_single": "Drag en drop een afbeelding om te uploaden, of klik om een bestand te selecteren."
},
"references": {
"all_missing": "De gerefereerde elementen konden niet gevonden worden.",
"many_missing": "Een of meer van de gerefereerde elementen is niet meer beschikbaar.",
"single_missing": "Een van de gerefereerde elementen is niet meer beschikbaar"
},
"password": {
"toggle_visible": "Verberg wachtwoord",
"toggle_hidden": "Toon wachtwoord"
}
},
"message": {
"about": "Over",
"are_you_sure": "Weet u het zeker?",
"bulk_delete_content": "Weet u zeker dat u dit %{name} item wilt verwijderen? |||| Weet u zeker dat u deze %{smart_count} items wilt verwijderen?",
"bulk_delete_title": "Verwijder %{name} |||| Verwijder %{smart_count} %{name}",
"delete_content": "Weet u zeker dat u dit item wilt verwijderen?",
"delete_title": "%{name} #%{id} verwijderen",
"details": "Details",
"error": "Er is een clientfout opgetreden en uw aanvraag kon niet worden voltooid.",
"invalid_form": "Het formulier is ongeldig. Controleer a.u.b. de foutmeldingen",
"loading": "De pagina is aan het laden, een moment a.u.b.",
"no": "Nee",
"not_found": "U heeft een verkeerde URL ingevoerd of een defecte link aangeklikt.",
"yes": "Ja",
"unsaved_changes": "Sommige van uw wijzigingen zijn niet opgeslagen. Weet ue zeker dat u ze wilt negeren?"
},
"navigation": {
"no_results": "Geen resultaten gevonden",
"no_more_results": "Pagina %{page} ligt buiten het bereik. Probeer de vorige pagina.",
"page_out_of_boundaries": "Paginanummer %{page} buiten bereik",
"page_out_from_end": "Laatste pagina",
"page_out_from_begin": "Eerste pagina",
"page_range_info": "%{offsetBegin}-%{offsetEnd} van %{total}",
"page_rows_per_page": "Rijen per pagina:",
"next": "Volgende",
"prev": "Vorige"
},
"notification": {
"updated": "Element bijgewerkt |||| %{smart_count} elementen bijgewerkt",
"created": "Element toegevoegd",
"deleted": "Element verwijderd |||| %{smart_count} elementen verwijderd",
"bad_item": "Incorrect element",
"item_doesnt_exist": "Element bestaat niet",
"http_error": "Server communicatie fout",
"data_provider_error": "dataProvider fout. Open console voor meer details.",
"i18n_error": "Kan de vertalingen voor de opgegeven taal niet laden",
"canceled": "Actie geannuleerd",
"logged_out": "Uw sessie is beëindigd, maak opnieuw verbinding."
}
},
"message": {
"note": "Notitie",
"transcodingDisabled": "Het wijzigen van de transcoderingsconfiguratie via de web interface is om veiligheidsredenen uitgeschakeld. Als u transcoderingsopties wilt wijzigen (bewerken of toevoegen), start u de server opnieuw op met de %{config} configuratie-optie.",
"transcodingEnabled": "Navidrome werkt momenteel met %{config}, waardoor het mogelijk is om systeemopdrachten uit te voeren vanuit de transcoderingsinstellingen via de web interface. We raden aan om het om veiligheidsredenen uit te schakelen en alleen in te schakelen bij het configureren van transcoderingsopties."
},
"menu": {
"library": "Bibliotheek",
"settings": "Instellingen",
"version": "Versie %{version}",
"theme": "Thema",
"personal": {
"name": "Persoonlijk",
"options": {
"theme": "Thema",
"language": "Taal"
}
}
},
"player": {
"playListsText": "Afspeellijst afspelen",
"openText": "Openen",
"closeText": "Sluiten",
"notContentText": "Geen muziek",
"clickToPlayText": "Klik om af te spelen",
"clickToPauseText": "Klik om te pauzeren",
"nextTrackText": "Volgende",
"previousTrackText": "Vorige",
"reloadText": "Herladen",
"volumeText": "Volume",
"toggleLyricText": "Songtekst aan/uit",
"toggleMiniModeText": "Minimaliseren",
"destroyText": "Vernietigen",
"downloadText": "Downloaden",
"removeAudioListsText": "Audiolijsten verwijderen",
"clickToDeleteText": "Klik om %{name} te verwijderen",
"emptyLyricText": "Geen songtekst",
"playModeText": {
"order": "In volgorde",
"orderLoop": "Herhalen",
"singleLoop": "Herhaal Eenmalig",
"shufflePlay": "Shuffle"
}
}
}

256
resources/i18n/pt.json Normal file
View File

@@ -0,0 +1,256 @@
{
"languageName": "Português",
"resources": {
"song": {
"name": "Música |||| Músicas",
"fields": {
"albumArtist": "Artista",
"duration": "Duração",
"trackNumber": "#",
"playCount": "Execuções",
"title": "Título",
"artist": "Artista",
"album": "Álbum",
"path": "Arquivo",
"genre": "Gênero",
"compilation": "Coletânea",
"year": "Ano",
"size": "Tamanho",
"updatedAt": "Últ. Atualização"
},
"actions": {
"addToQueue": "Tocar por último",
"playNow": "Tocar agora"
}
},
"album": {
"name": "Álbum |||| Álbuns",
"fields": {
"albumArtist": "Artista",
"artist": "Artista",
"duration": "Duração",
"songCount": "Músicas",
"playCount": "Execuções",
"name": "Nome",
"genre": "Gênero",
"compilation": "Coletânea",
"year": "Ano"
},
"actions": {
"playAll": "Tocar",
"playNext": "Tocar em seguida",
"addToQueue": "Tocar no fim",
"shuffle": "Aleatório"
}
},
"artist": {
"name": "Artista |||| Artistas",
"fields": {
"name": "Nome",
"albumCount": "Total de Álbuns"
}
},
"user": {
"name": "Usuário |||| Usuários",
"fields": {
"userName": "Usuário",
"isAdmin": "Admin?",
"lastLoginAt": "Últ. Login",
"updatedAt": "Últ. Atualização",
"name": "Nome"
}
},
"player": {
"name": "Tocador |||| Tocadores",
"fields": {
"name": "Nome",
"transcodingId": "Conversão",
"maxBitRate": "Bitrate máx",
"client": "Cliente",
"userName": "Usuário",
"lastSeen": "Últ. acesso"
}
},
"transcoding": {
"name": "Conversão |||| Conversões",
"fields": {
"name": "Nome",
"targetFormat": "Formato",
"defaultBitRate": "Bitrate padrão",
"command": "Comando"
}
}
},
"ra": {
"auth": {
"welcome1": "Obrigado por instalar Navidrome!",
"welcome2": "Para iniciar, crie um usuário admin",
"confirmPassword": "Confirme a senha",
"buttonCreateAdmin": "Criar Admin",
"auth_check_error": "Por favor, faça login para continuar",
"user_menu": "Perfil",
"username": "Usuário",
"password": "Senha",
"sign_in": "Entrar",
"sign_in_error": "Erro na autenticação, tente novamente.",
"logout": "Sair"
},
"validation": {
"invalidChars": "Somente use letras e numeros",
"passwordDoesNotMatch": "Senha não confere",
"required": "Obrigatório",
"minLength": "Deve ser ter no mínimo %{min} caracteres",
"maxLength": "Deve ter no máximo %{max} caracteres",
"minValue": "Deve ser %{min} ou maior",
"maxValue": "Deve ser %{max} ou menor",
"number": "Deve ser um número",
"email": "Deve ser um email válido",
"oneOf": "Deve ser uma das seguintes opções: %{options}",
"regex": "Deve ter o formato específico (regexp): %{pattern}"
},
"action": {
"add_filter": "Adicionar Filtro",
"add": "Adicionar",
"back": "Voltar",
"bulk_actions": "1 item selecionado |||| %{smart_count} itens selecionados",
"cancel": "Cancelar",
"clear_input_value": "Limpar campo",
"clone": "Duplicar",
"confirm": "Confirmar",
"create": "Novo",
"delete": "Deletar",
"edit": "Editar",
"export": "Exportar",
"list": "Listar",
"refresh": "Atualizar",
"remove_filter": "Cancelar filtro",
"remove": "Excluir",
"save": "Salvar",
"search": "Buscar",
"show": "Exibir",
"sort": "Ordenar",
"undo": "Desfazer",
"expand": "Expandir",
"close": "Fechar",
"open_menu": "Abrir menu",
"close_menu": "Fechar menu"
},
"boolean": {
"true": "Sim",
"false": "Não"
},
"page": {
"create": "Criar %{name}",
"dashboard": "Painel de Controle",
"edit": "%{name} #%{id}",
"error": "Um erro ocorreu",
"list": "Listar %{name}",
"loading": "Carregando",
"not_found": "Não encontrado",
"show": "%{name} #%{id}",
"empty": "Ainda não há nenhum registro em %{name}",
"invite": "Gostaria de criar um novo?"
},
"input": {
"file": {
"upload_several": "Arraste alguns arquivos para fazer o upload, ou clique para selecioná-los.",
"upload_single": "Arraste o arquivo para fazer o upload, ou clique para selecioná-lo."
},
"image": {
"upload_several": "Arraste algumas imagens para fazer o upload ou clique para selecioná-las",
"upload_single": "Arraste um arquivo para upload ou clique em selecionar arquivo."
},
"references": {
"all_missing": "Não foi possível encontrar os dados das referencias.",
"many_missing": "Pelo menos uma das referências passadas não está mais disponível.",
"single_missing": "A referência passada aparenta não estar mais disponível."
},
"password": {
"toggle_visible": "Esconder senha",
"toggle_hidden": "Mostrar senha"
}
},
"message": {
"about": "Sobre",
"are_you_sure": "Tem certeza?",
"bulk_delete_content": "Você tem certeza que deseja excluir %{name}? |||| Você tem certeza que deseja excluir estes %{smart_count} itens?",
"bulk_delete_title": "Excluir %{name} |||| Excluir %{smart_count} %{name} itens",
"delete_content": "Você tem certeza que deseja excluir?",
"delete_title": "Excluir %{name} #%{id}",
"details": "Detalhes",
"error": "Um erro ocorreu e a sua requisição não pôde ser completada.",
"invalid_form": "Este formulário não está valido. Certifique-se de corrigir os erros",
"loading": "A página está carregando. Um momento, por favor",
"no": "Não",
"not_found": "Foi digitada uma URL inválida, ou o link pode estar quebrado.",
"yes": "Sim",
"unsaved_changes": "Algumas das suas mudanças não foram salvas, deseja realmente ignorá-las?"
},
"navigation": {
"no_results": "Nenhum resultado encontrado",
"no_more_results": "A página numero %{page} está fora dos limites. Tente a página anterior.",
"page_out_of_boundaries": "Página %{page} fora do limite",
"page_out_from_end": "Não é possível ir após a última página",
"page_out_from_begin": "Não é possível ir antes da primeira página",
"page_range_info": "%{offsetBegin}-%{offsetEnd} de %{total}",
"page_rows_per_page": "Resultados por página:",
"next": "Próximo",
"prev": "Anterior"
},
"notification": {
"updated": "Item atualizado com sucesso |||| %{smart_count} itens foram atualizados com sucesso",
"created": "Item criado com sucesso",
"deleted": "Item removido com sucesso! |||| %{smart_count} itens foram removidos com sucesso",
"bad_item": "Item incorreto",
"item_doesnt_exist": "Esse item não existe mais",
"http_error": "Erro na comunicação com servidor",
"data_provider_error": "Erro interno do servidor. Entre em contato",
"i18n_error": "Não foi possível carregar as traduções para o idioma especificado",
"canceled": "Ação cancelada",
"logged_out": "Sua sessão foi encerrada. Por favor, reconecte"
}
},
"message": {
"note": "ATENÇÃO",
"transcodingDisabled": "",
"transcodingEnabled": ""
},
"menu": {
"library": "Biblioteca",
"settings": "Configurações",
"version": "Versão %{version}",
"theme": "Tema",
"personal": {
"name": "Pessoal",
"options": {
"theme": "Tema",
"language": "Língua"
}
}
},
"player": {
"playListsText": "Fila de Execução",
"openText": "Abrir",
"closeText": "Fechar",
"notContentText": "",
"clickToPlayText": "Clique para tocar",
"clickToPauseText": "Clique para pausar",
"nextTrackText": "Próxima faixa",
"previousTrackText": "Faixa anterior",
"reloadText": "Recarregar",
"volumeText": "Volume",
"toggleLyricText": "",
"toggleMiniModeText": "Minimizar",
"destroyText": "",
"downloadText": "Baixar",
"removeAudioListsText": "Limpar fila de execução",
"clickToDeleteText": "Clique para remover %{name}",
"emptyLyricText": "Letra não disponível",
"playModeText": {
"order": "Em ordem",
"orderLoop": "Repetir tudo",
"singleLoop": "Repetir",
"shufflePlay": "Aleatório"
}
}
}

256
resources/i18n/tr.json Normal file
View File

@@ -0,0 +1,256 @@
{
"languageName": "Türkçe",
"resources": {
"song": {
"name": "Şarkı |||| Şarkılar",
"fields": {
"albumArtist": "Albüm sanatçısı",
"duration": "Süre",
"trackNumber": "Parça #",
"playCount": "Oynatma",
"title": "Isim",
"artist": "Sanatçı",
"album": "Albüm",
"path": "Dosya yolu",
"genre": "Tür",
"compilation": "Derleme",
"year": "Yıl",
"size": "Dosya boyutu",
"updatedAt": "Yüklendiği zaman"
},
"actions": {
"addToQueue": "Sonra çal",
"playNow": "Şimdi cal"
}
},
"album": {
"name": "Albüm |||| Albümler",
"fields": {
"albumArtist": "Albüm sanatçısı",
"artist": "Sanatçı",
"duration": "Süre",
"songCount": "Şarkılar",
"playCount": "Oynatma",
"name": "Ad",
"genre": "Tür",
"compilation": "Derleme",
"year": "Yıl"
},
"actions": {
"playAll": "Çaldır",
"playNext": "Sonrakini çal",
"addToQueue": "Sonra çal",
"shuffle": "Karıştır"
}
},
"artist": {
"name": "Sanatçı |||| Sanatçılar",
"fields": {
"name": "Ad",
"albumCount": "Albüm Sayısı"
}
},
"user": {
"name": "Kullanıcı |||| Kullanıcılar",
"fields": {
"userName": "Kullanıcı adı",
"isAdmin": "Yönetici mi",
"lastLoginAt": "Son Giriş Tarihi",
"updatedAt": "Güncelleme Tarihi",
"name": "Ad"
}
},
"player": {
"name": "Çalar |||| Çalarlar",
"fields": {
"name": "Ad",
"transcodingId": "Kod dönüştürme kimliği",
"maxBitRate": "Maks. bit orani",
"client": "Cihaz",
"userName": "Kullanıcı adı",
"lastSeen": "Son Görülme"
}
},
"transcoding": {
"name": "Transcoding |||| Transcodings",
"fields": {
"name": "Ad",
"targetFormat": "Hedef Formatı",
"defaultBitRate": "Varsayılan bit orani",
"command": "komut"
}
}
},
"ra": {
"auth": {
"welcome1": "Navidrome'yi yüklediğiniz için teşekkürler!",
"welcome2": "Başlamak için bir yönetici kullanıcı oluştur",
"confirmPassword": "Şifreyi Onayla",
"buttonCreateAdmin": "Yönetici oluştur",
"auth_check_error": "Devam etmek için lütfen giriş yap",
"user_menu": "Profil",
"username": "Kullanıcı adı",
"password": "Parola",
"sign_in": "Giriş yap",
"sign_in_error": "Giriş başarısız. Lütfen tekrar deneyin",
"logout": ıkış"
},
"validation": {
"invalidChars": "Lütfen sadece harf ve rakam kullan",
"passwordDoesNotMatch": "Şifre eşleşmiyor",
"required": "Zorunlu alan",
"minLength": "En az %{min} karakter",
"maxLength": "En fazla %{max} karakter",
"minValue": "En az %{min} olmalı",
"maxValue": "En fazla %{max} olmali",
"number": "Sayısal bir değer olmalı",
"email": "E-posta geçerli değil",
"oneOf": "Bunlardan biri olmalı: %{options}",
"regex": "Belirli bir formatla eşleşmelidir (regexp): %{pattern}"
},
"action": {
"add_filter": "Filtre ekle",
"add": "Ekle",
"back": "Geri Dön",
"bulk_actions": "1 seçildi |||| %{smart_count} seçildi",
"cancel": "İptal",
"clear_input_value": "Temizle",
"clone": "Klonla",
"confirm": "Onayla",
"create": "Oluştur",
"delete": "Sil",
"edit": "Düzenle",
"export": "Dışa aktar",
"list": "Listele",
"refresh": "Yenile",
"remove_filter": "Filtreyi kaldır",
"remove": "Kaldır",
"save": "Kaydet",
"search": "Ara",
"show": "Göster",
"sort": "Sırala",
"undo": "Geri al",
"expand": "Genişlettir",
"close": "Kapat",
"open_menu": "Menüyü aç",
"close_menu": "Menüyü kapat"
},
"boolean": {
"true": "Evet",
"false": "Hayır"
},
"page": {
"create": "%{name} oluştur",
"dashboard": "Ana Sayfa",
"edit": "%{name} #%{id}",
"error": "Bazı şeyler yolunda değil",
"list": "%{name} listesi",
"loading": "Yükleniyor",
"not_found": "Sayfa bulunamadı",
"show": "%{name} #%{id}",
"empty": "Henüz %{name} yok.",
"invite": "Bir tane eklemek ister misin?"
},
"input": {
"file": {
"upload_several": "Yüklemek istediğiniz dosyaları buraya sürükleyin ya da seçmek için tıklayın.",
"upload_single": "Yüklemek istediğiniz dosyayı buraya sürükleyin ya da seçmek için tıklayın.."
},
"image": {
"upload_several": "Yüklemek istediğiniz resimleri buraya sürükleyin ya da seçmek için tıklayın.",
"upload_single": "Yüklemek istediğiniz resmi buraya sürükleyin ya da seçmek için tıklayın."
},
"references": {
"all_missing": "Referans verileri bulunamadı.",
"many_missing": "İlişkilendirilmiş referanslardan en az biri artık mevcut değil.",
"single_missing": "İlişkilendirilmiş referans artık mevcut değil."
},
"password": {
"toggle_visible": "Şifreyi gizle",
"toggle_hidden": "Şifreyi göster"
}
},
"message": {
"about": "Hakkında",
"are_you_sure": "Emin misiniz?",
"bulk_delete_content": "%{name} silmek istediğinizden emin misiniz? |||| %{smart_count} öğeyi silmek istediğinizden emin misiniz?",
"bulk_delete_title": "%{name} sil |||| %{smart_count} %{name} öğesi sil",
"delete_content": "Bu öğeyi silmek istediğinizden emin misiniz?",
"delete_title": "%{name} #%{id} Sil",
"details": "Detaylar",
"error": "Bir istemci hatası oluştu ve isteğiniz tamamlanamadı.",
"invalid_form": "Form geçerli değil. Lütfen hataları kontrol edin",
"loading": "Sayfa yükleniyor, lütfen bekleyiniz",
"no": "Hayır",
"not_found": "Hatalı bir URL girdiniz ya da yanlış bir linke tıkladınız",
"yes": "Evet",
"unsaved_changes": "Yaptığın değişikliklerin bazıları kaydedilmedi. Onları yoksaymak istediğinizden emin misin?"
},
"navigation": {
"no_results": "Kayıt bulunamadı",
"no_more_results": "%{page} sayfası mevcut değil. Önceki sayfayı deneyin.",
"page_out_of_boundaries": "%{page} sayfası mevcut değil",
"page_out_from_end": "Son sayfadan ileri gidemezsin",
"page_out_from_begin": "1. sayfadan geri gidemezsin",
"page_range_info": "%{offsetBegin}-%{offsetEnd} of %{total}",
"page_rows_per_page": "Sayfa başına kayıtlar",
"next": "Sonraki",
"prev": "Önceki"
},
"notification": {
"updated": "Öğe güncellendi |||| %{smart_count} öğe güncellendi",
"created": "Öğe oluşturuldu",
"deleted": "Öğe silindi |||| %{smart_count} öğe silindi",
"bad_item": "Hatalı öğe",
"item_doesnt_exist": "Öğe bulunamadı",
"http_error": "Sunucu iletişim hatası",
"data_provider_error": "dataProvider hatası. Detay için konsolu gözden geçir.",
"i18n_error": "Belirtilen dil için çeviriler yüklenemedi",
"canceled": "Eylem iptal edildi",
"logged_out": "Oturumunuz sona erdi, Lütfen yeniden bağlanın."
}
},
"message": {
"note": "NOT",
"transcodingDisabled": "Transcoding ayarlari web arayüzü üzerinden değiştirilmesi güvenlik nedeniyle devre dışı bırakılmıştır. Kod dönüştürme seçeneklerini değiştirmek (düzenlemek veya eklemek) istiyorsan, %{config} seçeneğiyle sunucuyu yeniden başlatın.",
"transcodingEnabled": "Navidrome şu anda %{config} ile çalışıyor, web arayüzünü kullanarak kod dönüştürme ayarlarından sistem komutlarını çalıştırmayı mümkün kılıyor. Güvenlik nedeniyle devre dışı bırakmanızı ve yalnızca Kod Dönüştürme seçeneklerini yapılandırırken etkinleştirmenizi öneririz."
},
"menu": {
"library": "Müzik kütüphanesi",
"settings": "Ayarlar",
"version": "Sürüm %{version}",
"theme": "Tema",
"personal": {
"name": "Kişisel",
"options": {
"theme": "Tema",
"language": "Dil"
}
}
},
"player": {
"playListsText": "Oynatma Sırası",
"openText": "Aç",
"closeText": "Kapat",
"notContentText": "Müzik yok",
"clickToPlayText": "Oynatmak için tıkla",
"clickToPauseText": "Duraklatmak için tıkla",
"nextTrackText": "Sonraki parça",
"previousTrackText": "Önceki parça",
"reloadText": "Tekrar yükle",
"volumeText": "Ses",
"toggleLyricText": "Şarkı sözü aç/kapat",
"toggleMiniModeText": "Küçült",
"destroyText": "Yık",
"downloadText": "İndir",
"removeAudioListsText": "Ses listelerini sil",
"clickToDeleteText": "%{name} silmek için tıkla",
"emptyLyricText": "Şarkı sözü yok",
"playModeText": {
"order": "Sırayla",
"orderLoop": "Tekrar et",
"singleLoop": "Birini tekrarla",
"shufflePlay": "Karıştır"
}
}
}

256
resources/i18n/zn.json Normal file
View File

@@ -0,0 +1,256 @@
{
"languageName": "简体中文",
"resources": {
"song": {
"name": "歌曲 |||| 曲库",
"fields": {
"albumArtist": "专辑歌手",
"duration": "时长",
"trackNumber": "音轨 #",
"playCount": "播放次数",
"title": "",
"artist": "",
"album": "",
"path": "",
"genre": "",
"compilation": "",
"year": "",
"size": "",
"updatedAt": ""
},
"actions": {
"addToQueue": "稍后播放",
"playNow": ""
}
},
"album": {
"name": "专辑 |||| 专辑",
"fields": {
"albumArtist": "专辑歌手",
"artist": "歌手",
"duration": "时长",
"songCount": "曲目数",
"playCount": "播放次数",
"name": "",
"genre": "",
"compilation": "",
"year": ""
},
"actions": {
"playAll": "播放",
"playNext": "播放下一首",
"addToQueue": "稍后播放",
"shuffle": "刷新"
}
},
"artist": {
"name": "歌手 |||| 歌手",
"fields": {
"name": "",
"albumCount": ""
}
},
"user": {
"name": "用户 |||| 用户",
"fields": {
"userName": "用户名",
"isAdmin": "",
"lastLoginAt": "",
"updatedAt": "",
"name": ""
}
},
"player": {
"name": "用户 |||| 用户",
"fields": {
"name": "",
"transcodingId": "",
"maxBitRate": "",
"client": "",
"userName": "",
"lastSeen": ""
}
},
"transcoding": {
"name": "转码 |||| 转码",
"fields": {
"name": "",
"targetFormat": "",
"defaultBitRate": "",
"command": ""
}
}
},
"ra": {
"auth": {
"welcome1": "感谢您安装Navidrome!",
"welcome2": "为了开始使用,请创建一个管理员账户",
"confirmPassword": "确认密码",
"buttonCreateAdmin": "创建管理员",
"auth_check_error": "",
"user_menu": "设置",
"username": "用户名",
"password": "密码",
"sign_in": "登录",
"sign_in_error": "验证失败, 请重试",
"logout": "退出"
},
"validation": {
"invalidChars": "请只使用字母和数字",
"passwordDoesNotMatch": "密码不匹配",
"required": "必填",
"minLength": "必须不少于 %{min} 个字符",
"maxLength": "必须不多于 %{max} 个字符",
"minValue": "必须不小于 %{min}",
"maxValue": "必须不大于 %{max}",
"number": "必须为数字",
"email": "必须是有效的邮箱",
"oneOf": "必须为: %{options}其中一项",
"regex": "必须符合指定的格式 (regexp): %{pattern}"
},
"action": {
"add_filter": "增加检索",
"add": "增加",
"back": "回退",
"bulk_actions": "选中%{smart_count}项",
"cancel": "取消",
"clear_input_value": "",
"clone": "",
"confirm": "",
"create": "新建",
"delete": "删除",
"edit": "编辑",
"export": "导出",
"list": "列表",
"refresh": "刷新",
"remove_filter": "移除检索",
"remove": "删除",
"save": "保存",
"search": "检索",
"show": "显示",
"sort": "排序",
"undo": "撤销",
"expand": "",
"close": "",
"open_menu": "",
"close_menu": ""
},
"boolean": {
"true": "是",
"false": "否"
},
"page": {
"create": "新建 %{name}",
"dashboard": "概览",
"edit": "%{name} #%{id}",
"error": "出现错误",
"list": "%{name} 列表",
"loading": "加载中",
"not_found": "未发现",
"show": "%{name} #%{id}",
"empty": "",
"invite": ""
},
"input": {
"file": {
"upload_several": "将文件集合拖拽到这里, 或点击这里选择文件集合.",
"upload_single": "将文件拖拽到这里, 或点击这里选择文件."
},
"image": {
"upload_several": "将图片文件集合拖拽到这里, 或点击这里选择图片文件集合.",
"upload_single": "将图片文件拖拽到这里, 或点击这里选择图片文件."
},
"references": {
"all_missing": "未找到参考数据.",
"many_missing": "至少有一条参考数据不再可用.",
"single_missing": "关联的参考数据不再可用."
},
"password": {
"toggle_visible": "",
"toggle_hidden": ""
}
},
"message": {
"about": "关于",
"are_you_sure": "您确定操作?",
"bulk_delete_content": "您确定要删除 %{name}? |||| 您确定要删除 %{smart_count} 项?",
"bulk_delete_title": "删除 %{name} |||| 删除 %{smart_count}项 %{name} ",
"delete_content": "您确定要删除该条目?",
"delete_title": "删除 %{name} #%{id}",
"details": "",
"error": "",
"invalid_form": "表单输入无效. 请检查错误提示",
"loading": "正在加载页面, 请稍候",
"no": "否",
"not_found": "您输入了错误的URL或者错误的链接.",
"yes": "是",
"unsaved_changes": ""
},
"navigation": {
"no_results": "结果为空",
"no_more_results": "页码 %{page} 超出边界. 试试上一页.",
"page_out_of_boundaries": "页码 %{page} 超出边界",
"page_out_from_end": "已到最末页",
"page_out_from_begin": "已到最前页",
"page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}",
"page_rows_per_page": "每页行数:",
"next": "向后",
"prev": "向前"
},
"notification": {
"updated": "条目已更新 |||| %{smart_count} 项条目已更新",
"created": "条目已新建",
"deleted": "条目已删除 |||| %{smart_count} 项条目已删除",
"bad_item": "不正确的条目",
"item_doesnt_exist": "条目不存在",
"http_error": "与服务通信出错",
"data_provider_error": "dataProvider错误. 请检查console的详细信息.",
"i18n_error": "",
"canceled": "取消动作",
"logged_out": ""
}
},
"message": {
"note": "",
"transcodingDisabled": "",
"transcodingEnabled": ""
},
"menu": {
"library": "曲库",
"settings": "设置",
"version": "版本 %{version}",
"theme": "主题",
"personal": {
"name": "个性化",
"options": {
"theme": "主题",
"language": "语言"
}
}
},
"player": {
"playListsText": "播放队列",
"openText": "打开",
"closeText": "关闭",
"notContentText": "无音乐",
"clickToPlayText": "点击播放",
"clickToPauseText": "点击暂停",
"nextTrackText": "下一首",
"previousTrackText": "上一首",
"reloadText": "Reload",
"volumeText": "音量",
"toggleLyricText": "切换歌词",
"toggleMiniModeText": "最小化",
"destroyText": "损坏",
"downloadText": "下载",
"removeAudioListsText": "清空播放列表",
"clickToDeleteText": "点击删除 %{name}",
"emptyLyricText": "无歌词",
"playModeText": {
"order": "顺序播放",
"orderLoop": "列表循环",
"singleLoop": "单曲循环",
"shufflePlay": "随机播放"
}
}
}

View File

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

View File

@@ -61,7 +61,12 @@ func (s *ChangeDetector) loadDir(dirPath string) (children []string, lastUpdated
return
}
for _, f := range files {
if f.IsDir() {
isDir, err := IsDirOrSymlinkToDir(dirPath, f)
// Skip invalid symlinks
if err != nil {
continue
}
if isDir {
children = append(children, filepath.Join(dirPath, f.Name()))
} else {
if f.ModTime().After(lastUpdated) {
@@ -72,6 +77,26 @@ func (s *ChangeDetector) loadDir(dirPath string) (children []string, lastUpdated
return
}
// IsDirOrSymlinkToDir returns true if and only if the Dirent represents a file
// system directory, or a symbolic link to a directory. Note that if the Dirent
// is not a directory but is a symbolic link, this method will resolve by
// sending a request to the operating system to follow the symbolic link.
// Copied from github.com/karrick/godirwalk
func IsDirOrSymlinkToDir(baseDir string, info os.FileInfo) (bool, error) {
if info.IsDir() {
return true, nil
}
if info.Mode()&os.ModeSymlink == 0 {
return false, nil
}
// Does this symlink point to a directory?
info, err := os.Stat(filepath.Join(baseDir, info.Name()))
if err != nil {
return false, err
}
return info.IsDir(), nil
}
func (s *ChangeDetector) loadMap(dirMap dirInfoMap, path string, since time.Time, maybe bool) error {
children, lastUpdated, err := s.loadDir(path)
if err != nil {

View File

@@ -103,13 +103,32 @@ var _ = Describe("ChangeDetector", func() {
Expect(changed).To(BeEmpty())
Expect(changed).To(BeEmpty())
f, err := os.Create(filepath.Join(testFolder, "a", "b", "new.txt"))
f.Close()
f, _ := os.Create(filepath.Join(testFolder, "a", "b", "new.txt"))
_ = f.Close()
changed, deleted, err = newScanner.Scan(lastModifiedSince)
Expect(err).To(BeNil())
Expect(deleted).To(BeEmpty())
Expect(changed).To(ConsistOf(P("a/b")))
})
Describe("IsDirOrSymlinkToDir", func() {
It("returns true for normal dirs", func() {
dir, _ := os.Stat("tests/fixtures")
Expect(IsDirOrSymlinkToDir("tests", dir)).To(BeTrue())
})
It("returns true for symlinks to dirs", func() {
dir, _ := os.Stat("tests/fixtures/symlink2dir")
Expect(IsDirOrSymlinkToDir("tests/fixtures", dir)).To(BeTrue())
})
It("returns false for files", func() {
dir, _ := os.Stat("tests/fixtures/test.mp3")
Expect(IsDirOrSymlinkToDir("tests/fixtures", dir)).To(BeFalse())
})
It("returns false for symlinks to files", func() {
dir, _ := os.Stat("tests/fixtures/symlink")
Expect(IsDirOrSymlinkToDir("tests/fixtures", dir)).To(BeFalse())
})
})
})
// I hate time-based tests....

View File

@@ -3,6 +3,7 @@ package scanner
import (
"bufio"
"errors"
"fmt"
"mime"
"os"
"os/exec"
@@ -24,10 +25,16 @@ type Metadata struct {
tags map[string]string
}
func (m *Metadata) Title() string { return m.getTag("title", "sort_name") }
func (m *Metadata) Album() string { return m.getTag("album", "sort_album") }
func (m *Metadata) Artist() string { return m.getTag("artist", "sort_artist") }
func (m *Metadata) AlbumArtist() string { return m.getTag("album_artist") }
func (m *Metadata) Title() string { return m.getTag("title", "sort_name") }
func (m *Metadata) Album() string { return m.getTag("album", "sort_album") }
func (m *Metadata) Artist() string { return m.getTag("artist", "sort_artist") }
func (m *Metadata) AlbumArtist() string { return m.getTag("album_artist", "albumartist") }
func (m *Metadata) SortTitle() string { return m.getSortTag("", "title", "name") }
func (m *Metadata) SortAlbum() string { return m.getSortTag("", "album") }
func (m *Metadata) SortArtist() string { return m.getSortTag("", "artist") }
func (m *Metadata) SortAlbumArtist() string {
return m.getSortTag("tso2", "albumartist", "album_artist")
}
func (m *Metadata) Composer() string { return m.getTag("composer", "tcm", "sort_composer") }
func (m *Metadata) Genre() string { return m.getTag("genre") }
func (m *Metadata) Year() int { return m.parseYear("date") }
@@ -77,7 +84,7 @@ func ExtractAllMetadata(inputs []string) (map[string]*Metadata, error) {
args := createProbeCommand(inputs)
log.Trace("Executing command", "args", args)
cmd := exec.Command(args[0], args[1:]...)
cmd := exec.Command(args[0], args[1:]...) // #nosec
output, _ := cmd.CombinedOutput()
mds := map[string]*Metadata{}
if len(output) == 0 {
@@ -99,7 +106,7 @@ var (
inputRegex = regexp.MustCompile(`(?m)^Input #\d+,.*,\sfrom\s'(.*)'`)
// TITLE : Back In Black
tagsRx = regexp.MustCompile(`(?i)^\s{4,6}(\w+)\s*:(.*)`)
tagsRx = regexp.MustCompile(`(?i)^\s{4,6}([\w-]+)\s*:(.*)`)
// Duration: 00:04:16.00, start: 0.000000, bitrate: 995 kb/s`
durationRx = regexp.MustCompile(`^\s\sDuration: ([\d.:]+).*bitrate: (\d+)`)
@@ -212,7 +219,7 @@ func (m *Metadata) parseYear(tagName string) int {
if v, ok := m.tags[tagName]; ok {
match := dateRegex.FindStringSubmatch(v)
if len(match) == 0 {
log.Error("Error parsing year from ffmpeg date field. Please report this issue", "file", m.filePath, "date", v)
log.Warn("Error parsing year from ffmpeg date field", "file", m.filePath, "date", v)
return 0
}
year, _ := strconv.Atoi(match[1])
@@ -230,6 +237,18 @@ func (m *Metadata) getTag(tags ...string) string {
return ""
}
func (m *Metadata) getSortTag(originalTag string, tags ...string) string {
formats := []string{"sort%s", "sort_%s", "sort-%s", "%ssort", "%s_sort", "%s-sort"}
all := []string{originalTag}
for _, tag := range tags {
for _, format := range formats {
name := fmt.Sprintf(format, tag)
all = append(all, name)
}
}
return m.getTag(all...)
}
func (m *Metadata) parseTuple(tags ...string) (int, int) {
for _, tagName := range tags {
if v, ok := m.tags[tagName]; ok {

View File

@@ -62,7 +62,7 @@ var _ = Describe("Metadata", func() {
})
It("returns empty map if there are no audio files in path", func() {
Expect(LoadAllAudioFiles(".")).To(BeEmpty())
Expect(LoadAllAudioFiles("tests/fixtures/empty_folder")).To(BeEmpty())
})
})
@@ -204,6 +204,30 @@ Tracklist:
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithMultilineComment)
Expect(md.Comment()).To(Equal(expectedComment))
})
It("parses sort tags correctly", func() {
const output = `
Input #0, mp3, from '/Users/deluan/Downloads/椎名林檎 - 加爾基 精液 栗ノ花 - 2003/02 - ドツペルゲンガー.mp3':
Metadata:
title-sort : Dopperugengā
album : 加爾基 精液 栗ノ花
artist : 椎名林檎
album_artist : 椎名林檎
title : ドツペルゲンガー
albumsort : Kalk Samen Kuri No Hana
artist_sort : Shiina, Ringo
ALBUMARTISTSORT : Shiina, Ringo
`
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
Expect(md.Title()).To(Equal("ドツペルゲンガー"))
Expect(md.Album()).To(Equal("加爾基 精液 栗ノ花"))
Expect(md.Artist()).To(Equal("椎名林檎"))
Expect(md.AlbumArtist()).To(Equal("椎名林檎"))
Expect(md.SortTitle()).To(Equal("Dopperugengā"))
Expect(md.SortAlbum()).To(Equal("Kalk Samen Kuri No Hana"))
Expect(md.SortArtist()).To(Equal("Shiina, Ringo"))
Expect(md.SortAlbumArtist()).To(Equal("Shiina, Ringo"))
})
})
Context("parseYear", func() {
@@ -231,7 +255,7 @@ Tracklist:
It("creates a valid command line", func() {
args := createProbeCommand([]string{"/music library/one.mp3", "/music library/two.mp3"})
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/one.mp3", "-i", "/music library/two.mp3", "-f", "ffmetadata" }))
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/one.mp3", "-i", "/music library/two.mp3", "-f", "ffmetadata"}))
})
})

View File

@@ -34,7 +34,7 @@ func (s *Scanner) Rescan(mediaFolder string, fullRescan bool) error {
log.Debug("Scanning folder (full scan)", "folder", mediaFolder)
}
err := folderScanner.Scan(log.NewContext(nil), lastModifiedSince)
err := folderScanner.Scan(log.NewContext(context.TODO()), lastModifiedSince)
if err != nil {
log.Error("Error importing MediaFolder", "folder", mediaFolder, err)
}
@@ -59,7 +59,7 @@ func (s *Scanner) RescanAll(fullRescan bool) error {
func (s *Scanner) Status() []StatusInfo { return nil }
func (s *Scanner) getLastModifiedSince(folder string) time.Time {
ms, err := s.ds.Property(nil).Get(model.PropLastScan + "-" + folder)
ms, err := s.ds.Property(context.TODO()).Get(model.PropLastScan + "-" + folder)
if err != nil {
return time.Time{}
}
@@ -72,11 +72,13 @@ func (s *Scanner) getLastModifiedSince(folder string) time.Time {
func (s *Scanner) updateLastModifiedSince(folder string, t time.Time) {
millis := t.UnixNano() / int64(time.Millisecond)
s.ds.Property(nil).Put(model.PropLastScan+"-"+folder, fmt.Sprint(millis))
if err := s.ds.Property(context.TODO()).Put(model.PropLastScan+"-"+folder, fmt.Sprint(millis)); err != nil {
log.Error("Error updating DB after scan", err)
}
}
func (s *Scanner) loadFolders() {
fs, _ := s.ds.MediaFolder(nil).GetAll()
fs, _ := s.ds.MediaFolder(context.TODO()).GetAll()
for _, f := range fs {
log.Info("Configuring Media Folder", "name", f.Name, "path", f.Path)
s.folders[f.Path] = NewTagScanner(f.Path, s.ds)
@@ -85,12 +87,6 @@ func (s *Scanner) loadFolders() {
type Status int
const (
StatusComplete Status = iota
StatusInProgress
StatusError
)
type StatusInfo struct {
MediaFolder string
Status Status

View File

@@ -13,6 +13,8 @@ import (
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/utils"
"github.com/kennygrant/sanitize"
)
type TagScanner struct {
@@ -111,7 +113,7 @@ func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time) erro
return err
}
err = s.ds.GC(log.NewContext(nil))
err = s.ds.GC(log.NewContext(context.TODO()))
log.Info("Finished Music Folder", "folder", s.rootFolder, "elapsed", time.Since(start))
return err
@@ -241,7 +243,7 @@ func (s *TagScanner) loadTracks(filePaths []string) (model.MediaFiles, error) {
}
func (s *TagScanner) toMediaFile(md *Metadata) model.MediaFile {
mf := model.MediaFile{}
mf := &model.MediaFile{}
mf.ID = s.trackID(md)
mf.Title = s.mapTrackTitle(md)
mf.Album = md.Album()
@@ -262,12 +264,25 @@ func (s *TagScanner) toMediaFile(md *Metadata) model.MediaFile {
mf.Suffix = md.Suffix()
mf.Size = md.Size()
mf.HasCoverArt = md.HasPicture()
mf.SortTitle = md.SortTitle()
mf.SortAlbumName = md.SortAlbum()
mf.SortArtistName = md.SortArtist()
mf.SortAlbumArtistName = md.SortAlbumArtist()
mf.OrderAlbumName = sanitizeFieldForSorting(mf.Album)
mf.OrderArtistName = sanitizeFieldForSorting(mf.Artist)
mf.OrderAlbumArtistName = sanitizeFieldForSorting(mf.AlbumArtist)
// TODO Get Creation time. https://github.com/djherbis/times ?
mf.CreatedAt = md.ModificationTime()
mf.UpdatedAt = md.ModificationTime()
return mf
return *mf
}
func sanitizeFieldForSorting(originalValue string) string {
v := utils.NoArticle(originalValue)
v = strings.TrimSpace(sanitize.Accents(v))
return utils.NoArticle(v)
}
func (s *TagScanner) mapTrackTitle(md *Metadata) string {

View File

@@ -0,0 +1,21 @@
package scanner
import (
"github.com/deluan/navidrome/conf"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("TagScanner", func() {
Describe("sanitizeFieldForSorting", func() {
BeforeEach(func() {
conf.Server.IgnoredArticles = "The"
})
It("sanitize accents", func() {
Expect(sanitizeFieldForSorting("Céu")).To(Equal("Ceu"))
})
It("removes articles", func() {
Expect(sanitizeFieldForSorting("The Beatles")).To(Equal("Beatles"))
})
})
})

View File

@@ -7,6 +7,7 @@ import (
"strings"
"github.com/deluan/navidrome/assets"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/engine/auth"
"github.com/deluan/navidrome/model"
"github.com/deluan/rest"
@@ -41,15 +42,16 @@ func (app *Router) routes(path string) http.Handler {
r.Use(mapAuthHeader())
r.Use(jwtauth.Verifier(auth.TokenAuth))
r.Use(authenticator(app.ds))
app.R(r, "/user", model.User{})
app.R(r, "/song", model.MediaFile{})
app.R(r, "/album", model.Album{})
app.R(r, "/artist", model.Artist{})
app.R(r, "/transcoding", model.Transcoding{})
app.R(r, "/player", model.Player{})
app.R(r, "/user", model.User{}, true)
app.R(r, "/song", model.MediaFile{}, true)
app.R(r, "/album", model.Album{}, true)
app.R(r, "/artist", model.Artist{}, true)
app.R(r, "/player", model.Player{}, true)
app.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
app.addResource(r, "/translation", newTranslationRepository, false)
// Keepalive endpoint to be used to keep the session valid (ex: while playing songs)
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"response":"ok"}`)) })
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"response":"ok"}`)) })
})
// Serve UI app assets
@@ -59,18 +61,26 @@ func (app *Router) routes(path string) http.Handler {
return r
}
func (app *Router) R(r chi.Router, pathPrefix string, model interface{}) {
func (app *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) {
constructor := func(ctx context.Context) rest.Repository {
return app.ds.Resource(ctx, model)
}
app.addResource(r, pathPrefix, constructor, persistable)
}
func (app *Router) addResource(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) {
r.Route(pathPrefix, func(r chi.Router) {
r.Get("/", rest.GetAll(constructor))
r.Post("/", rest.Post(constructor))
r.Route("/{id:[0-9a-f\\-]+}", func(r chi.Router) {
if persistable {
r.Post("/", rest.Post(constructor))
}
r.Route("/{id}", func(r chi.Router) {
r.Use(UrlParams)
r.Get("/", rest.Get(constructor))
r.Put("/", rest.Put(constructor))
r.Delete("/", rest.Delete(constructor))
if persistable {
r.Put("/", rest.Put(constructor))
r.Delete("/", rest.Delete(constructor))
}
})
})
}

View File

@@ -6,7 +6,6 @@ import (
"errors"
"net/http"
"strings"
"sync"
"time"
"github.com/deluan/navidrome/consts"
@@ -20,7 +19,6 @@ import (
)
var (
once sync.Once
ErrFirstTime = errors.New("no users created")
)
@@ -31,7 +29,7 @@ func Login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) {
username, password, err := getCredentialsFromBody(r)
if err != nil {
log.Error(r, "Parsing request body", err)
rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error())
_ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error())
return
}
@@ -42,21 +40,21 @@ func Login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) {
func handleLogin(ds model.DataStore, username string, password string, w http.ResponseWriter, r *http.Request) {
user, err := validateLogin(ds.User(r.Context()), username, password)
if err != nil {
rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authentication user. Please try again")
_ = rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authentication user. Please try again")
return
}
if user == nil {
log.Warn(r, "Unsuccessful login", "username", username, "request", r.Header)
rest.RespondWithError(w, http.StatusUnauthorized, "Invalid username or password")
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Invalid username or password")
return
}
tokenString, err := auth.CreateToken(user)
if err != nil {
rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authenticating user. Please try again")
_ = rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authenticating user. Please try again")
return
}
rest.RespondWithJSON(w, http.StatusOK,
_ = rest.RespondWithJSON(w, http.StatusOK,
map[string]interface{}{
"message": "User '" + username + "' authenticated successfully",
"token": tokenString,
@@ -71,7 +69,7 @@ func getCredentialsFromBody(r *http.Request) (username string, password string,
decoder := json.NewDecoder(r.Body)
if err = decoder.Decode(&data); err != nil {
log.Error(r, "parsing request body", err)
err = errors.New("Invalid request payload")
err = errors.New("invalid request payload")
return
}
username = data["username"]
@@ -86,21 +84,21 @@ func CreateAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request
username, password, err := getCredentialsFromBody(r)
if err != nil {
log.Error(r, "parsing request body", err)
rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error())
_ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error())
return
}
c, err := ds.User(r.Context()).CountAll()
if err != nil {
rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
return
}
if c > 0 {
rest.RespondWithError(w, http.StatusForbidden, "Cannot create another first admin")
_ = rest.RespondWithError(w, http.StatusForbidden, "Cannot create another first admin")
return
}
err = createDefaultUser(r.Context(), ds, username, password)
if err != nil {
rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
return
}
handleLogin(ds, username, password, w, r)
@@ -186,11 +184,11 @@ func authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, err := getToken(ds, r.Context())
if err == ErrFirstTime {
rest.RespondWithJSON(w, http.StatusUnauthorized, map[string]string{"message": ErrFirstTime.Error()})
_ = rest.RespondWithJSON(w, http.StatusUnauthorized, map[string]string{"message": ErrFirstTime.Error()})
return
}
if err != nil {
rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
return
}
@@ -200,7 +198,7 @@ func authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
newTokenString, err := auth.TouchToken(token)
if err != nil {
log.Error(r, "signing new token", err)
rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
return
}

View File

@@ -13,7 +13,7 @@ import (
"github.com/deluan/navidrome/model"
)
// Injects the `firstTime` config in the `index.html` template
// Injects the config in the `index.html` template
func ServeIndex(ds model.DataStore, fs http.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c, err := ds.User(r.Context()).CountAll()
@@ -21,13 +21,22 @@ func ServeIndex(ds model.DataStore, fs http.FileSystem) http.HandlerFunc {
t := getIndexTemplate(r, fs)
appConfig := map[string]interface{}{
"version": consts.Version(),
"firstTime": firstTime,
"baseURL": strings.TrimSuffix(conf.Server.BaseURL, "/"),
"loginBackgroundURL": conf.Server.UILoginBackgroundURL,
if err != nil {
log.Error("Error loading default English translation file", err)
}
appConfig := map[string]interface{}{
"version": consts.Version(),
"firstTime": firstTime,
"baseURL": strings.TrimSuffix(conf.Server.BaseURL, "/"),
"loginBackgroundURL": conf.Server.UILoginBackgroundURL,
"enableTranscodingConfig": conf.Server.EnableTranscodingConfig,
}
j, err := json.Marshal(appConfig)
if err != nil {
log.Error(r, "Error converting config to JSON", "config", appConfig, err)
} else {
log.Trace(r, "Injecting config in index.html", "config", string(j))
}
j, _ := json.Marshal(appConfig)
data := map[string]interface{}{
"AppConfig": string(j),

124
server/app/translations.go Normal file
View File

@@ -0,0 +1,124 @@
package app
import (
"bytes"
"context"
"encoding/json"
"path/filepath"
"strings"
"sync"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/resources"
"github.com/deluan/rest"
)
const i18nFolder = "i18n"
type translation struct {
ID string `json:"id"`
Name string `json:"name"`
Data string `json:"data"`
}
var (
once sync.Once
translations map[string]translation
)
func newTranslationRepository(context.Context) rest.Repository {
if err := loadTranslations(); err != nil {
log.Error("Error loading translation files", err)
}
return &translationRepository{}
}
type translationRepository struct{}
func (r *translationRepository) Read(id string) (interface{}, error) {
if t, ok := translations[id]; ok {
return t, nil
}
return nil, rest.ErrNotFound
}
// Simple Count implementation. Does not support any `options`
func (r *translationRepository) Count(options ...rest.QueryOptions) (int64, error) {
return int64(len(translations)), nil
}
// Simple ReadAll implementation, only returns IDs. Does not support any `options`
func (r *translationRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
var result []translation
for _, t := range translations {
t.Data = ""
result = append(result, t)
}
return result, nil
}
func (r *translationRepository) EntityName() string {
return "translation"
}
func (r *translationRepository) NewInstance() interface{} {
return &translation{}
}
func loadTranslations() (loadError error) {
once.Do(func() {
translations = make(map[string]translation)
dir, err := resources.AssetFile().Open(i18nFolder)
if err != nil {
loadError = err
return
}
files, err := dir.Readdir(0)
if err != nil {
loadError = err
return
}
var languages []string
for _, f := range files {
t, err := loadTranslation(f.Name())
if err != nil {
log.Error("Error loading translation file", "file", f.Name(), err)
continue
}
translations[t.ID] = t
languages = append(languages, t.ID)
}
log.Info("Loading translations", "languages", languages)
})
return
}
func loadTranslation(fileName string) (translation translation, err error) {
// Get id and full path
name := filepath.Base(fileName)
id := strings.TrimSuffix(name, filepath.Ext(name))
filePath := filepath.Join(i18nFolder, name)
// Load translation from json file
data, err := resources.Asset(filePath)
if err != nil {
return
}
var out map[string]interface{}
if err = json.Unmarshal(data, &out); err != nil {
return
}
// Compress JSON
buf := new(bytes.Buffer)
if err = json.Compact(buf, data); err != nil {
return
}
translation.Data = buf.String()
translation.Name = out["languageName"].(string)
translation.ID = id
return
}
var _ rest.Repository = (*translationRepository)(nil)

View File

@@ -1,6 +1,7 @@
package server
import (
"context"
"encoding/json"
"fmt"
"time"
@@ -18,7 +19,8 @@ func initialSetup(ds model.DataStore) {
return err
}
_, err := ds.Property(nil).Get(consts.InitialSetupFlagKey)
properties := ds.Property(context.TODO())
_, err := properties.Get(consts.InitialSetupFlagKey)
if err == nil {
return nil
}
@@ -33,13 +35,14 @@ func initialSetup(ds model.DataStore) {
}
}
err = ds.Property(nil).Put(consts.InitialSetupFlagKey, time.Now().String())
err = properties.Put(consts.InitialSetupFlagKey, time.Now().String())
return err
})
}
func createInitialAdminUser(ds model.DataStore) error {
c, err := ds.User(nil).CountAll()
users := ds.User(context.TODO())
c, err := users.CountAll()
if err != nil {
panic(fmt.Sprintf("Could not access User table: %s", err))
}
@@ -59,7 +62,7 @@ func createInitialAdminUser(ds model.DataStore) error {
Password: initialPassword,
IsAdmin: true,
}
err := ds.User(nil).Put(&initialUser)
err := users.Put(&initialUser)
if err != nil {
log.Error("Could not create initial admin user", "user", initialUser, err)
}
@@ -68,13 +71,14 @@ func createInitialAdminUser(ds model.DataStore) error {
}
func createJWTSecret(ds model.DataStore) error {
_, err := ds.Property(nil).Get(consts.JWTSecretKey)
properties := ds.Property(context.TODO())
_, err := properties.Get(consts.JWTSecretKey)
if err == nil {
return nil
}
jwtSecret, _ := uuid.NewRandom()
log.Warn("Creating JWT secret, used for encrypting UI sessions")
err = ds.Property(nil).Put(consts.JWTSecretKey, jwtSecret.String())
err = properties.Put(consts.JWTSecretKey, jwtSecret.String())
if err != nil {
log.Error("Could not save JWT secret in DB", err)
}
@@ -82,8 +86,8 @@ func createJWTSecret(ds model.DataStore) error {
}
func createDefaultTranscodings(ds model.DataStore) error {
repo := ds.Transcoding(nil)
c, _ := repo.CountAll()
transcodings := ds.Transcoding(context.TODO())
c, _ := transcodings.CountAll()
if c != 0 {
return nil
}
@@ -98,7 +102,7 @@ func createDefaultTranscodings(ds model.DataStore) error {
return err
}
log.Info("Creating default transcoding config", "name", t.Name)
if err = repo.Put(&t); err != nil {
if err = transcodings.Put(&t); err != nil {
return err
}
}

View File

@@ -1,7 +1,6 @@
package subsonic
import (
"context"
"errors"
"net/http"
@@ -12,37 +11,45 @@ import (
)
type AlbumListController struct {
listGen engine.ListGenerator
listFunctions map[string]strategy
listGen engine.ListGenerator
}
func NewAlbumListController(listGen engine.ListGenerator) *AlbumListController {
c := &AlbumListController{
listGen: listGen,
}
c.listFunctions = map[string]strategy{
"random": c.listGen.GetRandom,
"newest": c.listGen.GetNewest,
"recent": c.listGen.GetRecent,
"frequent": c.listGen.GetFrequent,
"highest": c.listGen.GetHighest,
"alphabeticalByName": c.listGen.GetByName,
"alphabeticalByArtist": c.listGen.GetByArtist,
"starred": c.listGen.GetStarred,
}
return c
}
type strategy func(ctx context.Context, offset int, size int) (engine.Entries, error)
func (c *AlbumListController) getAlbumList(r *http.Request) (engine.Entries, error) {
func (c *AlbumListController) getNewAlbumList(r *http.Request) (engine.Entries, error) {
typ, err := RequiredParamString(r, "type", "Required string parameter 'type' is not present")
if err != nil {
return nil, err
}
listFunc, found := c.listFunctions[typ]
if !found {
var filter engine.ListFilter
switch typ {
case "newest":
filter = engine.ByNewest()
case "recent":
filter = engine.ByRecent()
case "random":
filter = engine.ByRandom()
case "alphabeticalByName":
filter = engine.ByName()
case "alphabeticalByArtist":
filter = engine.ByArtist()
case "frequent":
filter = engine.ByFrequent()
case "starred":
filter = engine.ByStarred()
case "highest":
filter = engine.ByRating()
case "byGenre":
filter = engine.ByGenre(utils.ParamString(r, "genre"))
case "byYear":
filter = engine.ByYear(utils.ParamInt(r, "fromYear", 0), utils.ParamInt(r, "toYear", 0))
default:
log.Error(r, "albumList type not implemented", "type", typ)
return nil, errors.New("Not implemented!")
}
@@ -50,7 +57,7 @@ func (c *AlbumListController) getAlbumList(r *http.Request) (engine.Entries, err
offset := utils.ParamInt(r, "offset", 0)
size := utils.MinInt(utils.ParamInt(r, "size", 10), 500)
albums, err := listFunc(r.Context(), offset, size)
albums, err := c.listGen.GetAlbums(r.Context(), offset, size, filter)
if err != nil {
log.Error(r, "Error retrieving albums", "error", err)
return nil, errors.New("Internal Error")
@@ -60,7 +67,7 @@ func (c *AlbumListController) getAlbumList(r *http.Request) (engine.Entries, err
}
func (c *AlbumListController) GetAlbumList(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
albums, err := c.getAlbumList(r)
albums, err := c.getNewAlbumList(r)
if err != nil {
return nil, NewError(responses.ErrorGeneric, err.Error())
}
@@ -71,7 +78,7 @@ func (c *AlbumListController) GetAlbumList(w http.ResponseWriter, r *http.Reques
}
func (c *AlbumListController) GetAlbumList2(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
albums, err := c.getAlbumList(r)
albums, err := c.getNewAlbumList(r)
if err != nil {
return nil, NewError(responses.ErrorGeneric, err.Error())
}
@@ -134,8 +141,27 @@ func (c *AlbumListController) GetNowPlaying(w http.ResponseWriter, r *http.Reque
func (c *AlbumListController) GetRandomSongs(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
size := utils.MinInt(utils.ParamInt(r, "size", 10), 500)
genre := utils.ParamString(r, "genre")
fromYear := utils.ParamInt(r, "fromYear", 0)
toYear := utils.ParamInt(r, "toYear", 0)
songs, err := c.listGen.GetRandomSongs(r.Context(), size, genre)
songs, err := c.listGen.GetSongs(r.Context(), 0, size, engine.SongsByRandom(genre, fromYear, toYear))
if err != nil {
log.Error(r, "Error retrieving random songs", "error", err)
return nil, NewError(responses.ErrorGeneric, "Internal Error")
}
response := NewResponse()
response.RandomSongs = &responses.Songs{}
response.RandomSongs.Songs = ToChildren(r.Context(), songs)
return response, nil
}
func (c *AlbumListController) GetSongsByGenre(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
count := utils.MinInt(utils.ParamInt(r, "count", 10), 500)
offset := utils.MinInt(utils.ParamInt(r, "offset", 0), 500)
genre := utils.ParamString(r, "genre")
songs, err := c.listGen.GetSongs(r.Context(), offset, count, engine.SongsByGenre(genre))
if err != nil {
log.Error(r, "Error retrieving random songs", "error", err)
return nil, NewError(responses.ErrorGeneric, "Internal Error")

View File

@@ -18,7 +18,7 @@ type fakeListGen struct {
recvSize int
}
func (lg *fakeListGen) GetNewest(ctx context.Context, offset int, size int) (engine.Entries, error) {
func (lg *fakeListGen) GetAlbums(ctx context.Context, offset int, size int, filter engine.ListFilter) (engine.Entries, error) {
if lg.err != nil {
return nil, lg.err
}

View File

@@ -5,15 +5,18 @@ import (
"encoding/xml"
"fmt"
"net/http"
"runtime"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/engine"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/server/subsonic/responses"
"github.com/deluan/navidrome/utils"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
)
const Version = "1.8.0"
const Version = "1.10.2"
type Handler = func(http.ResponseWriter, *http.Request) (*responses.Subsonic, error)
@@ -35,7 +38,6 @@ type Router struct {
func New(browser engine.Browser, cover engine.Cover, listGenerator engine.ListGenerator, users engine.Users,
playlists engine.Playlists, ratings engine.Ratings, scrobbler engine.Scrobbler, search engine.Search,
streamer engine.MediaStreamer, players engine.Players) *Router {
r := &Router{Browser: browser, Cover: cover, ListGenerator: listGenerator, Playlists: playlists,
Ratings: ratings, Scrobbler: scrobbler, Search: search, Users: users, Streamer: streamer, Players: players}
r.mux = r.routes()
@@ -86,6 +88,7 @@ func (api *Router) routes() http.Handler {
H(withPlayer, "getStarred2", c.GetStarred2)
H(withPlayer, "getNowPlaying", c.GetNowPlaying)
H(withPlayer, "getRandomSongs", c.GetRandomSongs)
H(withPlayer, "getSongsByGenre", c.GetSongsByGenre)
})
r.Group(func(r chi.Router) {
c := initMediaAnnotationController(api)
@@ -115,8 +118,11 @@ func (api *Router) routes() http.Handler {
})
r.Group(func(r chi.Router) {
c := initMediaRetrievalController(api)
H(r, "getAvatar", c.GetAvatar)
H(r, "getCoverArt", c.GetCoverArt)
// configure request throttling
maxRequests := utils.MaxInt(2, runtime.NumCPU())
withThrottle := r.With(middleware.ThrottleBacklog(maxRequests, consts.RequestThrottleBacklogLimit, consts.RequestThrottleBacklogTimeout))
H(withThrottle, "getAvatar", c.GetAvatar)
H(withThrottle, "getCoverArt", c.GetCoverArt)
})
r.Group(func(r chi.Router) {
c := initStreamController(api)
@@ -155,7 +161,7 @@ func H(r chi.Router, path string, f Handler) {
func HGone(r chi.Router, path string) {
handle := func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(410)
w.Write([]byte("This endpoint will not be implemented"))
_, _ = w.Write([]byte("This endpoint will not be implemented"))
}
r.HandleFunc("/"+path, handle)
r.HandleFunc("/"+path+".view", handle)
@@ -200,5 +206,7 @@ func SendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub
} else {
log.Warn(r.Context(), "API: Failed response", "error", payload.Error.Code, "message", payload.Error.Message)
}
w.Write(response)
if _, err := w.Write(response); err != nil {
log.Error(r, "Error sending response to client", "payload", string(response), err)
}
}

View File

@@ -7,8 +7,8 @@ import (
"github.com/deluan/navidrome/engine"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/resources"
"github.com/deluan/navidrome/server/subsonic/responses"
"github.com/deluan/navidrome/static"
"github.com/deluan/navidrome/utils"
)
@@ -21,13 +21,13 @@ func NewMediaRetrievalController(cover engine.Cover) *MediaRetrievalController {
}
func (c *MediaRetrievalController) GetAvatar(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
f, err := static.AssetFile().Open("navidrome-310x310.png")
f, err := resources.AssetFile().Open("navidrome-310x310.png")
if err != nil {
log.Error(r, "Image not found", err)
return nil, NewError(responses.ErrorDataNotFound, "Avatar image not found")
}
defer f.Close()
io.Copy(w, f)
_, _ = io.Copy(w, f)
return nil, nil
}

View File

@@ -24,8 +24,8 @@ func (c *fakeCover) Get(ctx context.Context, id string, size int, out io.Writer)
}
c.recvId = id
c.recvSize = size
out.Write([]byte(c.data))
return nil
_, err := out.Write([]byte(c.data))
return err
}
var _ = Describe("MediaRetrievalController", func() {

View File

@@ -28,6 +28,7 @@ type Subsonic struct {
NowPlaying *NowPlaying `xml:"nowPlaying,omitempty" json:"nowPlaying,omitempty"`
Song *Child `xml:"song,omitempty" json:"song,omitempty"`
RandomSongs *Songs `xml:"randomSongs,omitempty" json:"randomSongs,omitempty"`
SongsByGenre *Songs `xml:"songsByGenre,omitempty" json:"songsByGenre,omitempty"`
Genres *Genres `xml:"genres,omitempty" json:"genres,omitempty"`
// ID3

View File

File diff suppressed because one or more lines are too long

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

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