mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-06 05:48:09 -05:00
Compare commits
228 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af5c2b5a42 | ||
|
|
62e7492357 | ||
|
|
53a4ea673b | ||
|
|
c530ccf138 | ||
|
|
fa5dc5af10 | ||
|
|
bbd3882a75 | ||
|
|
12b4a48842 | ||
|
|
37f7625c7d | ||
|
|
7612a55859 | ||
|
|
3d5a1cef92 | ||
|
|
552989a05b | ||
|
|
6a6fa3e3b5 | ||
|
|
c7ef4bd803 | ||
|
|
22507c9789 | ||
|
|
87feac041b | ||
|
|
f82df70302 | ||
|
|
364e699ac1 | ||
|
|
0798959be8 | ||
|
|
4209e14208 | ||
|
|
77dbafff0f | ||
|
|
db67c1277e | ||
|
|
7b0a8f47de | ||
|
|
16865f0fca | ||
|
|
5965459bb9 | ||
|
|
66818b25ec | ||
|
|
e7fab8bb7b | ||
|
|
8befe10ee6 | ||
|
|
218d14727a | ||
|
|
50a4ce6ba2 | ||
|
|
8130c05ccc | ||
|
|
15952a3c7f | ||
|
|
9a99a2bd49 | ||
|
|
c7b65509ae | ||
|
|
6b09dc7198 | ||
|
|
86ab35069d | ||
|
|
413292da6b | ||
|
|
694968c607 | ||
|
|
6dc70d6810 | ||
|
|
bfeb8ef6b3 | ||
|
|
ba28e9a109 | ||
|
|
2f7a3c5eda | ||
|
|
cb3ba23fce | ||
|
|
72cde6dfde | ||
|
|
751e42c705 | ||
|
|
ded9ab53e5 | ||
|
|
416b5c7d13 | ||
|
|
afb31c3eae | ||
|
|
dd57278ba2 | ||
|
|
2a3cd08f20 | ||
|
|
a7a0e23956 | ||
|
|
4cf43ed735 | ||
|
|
ebad96b8a4 | ||
|
|
e981ee27c0 | ||
|
|
965dbccd48 | ||
|
|
695f82a1a0 | ||
|
|
16afd3a490 | ||
|
|
67f2a89d89 | ||
|
|
bf1f93ef1a | ||
|
|
ebf7354df4 | ||
|
|
c0066ebd85 | ||
|
|
cd5bce7b16 | ||
|
|
d613b19306 | ||
|
|
a2d9aaeff8 | ||
|
|
49392e06a7 | ||
|
|
181cb8a2b7 | ||
|
|
31882abf6f | ||
|
|
0d8eaa2878 | ||
|
|
f4bffb1676 | ||
|
|
f21847308c | ||
|
|
9c3b14c5c4 | ||
|
|
8cd405d15e | ||
|
|
35bec14d4d | ||
|
|
321b3c5a64 | ||
|
|
b7e50f7731 | ||
|
|
2e9c81c3de | ||
|
|
49647423aa | ||
|
|
9f62533bb0 | ||
|
|
7d58f4469a | ||
|
|
974816f0a2 | ||
|
|
7665478a52 | ||
|
|
bde5be347b | ||
|
|
aae79b4561 | ||
|
|
ce0db8344b | ||
|
|
5987cd0c08 | ||
|
|
e7cf74d863 | ||
|
|
2ddd3acba6 | ||
|
|
028723f721 | ||
|
|
50ff8bcce7 | ||
|
|
e966d94c0b | ||
|
|
86fe1e3b2c | ||
|
|
eed54d7e10 | ||
|
|
ab36344d76 | ||
|
|
e5d03a3bdb | ||
|
|
30813cd34b | ||
|
|
6164f37c9e | ||
|
|
9e79b5cbf2 | ||
|
|
8c707b4e0c | ||
|
|
910091f1f1 | ||
|
|
2e1b985d30 | ||
|
|
100b80528e | ||
|
|
bde9d5f954 | ||
|
|
69615f1aa1 | ||
|
|
a99b9b4d44 | ||
|
|
9892524ab8 | ||
|
|
9fe978953c | ||
|
|
5425c1a4d7 | ||
|
|
afe1e4cfcd | ||
|
|
20cdd38fc4 | ||
|
|
913a4cf546 | ||
|
|
121ada5acd | ||
|
|
e59a95c9eb | ||
|
|
d75f286ae8 | ||
|
|
30d3f1eda0 | ||
|
|
6a1f9678b1 | ||
|
|
a0977ce48a | ||
|
|
b3d8038686 | ||
|
|
0714f08274 | ||
|
|
cbeaadf8e2 | ||
|
|
3e282df639 | ||
|
|
ce7940bbfc | ||
|
|
92c31c961d | ||
|
|
4bf4765442 | ||
|
|
6d947f6f7e | ||
|
|
8c7d95c135 | ||
|
|
d4447e373f | ||
|
|
3bd6f82c80 | ||
|
|
da26c5cfe0 | ||
|
|
023d7bfa8a | ||
|
|
48a627885c | ||
|
|
91b470c93b | ||
|
|
1c82bf5179 | ||
|
|
0d9dcebf32 | ||
|
|
5994c31f4c | ||
|
|
804fb716db | ||
|
|
d3a2f769b7 | ||
|
|
68a84ec832 | ||
|
|
9e48d87f84 | ||
|
|
9712a5b1c6 | ||
|
|
9422373be0 | ||
|
|
bc8132ef1f | ||
|
|
82bc8cd0aa | ||
|
|
1e5ab59df8 | ||
|
|
28ad91a9d6 | ||
|
|
e40e86590c | ||
|
|
12818fb590 | ||
|
|
aaeaa3c590 | ||
|
|
053909196c | ||
|
|
6a87fc88f7 | ||
|
|
975986ab16 | ||
|
|
778f474d26 | ||
|
|
b2acec0a09 | ||
|
|
e7202339af | ||
|
|
8c2e4da396 | ||
|
|
a4d3bf42a7 | ||
|
|
765557d739 | ||
|
|
86afd16cc8 | ||
|
|
133fed344f | ||
|
|
84bbcdbfc2 | ||
|
|
1823159b25 | ||
|
|
0b5ed9eb80 | ||
|
|
a56d5bc850 | ||
|
|
ccc871d1f7 | ||
|
|
d3e142233b | ||
|
|
a42aeff88d | ||
|
|
7cdbc04c5e | ||
|
|
f3fae7e233 | ||
|
|
074732b1dc | ||
|
|
66a9cbb7d9 | ||
|
|
fa3471f527 | ||
|
|
9072412812 | ||
|
|
cca32360db | ||
|
|
85d48478e8 | ||
|
|
2183eb6498 | ||
|
|
ea435d0f60 | ||
|
|
f645c4769c | ||
|
|
5e87280750 | ||
|
|
526b6597c8 | ||
|
|
5dce499d6d | ||
|
|
fbd87ba577 | ||
|
|
63b5191ea7 | ||
|
|
af00503b77 | ||
|
|
85185e3b98 | ||
|
|
83eaafcbfb | ||
|
|
93ce0b5683 | ||
|
|
47549ecfc1 | ||
|
|
ed1ca65ad5 | ||
|
|
8d6b5f9d02 | ||
|
|
76fdcd112b | ||
|
|
18e1c169f9 | ||
|
|
4bc4daa68f | ||
|
|
cc1659aa73 | ||
|
|
31c598de07 | ||
|
|
2e2a647e67 | ||
|
|
d169f54e7d | ||
|
|
1494be9aaa | ||
|
|
c73f64ee3a | ||
|
|
806b13cf42 | ||
|
|
2c860edeb5 | ||
|
|
6a550dab77 | ||
|
|
3972616585 | ||
|
|
d0ce030386 | ||
|
|
947353610c | ||
|
|
2b57b98a4b | ||
|
|
1a96e9fe65 | ||
|
|
21da1df4ea | ||
|
|
d21932bd1b | ||
|
|
c72add516a | ||
|
|
d200933b68 | ||
|
|
943082ef4e | ||
|
|
c3fb4e1282 | ||
|
|
9c8f779f42 | ||
|
|
815623715e | ||
|
|
7221b49b98 | ||
|
|
cf8d08ec26 | ||
|
|
2a756eab88 | ||
|
|
104679ca6e | ||
|
|
5621551dd0 | ||
|
|
3214783ce9 | ||
|
|
34b01c2cbf | ||
|
|
0d2a8f5338 | ||
|
|
b7fedddfd8 | ||
|
|
1d742cf8c7 | ||
|
|
7505b5c554 | ||
|
|
174ad9e9da | ||
|
|
ba0ee6aba4 | ||
|
|
6b38acad49 | ||
|
|
ee8943f338 | ||
|
|
86a87b4bb1 |
@@ -4,16 +4,18 @@
|
||||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
|
||||
"VARIANT": "1.17",
|
||||
"VARIANT": "1.19",
|
||||
// Options
|
||||
"INSTALL_NODE": "true",
|
||||
"NODE_VERSION": "v16"
|
||||
}
|
||||
},
|
||||
"workspaceMount": "",
|
||||
"runArgs": [
|
||||
"--cap-add=SYS_PTRACE",
|
||||
"--security-opt",
|
||||
"seccomp=unconfined"
|
||||
"seccomp=unconfined",
|
||||
"--volume=${localWorkspaceFolder}:/workspaces/${localWorkspaceFolderBasename}:Z"
|
||||
],
|
||||
// Set *default* container specific settings.json values on container create.
|
||||
"settings": {
|
||||
|
||||
54
.github/workflows/download-link-on-pr.yml
vendored
Normal file
54
.github/workflows/download-link-on-pr.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
name: Add download link to PR
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Test workflow with upload']
|
||||
types: [completed]
|
||||
jobs:
|
||||
pr_comment:
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v3
|
||||
with:
|
||||
# This snippet is public-domain, taken from
|
||||
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
|
||||
script: |
|
||||
const {owner, repo} = context.repo;
|
||||
const run_id = ${{github.event.workflow_run.id}};
|
||||
const pull_head_sha = '${{github.event.workflow_run.head_sha}}';
|
||||
const pull_user_id = ${{github.event.sender.id}};
|
||||
|
||||
const issue_number = await (async () => {
|
||||
const pulls = await github.pulls.list({owner, repo});
|
||||
for await (const {data} of github.paginate.iterator(pulls)) {
|
||||
for (const pull of data) {
|
||||
if (pull.head.sha === pull_head_sha && pull.user.id === pull_user_id) {
|
||||
return pull.number;
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
if (issue_number) {
|
||||
core.info(`Using pull request ${issue_number}`);
|
||||
} else {
|
||||
return core.error(`No matching pull request found`);
|
||||
}
|
||||
|
||||
const {data: {artifacts}} = await github.actions.listWorkflowRunArtifacts({owner, repo, run_id});
|
||||
if (!artifacts.length) {
|
||||
return core.error(`No artifacts found`);
|
||||
}
|
||||
let body = `Download the artifacts for this pull request:\n`;
|
||||
for (const art of artifacts) {
|
||||
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
|
||||
}
|
||||
|
||||
const {data: comments} = await github.issues.listComments({repo, owner, issue_number});
|
||||
const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]');
|
||||
if (existing_comment) {
|
||||
core.info(`Updating comment ${existing_comment.id}`);
|
||||
await github.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
|
||||
} else {
|
||||
core.info(`Creating a comment`);
|
||||
await github.issues.createComment({repo, owner, issue_number, body});
|
||||
}
|
||||
2
.github/workflows/pipeline.dockerfile
vendored
2
.github/workflows/pipeline.dockerfile
vendored
@@ -6,7 +6,7 @@ ARG TARGETPLATFORM
|
||||
RUN echo "Target Platform = ${TARGETPLATFORM}"
|
||||
|
||||
COPY dist .
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then cp navidrome_linux_amd64_linux_amd64/navidrome /navidrome; fi
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then cp navidrome_linux_amd64_linux_amd64_v1/navidrome /navidrome; fi
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/386" ]; then cp navidrome_linux_386_linux_386/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
|
||||
|
||||
29
.github/workflows/pipeline.yml
vendored
29
.github/workflows/pipeline.yml
vendored
@@ -16,18 +16,18 @@ jobs:
|
||||
- name: Install taglib
|
||||
run: sudo apt-get install libtag1-dev
|
||||
|
||||
- name: Set up Go 1.17
|
||||
uses: actions/setup-go@v2
|
||||
- name: Set up Go 1.19
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.17
|
||||
go-version: 1.19
|
||||
id: go
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v2
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
with:
|
||||
version: v1.40
|
||||
version: latest
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
args: --timeout 2m
|
||||
|
||||
@@ -48,20 +48,19 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
go_version: [1.16.x, 1.17.x]
|
||||
go_version: [1.18.x,1.19.x]
|
||||
steps:
|
||||
- name: Install taglib
|
||||
run: sudo apt-get install libtag1-dev
|
||||
|
||||
- name: Set up Go ${{ matrix.go_version }}
|
||||
uses: actions/setup-go@v2
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
stable: '!contains(${{ matrix.go_version }}, "beta") && !contains(${{ matrix.go_version }}, "rc")'
|
||||
go-version: ${{ matrix.go_version }}
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/cache@v2
|
||||
id: cache-go
|
||||
@@ -86,7 +85,7 @@ jobs:
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 16
|
||||
@@ -125,7 +124,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -142,7 +141,7 @@ jobs:
|
||||
|
||||
- name: Run GoReleaser - SNAPSHOT
|
||||
if: startsWith(github.ref, 'refs/tags/') != true
|
||||
uses: docker://deluan/ci-goreleaser:1.17.1-1
|
||||
uses: docker://deluan/ci-goreleaser:1.19.1-3
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -150,7 +149,7 @@ jobs:
|
||||
|
||||
- name: Run GoReleaser - RELEASE
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: docker://deluan/ci-goreleaser:1.17.1-1
|
||||
uses: docker://deluan/ci-goreleaser:1.19.1-3
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -182,7 +181,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v1
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
|
||||
- uses: actions/download-artifact@v2
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
run:
|
||||
go: "1.19"
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- asasalint
|
||||
- asciicheck
|
||||
- bidichk
|
||||
- bodyclose
|
||||
- deadcode
|
||||
- depguard
|
||||
- dogsled
|
||||
- durationcheck
|
||||
- errcheck
|
||||
- errorlint
|
||||
- exportloopref
|
||||
- gocyclo
|
||||
- goprintffuncname
|
||||
- gosec
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- interfacer
|
||||
- misspell
|
||||
- nilerr
|
||||
- rowserrcheck
|
||||
- staticcheck
|
||||
- structcheck
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unused
|
||||
- varcheck
|
||||
- whitespace
|
||||
|
||||
issues:
|
||||
|
||||
@@ -18,6 +18,7 @@ builds:
|
||||
- id: navidrome_linux_386
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- PKG_CONFIG_PATH=/i386/lib/pkgconfig
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
@@ -31,8 +32,9 @@ builds:
|
||||
- id: navidrome_linux_arm
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=arm-linux-gnueabi-gcc
|
||||
- CXX=arm-linux-gnueabi-g++
|
||||
- CC=arm-linux-gnueabihf-gcc
|
||||
- CXX=arm-linux-gnueabihf-g++
|
||||
- PKG_CONFIG_PATH=/arm/lib/pkgconfig
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
@@ -52,6 +54,7 @@ builds:
|
||||
- CGO_ENABLED=1
|
||||
- CC=aarch64-linux-gnu-gcc
|
||||
- CXX=aarch64-linux-gnu-g++
|
||||
- PKG_CONFIG_PATH=/arm64/lib/pkgconfig
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
|
||||
35
Makefile
35
Makefile
@@ -1,7 +1,7 @@
|
||||
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
||||
NODE_VERSION=$(shell cat .nvmrc)
|
||||
|
||||
ifneq ("$(wildcard .git)","")
|
||||
ifneq ("$(wildcard .git/HEAD)","")
|
||||
GIT_SHA=$(shell git rev-parse --short HEAD)
|
||||
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)
|
||||
else
|
||||
@@ -9,7 +9,7 @@ GIT_SHA=source_archive
|
||||
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))
|
||||
endif
|
||||
|
||||
CI_RELEASER_VERSION=1.17.1-1 ## https://github.com/navidrome/ci-goreleaser
|
||||
CI_RELEASER_VERSION=1.19.1-3 ## https://github.com/navidrome/ci-goreleaser
|
||||
|
||||
setup: check_env download-deps setup-git ##@1_Run_First Install dependencies and prepare development environment
|
||||
@echo Downloading Node dependencies...
|
||||
@@ -25,7 +25,7 @@ server: check_go_env ##@Development Start the backend in development mode
|
||||
.PHONY: server
|
||||
|
||||
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
|
||||
go run github.com/onsi/ginkgo/ginkgo watch -notify ./...
|
||||
go run github.com/onsi/ginkgo/v2/ginkgo watch -notify ./...
|
||||
.PHONY: watch
|
||||
|
||||
test: ##@Development Run Go tests
|
||||
@@ -49,7 +49,7 @@ wire: check_go_env ##@Development Update Dependency Injection
|
||||
.PHONY: wire
|
||||
|
||||
snapshots: ##@Development Update (GoLang) Snapshot tests
|
||||
UPDATE_SNAPSHOTS=true go run github.com/onsi/ginkgo/ginkgo ./server/subsonic/...
|
||||
UPDATE_SNAPSHOTS=true go run github.com/onsi/ginkgo/v2/ginkgo ./server/subsonic/...
|
||||
.PHONY: snapshots
|
||||
|
||||
migration: ##@Development Create an empty migration file
|
||||
@@ -69,8 +69,7 @@ setup-git: ##@Development Setup Git hooks (pre-commit and pre-push)
|
||||
buildall: buildjs build ##@Build Build the project, both frontend and backend
|
||||
.PHONY: buildall
|
||||
|
||||
build: check_go_env ##@Build Build only backend
|
||||
@echo "WARNING: This command does not build the frontend, it uses the latest one built by 'make buildjs'"
|
||||
build: warning-noui-build check_go_env ##@Build Build only backend
|
||||
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT" -tags=netgo
|
||||
.PHONY: build
|
||||
|
||||
@@ -78,14 +77,12 @@ buildjs: check_node_env ##@Build Build only frontend
|
||||
@(cd ./ui && npm run build)
|
||||
.PHONY: buildjs
|
||||
|
||||
all: ##@Cross_Compilation Build binaries for all supported platforms. It does not build the frontend
|
||||
@echo "WARNING: This command does not builds the frontend, it uses the latest one built by 'make buildjs'"
|
||||
all: warning-noui-build ##@Cross_Compilation Build binaries for all supported platforms. It does not build the frontend
|
||||
docker run -t -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \
|
||||
goreleaser release --rm-dist --skip-publish --snapshot
|
||||
.PHONY: all
|
||||
|
||||
single: ##@Cross_Compilation Build binaries for a single supported platforms. It does not build the frontend
|
||||
@echo "WARNING: This command does not build the frontend, it uses the latest one built by 'make buildjs'"
|
||||
single: warning-noui-build ##@Cross_Compilation Build binaries for a single supported platforms. It does not build the frontend
|
||||
@if [ -z "${GOOS}" -o -z "${GOARCH}" ]; then \
|
||||
echo "Usage: GOOS=<os> GOARCH=<arch> make single"; \
|
||||
echo "Options:"; \
|
||||
@@ -97,6 +94,22 @@ single: ##@Cross_Compilation Build binaries for a single supported platforms. It
|
||||
goreleaser build --rm-dist --snapshot --single-target --id navidrome_${GOOS}_${GOARCH}
|
||||
.PHONY: single
|
||||
|
||||
warning-noui-build:
|
||||
@echo "WARNING: This command does not build the frontend, it uses the latest built with 'make buildjs'"
|
||||
.PHONY: warning-noui-build
|
||||
|
||||
get-music: ##@Development Download some free music from Navidrome's demo instance
|
||||
mkdir -p music
|
||||
( cd music; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=ec2093ec4801402f1e17cc462195cdbb" > brock.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=NavidromeUI&id=b376eeb4652d2498aa2b25ba0696725e" > back_on_earth.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=NavidromeUI&id=e49c609b542fc51899ee8b53aa858cb4" > ugress.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=NavidromeUI&id=350bcab3a4c1d93869e39ce496464f03" > voodoocuts.zip; \
|
||||
for file in *.zip; do unzip -n $${file}; done )
|
||||
@echo "Done. Remember to set your MusicFolder to ./music"
|
||||
.PHONY: get-music
|
||||
|
||||
|
||||
##########################################
|
||||
#### Miscellaneous
|
||||
|
||||
@@ -111,7 +124,7 @@ release:
|
||||
|
||||
download-deps:
|
||||
@echo Downloading Go dependencies...
|
||||
@go mod download -x
|
||||
@go mod download
|
||||
@go mod tidy # To revert any changes made by the `go mod download` command
|
||||
.PHONY: download-deps
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<img src="resources/logo-192x192.png" alt="Navidrome logo" title="navidrome" align="right" height="60px" />
|
||||
<a href="https://www.navidrome.org"><img src="resources/logo-192x192.png" alt="Navidrome logo" title="navidrome" align="right" height="60px" /></a>
|
||||
|
||||
# Navidrome Music Server
|
||||
|
||||
@@ -27,6 +27,10 @@ please file a [GitHub issue](https://github.com/navidrome/navidrome/issues) or j
|
||||
|
||||
See instructions in the [project's website](https://www.navidrome.org/docs/installation/)
|
||||
|
||||
If you plan to host Navidrome in the cloud, a great option is to get a virtual server at [BuyVM](https://my.frantech.ca/aff.php?aff=4605).
|
||||
They have plans that start at $3.50/month! If you decide to sign up, please consider using our [affliliate link](https://my.frantech.ca/aff.php?aff=4605),
|
||||
to help support the project <3
|
||||
|
||||
## Features
|
||||
|
||||
- Handles very **large music collections**
|
||||
|
||||
23
cmd/root.go
23
cmd/root.go
@@ -10,8 +10,11 @@ import (
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
"github.com/navidrome/navidrome/scheduler"
|
||||
"github.com/oklog/run"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
@@ -31,7 +34,7 @@ Complete documentation is available at https://www.navidrome.org/docs`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runNavidrome()
|
||||
},
|
||||
Version: consts.Version(),
|
||||
Version: consts.Version,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -45,7 +48,7 @@ func Execute() {
|
||||
|
||||
func preRun() {
|
||||
if !noBanner {
|
||||
println(consts.Banner())
|
||||
println(resources.Banner())
|
||||
}
|
||||
conf.Load()
|
||||
}
|
||||
@@ -68,6 +71,7 @@ func runNavidrome() {
|
||||
|
||||
if err := g.Run(); err != nil {
|
||||
log.Error("Fatal error in Navidrome. Aborting", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +83,12 @@ func startServer() (func() error, func(err error)) {
|
||||
if conf.Server.LastFM.Enabled {
|
||||
a.MountRouter("LastFM Auth", consts.URLPathNativeAPI+"/lastfm", CreateLastFMRouter())
|
||||
}
|
||||
if conf.Server.ListenBrainz.Enabled {
|
||||
a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter())
|
||||
}
|
||||
if conf.Server.Prometheus.Enabled {
|
||||
a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, promhttp.Handler())
|
||||
}
|
||||
return a.Run(fmt.Sprintf("%s:%d", conf.Server.Address, conf.Server.Port))
|
||||
}, func(err error) {
|
||||
if err != nil {
|
||||
@@ -107,7 +117,7 @@ func startSignaler() (func() error, func(err error)) {
|
||||
}
|
||||
log.Info(ctx, "Triggered scan complete", "elapsed", time.Since(start).Round(100*time.Millisecond))
|
||||
case <-ctx.Done():
|
||||
break
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}, func(err error) {
|
||||
@@ -186,12 +196,19 @@ func init() {
|
||||
rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache")
|
||||
rootCmd.Flags().Bool("autoimportplaylists", viper.GetBool("autoimportplaylists"), "enable/disable .m3u playlist auto-import`")
|
||||
|
||||
rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint`")
|
||||
rootCmd.Flags().String("prometheus.metricspath", viper.GetString("prometheus.metricspath"), "http endpoint for prometheus metrics")
|
||||
|
||||
_ = viper.BindPFlag("address", rootCmd.Flags().Lookup("address"))
|
||||
_ = viper.BindPFlag("port", rootCmd.Flags().Lookup("port"))
|
||||
_ = viper.BindPFlag("sessiontimeout", rootCmd.Flags().Lookup("sessiontimeout"))
|
||||
_ = viper.BindPFlag("scaninterval", rootCmd.Flags().Lookup("scaninterval"))
|
||||
_ = viper.BindPFlag("baseurl", rootCmd.Flags().Lookup("baseurl"))
|
||||
_ = viper.BindPFlag("uiloginbackgroundurl", rootCmd.Flags().Lookup("uiloginbackgroundurl"))
|
||||
|
||||
_ = viper.BindPFlag("prometheus.enabled", rootCmd.Flags().Lookup("prometheus.enabled"))
|
||||
_ = viper.BindPFlag("prometheus.metricspath", rootCmd.Flags().Lookup("prometheus.metricspath"))
|
||||
|
||||
_ = viper.BindPFlag("enabletranscodingconfig", rootCmd.Flags().Lookup("enabletranscodingconfig"))
|
||||
_ = viper.BindPFlag("transcodingcachesize", rootCmd.Flags().Lookup("transcodingcachesize"))
|
||||
_ = viper.BindPFlag("imagecachesize", rootCmd.Flags().Lookup("imagecachesize"))
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
var fullRescan bool
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//go:build !windows && !plan9
|
||||
// +build !windows,!plan9
|
||||
|
||||
package cmd
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Code generated by Wire. DO NOT EDIT.
|
||||
|
||||
//go:generate go run github.com/google/wire/cmd/wire
|
||||
//+build !wireinject
|
||||
//go:build !wireinject
|
||||
// +build !wireinject
|
||||
|
||||
package cmd
|
||||
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/transcoder"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
@@ -54,8 +56,9 @@ func CreateSubsonicAPIRouter() *subsonic.Router {
|
||||
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
|
||||
scanner := GetScanner()
|
||||
broker := events.GetBroker()
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker)
|
||||
router := subsonic.New(dataStore, artwork, mediaStreamer, archiver, players, externalMetadata, scanner, broker, playTracker)
|
||||
router := subsonic.New(dataStore, artwork, mediaStreamer, archiver, players, externalMetadata, scanner, broker, playlists, playTracker)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -66,20 +69,28 @@ func CreateLastFMRouter() *lastfm.Router {
|
||||
return router
|
||||
}
|
||||
|
||||
func CreateListenBrainzRouter() *listenbrainz.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
router := listenbrainz.NewRouter(dataStore)
|
||||
return router
|
||||
}
|
||||
|
||||
func createScanner() scanner.Scanner {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
artworkCache := core.GetImageCache()
|
||||
artwork := core.NewArtwork(dataStore, artworkCache)
|
||||
cacheWarmer := core.NewCacheWarmer(artwork, artworkCache)
|
||||
broker := events.GetBroker()
|
||||
scannerScanner := scanner.New(dataStore, cacheWarmer, broker)
|
||||
scannerScanner := scanner.New(dataStore, playlists, cacheWarmer, broker)
|
||||
return scannerScanner
|
||||
}
|
||||
|
||||
// wire_injectors.go:
|
||||
|
||||
var allProviders = wire.NewSet(core.Set, subsonic.New, nativeapi.New, persistence.New, lastfm.NewRouter, events.GetBroker, db.Db)
|
||||
var allProviders = wire.NewSet(core.Set, subsonic.New, nativeapi.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, db.Db)
|
||||
|
||||
// Scanner must be a Singleton
|
||||
var (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//go:build wireinject
|
||||
// +build wireinject
|
||||
|
||||
package cmd
|
||||
|
||||
@@ -9,6 +8,7 @@ import (
|
||||
"github.com/google/wire"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
@@ -24,6 +24,7 @@ var allProviders = wire.NewSet(
|
||||
nativeapi.New,
|
||||
persistence.New,
|
||||
lastfm.NewRouter,
|
||||
listenbrainz.NewRouter,
|
||||
events.GetBroker,
|
||||
db.Db,
|
||||
)
|
||||
@@ -54,6 +55,12 @@ func CreateLastFMRouter() *lastfm.Router {
|
||||
))
|
||||
}
|
||||
|
||||
func CreateListenBrainzRouter() *listenbrainz.Router {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
||||
// Scanner must be a Singleton
|
||||
var (
|
||||
onceScanner sync.Once
|
||||
|
||||
@@ -29,6 +29,7 @@ type configOptions struct {
|
||||
UILoginBackgroundURL string
|
||||
EnableTranscodingConfig bool
|
||||
EnableDownloads bool
|
||||
EnableExternalServices bool
|
||||
TranscodingCacheSize string
|
||||
ImageCacheSize string
|
||||
AutoImportPlaylists bool
|
||||
@@ -47,6 +48,7 @@ type configOptions struct {
|
||||
EnableStarRating bool
|
||||
EnableUserEditing bool
|
||||
DefaultTheme string
|
||||
DefaultLanguage string
|
||||
EnableCoverAnimation bool
|
||||
GATrackingID string
|
||||
EnableLogRedacting bool
|
||||
@@ -55,12 +57,14 @@ type configOptions struct {
|
||||
PasswordEncryptionKey string
|
||||
ReverseProxyUserHeader string
|
||||
ReverseProxyWhitelist string
|
||||
Prometheus prometheusOptions
|
||||
|
||||
Scanner scannerOptions
|
||||
|
||||
Agents string
|
||||
LastFM lastfmOptions
|
||||
Spotify spotifyOptions
|
||||
Agents string
|
||||
LastFM lastfmOptions
|
||||
Spotify spotifyOptions
|
||||
ListenBrainz listenBrainzOptions
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
DevLogSourceLine bool
|
||||
@@ -93,6 +97,16 @@ type spotifyOptions struct {
|
||||
Secret string
|
||||
}
|
||||
|
||||
type listenBrainzOptions struct {
|
||||
Enabled bool
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
type prometheusOptions struct {
|
||||
Enabled bool
|
||||
MetricsPath string
|
||||
}
|
||||
|
||||
var (
|
||||
Server = &configOptions{}
|
||||
hooks []func()
|
||||
@@ -137,12 +151,27 @@ func Load() {
|
||||
fmt.Println(prettyConf)
|
||||
}
|
||||
|
||||
if !Server.EnableExternalServices {
|
||||
disableExternalServices()
|
||||
}
|
||||
|
||||
// Call init hooks
|
||||
for _, hook := range hooks {
|
||||
hook()
|
||||
}
|
||||
}
|
||||
|
||||
func disableExternalServices() {
|
||||
log.Info("All external integrations are DISABLED!")
|
||||
Server.LastFM.Enabled = false
|
||||
Server.Spotify.ID = ""
|
||||
Server.ListenBrainz.Enabled = false
|
||||
Server.Agents = ""
|
||||
if Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL {
|
||||
Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURLOffline
|
||||
}
|
||||
}
|
||||
|
||||
func validateScanSchedule() error {
|
||||
if Server.ScanInterval != -1 {
|
||||
log.Warn("ScanInterval is DEPRECATED. Please use ScanSchedule. See docs at https://navidrome.org/docs/usage/configuration-options/")
|
||||
@@ -194,6 +223,7 @@ func init() {
|
||||
viper.SetDefault("autoimportplaylists", true)
|
||||
viper.SetDefault("playlistspath", consts.DefaultPlaylistsPath)
|
||||
viper.SetDefault("enabledownloads", true)
|
||||
viper.SetDefault("enableexternalservices", true)
|
||||
|
||||
// Config options only valid for file/env configuration
|
||||
viper.SetDefault("searchfullstring", false)
|
||||
@@ -209,6 +239,7 @@ func init() {
|
||||
viper.SetDefault("enablestarrating", true)
|
||||
viper.SetDefault("enableuserediting", true)
|
||||
viper.SetDefault("defaulttheme", "Dark")
|
||||
viper.SetDefault("defaultlanguage", "")
|
||||
viper.SetDefault("enablecoveranimation", true)
|
||||
viper.SetDefault("gatrackingid", "")
|
||||
viper.SetDefault("enablelogredacting", true)
|
||||
@@ -219,6 +250,9 @@ func init() {
|
||||
viper.SetDefault("reverseproxyuserheader", "Remote-User")
|
||||
viper.SetDefault("reverseproxywhitelist", "")
|
||||
|
||||
viper.SetDefault("prometheus.enabled", false)
|
||||
viper.SetDefault("prometheus.metricspath", "/metrics")
|
||||
|
||||
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
|
||||
viper.SetDefault("scanner.genreseparators", ";/,")
|
||||
|
||||
@@ -229,6 +263,8 @@ func init() {
|
||||
viper.SetDefault("lastfm.secret", consts.LastFMAPISecret)
|
||||
viper.SetDefault("spotify.id", "")
|
||||
viper.SetDefault("spotify.secret", "")
|
||||
viper.SetDefault("listenbrainz.enabled", true)
|
||||
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
viper.SetDefault("devlogsourceline", false)
|
||||
@@ -240,7 +276,7 @@ func init() {
|
||||
viper.SetDefault("devenableshare", false)
|
||||
viper.SetDefault("devenablebufferedscrobble", true)
|
||||
viper.SetDefault("devsidebarplaylists", true)
|
||||
viper.SetDefault("devshowartistpage", false)
|
||||
viper.SetDefault("devshowartistpage", true)
|
||||
}
|
||||
|
||||
func InitConfig(cfgFile string) {
|
||||
|
||||
@@ -35,16 +35,20 @@ const (
|
||||
|
||||
// Login backgrounds from https://unsplash.com/collections/20072696/navidrome
|
||||
DefaultUILoginBackgroundURL = "https://source.unsplash.com/collection/20072696/1600x900"
|
||||
// In case external integrations are disabled
|
||||
DefaultUILoginBackgroundURLOffline = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAIAAAAiOjnJAAAABGdBTUEAALGPC/xhBQAAAiJJREFUeF7t0IEAAAAAw6D5Ux/khVBhwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDDwMDDVlwABBWcSrQAAAABJRU5ErkJggg=="
|
||||
|
||||
RequestThrottleBacklogLimit = 100
|
||||
RequestThrottleBacklogTimeout = time.Minute
|
||||
|
||||
ArtistInfoTimeToLive = 3 * 24 * time.Hour
|
||||
ServerReadHeaderTimeout = 3 * time.Second
|
||||
|
||||
ArtistInfoTimeToLive = 24 * time.Hour
|
||||
|
||||
I18nFolder = "i18n"
|
||||
SkipScanFile = ".ndignore"
|
||||
|
||||
PlaceholderAlbumArt = "navidrome-600x600.png"
|
||||
PlaceholderAlbumArt = "placeholder.png"
|
||||
PlaceholderAvatar = "logo-192x192.png"
|
||||
|
||||
DefaultHttpClientTimeOut = 10 * time.Second
|
||||
@@ -66,7 +70,7 @@ const (
|
||||
|
||||
// Shared secrets (only add here "secrets" that can be public)
|
||||
const (
|
||||
LastFMAPIKey = "9b94a5515ea66b2da3ec03c12300327e"
|
||||
LastFMAPIKey = "9b94a5515ea66b2da3ec03c12300327e" // nolint:gosec
|
||||
LastFMAPISecret = "74cb6557cec7171d921af5d7d887c587" // nolint:gosec
|
||||
)
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ var audioFormats = map[string]format{
|
||||
".dsf": {typ: "audio/dsd", lossless: true},
|
||||
".wv": {typ: "audio/x-wavpack", lossless: true},
|
||||
".wvp": {typ: "audio/x-wavpack", lossless: true},
|
||||
".mka": {typ: "audio/x-matroska"},
|
||||
}
|
||||
var imageFormats = map[string]string{
|
||||
".gif": "image/gif",
|
||||
@@ -56,4 +57,8 @@ func init() {
|
||||
for ext, typ := range imageFormats {
|
||||
_ = mime.AddExtensionType(ext, typ)
|
||||
}
|
||||
|
||||
// In some circumstances, Windows sets JS mime-type to `text/plain`!
|
||||
_ = mime.AddExtensionType(".js", "text/javascript")
|
||||
_ = mime.AddExtensionType(".css", "text/css")
|
||||
}
|
||||
|
||||
@@ -11,15 +11,16 @@ var (
|
||||
gitSha string
|
||||
)
|
||||
|
||||
// Formats:
|
||||
// Version holds the version string, with tag and git sha info.
|
||||
// Examples:
|
||||
// dev
|
||||
// v0.2.0 (5b84188)
|
||||
// v0.3.2-SNAPSHOT (715f552)
|
||||
// master (9ed35cb)
|
||||
func Version() string {
|
||||
var Version = func() string {
|
||||
if gitSha == "" {
|
||||
return "dev"
|
||||
}
|
||||
gitTag = strings.TrimPrefix(gitTag, "v")
|
||||
return fmt.Sprintf("%s (%s)", gitTag, gitSha)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -18,7 +18,10 @@ type Agents struct {
|
||||
}
|
||||
|
||||
func New(ds model.DataStore) *Agents {
|
||||
order := strings.Split(conf.Server.Agents, ",")
|
||||
var order []string
|
||||
if conf.Server.Agents != "" {
|
||||
order = strings.Split(conf.Server.Agents, ",")
|
||||
}
|
||||
order = append(order, PlaceholderAgentName)
|
||||
var res []Interface
|
||||
for _, name := range order {
|
||||
@@ -51,7 +54,7 @@ func (a *Agents) GetMBID(ctx context.Context, id string, name string) (string, e
|
||||
mbid, err := agent.GetMBID(ctx, id, name)
|
||||
if mbid != "" && err == nil {
|
||||
log.Debug(ctx, "Got MBID", "agent", ag.AgentName(), "artist", name, "mbid", mbid, "elapsed", time.Since(start))
|
||||
return mbid, err
|
||||
return mbid, nil
|
||||
}
|
||||
}
|
||||
return "", ErrNotFound
|
||||
@@ -70,7 +73,7 @@ func (a *Agents) GetURL(ctx context.Context, id, name, mbid string) (string, err
|
||||
url, err := agent.GetURL(ctx, id, name, mbid)
|
||||
if url != "" && err == nil {
|
||||
log.Debug(ctx, "Got External Url", "agent", ag.AgentName(), "artist", name, "url", url, "elapsed", time.Since(start))
|
||||
return url, err
|
||||
return url, nil
|
||||
}
|
||||
}
|
||||
return "", ErrNotFound
|
||||
@@ -89,7 +92,7 @@ func (a *Agents) GetBiography(ctx context.Context, id, name, mbid string) (strin
|
||||
bio, err := agent.GetBiography(ctx, id, name, mbid)
|
||||
if bio != "" && err == nil {
|
||||
log.Debug(ctx, "Got Biography", "agent", ag.AgentName(), "artist", name, "len", len(bio), "elapsed", time.Since(start))
|
||||
return bio, err
|
||||
return bio, nil
|
||||
}
|
||||
}
|
||||
return "", ErrNotFound
|
||||
@@ -107,7 +110,11 @@ func (a *Agents) GetSimilar(ctx context.Context, id, name, mbid string, limit in
|
||||
}
|
||||
similar, err := agent.GetSimilar(ctx, id, name, mbid, limit)
|
||||
if len(similar) > 0 && err == nil {
|
||||
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start))
|
||||
if log.CurrentLevel() >= log.LevelTrace {
|
||||
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start))
|
||||
} else {
|
||||
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similarReceived", len(similar), "elapsed", time.Since(start))
|
||||
}
|
||||
return similar, err
|
||||
}
|
||||
}
|
||||
@@ -127,7 +134,7 @@ func (a *Agents) GetImages(ctx context.Context, id, name, mbid string) ([]Artist
|
||||
images, err := agent.GetImages(ctx, id, name, mbid)
|
||||
if len(images) > 0 && err == nil {
|
||||
log.Debug(ctx, "Got Images", "agent", ag.AgentName(), "artist", name, "images", images, "elapsed", time.Since(start))
|
||||
return images, err
|
||||
return images, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
@@ -146,7 +153,7 @@ func (a *Agents) GetTopSongs(ctx context.Context, id, artistName, mbid string, c
|
||||
songs, err := agent.GetTopSongs(ctx, id, artistName, mbid, count)
|
||||
if len(songs) > 0 && err == nil {
|
||||
log.Debug(ctx, "Got Top Songs", "agent", ag.AgentName(), "artist", artistName, "songs", songs, "elapsed", time.Since(start))
|
||||
return songs, err
|
||||
return songs, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package lastfm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@@ -14,12 +15,13 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
lastFMAgentName = "lastfm"
|
||||
lastFMAgentName = "lastfm"
|
||||
sessionKeyProperty = "LastFMSessionKey"
|
||||
)
|
||||
|
||||
type lastfmAgent struct {
|
||||
ds model.DataStore
|
||||
sessionKeys *sessionKeys
|
||||
sessionKeys *agents.SessionKeys
|
||||
apiKey string
|
||||
secret string
|
||||
lang string
|
||||
@@ -32,7 +34,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
|
||||
lang: conf.Server.LastFM.Language,
|
||||
apiKey: conf.Server.LastFM.ApiKey,
|
||||
secret: conf.Server.LastFM.Secret,
|
||||
sessionKeys: &sessionKeys{ds: ds},
|
||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
}
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
@@ -117,7 +119,9 @@ func (l *lastfmAgent) GetTopSongs(ctx context.Context, id, artistName, mbid stri
|
||||
|
||||
func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, mbid string) (*Artist, error) {
|
||||
a, err := l.client.ArtistGetInfo(ctx, name, mbid)
|
||||
lfErr, isLastFMError := err.(*lastFMError)
|
||||
var lfErr *lastFMError
|
||||
isLastFMError := errors.As(err, &lfErr)
|
||||
|
||||
if mbid != "" && ((err == nil && a.Name == "[unknown]") || (isLastFMError && lfErr.Code == 6)) {
|
||||
log.Warn(ctx, "LastFM/artist.getInfo could not find artist by mbid, trying again", "artist", name, "mbid", mbid)
|
||||
return l.callArtistGetInfo(ctx, name, "")
|
||||
@@ -132,7 +136,8 @@ func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, mbid s
|
||||
|
||||
func (l *lastfmAgent) callArtistGetSimilar(ctx context.Context, name string, mbid string, limit int) ([]Artist, error) {
|
||||
s, err := l.client.ArtistGetSimilar(ctx, name, mbid, limit)
|
||||
lfErr, isLastFMError := err.(*lastFMError)
|
||||
var lfErr *lastFMError
|
||||
isLastFMError := errors.As(err, &lfErr)
|
||||
if mbid != "" && ((err == nil && s.Attr.Artist == "[unknown]") || (isLastFMError && lfErr.Code == 6)) {
|
||||
log.Warn(ctx, "LastFM/artist.getSimilar could not find artist by mbid, trying again", "artist", name, "mbid", mbid)
|
||||
return l.callArtistGetSimilar(ctx, name, "", limit)
|
||||
@@ -146,7 +151,8 @@ func (l *lastfmAgent) callArtistGetSimilar(ctx context.Context, name string, mbi
|
||||
|
||||
func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName, mbid string, count int) ([]Track, error) {
|
||||
t, err := l.client.ArtistGetTopTracks(ctx, artistName, mbid, count)
|
||||
lfErr, isLastFMError := err.(*lastFMError)
|
||||
var lfErr *lastFMError
|
||||
isLastFMError := errors.As(err, &lfErr)
|
||||
if mbid != "" && ((err == nil && t.Attr.Artist == "[unknown]") || (isLastFMError && lfErr.Code == 6)) {
|
||||
log.Warn(ctx, "LastFM/artist.getTopTracks could not find artist by mbid, trying again", "artist", artistName, "mbid", mbid)
|
||||
return l.callArtistGetTopTracks(ctx, artistName, "", count)
|
||||
@@ -159,7 +165,7 @@ func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName, mb
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error {
|
||||
sk, err := l.sessionKeys.get(ctx, userId)
|
||||
sk, err := l.sessionKeys.Get(ctx, userId)
|
||||
if err != nil || sk == "" {
|
||||
return scrobbler.ErrNotAuthorized
|
||||
}
|
||||
@@ -181,7 +187,7 @@ func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *mode
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error {
|
||||
sk, err := l.sessionKeys.get(ctx, userId)
|
||||
sk, err := l.sessionKeys.Get(ctx, userId)
|
||||
if err != nil || sk == "" {
|
||||
return scrobbler.ErrNotAuthorized
|
||||
}
|
||||
@@ -203,7 +209,8 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lfErr, isLastFMError := err.(*lastFMError)
|
||||
var lfErr *lastFMError
|
||||
isLastFMError := errors.As(err, &lfErr)
|
||||
if !isLastFMError {
|
||||
log.Warn(ctx, "Last.fm client.scrobble returned error", "track", s.Title, err)
|
||||
return scrobbler.ErrRetryLater
|
||||
@@ -215,7 +222,7 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
sk, err := l.sessionKeys.get(ctx, userId)
|
||||
sk, err := l.sessionKeys.Get(ctx, userId)
|
||||
return err == nil && sk != ""
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -25,7 +27,7 @@ var tokenReceivedPage []byte
|
||||
type Router struct {
|
||||
http.Handler
|
||||
ds model.DataStore
|
||||
sessionKeys *sessionKeys
|
||||
sessionKeys *agents.SessionKeys
|
||||
client *Client
|
||||
apiKey string
|
||||
secret string
|
||||
@@ -36,7 +38,7 @@ func NewRouter(ds model.DataStore) *Router {
|
||||
ds: ds,
|
||||
apiKey: conf.Server.LastFM.ApiKey,
|
||||
secret: conf.Server.LastFM.Secret,
|
||||
sessionKeys: &sessionKeys{ds: ds},
|
||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
}
|
||||
r.Handler = r.routes()
|
||||
hc := &http.Client{
|
||||
@@ -63,10 +65,10 @@ func (s *Router) routes() http.Handler {
|
||||
}
|
||||
|
||||
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]interface{}{"status": true}
|
||||
resp := map[string]interface{}{}
|
||||
u, _ := request.UserFrom(r.Context())
|
||||
key, err := s.sessionKeys.get(r.Context(), u.ID)
|
||||
if err != nil && err != model.ErrNotFound {
|
||||
key, err := s.sessionKeys.Get(r.Context(), u.ID)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
resp["error"] = err
|
||||
resp["status"] = false
|
||||
_ = rest.RespondWithJSON(w, http.StatusInternalServerError, resp)
|
||||
@@ -78,7 +80,7 @@ func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Router) unlink(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := request.UserFrom(r.Context())
|
||||
err := s.sessionKeys.delete(r.Context(), u.ID)
|
||||
err := s.sessionKeys.Delete(r.Context(), u.ID)
|
||||
if err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
@@ -119,7 +121,7 @@ func (s *Router) fetchSessionKey(ctx context.Context, uid, token string) error {
|
||||
"requestId", middleware.GetReqID(ctx), err)
|
||||
return err
|
||||
}
|
||||
err = s.sessionKeys.put(ctx, uid, sessionKey)
|
||||
err = s.sessionKeys.Put(ctx, uid, sessionKey)
|
||||
if err != nil {
|
||||
log.Error("Could not save LastFM session key", "userId", uid, "requestId", middleware.GetReqID(ctx), err)
|
||||
}
|
||||
|
||||
@@ -157,11 +157,11 @@ func (c *Client) Scrobble(ctx context.Context, sessionKey string, info ScrobbleI
|
||||
}
|
||||
if resp.Scrobbles.Scrobble.IgnoredMessage.Code != "0" {
|
||||
log.Warn(ctx, "LastFM: Scrobble was ignored", "code", resp.Scrobbles.Scrobble.IgnoredMessage.Code,
|
||||
"text", resp.Scrobbles.Scrobble.IgnoredMessage.Text)
|
||||
"text", resp.Scrobbles.Scrobble.IgnoredMessage.Text, "info", info)
|
||||
}
|
||||
if resp.Scrobbles.Attr.Accepted != 1 {
|
||||
log.Warn(ctx, "LastFM: Scrobble was not accepted", "code", resp.Scrobbles.Scrobble.IgnoredMessage.Code,
|
||||
"text", resp.Scrobbles.Scrobble.IgnoredMessage.Text)
|
||||
"text", resp.Scrobbles.Scrobble.IgnoredMessage.Text, "info", info)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionKeyProperty = "LastFMSessionKey"
|
||||
)
|
||||
|
||||
// sessionKeys is a simple wrapper around the UserPropsRepository
|
||||
type sessionKeys struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func (sk *sessionKeys) put(ctx context.Context, userId, sessionKey string) error {
|
||||
return sk.ds.UserProps(ctx).Put(userId, sessionKeyProperty, sessionKey)
|
||||
}
|
||||
|
||||
func (sk *sessionKeys) get(ctx context.Context, userId string) (string, error) {
|
||||
return sk.ds.UserProps(ctx).Get(userId, sessionKeyProperty)
|
||||
}
|
||||
|
||||
func (sk *sessionKeys) delete(ctx context.Context, userId string) error {
|
||||
return sk.ds.UserProps(ctx).Delete(userId, sessionKeyProperty)
|
||||
}
|
||||
117
core/agents/listenbrainz/agent.go
Normal file
117
core/agents/listenbrainz/agent.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package listenbrainz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
listenBrainzAgentName = "listenbrainz"
|
||||
sessionKeyProperty = "ListenBrainzSessionKey"
|
||||
)
|
||||
|
||||
type listenBrainzAgent struct {
|
||||
ds model.DataStore
|
||||
sessionKeys *agents.SessionKeys
|
||||
baseURL string
|
||||
client *Client
|
||||
}
|
||||
|
||||
func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent {
|
||||
l := &listenBrainzAgent{
|
||||
ds: ds,
|
||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
baseURL: conf.Server.ListenBrainz.BaseURL,
|
||||
}
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
l.client = NewClient(l.baseURL, chc)
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *listenBrainzAgent) AgentName() string {
|
||||
return listenBrainzAgentName
|
||||
}
|
||||
|
||||
func (l *listenBrainzAgent) formatListen(track *model.MediaFile) listenInfo {
|
||||
li := listenInfo{
|
||||
TrackMetadata: trackMetadata{
|
||||
ArtistName: track.Artist,
|
||||
TrackName: track.Title,
|
||||
ReleaseName: track.Album,
|
||||
AdditionalInfo: additionalInfo{
|
||||
TrackNumber: track.TrackNumber,
|
||||
ArtistMbzIDs: []string{track.MbzArtistID},
|
||||
TrackMbzID: track.MbzTrackID,
|
||||
ReleaseMbID: track.MbzAlbumID,
|
||||
},
|
||||
},
|
||||
}
|
||||
return li
|
||||
}
|
||||
|
||||
func (l *listenBrainzAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error {
|
||||
sk, err := l.sessionKeys.Get(ctx, userId)
|
||||
if err != nil || sk == "" {
|
||||
return scrobbler.ErrNotAuthorized
|
||||
}
|
||||
|
||||
li := l.formatListen(track)
|
||||
err = l.client.UpdateNowPlaying(ctx, sk, li)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "ListenBrainz UpdateNowPlaying returned error", "track", track.Title, err)
|
||||
return scrobbler.ErrUnrecoverable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *listenBrainzAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error {
|
||||
sk, err := l.sessionKeys.Get(ctx, userId)
|
||||
if err != nil || sk == "" {
|
||||
return scrobbler.ErrNotAuthorized
|
||||
}
|
||||
|
||||
li := l.formatListen(&s.MediaFile)
|
||||
li.ListenedAt = int(s.TimeStamp.Unix())
|
||||
err = l.client.Scrobble(ctx, sk, li)
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var lbErr *listenBrainzError
|
||||
isListenBrainzError := errors.As(err, &lbErr)
|
||||
if !isListenBrainzError {
|
||||
log.Warn(ctx, "ListenBrainz Scrobble returned HTTP error", "track", s.Title, err)
|
||||
return scrobbler.ErrRetryLater
|
||||
}
|
||||
if lbErr.Code == 500 || lbErr.Code == 503 {
|
||||
return scrobbler.ErrRetryLater
|
||||
}
|
||||
return scrobbler.ErrUnrecoverable
|
||||
}
|
||||
|
||||
func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
sk, err := l.sessionKeys.Get(ctx, userId)
|
||||
return err == nil && sk != ""
|
||||
}
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
if conf.Server.ListenBrainz.Enabled {
|
||||
scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
||||
return listenBrainzConstructor(ds)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
158
core/agents/listenbrainz/agent_test.go
Normal file
158
core/agents/listenbrainz/agent_test.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package listenbrainz
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
. "github.com/onsi/gomega/gstruct"
|
||||
)
|
||||
|
||||
var _ = Describe("listenBrainzAgent", func() {
|
||||
var ds model.DataStore
|
||||
var ctx context.Context
|
||||
var agent *listenBrainzAgent
|
||||
var httpClient *tests.FakeHttpClient
|
||||
var track *model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
ctx = context.Background()
|
||||
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
agent = listenBrainzConstructor(ds)
|
||||
agent.client = NewClient("http://localhost:8080", httpClient)
|
||||
track = &model.MediaFile{
|
||||
ID: "123",
|
||||
Title: "Track Title",
|
||||
Album: "Track Album",
|
||||
Artist: "Track Artist",
|
||||
TrackNumber: 1,
|
||||
MbzTrackID: "mbz-123",
|
||||
MbzAlbumID: "mbz-456",
|
||||
MbzArtistID: "mbz-789",
|
||||
}
|
||||
})
|
||||
|
||||
Describe("formatListen", func() {
|
||||
It("constructs the listenInfo properly", func() {
|
||||
var idArtistId = func(element interface{}) string {
|
||||
return element.(string)
|
||||
}
|
||||
|
||||
lr := agent.formatListen(track)
|
||||
Expect(lr).To(MatchAllFields(Fields{
|
||||
"ListenedAt": Equal(0),
|
||||
"TrackMetadata": MatchAllFields(Fields{
|
||||
"ArtistName": Equal(track.Artist),
|
||||
"TrackName": Equal(track.Title),
|
||||
"ReleaseName": Equal(track.Album),
|
||||
"AdditionalInfo": MatchAllFields(Fields{
|
||||
"TrackNumber": Equal(track.TrackNumber),
|
||||
"TrackMbzID": Equal(track.MbzTrackID),
|
||||
"ReleaseMbID": Equal(track.MbzAlbumID),
|
||||
"ArtistMbzIDs": MatchAllElements(idArtistId, Elements{
|
||||
"mbz-789": Equal(track.MbzArtistID),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("NowPlaying", func() {
|
||||
It("updates NowPlaying successfully", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
|
||||
|
||||
err := agent.NowPlaying(ctx, "user-1", track)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns ErrNotAuthorized if user is not linked", func() {
|
||||
err := agent.NowPlaying(ctx, "user-2", track)
|
||||
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Scrobble", func() {
|
||||
var sc scrobbler.Scrobble
|
||||
|
||||
BeforeEach(func() {
|
||||
sc = scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}
|
||||
})
|
||||
|
||||
It("sends a Scrobble successfully", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", sc)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("sets the Timestamp properly", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", sc)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
decoder := json.NewDecoder(httpClient.SavedRequest.Body)
|
||||
var lr listenBrainzRequestBody
|
||||
err = decoder.Decode(&lr)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lr.Payload[0].ListenedAt).To(Equal(int(sc.TimeStamp.Unix())))
|
||||
})
|
||||
|
||||
It("returns ErrNotAuthorized if user is not linked", func() {
|
||||
err := agent.Scrobble(ctx, "user-2", sc)
|
||||
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater on error 503", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 503, "error": "Cannot submit listens to queue, please try again later."}`)),
|
||||
StatusCode: 503,
|
||||
}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", sc)
|
||||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater on error 500", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 500, "error": "Something went wrong. Please try again."}`)),
|
||||
StatusCode: 500,
|
||||
}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", sc)
|
||||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater on http errors", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`Bad Gateway`)),
|
||||
StatusCode: 500,
|
||||
}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", sc)
|
||||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
||||
})
|
||||
|
||||
It("returns ErrUnrecoverable on other errors", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 400, "error": "BadRequest: Invalid JSON document submitted."}`)),
|
||||
StatusCode: 400,
|
||||
}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", sc)
|
||||
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
|
||||
})
|
||||
})
|
||||
})
|
||||
121
core/agents/listenbrainz/auth_router.go
Normal file
121
core/agents/listenbrainz/auth_router.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package listenbrainz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
)
|
||||
|
||||
type sessionKeysRepo interface {
|
||||
Put(ctx context.Context, userId, sessionKey string) error
|
||||
Get(ctx context.Context, userId string) (string, error)
|
||||
Delete(ctx context.Context, userId string) error
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
http.Handler
|
||||
ds model.DataStore
|
||||
sessionKeys sessionKeysRepo
|
||||
client *Client
|
||||
}
|
||||
|
||||
func NewRouter(ds model.DataStore) *Router {
|
||||
r := &Router{
|
||||
ds: ds,
|
||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
}
|
||||
r.Handler = r.routes()
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
r.client = NewClient(conf.Server.ListenBrainz.BaseURL, hc)
|
||||
return r
|
||||
}
|
||||
|
||||
func (s *Router) routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.Authenticator(s.ds))
|
||||
r.Use(server.JWTRefresher)
|
||||
|
||||
r.Get("/link", s.getLinkStatus)
|
||||
r.Put("/link", s.link)
|
||||
r.Delete("/link", s.unlink)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]interface{}{}
|
||||
u, _ := request.UserFrom(r.Context())
|
||||
key, err := s.sessionKeys.Get(r.Context(), u.ID)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
resp["error"] = err
|
||||
resp["status"] = false
|
||||
_ = rest.RespondWithJSON(w, http.StatusInternalServerError, resp)
|
||||
return
|
||||
}
|
||||
resp["status"] = key != ""
|
||||
_ = rest.RespondWithJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *Router) link(w http.ResponseWriter, r *http.Request) {
|
||||
type tokenPayload struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
var payload tokenPayload
|
||||
err := json.NewDecoder(r.Body).Decode(&payload)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if payload.Token == "" {
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "Token is required")
|
||||
return
|
||||
}
|
||||
|
||||
u, _ := request.UserFrom(r.Context())
|
||||
resp, err := s.client.ValidateToken(r.Context(), payload.Token)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Could not validate ListenBrainz token", "userId", u.ID, "requestId", middleware.GetReqID(r.Context()), err)
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !resp.Valid {
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "Invalid token")
|
||||
return
|
||||
}
|
||||
|
||||
err = s.sessionKeys.Put(r.Context(), u.ID, payload.Token)
|
||||
if err != nil {
|
||||
log.Error("Could not save ListenBrainz token", "userId", u.ID, "requestId", middleware.GetReqID(r.Context()), err)
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
_ = rest.RespondWithJSON(w, http.StatusOK, map[string]interface{}{"status": resp.Valid, "user": resp.UserName})
|
||||
}
|
||||
|
||||
func (s *Router) unlink(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := request.UserFrom(r.Context())
|
||||
err := s.sessionKeys.Delete(r.Context(), u.ID)
|
||||
if err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
_ = rest.RespondWithJSON(w, http.StatusOK, map[string]string{})
|
||||
}
|
||||
}
|
||||
130
core/agents/listenbrainz/auth_router_test.go
Normal file
130
core/agents/listenbrainz/auth_router_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package listenbrainz
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ListenBrainz Auth Router", func() {
|
||||
var sk *fakeSessionKeys
|
||||
var httpClient *tests.FakeHttpClient
|
||||
var r Router
|
||||
var req *http.Request
|
||||
var resp *httptest.ResponseRecorder
|
||||
|
||||
BeforeEach(func() {
|
||||
sk = &fakeSessionKeys{KeyName: sessionKeyProperty}
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
cl := NewClient("http://localhost/", httpClient)
|
||||
r = Router{
|
||||
sessionKeys: sk,
|
||||
client: cl,
|
||||
}
|
||||
resp = httptest.NewRecorder()
|
||||
})
|
||||
|
||||
Describe("getLinkStatus", func() {
|
||||
It("returns false when there is no stored session key", func() {
|
||||
req = httptest.NewRequest("GET", "/listenbrainz/link", nil)
|
||||
r.getLinkStatus(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||
var parsed map[string]interface{}
|
||||
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
|
||||
Expect(parsed["status"]).To(Equal(false))
|
||||
})
|
||||
|
||||
It("returns true when there is a stored session key", func() {
|
||||
sk.KeyValue = "sk-1"
|
||||
req = httptest.NewRequest("GET", "/listenbrainz/link", nil)
|
||||
r.getLinkStatus(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||
var parsed map[string]interface{}
|
||||
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
|
||||
Expect(parsed["status"]).To(Equal(true))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("link", func() {
|
||||
It("returns bad request when no token is sent", func() {
|
||||
req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{}`))
|
||||
r.link(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("returns bad request when the token is invalid", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token invalid.", "valid": false}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "invalid-tok-1"}`))
|
||||
r.link(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("returns true and the username when the token is valid", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "tok-1"}`))
|
||||
r.link(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||
var parsed map[string]interface{}
|
||||
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
|
||||
Expect(parsed["status"]).To(Equal(true))
|
||||
Expect(parsed["user"]).To(Equal("ListenBrainzUser"))
|
||||
})
|
||||
|
||||
It("saves the session key when the token is valid", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "tok-1"}`))
|
||||
r.link(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||
Expect(sk.KeyValue).To(Equal("tok-1"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("unlink", func() {
|
||||
It("removes the session key when unlinking", func() {
|
||||
sk.KeyValue = "tok-1"
|
||||
req = httptest.NewRequest("DELETE", "/listenbrainz/link", nil)
|
||||
r.unlink(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||
Expect(sk.KeyValue).To(Equal(""))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type fakeSessionKeys struct {
|
||||
KeyName string
|
||||
KeyValue string
|
||||
}
|
||||
|
||||
func (sk *fakeSessionKeys) Put(ctx context.Context, userId, sessionKey string) error {
|
||||
sk.KeyValue = sessionKey
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sk *fakeSessionKeys) Get(ctx context.Context, userId string) (string, error) {
|
||||
return sk.KeyValue, nil
|
||||
}
|
||||
|
||||
func (sk *fakeSessionKeys) Delete(ctx context.Context, userId string) error {
|
||||
sk.KeyValue = ""
|
||||
return nil
|
||||
}
|
||||
172
core/agents/listenbrainz/client.go
Normal file
172
core/agents/listenbrainz/client.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package listenbrainz
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
type listenBrainzError struct {
|
||||
Code int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *listenBrainzError) Error() string {
|
||||
return fmt.Sprintf("ListenBrainz error(%d): %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
type httpDoer interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func NewClient(baseURL string, hc httpDoer) *Client {
|
||||
return &Client{baseURL, hc}
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
hc httpDoer
|
||||
}
|
||||
|
||||
type listenBrainzResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error"`
|
||||
Status string `json:"status"`
|
||||
Valid bool `json:"valid"`
|
||||
UserName string `json:"user_name"`
|
||||
}
|
||||
|
||||
type listenBrainzRequest struct {
|
||||
ApiKey string
|
||||
Body listenBrainzRequestBody
|
||||
}
|
||||
|
||||
type listenBrainzRequestBody struct {
|
||||
ListenType listenType `json:"listen_type,omitempty"`
|
||||
Payload []listenInfo `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type listenType string
|
||||
|
||||
const (
|
||||
Single listenType = "single"
|
||||
PlayingNow listenType = "playing_now"
|
||||
)
|
||||
|
||||
type listenInfo struct {
|
||||
ListenedAt int `json:"listened_at,omitempty"`
|
||||
TrackMetadata trackMetadata `json:"track_metadata,omitempty"`
|
||||
}
|
||||
|
||||
type trackMetadata struct {
|
||||
ArtistName string `json:"artist_name,omitempty"`
|
||||
TrackName string `json:"track_name,omitempty"`
|
||||
ReleaseName string `json:"release_name,omitempty"`
|
||||
AdditionalInfo additionalInfo `json:"additional_info,omitempty"`
|
||||
}
|
||||
|
||||
type additionalInfo struct {
|
||||
TrackNumber int `json:"tracknumber,omitempty"`
|
||||
TrackMbzID string `json:"track_mbid,omitempty"`
|
||||
ArtistMbzIDs []string `json:"artist_mbids,omitempty"`
|
||||
ReleaseMbID string `json:"release_mbid,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) ValidateToken(ctx context.Context, apiKey string) (*listenBrainzResponse, error) {
|
||||
r := &listenBrainzRequest{
|
||||
ApiKey: apiKey,
|
||||
}
|
||||
response, err := c.makeRequest(http.MethodGet, "validate-token", r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *Client) UpdateNowPlaying(ctx context.Context, apiKey string, li listenInfo) error {
|
||||
r := &listenBrainzRequest{
|
||||
ApiKey: apiKey,
|
||||
Body: listenBrainzRequestBody{
|
||||
ListenType: PlayingNow,
|
||||
Payload: []listenInfo{li},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := c.makeRequest(http.MethodPost, "submit-listens", r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Status != "ok" {
|
||||
log.Warn(ctx, "ListenBrainz: NowPlaying was not accepted", "status", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Scrobble(ctx context.Context, apiKey string, li listenInfo) error {
|
||||
r := &listenBrainzRequest{
|
||||
ApiKey: apiKey,
|
||||
Body: listenBrainzRequestBody{
|
||||
ListenType: Single,
|
||||
Payload: []listenInfo{li},
|
||||
},
|
||||
}
|
||||
resp, err := c.makeRequest(http.MethodPost, "submit-listens", r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Status != "ok" {
|
||||
log.Warn(ctx, "ListenBrainz: Scrobble was not accepted", "status", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) path(endpoint string) (string, error) {
|
||||
u, err := url.Parse(c.baseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
u.Path = path.Join(u.Path, endpoint)
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (c *Client) makeRequest(method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) {
|
||||
b, _ := json.Marshal(r.Body)
|
||||
uri, err := c.path(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, _ := http.NewRequest(method, uri, bytes.NewBuffer(b))
|
||||
|
||||
if r.ApiKey != "" {
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Token %s", r.ApiKey))
|
||||
}
|
||||
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
decoder := json.NewDecoder(resp.Body)
|
||||
|
||||
var response listenBrainzResponse
|
||||
jsonErr := decoder.Decode(&response)
|
||||
if resp.StatusCode != 200 && jsonErr != nil {
|
||||
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
|
||||
}
|
||||
if jsonErr != nil {
|
||||
return nil, jsonErr
|
||||
}
|
||||
if response.Code != 0 && response.Code != 200 {
|
||||
return &response, &listenBrainzError{Code: response.Code, Message: response.Error}
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
115
core/agents/listenbrainz/client_test.go
Normal file
115
core/agents/listenbrainz/client_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package listenbrainz
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Client", func() {
|
||||
var httpClient *tests.FakeHttpClient
|
||||
var client *Client
|
||||
BeforeEach(func() {
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
client = NewClient("BASE_URL/", httpClient)
|
||||
})
|
||||
|
||||
Describe("listenBrainzResponse", func() {
|
||||
It("parses a response properly", func() {
|
||||
var response listenBrainzResponse
|
||||
err := json.Unmarshal([]byte(`{"code": 200, "message": "Message", "user_name": "UserName", "valid": true, "status": "ok", "error": "Error"}`), &response)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response.Code).To(Equal(200))
|
||||
Expect(response.Message).To(Equal("Message"))
|
||||
Expect(response.UserName).To(Equal("UserName"))
|
||||
Expect(response.Valid).To(BeTrue())
|
||||
Expect(response.Status).To(Equal("ok"))
|
||||
Expect(response.Error).To(Equal("Error"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ValidateToken", func() {
|
||||
BeforeEach(func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
})
|
||||
|
||||
It("formats the request properly", func() {
|
||||
_, err := client.ValidateToken(context.Background(), "LB-TOKEN")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/validate-token"))
|
||||
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
|
||||
})
|
||||
|
||||
It("parses and returns the response", func() {
|
||||
res, err := client.ValidateToken(context.Background(), "LB-TOKEN")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Valid).To(Equal(true))
|
||||
Expect(res.UserName).To(Equal("ListenBrainzUser"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with listenInfo", func() {
|
||||
var li listenInfo
|
||||
BeforeEach(func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
li = listenInfo{
|
||||
TrackMetadata: trackMetadata{
|
||||
ArtistName: "Track Artist",
|
||||
TrackName: "Track Title",
|
||||
ReleaseName: "Track Album",
|
||||
AdditionalInfo: additionalInfo{
|
||||
TrackNumber: 1,
|
||||
TrackMbzID: "mbz-123",
|
||||
ArtistMbzIDs: []string{"mbz-789"},
|
||||
ReleaseMbID: "mbz-456",
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Describe("UpdateNowPlaying", func() {
|
||||
It("formats the request properly", func() {
|
||||
Expect(client.UpdateNowPlaying(context.Background(), "LB-TOKEN", li)).To(Succeed())
|
||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens"))
|
||||
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
|
||||
|
||||
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
|
||||
f, _ := os.ReadFile("tests/fixtures/listenbrainz.nowplaying.request.json")
|
||||
Expect(body).To(MatchJSON(f))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Scrobble", func() {
|
||||
BeforeEach(func() {
|
||||
li.ListenedAt = 1635000000
|
||||
})
|
||||
|
||||
It("formats the request properly", func() {
|
||||
Expect(client.Scrobble(context.Background(), "LB-TOKEN", li)).To(Succeed())
|
||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens"))
|
||||
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
|
||||
|
||||
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
|
||||
f, _ := os.ReadFile("tests/fixtures/listenbrainz.scrobble.request.json")
|
||||
Expect(body).To(MatchJSON(f))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
17
core/agents/listenbrainz/listenbrainz_suite_test.go
Normal file
17
core/agents/listenbrainz/listenbrainz_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package listenbrainz
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestListenBrainz(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelCritical)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "ListenBrainz Test Suite")
|
||||
}
|
||||
25
core/agents/session_keys.go
Normal file
25
core/agents/session_keys.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// SessionKeys is a simple wrapper around the UserPropsRepository
|
||||
type SessionKeys struct {
|
||||
model.DataStore
|
||||
KeyName string
|
||||
}
|
||||
|
||||
func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error {
|
||||
return sk.DataStore.UserProps(ctx).Put(userId, sk.KeyName, sessionKey)
|
||||
}
|
||||
|
||||
func (sk *SessionKeys) Get(ctx context.Context, userId string) (string, error) {
|
||||
return sk.DataStore.UserProps(ctx).Get(userId, sk.KeyName)
|
||||
}
|
||||
|
||||
func (sk *SessionKeys) Delete(ctx context.Context, userId string) error {
|
||||
return sk.DataStore.UserProps(ctx).Delete(userId, sk.KeyName)
|
||||
}
|
||||
37
core/agents/session_keys_test.go
Normal file
37
core/agents/session_keys_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("SessionKeys", func() {
|
||||
ctx := context.Background()
|
||||
user := model.User{ID: "u-1"}
|
||||
ds := &tests.MockDataStore{MockedUserProps: &tests.MockedUserPropsRepo{}}
|
||||
sk := SessionKeys{DataStore: ds, KeyName: "fakeSessionKey"}
|
||||
|
||||
It("uses the assigned key name", func() {
|
||||
Expect(sk.KeyName).To(Equal("fakeSessionKey"))
|
||||
})
|
||||
It("stores a value in the DB", func() {
|
||||
Expect(sk.Put(ctx, user.ID, "test-stored-value")).To(BeNil())
|
||||
})
|
||||
It("fetches the stored value", func() {
|
||||
value, err := sk.Get(ctx, user.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(value).To(Equal("test-stored-value"))
|
||||
})
|
||||
It("deletes the stored value", func() {
|
||||
Expect(sk.Delete(ctx, user.ID)).To(BeNil())
|
||||
})
|
||||
It("handles a not found value", func() {
|
||||
_, err := sk.Get(ctx, "u-2")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package spotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
@@ -46,7 +47,7 @@ func (s *spotifyAgent) AgentName() string {
|
||||
func (s *spotifyAgent) GetImages(ctx context.Context, id, name, mbid string) ([]agents.ArtistImage, error) {
|
||||
a, err := s.searchArtist(ctx, name)
|
||||
if err != nil {
|
||||
if err == model.ErrNotFound {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "Artist not found in Spotify", "artist", name)
|
||||
} else {
|
||||
log.Error(ctx, "Error calling Spotify", "artist", name, err)
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
|
||||
@@ -54,12 +54,12 @@ func (a *archiver) ZipArtist(ctx context.Context, id string, out io.Writer) erro
|
||||
}
|
||||
|
||||
func (a *archiver) ZipPlaylist(ctx context.Context, id string, out io.Writer) error {
|
||||
pls, err := a.ds.Playlist(ctx).Get(id)
|
||||
pls, err := a.ds.Playlist(ctx).GetWithTracks(id)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err)
|
||||
return err
|
||||
}
|
||||
return a.zipTracks(ctx, id, out, pls.Tracks, a.createPlaylistHeader)
|
||||
return a.zipTracks(ctx, id, out, pls.MediaFiles(), a.createPlaylistHeader)
|
||||
}
|
||||
|
||||
func (a *archiver) zipTracks(ctx context.Context, id string, out io.Writer, mfs model.MediaFiles, ch createHeader) error {
|
||||
|
||||
@@ -57,7 +57,7 @@ func (ci *imageInfo) Key() string {
|
||||
|
||||
func (a *artwork) Get(ctx context.Context, id string, size int) (io.ReadCloser, error) {
|
||||
path, lastUpdate, err := a.getImagePath(ctx, id)
|
||||
if err != nil && err != model.ErrNotFound {
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ func (a *artwork) getImagePath(ctx context.Context, id string) (path string, las
|
||||
mf, err = a.ds.MediaFile(ctx).Get(id)
|
||||
|
||||
// If it is not, may be an albumId
|
||||
if err == model.ErrNotFound {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return a.getImagePath(ctx, "al-"+id)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -127,11 +127,11 @@ func (a *artwork) getArtwork(ctx context.Context, id string, path string, size i
|
||||
defer func() {
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error extracting image", "path", path, "size", size, err)
|
||||
reader, err = resources.FS.Open(consts.PlaceholderAlbumArt)
|
||||
reader, err = resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||
|
||||
if size != 0 && err == nil {
|
||||
var r io.ReadCloser
|
||||
r, err = resources.FS.Open(consts.PlaceholderAlbumArt)
|
||||
r, err = resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||
reader, err = resizeImage(r, size, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
@@ -39,6 +39,9 @@ var _ = Describe("Artwork", func() {
|
||||
Eventually(func() bool { return cache.Ready(context.TODO()) }).Should(BeTrue())
|
||||
artwork = NewArtwork(ds, cache)
|
||||
})
|
||||
AfterEach(func() {
|
||||
_ = os.RemoveAll(conf.Server.DataFolder)
|
||||
})
|
||||
|
||||
It("retrieves the external artwork art for an album", func() {
|
||||
r, err := artwork.Get(ctx, "al-444", 0)
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
@@ -25,10 +25,11 @@ const (
|
||||
oneDay = 24 * time.Hour
|
||||
)
|
||||
|
||||
var _ = BeforeSuite(func() {
|
||||
conf.Server.SessionTimeout = 2 * oneDay
|
||||
})
|
||||
|
||||
var _ = Describe("Auth", func() {
|
||||
BeforeSuite(func() {
|
||||
conf.Server.SessionTimeout = 2 * oneDay
|
||||
})
|
||||
|
||||
BeforeEach(func() {
|
||||
auth.Secret = []byte(testJWTSecret)
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
|
||||
@@ -8,10 +8,11 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/kennygrant/sanitize"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
_ "github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
_ "github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
_ "github.com/navidrome/navidrome/core/agents/spotify"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -82,8 +83,18 @@ func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, simi
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If we have fresh info, just return it and trigger a refresh in the background
|
||||
if time.Since(artist.ExternalInfoUpdatedAt) < consts.ArtistInfoTimeToLive {
|
||||
// If we don't have any info, retrieves it now
|
||||
if artist.ExternalInfoUpdatedAt.IsZero() {
|
||||
log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", artist.ExternalInfoUpdatedAt, "id", id, "name", artist.Name)
|
||||
err = e.refreshArtistInfo(ctx, artist)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// If info is expired, trigger a refresh in the background
|
||||
if time.Since(artist.ExternalInfoUpdatedAt) > consts.ArtistInfoTimeToLive {
|
||||
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", artist.ExternalInfoUpdatedAt, "name", artist.Name)
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
@@ -92,16 +103,8 @@ func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, simi
|
||||
log.Error("Error refreshing ArtistInfo", "id", id, "name", artist.Name, err)
|
||||
}
|
||||
}()
|
||||
log.Debug("Found cached ArtistInfo, refreshing in the background", "updatedAt", artist.ExternalInfoUpdatedAt, "name", artist.Name)
|
||||
err := e.loadSimilar(ctx, artist, similarCount, includeNotPresent)
|
||||
return &artist.Artist, err
|
||||
}
|
||||
|
||||
log.Debug(ctx, "ArtistInfo not cached or expired", "updatedAt", artist.ExternalInfoUpdatedAt, "id", id, "name", artist.Name)
|
||||
err = e.refreshArtistInfo(ctx, artist)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = e.loadSimilar(ctx, artist, similarCount, includeNotPresent)
|
||||
return &artist.Artist, err
|
||||
}
|
||||
@@ -162,28 +165,37 @@ func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count in
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
artists := model.Artists{artist.Artist}
|
||||
artists = append(artists, artist.SimilarArtists...)
|
||||
|
||||
weightedSongs := utils.NewWeightedRandomChooser()
|
||||
for _, a := range artists {
|
||||
addArtist := func(a model.Artist, weightedSongs *utils.WeightedChooser, count, artistWeight int) error {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
topCount := utils.MaxInt(count, 20)
|
||||
topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Name: a.Name, Artist: a}, topCount)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err)
|
||||
continue
|
||||
return nil
|
||||
}
|
||||
|
||||
weight := topCount * 4
|
||||
weight := topCount * (4 + artistWeight)
|
||||
for _, mf := range topSongs {
|
||||
weightedSongs.Put(mf, weight)
|
||||
weight -= 4
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err = addArtist(artist.Artist, weightedSongs, count, 10)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range artist.SimilarArtists {
|
||||
err := addArtist(a, weightedSongs, count, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var similarSongs model.MediaFiles
|
||||
@@ -242,6 +254,7 @@ func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, a
|
||||
if err == nil && len(mfs) > 0 {
|
||||
return &mfs[0], nil
|
||||
}
|
||||
return e.findMatchingTrack(ctx, "", artistID, title)
|
||||
}
|
||||
mfs, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
@@ -249,9 +262,10 @@ func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, a
|
||||
squirrel.Eq{"artist_id": artistID},
|
||||
squirrel.Eq{"album_artist_id": artistID},
|
||||
},
|
||||
squirrel.Like{"title": title},
|
||||
squirrel.Like{"order_title": strings.TrimSpace(sanitize.Accents(title))},
|
||||
},
|
||||
Sort: "starred desc, rating desc, year asc",
|
||||
Max: 1,
|
||||
})
|
||||
if err != nil || len(mfs) == 0 {
|
||||
return nil, model.ErrNotFound
|
||||
@@ -272,8 +286,7 @@ func (e *externalMetadata) callGetBiography(ctx context.Context, agent agents.Ar
|
||||
if bio == "" || err != nil {
|
||||
return
|
||||
}
|
||||
policy := bluemonday.UGCPolicy()
|
||||
bio = policy.Sanitize(bio)
|
||||
bio = utils.SanitizeText(bio)
|
||||
bio = strings.ReplaceAll(bio, "\n", " ")
|
||||
artist.Biography = strings.ReplaceAll(bio, "<a ", "<a target='_blank' ")
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
@@ -32,6 +32,9 @@ var _ = Describe("MediaStreamer", func() {
|
||||
Eventually(func() bool { return testCache.Ready(context.TODO()) }).Should(BeTrue())
|
||||
streamer = NewMediaStreamer(ds, ffmpeg, testCache)
|
||||
})
|
||||
AfterEach(func() {
|
||||
_ = os.RemoveAll(conf.Server.DataFolder)
|
||||
})
|
||||
|
||||
Context("NewStream", func() {
|
||||
It("returns a seekable stream if format is 'raw'", func() {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
|
||||
263
core/playlists.go
Normal file
263
core/playlists.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
)
|
||||
|
||||
type Playlists interface {
|
||||
ImportFile(ctx context.Context, dir string, fname string) (*model.Playlist, error)
|
||||
Update(ctx context.Context, playlistId string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
|
||||
}
|
||||
|
||||
type playlists struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func NewPlaylists(ds model.DataStore) Playlists {
|
||||
return &playlists{ds: ds}
|
||||
}
|
||||
|
||||
func IsPlaylist(filePath string) bool {
|
||||
extension := strings.ToLower(filepath.Ext(filePath))
|
||||
return extension == ".m3u" || extension == ".m3u8" || extension == ".nsp"
|
||||
}
|
||||
|
||||
func (s *playlists) ImportFile(ctx context.Context, dir string, fname string) (*model.Playlist, error) {
|
||||
pls, err := s.parsePlaylist(ctx, fname, dir)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing playlist", "path", filepath.Join(dir, fname), err)
|
||||
return nil, err
|
||||
}
|
||||
log.Debug("Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks))
|
||||
err = s.updatePlaylist(ctx, pls)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error updating playlist", "path", filepath.Join(dir, fname), err)
|
||||
}
|
||||
return pls, err
|
||||
}
|
||||
|
||||
func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, baseDir string) (*model.Playlist, error) {
|
||||
pls, err := s.newSyncedPlaylist(baseDir, playlistFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := os.Open(pls.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
extension := strings.ToLower(filepath.Ext(playlistFile))
|
||||
switch extension {
|
||||
case ".nsp":
|
||||
return s.parseNSP(ctx, pls, file)
|
||||
default:
|
||||
return s.parseM3U(ctx, pls, baseDir, file)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
|
||||
playlistPath := filepath.Join(baseDir, playlistFile)
|
||||
info, err := os.Stat(playlistPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var extension = filepath.Ext(playlistFile)
|
||||
var name = playlistFile[0 : len(playlistFile)-len(extension)]
|
||||
|
||||
pls := &model.Playlist{
|
||||
Name: name,
|
||||
Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile),
|
||||
Public: false,
|
||||
Path: playlistPath,
|
||||
Sync: true,
|
||||
UpdatedAt: info.ModTime(),
|
||||
}
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.Reader) (*model.Playlist, error) {
|
||||
nsp := &nspFile{}
|
||||
dec := json.NewDecoder(file)
|
||||
err := dec.Decode(nsp)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing SmartPlaylist", "playlist", pls.Name, err)
|
||||
return nil, err
|
||||
}
|
||||
pls.Rules = &nsp.Criteria
|
||||
if nsp.Name != "" {
|
||||
pls.Name = nsp.Name
|
||||
}
|
||||
if nsp.Comment != "" {
|
||||
pls.Comment = nsp.Comment
|
||||
}
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir string, file io.Reader) (*model.Playlist, error) {
|
||||
mediaFileRepository := s.ds.MediaFile(ctx)
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Split(scanLines)
|
||||
var mfs model.MediaFiles
|
||||
for scanner.Scan() {
|
||||
path := strings.TrimSpace(scanner.Text())
|
||||
// Skip empty lines and extended info
|
||||
if path == "" || strings.HasPrefix(path, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(path, "file://") {
|
||||
path = strings.TrimPrefix(path, "file://")
|
||||
path, _ = url.QueryUnescape(path)
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(baseDir, path)
|
||||
}
|
||||
mf, err := mediaFileRepository.FindByPath(path)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", path, err)
|
||||
continue
|
||||
}
|
||||
mfs = append(mfs, *mf)
|
||||
}
|
||||
pls.Tracks = nil
|
||||
pls.AddMediaFiles(mfs)
|
||||
|
||||
return pls, scanner.Err()
|
||||
}
|
||||
|
||||
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
|
||||
owner, _ := request.UserFrom(ctx)
|
||||
|
||||
pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
if err == nil && !pls.Sync {
|
||||
log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path)
|
||||
newPls.ID = pls.ID
|
||||
newPls.Name = pls.Name
|
||||
newPls.Comment = pls.Comment
|
||||
newPls.OwnerID = pls.OwnerID
|
||||
newPls.Public = pls.Public
|
||||
newPls.EvaluatedAt = time.Time{}
|
||||
} else {
|
||||
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
||||
newPls.OwnerID = owner.ID
|
||||
}
|
||||
return s.ds.Playlist(ctx).Put(newPls)
|
||||
}
|
||||
|
||||
// From https://stackoverflow.com/a/41433698
|
||||
func scanLines(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||
if atEOF && len(data) == 0 {
|
||||
return 0, nil, nil
|
||||
}
|
||||
if i := bytes.IndexAny(data, "\r\n"); i >= 0 {
|
||||
if data[i] == '\n' {
|
||||
// We have a line terminated by single newline.
|
||||
return i + 1, data[0:i], nil
|
||||
}
|
||||
advance = i + 1
|
||||
if len(data) > i+1 && data[i+1] == '\n' {
|
||||
advance += 1
|
||||
}
|
||||
return advance, data[0:i], nil
|
||||
}
|
||||
// If we're at EOF, we have a final, non-terminated line. Return it.
|
||||
if atEOF {
|
||||
return len(data), data, nil
|
||||
}
|
||||
// Request more data.
|
||||
return 0, nil, nil
|
||||
}
|
||||
|
||||
func (s *playlists) Update(ctx context.Context, playlistId string,
|
||||
name *string, comment *string, public *bool,
|
||||
idsToAdd []string, idxToRemove []int) error {
|
||||
needsInfoUpdate := name != nil || comment != nil || public != nil
|
||||
needsTrackRefresh := len(idxToRemove) > 0
|
||||
|
||||
return s.ds.WithTx(func(tx model.DataStore) error {
|
||||
var pls *model.Playlist
|
||||
var err error
|
||||
repo := tx.Playlist(ctx)
|
||||
if needsTrackRefresh {
|
||||
pls, err = repo.GetWithTracks(playlistId)
|
||||
pls.RemoveTracks(idxToRemove)
|
||||
pls.AddTracks(idsToAdd)
|
||||
} else {
|
||||
if len(idsToAdd) > 0 {
|
||||
_, err = repo.Tracks(playlistId).Add(idsToAdd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if needsInfoUpdate {
|
||||
pls, err = repo.Get(playlistId)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !needsTrackRefresh && !needsInfoUpdate {
|
||||
return nil
|
||||
}
|
||||
|
||||
if name != nil {
|
||||
pls.Name = *name
|
||||
}
|
||||
if comment != nil {
|
||||
pls.Comment = *comment
|
||||
}
|
||||
if public != nil {
|
||||
pls.Public = *public
|
||||
}
|
||||
// Special case: The playlist is now empty
|
||||
if len(idxToRemove) > 0 && len(pls.Tracks) == 0 {
|
||||
if err = repo.Tracks(playlistId).DeleteAll(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return repo.Put(pls)
|
||||
})
|
||||
}
|
||||
|
||||
type nspFile struct {
|
||||
criteria.Criteria
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
func (i *nspFile) UnmarshalJSON(data []byte) error {
|
||||
m := map[string]interface{}{}
|
||||
err := json.Unmarshal(data, &m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.Name, _ = m["name"].(string)
|
||||
i.Comment, _ = m["comment"].(string)
|
||||
return json.Unmarshal(data, &i.Criteria)
|
||||
}
|
||||
89
core/playlists_test.go
Normal file
89
core/playlists_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("IsPlaylist", func() {
|
||||
It("returns true for a M3U file", func() {
|
||||
Expect(IsPlaylist(filepath.Join("path", "to", "test.m3u"))).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns true for a M3U8 file", func() {
|
||||
Expect(IsPlaylist(filepath.Join("path", "to", "test.m3u8"))).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false for a non-playlist file", func() {
|
||||
Expect(IsPlaylist("testm3u")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Playlists", func() {
|
||||
var ds model.DataStore
|
||||
var ps Playlists
|
||||
ctx := context.Background()
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{
|
||||
MockedMediaFile: &mockedMediaFile{},
|
||||
MockedPlaylist: &mockedPlaylist{},
|
||||
}
|
||||
})
|
||||
|
||||
Describe("ImportFile", func() {
|
||||
BeforeEach(func() {
|
||||
ps = NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("parses well-formed playlists", func() {
|
||||
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/pls1.m3u")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(pls.Tracks).To(HaveLen(3))
|
||||
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/test.mp3"))
|
||||
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/test.ogg"))
|
||||
Expect(pls.Tracks[2].Path).To(Equal("/tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
|
||||
})
|
||||
|
||||
It("parses playlists using LF ending", func() {
|
||||
pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "lf-ended.m3u")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("parses playlists using CR ending (old Mac format)", func() {
|
||||
pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "cr-ended.m3u")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
type mockedMediaFile struct {
|
||||
model.MediaFileRepository
|
||||
}
|
||||
|
||||
func (r *mockedMediaFile) FindByPath(s string) (*model.MediaFile, error) {
|
||||
return &model.MediaFile{
|
||||
ID: "123",
|
||||
Path: s,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type mockedPlaylist struct {
|
||||
model.PlaylistRepository
|
||||
}
|
||||
|
||||
func (r *mockedPlaylist) FindByPath(string) (*model.Playlist, error) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (r *mockedPlaylist) Put(*model.Playlist) error {
|
||||
return nil
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package scrobbler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -92,16 +93,14 @@ func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string)
|
||||
MediaFile: entry.MediaFile,
|
||||
TimeStamp: entry.PlayTime,
|
||||
})
|
||||
if errors.Is(err, ErrRetryLater) {
|
||||
log.Warn(ctx, "Could not send scrobble. Will be retried", "userId", entry.UserID,
|
||||
"track", entry.Title, "artist", entry.Artist, "scrobbler", b.service, err)
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
switch err {
|
||||
case ErrRetryLater:
|
||||
log.Warn(ctx, "Could not send scrobble. Will be retried", "userId", entry.UserID,
|
||||
"track", entry.Title, "artist", entry.Artist, "scrobbler", b.service, err)
|
||||
return false
|
||||
default:
|
||||
log.Error(ctx, "Error sending scrobble to service. Discarding", "scrobbler", b.service,
|
||||
"userId", entry.UserID, "artist", entry.Artist, "track", entry.Title, err)
|
||||
}
|
||||
log.Error(ctx, "Error sending scrobble to service. Discarding", "scrobbler", b.service,
|
||||
"userId", entry.UserID, "artist", entry.Artist, "track", entry.Title, err)
|
||||
}
|
||||
err = buffer.Dequeue(entry)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
|
||||
"github.com/ReneKroon/ttlcache/v2"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -44,7 +45,7 @@ type playTracker struct {
|
||||
}
|
||||
|
||||
func GetPlayTracker(ds model.DataStore, broker events.Broker) PlayTracker {
|
||||
instance := singleton.Get(playTracker{}, func() interface{} {
|
||||
return singleton.GetInstance(func() *playTracker {
|
||||
m := ttlcache.NewCache()
|
||||
m.SkipTTLExtensionOnHit(true)
|
||||
_ = m.SetTTL(nowPlayingExpire)
|
||||
@@ -59,7 +60,6 @@ func GetPlayTracker(ds model.DataStore, broker events.Broker) PlayTracker {
|
||||
}
|
||||
return p
|
||||
})
|
||||
return instance.(*playTracker)
|
||||
}
|
||||
|
||||
func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string) error {
|
||||
@@ -85,6 +85,10 @@ func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, tra
|
||||
log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err)
|
||||
return
|
||||
}
|
||||
if t.Artist == consts.UnknownArtist {
|
||||
log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist)
|
||||
return
|
||||
}
|
||||
// TODO Parallelize
|
||||
for name, s := range p.scrobblers {
|
||||
if !s.IsAuthorized(ctx, userId) {
|
||||
@@ -94,7 +98,7 @@ func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, tra
|
||||
err := s.NowPlaying(ctx, userId, t)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error sending NowPlayingInfo", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
|
||||
return
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,9 +140,9 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro
|
||||
} else {
|
||||
success++
|
||||
event.With("song", mf.ID).With("album", mf.AlbumID).With("artist", mf.AlbumArtistID)
|
||||
log.Info("Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", username)
|
||||
log.Info("Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", username, "timestamp", s.Timestamp)
|
||||
if player.ScrobbleEnabled {
|
||||
_ = p.dispatchScrobble(ctx, mf, s.Timestamp)
|
||||
p.dispatchScrobble(ctx, mf, s.Timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,7 +168,11 @@ func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, times
|
||||
})
|
||||
}
|
||||
|
||||
func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, playTime time.Time) error {
|
||||
func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, playTime time.Time) {
|
||||
if t.Artist == consts.UnknownArtist {
|
||||
log.Debug(ctx, "Ignoring external Scrobble for track with unknown artist", "track", t.Title, "artist", t.Artist)
|
||||
return
|
||||
}
|
||||
u, _ := request.UserFrom(ctx)
|
||||
scrobble := Scrobble{MediaFile: *t, TimeStamp: playTime}
|
||||
for name, s := range p.scrobblers {
|
||||
@@ -172,17 +180,16 @@ func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile,
|
||||
continue
|
||||
}
|
||||
if conf.Server.DevEnableBufferedScrobble {
|
||||
log.Debug(ctx, "Buffering scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist)
|
||||
log.Debug(ctx, "Buffering Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist)
|
||||
} else {
|
||||
log.Debug(ctx, "Sending scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist)
|
||||
log.Debug(ctx, "Sending Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist)
|
||||
}
|
||||
err := s.Scrobble(ctx, u.ID, scrobble)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error sending Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
|
||||
return err
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var constructors map[string]Constructor
|
||||
|
||||
@@ -6,12 +6,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
@@ -78,6 +79,14 @@ var _ = Describe("PlayTracker", func() {
|
||||
|
||||
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.NowPlayingCalled).To(BeFalse())
|
||||
})
|
||||
It("does not send track to agent if artist is unknown", func() {
|
||||
track.Artist = consts.UnknownArtist
|
||||
|
||||
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.NowPlayingCalled).To(BeFalse())
|
||||
})
|
||||
@@ -146,7 +155,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
Expect(fake.ScrobbleCalled).To(BeFalse())
|
||||
})
|
||||
|
||||
It("does not send track to agent player is not enabled to send scrobbles", func() {
|
||||
It("does not send track to agent if player is not enabled to send scrobbles", func() {
|
||||
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false})
|
||||
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
||||
@@ -155,6 +164,15 @@ var _ = Describe("PlayTracker", func() {
|
||||
Expect(fake.ScrobbleCalled).To(BeFalse())
|
||||
})
|
||||
|
||||
It("does not send track to agent if artist is unknown", func() {
|
||||
track.Artist = consts.UnknownArtist
|
||||
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.ScrobbleCalled).To(BeFalse())
|
||||
})
|
||||
|
||||
It("increments play counts even if it cannot scrobble", func() {
|
||||
fake.Error = errors.New("error")
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
|
||||
@@ -49,6 +49,6 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (r *shareRepositoryWrapper) Update(entity interface{}, _ ...string) error {
|
||||
return r.Persistable.Update(entity, "description")
|
||||
func (r *shareRepositoryWrapper) Update(id string, entity interface{}, _ ...string) error {
|
||||
return r.Persistable.Update(id, entity, "description")
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ var _ = Describe("Share", func() {
|
||||
Describe("Update", func() {
|
||||
It("filters out read-only fields", func() {
|
||||
entity := "entity"
|
||||
err := repo.Update(entity)
|
||||
err := repo.Update("id", entity)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockedRepo.(*tests.MockShareRepo).Entity).To(Equal("entity"))
|
||||
Expect(mockedRepo.(*tests.MockShareRepo).Cols).To(ConsistOf("description"))
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
|
||||
@@ -20,4 +20,5 @@ var Set = wire.NewSet(
|
||||
transcoder.New,
|
||||
scrobbler.GetPlayTracker,
|
||||
NewShare,
|
||||
NewPlaylists,
|
||||
)
|
||||
|
||||
3
db/db.go
3
db/db.go
@@ -19,7 +19,7 @@ var (
|
||||
)
|
||||
|
||||
func Db() *sql.DB {
|
||||
instance := singleton.Get(&sql.DB{}, func() interface{} {
|
||||
return singleton.GetInstance(func() *sql.DB {
|
||||
Path = conf.Server.DbPath
|
||||
if Path == ":memory:" {
|
||||
Path = "file::memory:?cache=shared&_foreign_keys=on"
|
||||
@@ -32,7 +32,6 @@ func Db() *sql.DB {
|
||||
}
|
||||
return instance
|
||||
})
|
||||
return instance.(*sql.DB)
|
||||
}
|
||||
|
||||
func EnsureLatestVersion() {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
|
||||
37
db/migration/20211008205505_add_smart_playlist.go
Normal file
37
db/migration/20211008205505_add_smart_playlist.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upAddSmartPlaylist, downAddSmartPlaylist)
|
||||
}
|
||||
|
||||
func upAddSmartPlaylist(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table playlist
|
||||
add column rules varchar null;
|
||||
alter table playlist
|
||||
add column evaluated_at datetime null;
|
||||
create index if not exists playlist_evaluated_at
|
||||
on playlist(evaluated_at);
|
||||
|
||||
create table playlist_fields (
|
||||
field varchar(255) not null,
|
||||
playlist_id varchar(255) not null
|
||||
constraint playlist_fields_playlist_id_fk
|
||||
references playlist
|
||||
on update cascade on delete cascade
|
||||
);
|
||||
create unique index playlist_fields_idx
|
||||
on playlist_fields (field, playlist_id);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddSmartPlaylist(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
62
db/migration/20211023184825_add_order_title_to_media_file.go
Normal file
62
db/migration/20211023184825_add_order_title_to_media_file.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
|
||||
"github.com/kennygrant/sanitize"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upAddOrderTitleToMediaFile, downAddOrderTitleToMediaFile)
|
||||
}
|
||||
|
||||
func upAddOrderTitleToMediaFile(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table main.media_file
|
||||
add order_title varchar null collate NOCASE;
|
||||
create index if not exists media_file_order_title
|
||||
on media_file (order_title);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return upAddOrderTitleToMediaFile_populateOrderTitle(tx)
|
||||
}
|
||||
|
||||
//goland:noinspection GoSnakeCaseUsage
|
||||
func upAddOrderTitleToMediaFile_populateOrderTitle(tx *sql.Tx) error {
|
||||
rows, err := tx.Query(`select id, title from media_file`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
stmt, err := tx.Prepare("update media_file set order_title = ? where id = ?")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var id, title string
|
||||
for rows.Next() {
|
||||
err = rows.Scan(&id, &title)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
orderTitle := strings.TrimSpace(sanitize.Accents(title))
|
||||
_, err = stmt.Exec(orderTitle, id)
|
||||
if err != nil {
|
||||
log.Error("Error setting media_file's order_title", "title", title, "id", id, err)
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func downAddOrderTitleToMediaFile(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
47
db/migration/20211026191915_unescape_lyrics_and_comments.go
Normal file
47
db/migration/20211026191915_unescape_lyrics_and_comments.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upUnescapeLyricsAndComments, downUnescapeLyricsAndComments)
|
||||
}
|
||||
|
||||
func upUnescapeLyricsAndComments(tx *sql.Tx) error {
|
||||
rows, err := tx.Query(`select id, comment, lyrics, title from media_file`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
stmt, err := tx.Prepare("update media_file set comment = ?, lyrics = ? where id = ?")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var id, title string
|
||||
var comment, lyrics sql.NullString
|
||||
for rows.Next() {
|
||||
err = rows.Scan(&id, &comment, &lyrics, &title)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newComment := utils.SanitizeText(comment.String)
|
||||
newLyrics := utils.SanitizeText(lyrics.String)
|
||||
_, err = stmt.Exec(newComment, newLyrics, id)
|
||||
if err != nil {
|
||||
log.Error("Error unescaping media_file's lyrics and comments", "title", title, "id", id, err)
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func downUnescapeLyricsAndComments(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
60
db/migration/20211029213200_add_userid_to_playlist.go
Normal file
60
db/migration/20211029213200_add_userid_to_playlist.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upAddUseridToPlaylist, downAddUseridToPlaylist)
|
||||
}
|
||||
|
||||
func upAddUseridToPlaylist(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table playlist_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar(255) default '' not null,
|
||||
comment varchar(255) default '' not null,
|
||||
duration real default 0 not null,
|
||||
song_count integer default 0 not null,
|
||||
public bool default FALSE not null,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
path string default '' not null,
|
||||
sync bool default false not null,
|
||||
size integer default 0 not null,
|
||||
rules varchar,
|
||||
evaluated_at datetime,
|
||||
owner_id varchar(255) not null
|
||||
constraint playlist_user_user_id_fk
|
||||
references user
|
||||
on update cascade on delete cascade
|
||||
);
|
||||
|
||||
insert into playlist_dg_tmp(id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size, rules, evaluated_at, owner_id)
|
||||
select id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size, rules, evaluated_at,
|
||||
(select id from user where user_name = owner) as user_id from playlist;
|
||||
|
||||
drop table playlist;
|
||||
alter table playlist_dg_tmp rename to playlist;
|
||||
create index playlist_created_at
|
||||
on playlist (created_at);
|
||||
create index playlist_evaluated_at
|
||||
on playlist (evaluated_at);
|
||||
create index playlist_name
|
||||
on playlist (name);
|
||||
create index playlist_size
|
||||
on playlist (size);
|
||||
create index playlist_updated_at
|
||||
on playlist (updated_at);
|
||||
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddUseridToPlaylist(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upAddAlphabeticalByArtistIndex, downAddAlphabeticalByArtistIndex)
|
||||
}
|
||||
|
||||
func upAddAlphabeticalByArtistIndex(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create index album_alphabetical_by_artist
|
||||
ON album(compilation, order_album_artist_name, order_album_name)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddAlphabeticalByArtistIndex(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
22
db/migration/20211105162746_remove_invalid_artist_ids.go
Normal file
22
db/migration/20211105162746_remove_invalid_artist_ids.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upRemoveInvalidArtistIds, downRemoveInvalidArtistIds)
|
||||
}
|
||||
|
||||
func upRemoveInvalidArtistIds(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
update media_file set artist_id = '' where not exists(select 1 from artist where id = artist_id)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downRemoveInvalidArtistIds(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upAddMusicbrainzReleaseTrackId, downAddMusicbrainzReleaseTrackId)
|
||||
}
|
||||
|
||||
func upAddMusicbrainzReleaseTrackId(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table media_file
|
||||
add mbz_release_track_id varchar(255);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan needs to be performed to import more tags")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func downAddMusicbrainzReleaseTrackId(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
)
|
||||
|
||||
// Use this in migrations that need to communicate something important (braking changes, forced reindexes, etc...)
|
||||
// Use this in migrations that need to communicate something important (breaking changes, forced reindexes, etc...)
|
||||
func notice(tx *sql.Tx, msg string) {
|
||||
if isDBInitialized(tx) {
|
||||
fmt.Printf(`
|
||||
|
||||
249
go.mod
249
go.mod
@@ -1,56 +1,243 @@
|
||||
module github.com/navidrome/navidrome
|
||||
|
||||
go 1.16
|
||||
go 1.18
|
||||
|
||||
require (
|
||||
code.cloudfoundry.org/go-diodes v0.0.0-20190809170250-f77fb823c7ee
|
||||
github.com/ClickHouse/clickhouse-go v1.4.5 // indirect
|
||||
github.com/Masterminds/squirrel v1.5.0
|
||||
github.com/ReneKroon/ttlcache/v2 v2.8.1
|
||||
github.com/astaxie/beego v1.12.3
|
||||
github.com/Masterminds/squirrel v1.5.3
|
||||
github.com/ReneKroon/ttlcache/v2 v2.11.0
|
||||
github.com/beego/beego/v2 v2.0.5
|
||||
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible
|
||||
github.com/cespare/reflex v0.3.1
|
||||
github.com/deluan/rest v0.0.0-20210503015435-e7091d44f0ba
|
||||
github.com/denisenkom/go-mssqldb v0.10.0 // indirect
|
||||
github.com/dhowden/tag v0.0.0-20200412032933-5d76b8eaae27
|
||||
github.com/deluan/rest v0.0.0-20211101235434-380523c4bb47
|
||||
github.com/dhowden/tag v0.0.0-20220618230019-adf36e896086
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/djherbis/atime v1.1.0
|
||||
github.com/djherbis/fscache v0.10.2-0.20201024185917-a0daa9e52747
|
||||
github.com/djherbis/fscache v0.10.2-0.20220222230828-2909c950912d
|
||||
github.com/djherbis/stream v1.4.0
|
||||
github.com/dustin/go-humanize v1.0.0
|
||||
github.com/fatih/structs v1.1.0
|
||||
github.com/go-chi/chi/v5 v5.0.4
|
||||
github.com/go-chi/cors v1.2.0
|
||||
github.com/go-chi/httprate v0.5.1
|
||||
github.com/go-chi/chi/v5 v5.0.7
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/go-chi/httprate v0.7.0
|
||||
github.com/go-chi/jwtauth/v5 v5.0.2
|
||||
github.com/go-sql-driver/mysql v1.6.0 // indirect
|
||||
github.com/golangci/golangci-lint v1.42.1
|
||||
github.com/golangci/golangci-lint v1.49.0
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/google/wire v0.5.0
|
||||
github.com/kennygrant/sanitize v0.0.0-20170120101633-6a0bfdde8629
|
||||
github.com/kr/pretty v0.3.0
|
||||
github.com/lestrrat-go/jwx v1.2.7
|
||||
github.com/lestrrat-go/jwx v1.2.25
|
||||
github.com/matoous/go-nanoid v1.5.0
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible
|
||||
github.com/mattn/go-sqlite3 v1.14.15
|
||||
github.com/mattn/go-zglob v0.0.3
|
||||
github.com/microcosm-cc/bluemonday v1.0.15
|
||||
github.com/mileusna/useragent v1.0.2
|
||||
github.com/microcosm-cc/bluemonday v1.0.20
|
||||
github.com/mileusna/useragent v1.2.1
|
||||
github.com/oklog/run v1.1.0
|
||||
github.com/onsi/ginkgo v1.16.4
|
||||
github.com/onsi/gomega v1.16.0
|
||||
github.com/onsi/ginkgo/v2 v2.2.0
|
||||
github.com/onsi/gomega v1.20.2
|
||||
github.com/pressly/goose v2.7.0+incompatible
|
||||
github.com/prometheus/client_golang v1.13.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
github.com/spf13/cobra v1.2.1
|
||||
github.com/spf13/viper v1.9.0
|
||||
github.com/stretchr/testify v1.7.0
|
||||
github.com/unrolled/secure v1.0.9
|
||||
github.com/sirupsen/logrus v1.9.0
|
||||
github.com/spf13/cobra v1.5.0
|
||||
github.com/spf13/viper v1.13.0
|
||||
github.com/stretchr/testify v1.8.0
|
||||
github.com/unrolled/secure v1.13.0
|
||||
github.com/xrash/smetrics v0.0.0-20200730060457-89a2a8a1fb0b
|
||||
github.com/ziutek/mymysql v1.5.4 // indirect
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8
|
||||
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d
|
||||
golang.org/x/tools v0.1.7
|
||||
gopkg.in/djherbis/atime.v1 v1.0.0 // indirect
|
||||
gopkg.in/djherbis/stream.v1 v1.3.1
|
||||
golang.org/x/text v0.3.7
|
||||
golang.org/x/tools v0.1.12
|
||||
)
|
||||
|
||||
replace github.com/dhowden/tag => github.com/wader/tag v0.0.0-20200426234345-d072771f6a51
|
||||
require (
|
||||
4d63.com/gochecknoglobals v0.1.0 // indirect
|
||||
github.com/Antonboom/errname v0.1.7 // indirect
|
||||
github.com/Antonboom/nilnil v0.1.1 // indirect
|
||||
github.com/BurntSushi/toml v1.2.0 // indirect
|
||||
github.com/ClickHouse/clickhouse-go v1.4.5 // indirect
|
||||
github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect
|
||||
github.com/GaijinEntertainment/go-exhaustruct/v2 v2.3.0 // indirect
|
||||
github.com/Masterminds/semver v1.5.0 // indirect
|
||||
github.com/OpenPeeDeeP/depguard v1.1.0 // indirect
|
||||
github.com/alexkohler/prealloc v1.0.0 // indirect
|
||||
github.com/alingse/asasalint v0.0.11 // indirect
|
||||
github.com/ashanbrown/forbidigo v1.3.0 // indirect
|
||||
github.com/ashanbrown/makezero v1.1.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bkielbasa/cyclop v1.2.0 // indirect
|
||||
github.com/blizzy78/varnamelen v0.8.0 // indirect
|
||||
github.com/bombsimon/wsl/v3 v3.3.0 // indirect
|
||||
github.com/breml/bidichk v0.2.3 // indirect
|
||||
github.com/breml/errchkjson v0.3.0 // indirect
|
||||
github.com/butuzov/ireturn v0.1.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||
github.com/charithe/durationcheck v0.0.9 // indirect
|
||||
github.com/chavacava/garif v0.0.0-20220630083739-93517212f375 // indirect
|
||||
github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58 // indirect
|
||||
github.com/creack/pty v1.1.11 // indirect
|
||||
github.com/curioswitch/go-reassign v0.1.2 // indirect
|
||||
github.com/daixiang0/gci v0.6.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d // indirect
|
||||
github.com/denis-tingaikin/go-header v0.4.3 // indirect
|
||||
github.com/denisenkom/go-mssqldb v0.10.0 // indirect
|
||||
github.com/esimonov/ifshort v1.0.4 // indirect
|
||||
github.com/ettle/strcase v0.1.1 // indirect
|
||||
github.com/fatih/color v1.13.0 // indirect
|
||||
github.com/fatih/structtag v1.2.0 // indirect
|
||||
github.com/firefart/nonamedreturns v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.5.4 // indirect
|
||||
github.com/fzipp/gocyclo v0.6.0 // indirect
|
||||
github.com/go-critic/go-critic v0.6.4 // indirect
|
||||
github.com/go-sql-driver/mysql v1.6.0 // indirect
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect
|
||||
github.com/go-toolsmith/astcast v1.0.0 // indirect
|
||||
github.com/go-toolsmith/astcopy v1.0.1 // indirect
|
||||
github.com/go-toolsmith/astequal v1.0.2 // indirect
|
||||
github.com/go-toolsmith/astfmt v1.0.0 // indirect
|
||||
github.com/go-toolsmith/astp v1.0.0 // indirect
|
||||
github.com/go-toolsmith/strparse v1.0.0 // indirect
|
||||
github.com/go-toolsmith/typep v1.0.2 // indirect
|
||||
github.com/go-xmlfmt/xmlfmt v0.0.0-20191208150333-d5b6f63a941b // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/goccy/go-json v0.9.7 // indirect
|
||||
github.com/gofrs/flock v0.8.1 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/golangci/check v0.0.0-20180506172741-cfe4005ccda2 // indirect
|
||||
github.com/golangci/dupl v0.0.0-20180902072040-3e9179ac440a // indirect
|
||||
github.com/golangci/go-misc v0.0.0-20220329215616-d24fe342adfe // indirect
|
||||
github.com/golangci/gofmt v0.0.0-20190930125516-244bba706f1a // indirect
|
||||
github.com/golangci/lint-1 v0.0.0-20191013205115-297bf364a8e0 // indirect
|
||||
github.com/golangci/maligned v0.0.0-20180506175553-b1d89398deca // indirect
|
||||
github.com/golangci/misspell v0.3.5 // indirect
|
||||
github.com/golangci/revgrep v0.0.0-20220804021717-745bb2f7c2e6 // indirect
|
||||
github.com/golangci/unconvert v0.0.0-20180507085042-28b1c447d1f4 // indirect
|
||||
github.com/google/go-cmp v0.5.8 // indirect
|
||||
github.com/google/pprof v0.0.0-20220829040838-70bd9ae97f40 // indirect
|
||||
github.com/google/subcommands v1.0.1 // indirect
|
||||
github.com/gordonklaus/ineffassign v0.0.0-20210914165742-4cc7213b9bc8 // indirect
|
||||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
|
||||
github.com/gostaticanalysis/comment v1.4.2 // indirect
|
||||
github.com/gostaticanalysis/forcetypeassert v0.1.0 // indirect
|
||||
github.com/gostaticanalysis/nilerr v0.1.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.0.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/go-version v1.6.0 // indirect
|
||||
github.com/hashicorp/golang-lru v0.5.4 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/hexops/gotextdiff v1.0.3 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.0.0 // indirect
|
||||
github.com/jgautheron/goconst v1.5.1 // indirect
|
||||
github.com/jingyugao/rowserrcheck v1.1.1 // indirect
|
||||
github.com/jirfag/go-printf-func-name v0.0.0-20200119135958-7558a9eaa5af // indirect
|
||||
github.com/julz/importas v0.1.0 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/kisielk/errcheck v1.6.2 // indirect
|
||||
github.com/kisielk/gotool v1.0.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/kulti/thelper v0.6.3 // indirect
|
||||
github.com/kunwardeep/paralleltest v1.0.6 // indirect
|
||||
github.com/kyoh86/exportloopref v0.1.8 // indirect
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
||||
github.com/ldez/gomoddirectives v0.2.3 // indirect
|
||||
github.com/ldez/tagliatelle v0.3.1 // indirect
|
||||
github.com/leonklingele/grouper v1.1.0 // indirect
|
||||
github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.0 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/iter v1.0.1 // indirect
|
||||
github.com/lestrrat-go/option v1.0.0 // indirect
|
||||
github.com/lib/pq v1.10.6 // indirect
|
||||
github.com/lufeee/execinquery v1.2.1 // indirect
|
||||
github.com/magiconair/properties v1.8.6 // indirect
|
||||
github.com/maratori/testpackage v1.1.0 // indirect
|
||||
github.com/matoous/godox v0.0.0-20210227103229-6504466cf951 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.16 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.9 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
|
||||
github.com/mbilski/exhaustivestruct v1.2.0 // indirect
|
||||
github.com/mgechev/revive v1.2.3 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/moricho/tparallel v0.2.1 // indirect
|
||||
github.com/nakabonne/nestif v0.3.1 // indirect
|
||||
github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354 // indirect
|
||||
github.com/nishanths/exhaustive v0.8.1 // indirect
|
||||
github.com/nishanths/predeclared v0.2.2 // indirect
|
||||
github.com/ogier/pflag v0.0.1 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/onsi/ginkgo v1.16.5 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.5 // indirect
|
||||
github.com/phayes/checkstyle v0.0.0-20170904204023-bfd46e6a821d // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/polyfloyd/go-errorlint v1.0.2 // indirect
|
||||
github.com/prometheus/client_model v0.2.0 // indirect
|
||||
github.com/prometheus/common v0.37.0 // indirect
|
||||
github.com/prometheus/procfs v0.8.0 // indirect
|
||||
github.com/quasilyte/go-ruleguard v0.3.17 // indirect
|
||||
github.com/quasilyte/gogrep v0.0.0-20220120141003-628d8b3623b5 // indirect
|
||||
github.com/quasilyte/regex/syntax v0.0.0-20200407221936-30656e2c4a95 // indirect
|
||||
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
|
||||
github.com/rogpeppe/go-internal v1.8.1 // indirect
|
||||
github.com/ryancurrah/gomodguard v1.2.4 // indirect
|
||||
github.com/ryanrolds/sqlclosecheck v0.3.0 // indirect
|
||||
github.com/sanposhiho/wastedassign/v2 v2.0.6 // indirect
|
||||
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
|
||||
github.com/sashamelentyev/usestdlibvars v1.13.0 // indirect
|
||||
github.com/securego/gosec/v2 v2.13.1 // indirect
|
||||
github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c // indirect
|
||||
github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18 // indirect
|
||||
github.com/sivchari/containedctx v1.0.2 // indirect
|
||||
github.com/sivchari/nosnakecase v1.7.0 // indirect
|
||||
github.com/sivchari/tenv v1.7.0 // indirect
|
||||
github.com/sonatard/noctx v0.0.1 // indirect
|
||||
github.com/sourcegraph/go-diff v0.6.1 // indirect
|
||||
github.com/spf13/afero v1.8.2 // indirect
|
||||
github.com/spf13/cast v1.5.0 // indirect
|
||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
|
||||
github.com/stbenjam/no-sprintf-host-port v0.1.1 // indirect
|
||||
github.com/stretchr/objx v0.4.0 // indirect
|
||||
github.com/subosito/gotenv v1.4.1 // indirect
|
||||
github.com/sylvia7788/contextcheck v1.0.6 // indirect
|
||||
github.com/tdakkota/asciicheck v0.1.1 // indirect
|
||||
github.com/tetafro/godot v1.4.11 // indirect
|
||||
github.com/timakin/bodyclose v0.0.0-20210704033933-f49887972144 // indirect
|
||||
github.com/timonwong/logrlint v0.1.0 // indirect
|
||||
github.com/tomarrell/wrapcheck/v2 v2.6.2 // indirect
|
||||
github.com/tommy-muehle/go-mnd/v2 v2.5.0 // indirect
|
||||
github.com/ultraware/funlen v0.0.3 // indirect
|
||||
github.com/ultraware/whitespace v0.0.5 // indirect
|
||||
github.com/uudashr/gocognit v1.0.6 // indirect
|
||||
github.com/yagipy/maintidx v1.0.0 // indirect
|
||||
github.com/yeya24/promlinter v0.2.0 // indirect
|
||||
github.com/ziutek/mymysql v1.5.4 // indirect
|
||||
gitlab.com/bosi/decorder v0.2.3 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
go.uber.org/multierr v1.6.0 // indirect
|
||||
go.uber.org/zap v1.17.0 // indirect
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect
|
||||
golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20220613132600-b0d781184e0d // indirect
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
|
||||
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b // indirect
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect
|
||||
google.golang.org/protobuf v1.28.1 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
honnef.co/go/tools v0.3.3 // indirect
|
||||
mvdan.cc/gofumpt v0.3.1 // indirect
|
||||
mvdan.cc/interfacer v0.0.0-20180901003855-c20040233aed // indirect
|
||||
mvdan.cc/lint v0.0.0-20170908181259-adc824a0674b // indirect
|
||||
mvdan.cc/unparam v0.0.0-20220706161116-678bad134442 // indirect
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ package log
|
||||
import (
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
|
||||
1
main.go
1
main.go
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/cmd"
|
||||
_ "github.com/navidrome/navidrome/model/criteria"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -52,6 +52,7 @@ type AlbumRepository interface {
|
||||
Put(*Album) error
|
||||
Get(id string) (*Album, error)
|
||||
GetAll(...QueryOptions) (Albums, error)
|
||||
GetAllWithoutGenres(...QueryOptions) (Albums, error)
|
||||
Search(q string, offset int, size int) (Albums, error)
|
||||
Refresh(ids ...string) error
|
||||
AnnotatedRepository
|
||||
|
||||
102
model/criteria/criteria.go
Normal file
102
model/criteria/criteria.go
Normal file
@@ -0,0 +1,102 @@
|
||||
// Package criteria implements a Criteria API based on Masterminds/squirrel
|
||||
package criteria
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
type Expression = squirrel.Sqlizer
|
||||
|
||||
type Criteria struct {
|
||||
Expression
|
||||
Sort string
|
||||
Order string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
func (c Criteria) OrderBy() string {
|
||||
if c.Sort == "" {
|
||||
c.Sort = "title"
|
||||
}
|
||||
f := fieldMap[strings.ToLower(c.Sort)]
|
||||
var mapped string
|
||||
if f == nil {
|
||||
log.Error("Invalid field in 'sort' field. Using 'title'", "sort", c.Sort)
|
||||
mapped = fieldMap["title"].field
|
||||
} else {
|
||||
if f.order == "" {
|
||||
mapped = f.field
|
||||
} else {
|
||||
mapped = f.order
|
||||
}
|
||||
}
|
||||
if c.Order != "" {
|
||||
if strings.EqualFold(c.Order, "asc") || strings.EqualFold(c.Order, "desc") {
|
||||
mapped = mapped + " " + c.Order
|
||||
} else {
|
||||
log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.Order)
|
||||
}
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
|
||||
func (c Criteria) ToSql() (sql string, args []interface{}, err error) {
|
||||
return c.Expression.ToSql()
|
||||
}
|
||||
|
||||
func (c Criteria) MarshalJSON() ([]byte, error) {
|
||||
aux := struct {
|
||||
All []Expression `json:"all,omitempty"`
|
||||
Any []Expression `json:"any,omitempty"`
|
||||
Sort string `json:"sort,omitempty"`
|
||||
Order string `json:"order,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
}{
|
||||
Sort: c.Sort,
|
||||
Order: c.Order,
|
||||
Limit: c.Limit,
|
||||
Offset: c.Offset,
|
||||
}
|
||||
switch rules := c.Expression.(type) {
|
||||
case Any:
|
||||
aux.Any = rules
|
||||
case All:
|
||||
aux.All = rules
|
||||
default:
|
||||
aux.All = All{rules}
|
||||
}
|
||||
return json.Marshal(aux)
|
||||
}
|
||||
|
||||
func (c *Criteria) UnmarshalJSON(data []byte) error {
|
||||
var aux struct {
|
||||
All unmarshalConjunctionType `json:"all"`
|
||||
Any unmarshalConjunctionType `json:"any"`
|
||||
Sort string `json:"sort"`
|
||||
Order string `json:"order"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(aux.Any) > 0 {
|
||||
c.Expression = Any(aux.Any)
|
||||
} else if len(aux.All) > 0 {
|
||||
c.Expression = All(aux.All)
|
||||
} else {
|
||||
return errors.New("invalid criteria json. missing rules (key 'all' or 'any')")
|
||||
}
|
||||
c.Sort = aux.Sort
|
||||
c.Order = aux.Order
|
||||
c.Limit = aux.Limit
|
||||
c.Offset = aux.Offset
|
||||
return nil
|
||||
}
|
||||
16
model/criteria/criteria_suite_test.go
Normal file
16
model/criteria/criteria_suite_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package criteria
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestCriteria(t *testing.T) {
|
||||
log.SetLevel(log.LevelCritical)
|
||||
gomega.RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Criteria Suite")
|
||||
}
|
||||
90
model/criteria/criteria_test.go
Normal file
90
model/criteria/criteria_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package criteria
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Criteria", func() {
|
||||
var goObj Criteria
|
||||
var jsonObj string
|
||||
BeforeEach(func() {
|
||||
goObj = Criteria{
|
||||
Expression: All{
|
||||
Contains{"title": "love"},
|
||||
NotContains{"title": "hate"},
|
||||
Any{
|
||||
IsNot{"artist": "u2"},
|
||||
Is{"album": "best of"},
|
||||
},
|
||||
All{
|
||||
StartsWith{"comment": "this"},
|
||||
InTheRange{"year": []int{1980, 1990}},
|
||||
},
|
||||
},
|
||||
Sort: "title",
|
||||
Order: "asc",
|
||||
Limit: 20,
|
||||
Offset: 10,
|
||||
}
|
||||
var b bytes.Buffer
|
||||
err := json.Compact(&b, []byte(`
|
||||
{
|
||||
"all": [
|
||||
{ "contains": {"title": "love"} },
|
||||
{ "notContains": {"title": "hate"} },
|
||||
{ "any": [
|
||||
{ "isNot": {"artist": "u2"} },
|
||||
{ "is": {"album": "best of"} }
|
||||
]
|
||||
},
|
||||
{ "all": [
|
||||
{ "startsWith": {"comment": "this"} },
|
||||
{ "inTheRange": {"year":[1980,1990]} }
|
||||
]
|
||||
}
|
||||
],
|
||||
"sort": "title",
|
||||
"order": "asc",
|
||||
"limit": 20,
|
||||
"offset": 10
|
||||
}
|
||||
`))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
jsonObj = b.String()
|
||||
})
|
||||
|
||||
It("generates valid SQL", func() {
|
||||
sql, args, err := goObj.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal("(media_file.title LIKE ? AND media_file.title NOT LIKE ? AND (media_file.artist <> ? OR media_file.album = ?) AND (media_file.comment LIKE ? AND (media_file.year >= ? AND media_file.year <= ?)))"))
|
||||
gomega.Expect(args).To(gomega.ConsistOf("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990))
|
||||
})
|
||||
|
||||
It("marshals to JSON", func() {
|
||||
j, err := json.Marshal(goObj)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(string(j)).To(gomega.Equal(jsonObj))
|
||||
})
|
||||
|
||||
It("is reversible to/from JSON", func() {
|
||||
var newObj Criteria
|
||||
err := json.Unmarshal([]byte(jsonObj), &newObj)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
j, err := json.Marshal(newObj)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(string(j)).To(gomega.Equal(jsonObj))
|
||||
})
|
||||
|
||||
It("allows sort by random", func() {
|
||||
newObj := goObj
|
||||
newObj.Sort = "random"
|
||||
gomega.Expect(newObj.OrderBy()).To(gomega.Equal("random() asc"))
|
||||
})
|
||||
|
||||
})
|
||||
62
model/criteria/fields.go
Normal file
62
model/criteria/fields.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package criteria
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
var fieldMap = map[string]*mappedField{
|
||||
"title": {field: "media_file.title"},
|
||||
"album": {field: "media_file.album"},
|
||||
"artist": {field: "media_file.artist"},
|
||||
"albumartist": {field: "media_file.album_artist"},
|
||||
"hascoverart": {field: "media_file.has_cover_art"},
|
||||
"tracknumber": {field: "media_file.track_number"},
|
||||
"discnumber": {field: "media_file.disc_number"},
|
||||
"year": {field: "media_file.year"},
|
||||
"size": {field: "media_file.size"},
|
||||
"compilation": {field: "media_file.compilation"},
|
||||
"dateadded": {field: "media_file.created_at"},
|
||||
"datemodified": {field: "media_file.updated_at"},
|
||||
"discsubtitle": {field: "media_file.disc_subtitle"},
|
||||
"comment": {field: "media_file.comment"},
|
||||
"lyrics": {field: "media_file.lyrics"},
|
||||
"sorttitle": {field: "media_file.sort_title"},
|
||||
"sortalbum": {field: "media_file.sort_album_name"},
|
||||
"sortartist": {field: "media_file.sort_artist_name"},
|
||||
"sortalbumartist": {field: "media_file.sort_album_artist_name"},
|
||||
"albumtype": {field: "media_file.mbz_album_type"},
|
||||
"albumcomment": {field: "media_file.mbz_album_comment"},
|
||||
"catalognumber": {field: "media_file.catalog_num"},
|
||||
"filepath": {field: "media_file.path"},
|
||||
"filetype": {field: "media_file.suffix"},
|
||||
"duration": {field: "media_file.duration"},
|
||||
"bitrate": {field: "media_file.bit_rate"},
|
||||
"bpm": {field: "media_file.bpm"},
|
||||
"channels": {field: "media_file.channels"},
|
||||
"genre": {field: "genre.name"},
|
||||
"loved": {field: "COALESCE(annotation.starred, false)"},
|
||||
"dateloved": {field: "annotation.starred_at"},
|
||||
"lastplayed": {field: "annotation.play_date"},
|
||||
"playcount": {field: "COALESCE(annotation.play_count, 0)"},
|
||||
"rating": {field: "COALESCE(annotation.rating, 0)"},
|
||||
"random": {field: "", order: "random()"},
|
||||
}
|
||||
|
||||
type mappedField struct {
|
||||
field string
|
||||
order string
|
||||
}
|
||||
|
||||
func mapFields(expr map[string]interface{}) map[string]interface{} {
|
||||
m := make(map[string]interface{})
|
||||
for f, v := range expr {
|
||||
if dbf := fieldMap[strings.ToLower(f)]; dbf != nil && dbf.field != "" {
|
||||
m[dbf.field] = v
|
||||
} else {
|
||||
log.Error("Invalid field in criteria", "field", f)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
16
model/criteria/fields_test.go
Normal file
16
model/criteria/fields_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package criteria
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("fields", func() {
|
||||
Describe("mapFields", func() {
|
||||
It("ignores random fields", func() {
|
||||
m := map[string]interface{}{"random": "123"}
|
||||
m = mapFields(m)
|
||||
gomega.Expect(m).To(gomega.BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
125
model/criteria/json.go
Normal file
125
model/criteria/json.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package criteria
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type unmarshalConjunctionType []Expression
|
||||
|
||||
func (uc *unmarshalConjunctionType) UnmarshalJSON(data []byte) error {
|
||||
var raw []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var es unmarshalConjunctionType
|
||||
for _, e := range raw {
|
||||
for k, v := range e {
|
||||
k = strings.ToLower(k)
|
||||
expr := unmarshalExpression(k, v)
|
||||
if expr == nil {
|
||||
expr = unmarshalConjunction(k, v)
|
||||
}
|
||||
if expr == nil {
|
||||
return fmt.Errorf(`invalid expression key %s`, k)
|
||||
}
|
||||
es = append(es, expr)
|
||||
}
|
||||
}
|
||||
*uc = es
|
||||
return nil
|
||||
}
|
||||
|
||||
func unmarshalExpression(opName string, rawValue json.RawMessage) Expression {
|
||||
m := make(map[string]interface{})
|
||||
err := json.Unmarshal(rawValue, &m)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
switch opName {
|
||||
case "is":
|
||||
return Is(m)
|
||||
case "isnot":
|
||||
return IsNot(m)
|
||||
case "gt":
|
||||
return Gt(m)
|
||||
case "lt":
|
||||
return Lt(m)
|
||||
case "contains":
|
||||
return Contains(m)
|
||||
case "notcontains":
|
||||
return NotContains(m)
|
||||
case "startswith":
|
||||
return StartsWith(m)
|
||||
case "endswith":
|
||||
return EndsWith(m)
|
||||
case "intherange":
|
||||
return InTheRange(m)
|
||||
case "before":
|
||||
return Before(m)
|
||||
case "after":
|
||||
return After(m)
|
||||
case "inthelast":
|
||||
return InTheLast(m)
|
||||
case "notinthelast":
|
||||
return NotInTheLast(m)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unmarshalConjunction(conjName string, rawValue json.RawMessage) Expression {
|
||||
var items unmarshalConjunctionType
|
||||
err := json.Unmarshal(rawValue, &items)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
switch conjName {
|
||||
case "any":
|
||||
return Any(items)
|
||||
case "all":
|
||||
return All(items)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func marshalExpression(name string, value map[string]interface{}) ([]byte, error) {
|
||||
if len(value) != 1 {
|
||||
return nil, fmt.Errorf(`invalid %s expression length %d for values %v`, name, len(value), value)
|
||||
}
|
||||
b := strings.Builder{}
|
||||
b.WriteString(`{"` + name + `":{`)
|
||||
for f, v := range value {
|
||||
j, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.WriteString(`"` + f + `":`)
|
||||
b.Write(j)
|
||||
break
|
||||
}
|
||||
b.WriteString("}}")
|
||||
return []byte(b.String()), nil
|
||||
}
|
||||
|
||||
func marshalConjunction(name string, conj []Expression) ([]byte, error) {
|
||||
aux := struct {
|
||||
All []Expression `json:"all,omitempty"`
|
||||
Any []Expression `json:"any,omitempty"`
|
||||
}{}
|
||||
if name == "any" {
|
||||
aux.Any = conj
|
||||
} else {
|
||||
aux.All = conj
|
||||
}
|
||||
return json.Marshal(aux)
|
||||
}
|
||||
|
||||
type date time.Time
|
||||
|
||||
func (t date) MarshalJSON() ([]byte, error) {
|
||||
stamp := fmt.Sprintf(`"%s"`, time.Time(t).Format("2006-01-02"))
|
||||
return []byte(stamp), nil
|
||||
}
|
||||
229
model/criteria/operators.go
Normal file
229
model/criteria/operators.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package criteria
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
)
|
||||
|
||||
type (
|
||||
All squirrel.And
|
||||
And = All
|
||||
)
|
||||
|
||||
func (all All) ToSql() (sql string, args []interface{}, err error) {
|
||||
return squirrel.And(all).ToSql()
|
||||
}
|
||||
|
||||
func (all All) MarshalJSON() ([]byte, error) {
|
||||
return marshalConjunction("all", all)
|
||||
}
|
||||
|
||||
type (
|
||||
Any squirrel.Or
|
||||
Or = Any
|
||||
)
|
||||
|
||||
func (any Any) ToSql() (sql string, args []interface{}, err error) {
|
||||
return squirrel.Or(any).ToSql()
|
||||
}
|
||||
|
||||
func (any Any) MarshalJSON() ([]byte, error) {
|
||||
return marshalConjunction("any", any)
|
||||
}
|
||||
|
||||
type Is squirrel.Eq
|
||||
type Eq = Is
|
||||
|
||||
func (is Is) ToSql() (sql string, args []interface{}, err error) {
|
||||
return squirrel.Eq(mapFields(is)).ToSql()
|
||||
}
|
||||
|
||||
func (is Is) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("is", is)
|
||||
}
|
||||
|
||||
type IsNot squirrel.NotEq
|
||||
|
||||
func (in IsNot) ToSql() (sql string, args []interface{}, err error) {
|
||||
return squirrel.NotEq(mapFields(in)).ToSql()
|
||||
}
|
||||
|
||||
func (in IsNot) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("isNot", in)
|
||||
}
|
||||
|
||||
type Gt squirrel.Gt
|
||||
|
||||
func (gt Gt) ToSql() (sql string, args []interface{}, err error) {
|
||||
return squirrel.Gt(mapFields(gt)).ToSql()
|
||||
}
|
||||
|
||||
func (gt Gt) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("gt", gt)
|
||||
}
|
||||
|
||||
type Lt squirrel.Lt
|
||||
|
||||
func (lt Lt) ToSql() (sql string, args []interface{}, err error) {
|
||||
return squirrel.Lt(mapFields(lt)).ToSql()
|
||||
}
|
||||
|
||||
func (lt Lt) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("lt", lt)
|
||||
}
|
||||
|
||||
type Before squirrel.Lt
|
||||
|
||||
func (bf Before) ToSql() (sql string, args []interface{}, err error) {
|
||||
return squirrel.Lt(mapFields(bf)).ToSql()
|
||||
}
|
||||
|
||||
func (bf Before) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("before", bf)
|
||||
}
|
||||
|
||||
type After squirrel.Gt
|
||||
|
||||
func (af After) ToSql() (sql string, args []interface{}, err error) {
|
||||
return squirrel.Gt(mapFields(af)).ToSql()
|
||||
}
|
||||
|
||||
func (af After) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("after", af)
|
||||
}
|
||||
|
||||
type Contains map[string]interface{}
|
||||
|
||||
func (ct Contains) ToSql() (sql string, args []interface{}, err error) {
|
||||
lk := squirrel.Like{}
|
||||
for f, v := range mapFields(ct) {
|
||||
lk[f] = fmt.Sprintf("%%%s%%", v)
|
||||
}
|
||||
return lk.ToSql()
|
||||
}
|
||||
|
||||
func (ct Contains) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("contains", ct)
|
||||
}
|
||||
|
||||
type NotContains map[string]interface{}
|
||||
|
||||
func (nct NotContains) ToSql() (sql string, args []interface{}, err error) {
|
||||
lk := squirrel.NotLike{}
|
||||
for f, v := range mapFields(nct) {
|
||||
lk[f] = fmt.Sprintf("%%%s%%", v)
|
||||
}
|
||||
return lk.ToSql()
|
||||
}
|
||||
|
||||
func (nct NotContains) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("notContains", nct)
|
||||
}
|
||||
|
||||
type StartsWith map[string]interface{}
|
||||
|
||||
func (sw StartsWith) ToSql() (sql string, args []interface{}, err error) {
|
||||
lk := squirrel.Like{}
|
||||
for f, v := range mapFields(sw) {
|
||||
lk[f] = fmt.Sprintf("%s%%", v)
|
||||
}
|
||||
return lk.ToSql()
|
||||
}
|
||||
|
||||
func (sw StartsWith) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("startsWith", sw)
|
||||
}
|
||||
|
||||
type EndsWith map[string]interface{}
|
||||
|
||||
func (sw EndsWith) ToSql() (sql string, args []interface{}, err error) {
|
||||
lk := squirrel.Like{}
|
||||
for f, v := range mapFields(sw) {
|
||||
lk[f] = fmt.Sprintf("%%%s", v)
|
||||
}
|
||||
return lk.ToSql()
|
||||
}
|
||||
|
||||
func (sw EndsWith) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("endsWith", sw)
|
||||
}
|
||||
|
||||
type InTheRange map[string]interface{}
|
||||
|
||||
func (itr InTheRange) ToSql() (sql string, args []interface{}, err error) {
|
||||
var and squirrel.And
|
||||
for f, v := range mapFields(itr) {
|
||||
s := reflect.ValueOf(v)
|
||||
if s.Kind() != reflect.Slice || s.Len() != 2 {
|
||||
return "", nil, fmt.Errorf("invalid range for 'in' operator: %s", v)
|
||||
}
|
||||
and = append(and,
|
||||
squirrel.GtOrEq{f: s.Index(0).Interface()},
|
||||
squirrel.LtOrEq{f: s.Index(1).Interface()},
|
||||
)
|
||||
}
|
||||
return and.ToSql()
|
||||
}
|
||||
|
||||
func (itr InTheRange) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("inTheRange", itr)
|
||||
}
|
||||
|
||||
type InTheLast map[string]interface{}
|
||||
|
||||
func (itl InTheLast) ToSql() (sql string, args []interface{}, err error) {
|
||||
exp, err := inPeriod(itl, false)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return exp.ToSql()
|
||||
}
|
||||
|
||||
func (itl InTheLast) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("inTheLast", itl)
|
||||
}
|
||||
|
||||
type NotInTheLast map[string]interface{}
|
||||
|
||||
func (nitl NotInTheLast) ToSql() (sql string, args []interface{}, err error) {
|
||||
exp, err := inPeriod(nitl, true)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return exp.ToSql()
|
||||
}
|
||||
|
||||
func (nitl NotInTheLast) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("notInTheLast", nitl)
|
||||
}
|
||||
|
||||
func inPeriod(m map[string]interface{}, negate bool) (Expression, error) {
|
||||
var field string
|
||||
var value interface{}
|
||||
for f, v := range mapFields(m) {
|
||||
field, value = f, v
|
||||
break
|
||||
}
|
||||
str := fmt.Sprintf("%v", value)
|
||||
v, err := strconv.ParseInt(str, 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
firstDate := startOfPeriod(v, time.Now())
|
||||
|
||||
if negate {
|
||||
return Or{
|
||||
squirrel.Lt{field: firstDate},
|
||||
squirrel.Eq{field: nil},
|
||||
}, nil
|
||||
}
|
||||
return squirrel.Gt{field: firstDate}, nil
|
||||
}
|
||||
|
||||
func startOfPeriod(numDays int64, from time.Time) string {
|
||||
return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02")
|
||||
}
|
||||
70
model/criteria/operators_test.go
Normal file
70
model/criteria/operators_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package criteria
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Operators", func() {
|
||||
rangeStart := date(time.Date(2021, 10, 01, 0, 0, 0, 0, time.Local))
|
||||
rangeEnd := date(time.Date(2021, 11, 01, 0, 0, 0, 0, time.Local))
|
||||
|
||||
DescribeTable("ToSQL",
|
||||
func(op Expression, expectedSql string, expectedArgs ...any) {
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal(expectedSql))
|
||||
gomega.Expect(args).To(gomega.ConsistOf(expectedArgs...))
|
||||
},
|
||||
Entry("is [string]", Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"),
|
||||
Entry("is [bool]", Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true),
|
||||
Entry("isNot", IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"),
|
||||
Entry("gt", Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10),
|
||||
Entry("lt", Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10),
|
||||
Entry("contains", Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"),
|
||||
Entry("notContains", NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"),
|
||||
Entry("startsWith", StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"),
|
||||
Entry("endsWith", EndsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider"),
|
||||
Entry("inTheRange [number]", InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990),
|
||||
Entry("inTheRange [date]", InTheRange{"lastPlayed": []date{rangeStart, rangeEnd}}, "(annotation.play_date >= ? AND annotation.play_date <= ?)", rangeStart, rangeEnd),
|
||||
Entry("before", Before{"lastPlayed": rangeStart}, "annotation.play_date < ?", rangeStart),
|
||||
Entry("after", After{"lastPlayed": rangeStart}, "annotation.play_date > ?", rangeStart),
|
||||
// TODO These may be flaky
|
||||
Entry("inTheLast", InTheLast{"lastPlayed": 30}, "annotation.play_date > ?", startOfPeriod(30, time.Now())),
|
||||
Entry("notInTheLast", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", startOfPeriod(30, time.Now())),
|
||||
)
|
||||
|
||||
DescribeTable("JSON Marshaling",
|
||||
func(op Expression, jsonString string) {
|
||||
obj := And{op}
|
||||
newJs, err := json.Marshal(obj)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(string(newJs)).To(gomega.Equal(fmt.Sprintf(`{"all":[%s]}`, jsonString)))
|
||||
|
||||
var unmarshalObj unmarshalConjunctionType
|
||||
js := "[" + jsonString + "]"
|
||||
err = json.Unmarshal([]byte(js), &unmarshalObj)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(unmarshalObj[0]).To(gomega.Equal(op))
|
||||
},
|
||||
Entry("is [string]", Is{"title": "Low Rider"}, `{"is":{"title":"Low Rider"}}`),
|
||||
Entry("is [bool]", Is{"loved": false}, `{"is":{"loved":false}}`),
|
||||
Entry("isNot", IsNot{"title": "Low Rider"}, `{"isNot":{"title":"Low Rider"}}`),
|
||||
Entry("gt", Gt{"playCount": 10.0}, `{"gt":{"playCount":10}}`),
|
||||
Entry("lt", Lt{"playCount": 10.0}, `{"lt":{"playCount":10}}`),
|
||||
Entry("contains", Contains{"title": "Low Rider"}, `{"contains":{"title":"Low Rider"}}`),
|
||||
Entry("notContains", NotContains{"title": "Low Rider"}, `{"notContains":{"title":"Low Rider"}}`),
|
||||
Entry("startsWith", StartsWith{"title": "Low Rider"}, `{"startsWith":{"title":"Low Rider"}}`),
|
||||
Entry("endsWith", EndsWith{"title": "Low Rider"}, `{"endsWith":{"title":"Low Rider"}}`),
|
||||
Entry("inTheRange [number]", InTheRange{"year": []interface{}{1980.0, 1990.0}}, `{"inTheRange":{"year":[1980,1990]}}`),
|
||||
Entry("inTheRange [date]", InTheRange{"lastPlayed": []interface{}{"2021-10-01", "2021-11-01"}}, `{"inTheRange":{"lastPlayed":["2021-10-01","2021-11-01"]}}`),
|
||||
Entry("before", Before{"lastPlayed": "2021-10-01"}, `{"before":{"lastPlayed":"2021-10-01"}}`),
|
||||
Entry("after", After{"lastPlayed": "2021-10-01"}, `{"after":{"lastPlayed":"2021-10-01"}}`),
|
||||
Entry("inTheLast", InTheLast{"lastPlayed": 30.0}, `{"inTheLast":{"lastPlayed":30}}`),
|
||||
Entry("notInTheLast", NotInTheLast{"lastPlayed": 30.0}, `{"notInTheLast":{"lastPlayed":30}}`),
|
||||
)
|
||||
})
|
||||
@@ -35,6 +35,7 @@ type MediaFile struct {
|
||||
SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"`
|
||||
SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"`
|
||||
SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"`
|
||||
OrderTitle string `structs:"order_title" json:"orderTitle,omitempty"`
|
||||
OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"`
|
||||
OrderArtistName string `structs:"order_artist_name" json:"orderArtistName"`
|
||||
OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"`
|
||||
@@ -44,6 +45,7 @@ type MediaFile struct {
|
||||
Bpm int `structs:"bpm" json:"bpm,omitempty"`
|
||||
CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"`
|
||||
MbzTrackID string `structs:"mbz_track_id" json:"mbzTrackId,omitempty" orm:"column(mbz_track_id)"`
|
||||
MbzReleaseTrackID string `structs:"mbz_release_track_id" json:"mbzReleaseTrackId,omitempty" orm:"column(mbz_release_track_id)"`
|
||||
MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty" orm:"column(mbz_album_id)"`
|
||||
MbzArtistID string `structs:"mbz_artist_id" json:"mbzArtistId,omitempty" orm:"column(mbz_artist_id)"`
|
||||
MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty" orm:"column(mbz_album_artist_id)"`
|
||||
@@ -65,11 +67,13 @@ type MediaFileRepository interface {
|
||||
Put(m *MediaFile) error
|
||||
Get(id string) (*MediaFile, error)
|
||||
GetAll(options ...QueryOptions) (MediaFiles, error)
|
||||
Search(q string, offset int, size int) (MediaFiles, error)
|
||||
Delete(id string) error
|
||||
|
||||
// Queries by path to support the scanner, no Annotations or Bookmarks required in the response
|
||||
FindAllByPath(path string) (MediaFiles, error)
|
||||
FindByPath(path string) (*MediaFile, error)
|
||||
FindPathsRecursively(basePath string) ([]string, error)
|
||||
Search(q string, offset int, size int) (MediaFiles, error)
|
||||
Delete(id string) error
|
||||
DeleteByPath(path string) (int64, error)
|
||||
|
||||
AnnotatedRepository
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
type MediaFolder struct {
|
||||
ID int32
|
||||
Name string
|
||||
Path string
|
||||
}
|
||||
|
||||
func (f MediaFolder) FS() fs.FS {
|
||||
return os.DirFS(f.Path)
|
||||
}
|
||||
|
||||
type MediaFolders []MediaFolder
|
||||
|
||||
type MediaFolderRepository interface {
|
||||
|
||||
18
model/model_suite_test.go
Normal file
18
model/model_suite_test.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestModel(t *testing.T) {
|
||||
tests.Init(t, true)
|
||||
log.SetLevel(log.LevelCritical)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Model Suite")
|
||||
}
|
||||
@@ -1,32 +1,93 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
type Playlist struct {
|
||||
ID string `structs:"id" json:"id" orm:"column(id)"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
Comment string `structs:"comment" json:"comment"`
|
||||
Duration float32 `structs:"duration" json:"duration"`
|
||||
Size int64 `structs:"size" json:"size"`
|
||||
SongCount int `structs:"song_count" json:"songCount"`
|
||||
Owner string `structs:"owner" json:"owner"`
|
||||
Public bool `structs:"public" json:"public"`
|
||||
Tracks MediaFiles `structs:"-" json:"tracks,omitempty"`
|
||||
Path string `structs:"path" json:"path"`
|
||||
Sync bool `structs:"sync" json:"sync"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
ID string `structs:"id" json:"id" orm:"column(id)"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
Comment string `structs:"comment" json:"comment"`
|
||||
Duration float32 `structs:"duration" json:"duration"`
|
||||
Size int64 `structs:"size" json:"size"`
|
||||
SongCount int `structs:"song_count" json:"songCount"`
|
||||
OwnerName string `structs:"-" json:"ownerName"`
|
||||
OwnerID string `structs:"owner_id" json:"ownerId" orm:"column(owner_id)"`
|
||||
Public bool `structs:"public" json:"public"`
|
||||
Tracks PlaylistTracks `structs:"-" json:"tracks,omitempty"`
|
||||
Path string `structs:"path" json:"path"`
|
||||
Sync bool `structs:"sync" json:"sync"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
|
||||
// SmartPlaylist attributes
|
||||
Rules *criteria.Criteria `structs:"-" json:"rules"`
|
||||
EvaluatedAt time.Time `structs:"evaluated_at" json:"evaluatedAt"`
|
||||
}
|
||||
|
||||
func (pls Playlist) IsSmartPlaylist() bool {
|
||||
return pls.Rules != nil && pls.Rules.Expression != nil
|
||||
}
|
||||
|
||||
func (pls Playlist) MediaFiles() MediaFiles {
|
||||
if len(pls.Tracks) == 0 {
|
||||
return nil
|
||||
}
|
||||
return pls.Tracks.MediaFiles()
|
||||
}
|
||||
|
||||
func (pls *Playlist) RemoveTracks(idxToRemove []int) {
|
||||
var newTracks PlaylistTracks
|
||||
for i, t := range pls.Tracks {
|
||||
if utils.IntInSlice(i, idxToRemove) {
|
||||
continue
|
||||
}
|
||||
newTracks = append(newTracks, t)
|
||||
}
|
||||
pls.Tracks = newTracks
|
||||
}
|
||||
|
||||
func (pls *Playlist) AddTracks(mediaFileIds []string) {
|
||||
pos := len(pls.Tracks)
|
||||
for _, mfId := range mediaFileIds {
|
||||
pos++
|
||||
t := PlaylistTrack{
|
||||
ID: strconv.Itoa(pos),
|
||||
MediaFileID: mfId,
|
||||
MediaFile: MediaFile{ID: mfId},
|
||||
PlaylistID: pls.ID,
|
||||
}
|
||||
pls.Tracks = append(pls.Tracks, t)
|
||||
}
|
||||
}
|
||||
|
||||
func (pls *Playlist) AddMediaFiles(mfs MediaFiles) {
|
||||
pos := len(pls.Tracks)
|
||||
for _, mf := range mfs {
|
||||
pos++
|
||||
t := PlaylistTrack{
|
||||
ID: strconv.Itoa(pos),
|
||||
MediaFileID: mf.ID,
|
||||
MediaFile: mf,
|
||||
PlaylistID: pls.ID,
|
||||
}
|
||||
pls.Tracks = append(pls.Tracks, t)
|
||||
}
|
||||
}
|
||||
|
||||
type Playlists []Playlist
|
||||
|
||||
type PlaylistRepository interface {
|
||||
ResourceRepository
|
||||
CountAll(options ...QueryOptions) (int64, error)
|
||||
Exists(id string) (bool, error)
|
||||
Put(pls *Playlist) error
|
||||
Get(id string) (*Playlist, error)
|
||||
GetWithTracks(id string) (*Playlist, error)
|
||||
GetAll(options ...QueryOptions) (Playlists, error)
|
||||
FindByPath(path string) (*Playlist, error)
|
||||
Delete(id string) error
|
||||
@@ -42,13 +103,22 @@ type PlaylistTrack struct {
|
||||
|
||||
type PlaylistTracks []PlaylistTrack
|
||||
|
||||
func (plt PlaylistTracks) MediaFiles() MediaFiles {
|
||||
mfs := make(MediaFiles, len(plt))
|
||||
for i, t := range plt {
|
||||
mfs[i] = t.MediaFile
|
||||
}
|
||||
return mfs
|
||||
}
|
||||
|
||||
type PlaylistTrackRepository interface {
|
||||
ResourceRepository
|
||||
GetAll(options ...QueryOptions) (PlaylistTracks, error)
|
||||
Add(mediaFileIds []string) (int, error)
|
||||
AddAlbums(albumIds []string) (int, error)
|
||||
AddArtists(artistIds []string) (int, error)
|
||||
AddDiscs(discs []DiscID) (int, error)
|
||||
Update(mediaFileIds []string) error
|
||||
Delete(id string) error
|
||||
Delete(id ...string) error
|
||||
DeleteAll() error
|
||||
Reorder(pos int, newPos int) error
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
@@ -25,7 +25,7 @@ type albumRepository struct {
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
func NewAlbumRepository(ctx context.Context, o orm.Ormer) model.AlbumRepository {
|
||||
func NewAlbumRepository(ctx context.Context, o orm.QueryExecutor) model.AlbumRepository {
|
||||
r := &albumRepository{}
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
@@ -93,7 +93,18 @@ func (r *albumRepository) Exists(id string) (bool, error) {
|
||||
|
||||
func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder {
|
||||
sql := r.newSelectWithAnnotation("album.id", options...).Columns("album.*")
|
||||
return r.withGenres(sql).GroupBy("album.id")
|
||||
if len(options) > 0 && options[0].Filters != nil {
|
||||
s, _, _ := options[0].Filters.ToSql()
|
||||
// If there's any reference of genre in the filter, joins with genre
|
||||
if strings.Contains(s, "genre") {
|
||||
sql = r.withGenres(sql)
|
||||
// If there's no filter on genre_id, group the results by media_file.id
|
||||
if !strings.Contains(s, "genre_id") {
|
||||
sql = sql.GroupBy("album.id")
|
||||
}
|
||||
}
|
||||
}
|
||||
return sql
|
||||
}
|
||||
|
||||
func (r *albumRepository) Get(id string) (*model.Album, error) {
|
||||
@@ -118,13 +129,21 @@ func (r *albumRepository) Put(m *model.Album) error {
|
||||
}
|
||||
|
||||
func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, error) {
|
||||
res, err := r.GetAllWithoutGenres(options...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = r.loadAlbumGenres(&res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *albumRepository) GetAllWithoutGenres(options ...model.QueryOptions) (model.Albums, error) {
|
||||
sq := r.selectAlbum(options...)
|
||||
res := model.Albums{}
|
||||
err := r.queryAll(sq, &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = r.loadAlbumGenres(&res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
@@ -366,22 +385,5 @@ func (r *albumRepository) NewInstance() interface{} {
|
||||
return &model.Album{}
|
||||
}
|
||||
|
||||
func (r albumRepository) Delete(id string) error {
|
||||
return r.delete(Eq{"album.id": id})
|
||||
}
|
||||
|
||||
func (r albumRepository) Save(entity interface{}) (string, error) {
|
||||
album := entity.(*model.Album)
|
||||
id, err := r.put(album.ID, album)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (r albumRepository) Update(entity interface{}, cols ...string) error {
|
||||
album := entity.(*model.Album)
|
||||
_, err := r.put(album.ID, album)
|
||||
return err
|
||||
}
|
||||
|
||||
var _ model.AlbumRepository = (*albumRepository)(nil)
|
||||
var _ model.ResourceRepository = (*albumRepository)(nil)
|
||||
var _ rest.Persistable = (*albumRepository)(nil)
|
||||
|
||||
@@ -5,13 +5,13 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
@@ -88,19 +88,25 @@ var _ = Describe("AlbumRepository", func() {
|
||||
})
|
||||
|
||||
Describe("getCoverFromPath", func() {
|
||||
testFolder, _ := os.MkdirTemp("", "album_persistence_tests")
|
||||
if err := os.MkdirAll(testFolder, 0777); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if _, err := os.Create(filepath.Join(testFolder, "Cover.jpeg")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if _, err := os.Create(filepath.Join(testFolder, "FRONT.PNG")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
var testFolder, testPath, embeddedPath string
|
||||
BeforeEach(func() {
|
||||
testFolder, _ = os.MkdirTemp("", "album_persistence_tests")
|
||||
if err := os.MkdirAll(testFolder, 0777); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if _, err := os.Create(filepath.Join(testFolder, "Cover.jpeg")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if _, err := os.Create(filepath.Join(testFolder, "FRONT.PNG")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
testPath = filepath.Join(testFolder, "somefile.test")
|
||||
embeddedPath = filepath.Join(testFolder, "somefile.mp3")
|
||||
})
|
||||
AfterEach(func() {
|
||||
_ = os.RemoveAll(testFolder)
|
||||
})
|
||||
|
||||
testPath := filepath.Join(testFolder, "somefile.test")
|
||||
embeddedPath := filepath.Join(testFolder, "somefile.mp3")
|
||||
It("returns audio file for embedded cover", func() {
|
||||
conf.Server.CoverArtPriority = "embedded, cover.*, front.*"
|
||||
Expect(getCoverFromPath(testPath, embeddedPath)).To(Equal(""))
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
@@ -28,7 +28,7 @@ type dbArtist struct {
|
||||
SimilarArtists string `structs:"similar_artists" json:"similarArtists"`
|
||||
}
|
||||
|
||||
func NewArtistRepository(ctx context.Context, o orm.Ormer) model.ArtistRepository {
|
||||
func NewArtistRepository(ctx context.Context, o orm.QueryExecutor) model.ArtistRepository {
|
||||
r := &artistRepository{}
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
@@ -275,21 +275,5 @@ func (r *artistRepository) NewInstance() interface{} {
|
||||
return &model.Artist{}
|
||||
}
|
||||
|
||||
func (r artistRepository) Delete(id string) error {
|
||||
return r.delete(Eq{"artist.id": id})
|
||||
}
|
||||
|
||||
func (r artistRepository) Save(entity interface{}) (string, error) {
|
||||
artist := entity.(*model.Artist)
|
||||
err := r.Put(artist)
|
||||
return artist.ID, err
|
||||
}
|
||||
|
||||
func (r artistRepository) Update(entity interface{}, cols ...string) error {
|
||||
artist := entity.(*model.Artist)
|
||||
return r.Put(artist)
|
||||
}
|
||||
|
||||
var _ model.ArtistRepository = (*artistRepository)(nil)
|
||||
var _ model.ResourceRepository = (*artistRepository)(nil)
|
||||
var _ rest.Persistable = (*artistRepository)(nil)
|
||||
|
||||
@@ -3,11 +3,11 @@ package persistence
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
. "github.com/onsi/gomega/gstruct"
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -15,7 +15,7 @@ type genreRepository struct {
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
func NewGenreRepository(ctx context.Context, o orm.Ormer) model.GenreRepository {
|
||||
func NewGenreRepository(ctx context.Context, o orm.QueryExecutor) model.GenreRepository {
|
||||
r := &genreRepository{}
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
|
||||
@@ -3,11 +3,11 @@ package persistence_test
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
|
||||
@@ -37,17 +37,25 @@ func toSnakeCase(str string) string {
|
||||
}
|
||||
|
||||
func exists(subTable string, cond squirrel.Sqlizer) existsCond {
|
||||
return existsCond{subTable: subTable, cond: cond}
|
||||
return existsCond{subTable: subTable, cond: cond, not: false}
|
||||
}
|
||||
|
||||
func notExists(subTable string, cond squirrel.Sqlizer) existsCond {
|
||||
return existsCond{subTable: subTable, cond: cond, not: true}
|
||||
}
|
||||
|
||||
type existsCond struct {
|
||||
subTable string
|
||||
cond squirrel.Sqlizer
|
||||
not bool
|
||||
}
|
||||
|
||||
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)
|
||||
if e.not {
|
||||
sql = "not " + sql
|
||||
}
|
||||
return sql, args, err
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
@@ -59,6 +59,16 @@ var _ = Describe("Helpers", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("notExists", func() {
|
||||
It("constructs the correct NOT EXISTS query", func() {
|
||||
e := notExists("artist", squirrel.ConcatExpr("id = artist_id"))
|
||||
sql, args, err := e.ToSql()
|
||||
Expect(sql).To(Equal("not exists (select 1 from artist where id = artist_id)"))
|
||||
Expect(args).To(BeEmpty())
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("getMostFrequentMbzID", func() {
|
||||
It(`returns "" when no ids are passed`, func() {
|
||||
Expect(getMostFrequentMbzID(context.TODO(), " ", "", "")).To(Equal(""))
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -20,7 +20,7 @@ type mediaFileRepository struct {
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
func NewMediaFileRepository(ctx context.Context, o orm.Ormer) *mediaFileRepository {
|
||||
func NewMediaFileRepository(ctx context.Context, o orm.QueryExecutor) *mediaFileRepository {
|
||||
r := &mediaFileRepository{}
|
||||
r.ctx = ctx
|
||||
r.ormer = o
|
||||
@@ -61,7 +61,18 @@ func (r *mediaFileRepository) Put(m *model.MediaFile) error {
|
||||
func (r *mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder {
|
||||
sql := r.newSelectWithAnnotation("media_file.id", options...).Columns("media_file.*")
|
||||
sql = r.withBookmark(sql, "media_file.id")
|
||||
return r.withGenres(sql).GroupBy("media_file.id")
|
||||
if len(options) > 0 && options[0].Filters != nil {
|
||||
s, _, _ := options[0].Filters.ToSql()
|
||||
// If there's any reference of genre in the filter, joins with genre
|
||||
if strings.Contains(s, "genre") {
|
||||
sql = r.withGenres(sql)
|
||||
// If there's no filter on genre_id, group the results by media_file.id
|
||||
if !strings.Contains(s, "genre_id") {
|
||||
sql = sql.GroupBy("media_file.id")
|
||||
}
|
||||
}
|
||||
}
|
||||
return sql
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Get(id string) (*model.MediaFile, error) {
|
||||
@@ -89,7 +100,7 @@ func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.Media
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) FindByPath(path string) (*model.MediaFile, error) {
|
||||
sel := r.selectMediaFile().Where(Eq{"path": path})
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"path": path})
|
||||
var res model.MediaFiles
|
||||
if err := r.queryAll(sel, &res); err != nil {
|
||||
return nil, err
|
||||
@@ -118,7 +129,7 @@ func (r *mediaFileRepository) FindAllByPath(path string) (model.MediaFiles, erro
|
||||
// Query by path based on https://stackoverflow.com/a/13911906/653632
|
||||
path = cleanPath(path)
|
||||
pathLen := utf8.RuneCountInString(path)
|
||||
sel0 := r.selectMediaFile().Columns(fmt.Sprintf("substr(path, %d) AS item", pathLen+2)).
|
||||
sel0 := r.newSelect().Columns("media_file.*", fmt.Sprintf("substr(path, %d) AS item", pathLen+2)).
|
||||
Where(pathStartsWith(path))
|
||||
sel := r.newSelect().Columns("*", "item NOT GLOB '*"+string(os.PathSeparator)+"*' AS isLast").
|
||||
Where(Eq{"isLast": 1}).FromSelect(sel0, "sel0")
|
||||
@@ -166,6 +177,13 @@ func (r *mediaFileRepository) DeleteByPath(basePath string) (int64, error) {
|
||||
return r.executeSQL(del)
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) removeNonAlbumArtistIds() error {
|
||||
upd := Update(r.tableName).Set("artist_id", "").Where(notExists("artist", ConcatExpr("id = artist_id")))
|
||||
log.Debug(r.ctx, "Removing non-album artist_id")
|
||||
_, err := r.executeSQL(upd)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Search(q string, offset int, size int) (model.MediaFiles, error) {
|
||||
results := model.MediaFiles{}
|
||||
err := r.doSearch(q, offset, size, &results, "title")
|
||||
@@ -192,17 +210,5 @@ func (r *mediaFileRepository) NewInstance() interface{} {
|
||||
return &model.MediaFile{}
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Save(entity interface{}) (string, error) {
|
||||
mf := entity.(*model.MediaFile)
|
||||
err := r.Put(mf)
|
||||
return mf.ID, err
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Update(entity interface{}, cols ...string) error {
|
||||
mf := entity.(*model.MediaFile)
|
||||
return r.Put(mf)
|
||||
}
|
||||
|
||||
var _ model.MediaFileRepository = (*mediaFileRepository)(nil)
|
||||
var _ model.ResourceRepository = (*mediaFileRepository)(nil)
|
||||
var _ rest.Persistable = (*mediaFileRepository)(nil)
|
||||
|
||||
@@ -5,12 +5,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package persistence
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
@@ -12,7 +12,7 @@ type mediaFolderRepository struct {
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func NewMediaFolderRepository(ctx context.Context, o orm.Ormer) model.MediaFolderRepository {
|
||||
func NewMediaFolderRepository(ctx context.Context, o orm.QueryExecutor) model.MediaFolderRepository {
|
||||
return &mediaFolderRepository{ctx}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,14 +5,14 @@ import (
|
||||
"database/sql"
|
||||
"reflect"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type SQLStore struct {
|
||||
orm orm.Ormer
|
||||
orm orm.QueryExecutor
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
@@ -106,27 +106,10 @@ func (s *SQLStore) WithTx(block func(tx model.DataStore) error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = o.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newDb := &SQLStore{orm: o}
|
||||
err = block(newDb)
|
||||
|
||||
if err != nil {
|
||||
err2 := o.Rollback()
|
||||
if err2 != nil {
|
||||
return err2
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
err2 := o.Commit()
|
||||
if err2 != nil {
|
||||
return err2
|
||||
}
|
||||
return nil
|
||||
return o.DoTx(func(ctx context.Context, txOrm orm.TxOrmer) error {
|
||||
newDb := &SQLStore{orm: txOrm}
|
||||
return block(newDb)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *SQLStore) GC(ctx context.Context, rootFolder string) error {
|
||||
@@ -135,6 +118,11 @@ func (s *SQLStore) GC(ctx context.Context, rootFolder string) error {
|
||||
log.Error(ctx, "Error removing dangling tracks", err)
|
||||
return err
|
||||
}
|
||||
err = s.MediaFile(ctx).(*mediaFileRepository).removeNonAlbumArtistIds()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error removing non-album artist_ids", err)
|
||||
return err
|
||||
}
|
||||
err = s.Album(ctx).(*albumRepository).purgeEmpty()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error removing empty albums", err)
|
||||
@@ -177,7 +165,7 @@ func (s *SQLStore) GC(ctx context.Context, rootFolder string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) getOrmer() orm.Ormer {
|
||||
func (s *SQLStore) getOrmer() orm.QueryExecutor {
|
||||
if s.orm == nil {
|
||||
o, err := orm.NewOrmWithDB(db.Driver, "default", s.db)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/astaxie/beego/orm"
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
@@ -70,98 +70,108 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
plsBest = model.Playlist{
|
||||
Name: "Best",
|
||||
Comment: "No Comments",
|
||||
Owner: "userid",
|
||||
Public: true,
|
||||
SongCount: 2,
|
||||
Tracks: model.MediaFiles{{ID: "1001"}, {ID: "1003"}},
|
||||
}
|
||||
plsCool = model.Playlist{Name: "Cool", Owner: "userid", Tracks: model.MediaFiles{{ID: "1004"}}}
|
||||
testPlaylists = []*model.Playlist{&plsBest, &plsCool}
|
||||
plsBest model.Playlist
|
||||
plsCool model.Playlist
|
||||
testPlaylists []*model.Playlist
|
||||
)
|
||||
|
||||
func P(path string) string {
|
||||
return filepath.FromSlash(path)
|
||||
}
|
||||
|
||||
var _ = Describe("Initialize test DB", func() {
|
||||
// Initialize test DB
|
||||
// TODO Load this data setup from file(s)
|
||||
var _ = BeforeSuite(func() {
|
||||
o := orm.NewOrm()
|
||||
ctx := log.NewContext(context.TODO())
|
||||
user := model.User{ID: "userid", UserName: "userid"}
|
||||
ctx = request.WithUser(ctx, user)
|
||||
|
||||
// TODO Load this data setup from file(s)
|
||||
BeforeSuite(func() {
|
||||
o := orm.NewOrm()
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid"})
|
||||
ur := NewUserRepository(ctx, o)
|
||||
err := ur.Put(&user)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
gr := NewGenreRepository(ctx, o)
|
||||
for i := range testGenres {
|
||||
g := testGenres[i]
|
||||
err := gr.Put(&g)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
mr := NewMediaFileRepository(ctx, o)
|
||||
for i := range testSongs {
|
||||
s := testSongs[i]
|
||||
err := mr.Put(&s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
alr := NewAlbumRepository(ctx, o).(*albumRepository)
|
||||
for i := range testAlbums {
|
||||
a := testAlbums[i]
|
||||
err := alr.Put(&a)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
arr := NewArtistRepository(ctx, o)
|
||||
for i := range testArtists {
|
||||
a := testArtists[i]
|
||||
err := arr.Put(&a)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
pr := NewPlaylistRepository(ctx, o)
|
||||
for i := range testPlaylists {
|
||||
err := pr.Put(testPlaylists[i])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare annotations
|
||||
if err := arr.SetStar(true, artistBeatles.ID); err != nil {
|
||||
gr := NewGenreRepository(ctx, o)
|
||||
for i := range testGenres {
|
||||
g := testGenres[i]
|
||||
err := gr.Put(&g)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ar, _ := arr.Get(artistBeatles.ID)
|
||||
artistBeatles.Starred = true
|
||||
artistBeatles.StarredAt = ar.StarredAt
|
||||
testArtists[1] = artistBeatles
|
||||
}
|
||||
|
||||
if err := alr.SetStar(true, albumRadioactivity.ID); err != nil {
|
||||
mr := NewMediaFileRepository(ctx, o)
|
||||
for i := range testSongs {
|
||||
s := testSongs[i]
|
||||
err := mr.Put(&s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
al, _ := alr.Get(albumRadioactivity.ID)
|
||||
albumRadioactivity.Starred = true
|
||||
albumRadioactivity.StarredAt = al.StarredAt
|
||||
testAlbums[2] = albumRadioactivity
|
||||
}
|
||||
|
||||
if err := mr.SetStar(true, songComeTogether.ID); err != nil {
|
||||
alr := NewAlbumRepository(ctx, o).(*albumRepository)
|
||||
for i := range testAlbums {
|
||||
a := testAlbums[i]
|
||||
err := alr.Put(&a)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
mf, _ := mr.Get(songComeTogether.ID)
|
||||
songComeTogether.Starred = true
|
||||
songComeTogether.StarredAt = mf.StarredAt
|
||||
testSongs[1] = songComeTogether
|
||||
}
|
||||
|
||||
})
|
||||
arr := NewArtistRepository(ctx, o)
|
||||
for i := range testArtists {
|
||||
a := testArtists[i]
|
||||
err := arr.Put(&a)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
plsBest = model.Playlist{
|
||||
Name: "Best",
|
||||
Comment: "No Comments",
|
||||
OwnerID: "userid",
|
||||
OwnerName: "userid",
|
||||
Public: true,
|
||||
SongCount: 2,
|
||||
}
|
||||
plsBest.AddTracks([]string{"1001", "1003"})
|
||||
plsCool = model.Playlist{Name: "Cool", OwnerID: "userid", OwnerName: "userid"}
|
||||
plsCool.AddTracks([]string{"1004"})
|
||||
testPlaylists = []*model.Playlist{&plsBest, &plsCool}
|
||||
|
||||
pr := NewPlaylistRepository(ctx, o)
|
||||
for i := range testPlaylists {
|
||||
err := pr.Put(testPlaylists[i])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare annotations
|
||||
if err := arr.SetStar(true, artistBeatles.ID); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ar, _ := arr.Get(artistBeatles.ID)
|
||||
artistBeatles.Starred = true
|
||||
artistBeatles.StarredAt = ar.StarredAt
|
||||
testArtists[1] = artistBeatles
|
||||
|
||||
if err := alr.SetStar(true, albumRadioactivity.ID); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
al, _ := alr.Get(albumRadioactivity.ID)
|
||||
albumRadioactivity.Starred = true
|
||||
albumRadioactivity.StarredAt = al.StarredAt
|
||||
testAlbums[2] = albumRadioactivity
|
||||
|
||||
if err := mr.SetStar(true, songComeTogether.ID); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
mf, _ := mr.Get(songComeTogether.ID)
|
||||
songComeTogether.Starred = true
|
||||
songComeTogether.StarredAt = mf.StarredAt
|
||||
testSongs[1] = songComeTogether
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user