mirror of
https://github.com/navidrome/navidrome.git
synced 2026-09-08 11:40:11 -04:00
Compare commits
131
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
359ea3c61c | ||
|
|
03b813a2f6 | ||
|
|
af0a6615b2 | ||
|
|
7ce0d3e79f | ||
|
|
759ab26b19 | ||
|
|
356b0716b6 | ||
|
|
8a19fa9991 | ||
|
|
221d301c42 | ||
|
|
4cca7bce4e | ||
|
|
d91b5e8f4d | ||
|
|
03608d3eef | ||
|
|
cb396f3dba | ||
|
|
400a079fcd | ||
|
|
03844a9a36 | ||
|
|
5cd1fcb492 | ||
|
|
a4c289b28c | ||
|
|
f7b60c7952 | ||
|
|
ba8d427890 | ||
|
|
3f7226d253 | ||
|
|
00b8fbd789 | ||
|
|
31d94acfe7 | ||
|
|
b5164c61ab | ||
|
|
a83ebd1c98 | ||
|
|
d2a54243a8 | ||
|
|
b013b71ba9 | ||
|
|
ad92b752be | ||
|
|
f39d75e7d2 | ||
|
|
693abe2f6b | ||
|
|
a0fe728098 | ||
|
|
8f05f7815e | ||
|
|
2f5b2b5135 | ||
|
|
e7c6e78dd0 | ||
|
|
9ae9134a91 | ||
|
|
cefa6e9619 | ||
|
|
ab8a58157a | ||
|
|
be06196168 | ||
|
|
36aea8a11f | ||
|
|
aa93911991 | ||
|
|
c42570446b | ||
|
|
a887521d7a | ||
|
|
69e7d163fc | ||
|
|
6b8fcc37c6 | ||
|
|
197d357f02 | ||
|
|
549b812633 | ||
|
|
c63346de04 | ||
|
|
ba3974ee59 | ||
|
|
8939f31d55 | ||
|
|
d79b812467 | ||
|
|
55331b5fd9 | ||
|
|
d042fc138c | ||
|
|
55e10b9c77 | ||
|
|
49a14d4583 | ||
|
|
a50b2a1e72 | ||
|
|
4ddb0774ec | ||
|
|
0790f66627 | ||
|
|
d0fbba14ff | ||
|
|
903e3f070f | ||
|
|
0312eb33f1 | ||
|
|
5ecbe31a06 | ||
|
|
d8bc41fbb1 | ||
|
|
51c48bcacd | ||
|
|
75e5bc4e81 | ||
|
|
053a0fd6c0 | ||
|
|
767744a301 | ||
|
|
844dffa2f1 | ||
|
|
d76b49c6d1 | ||
|
|
94894fd511 | ||
|
|
d7c3a50f86 | ||
|
|
d4b2499e1e | ||
|
|
e08d4bef16 | ||
|
|
09e1cf6ae7 | ||
|
|
957130ca38 | ||
|
|
a25306f2c1 | ||
|
|
7c5aa1fafa | ||
|
|
928741ef25 | ||
|
|
ae1e0ddb11 | ||
|
|
e1b3412999 | ||
|
|
3cd5d16b0a | ||
|
|
f102036dc6 | ||
|
|
d2db41691e | ||
|
|
1ce561cc8e | ||
|
|
12f28b9d97 | ||
|
|
627266ec82 | ||
|
|
11e4aaed1b | ||
|
|
f03ca44a8e | ||
|
|
eeb1bd5f41 | ||
|
|
668869b6c7 | ||
|
|
24ba655dc3 | ||
|
|
ed4c0ef432 | ||
|
|
c885766854 | ||
|
|
692f0f99f6 | ||
|
|
157c917ca5 | ||
|
|
435fb0b076 | ||
|
|
6fd044fb09 | ||
|
|
30df004d4d | ||
|
|
82f9f88c0f | ||
|
|
3d86d44fd9 | ||
|
|
acd69f6a4f | ||
|
|
c4fd8e3125 | ||
|
|
27a83547f7 | ||
|
|
d004f99f8f | ||
|
|
4e34d3ac1f | ||
|
|
3476be01f7 | ||
|
|
2471bb9cf6 | ||
|
|
d9a215e1e3 | ||
|
|
d134de1061 | ||
|
|
bd8032b327 | ||
|
|
582d1b3cd9 | ||
|
|
cdd3432788 | ||
|
|
5bc2bbb70e | ||
|
|
14343d91b0 | ||
|
|
fc36f1daa6 | ||
|
|
652c27690b | ||
|
|
2bb13e5ff1 | ||
|
|
d1c5e6a2f2 | ||
|
|
0c3cc86535 | ||
|
|
b59eb32961 | ||
|
|
23bf256a66 | ||
|
|
d02bf9a53d | ||
|
|
ec75808153 | ||
|
|
7ad2907719 | ||
|
|
76c01566a9 | ||
|
|
1cf3fd9161 | ||
|
|
54de0dbc52 | ||
|
|
6f5f58ae9d | ||
|
|
821f22a86f | ||
|
|
74aa4d6fa5 | ||
|
|
dc4607c657 | ||
|
|
ddab0da207 | ||
|
|
08a71320ea | ||
|
|
44a5482493 |
No files matched your search
@@ -14,7 +14,7 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
&& apt-get -y install --no-install-recommends ffmpeg
|
||||
|
||||
# Install TagLib from cross-taglib releases
|
||||
ARG CROSS_TAGLIB_VERSION="2.1.1-1"
|
||||
ARG CROSS_TAGLIB_VERSION="2.2.0-1"
|
||||
ARG TARGETARCH
|
||||
RUN DOWNLOAD_ARCH="linux-${TARGETARCH}" \
|
||||
&& wget -q "https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/taglib-${DOWNLOAD_ARCH}.tar.gz" -O /tmp/cross-taglib.tar.gz \
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// Options
|
||||
"INSTALL_NODE": "true",
|
||||
"NODE_VERSION": "v24",
|
||||
"CROSS_TAGLIB_VERSION": "2.1.1-1"
|
||||
"CROSS_TAGLIB_VERSION": "2.2.0-1"
|
||||
}
|
||||
},
|
||||
"workspaceMount": "",
|
||||
|
||||
@@ -14,7 +14,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CROSS_TAGLIB_VERSION: "2.1.1-2"
|
||||
CROSS_TAGLIB_VERSION: "2.2.0-1"
|
||||
CGO_CFLAGS_ALLOW: "--define-prefix"
|
||||
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
|
||||
|
||||
@@ -117,7 +117,7 @@ jobs:
|
||||
- name: Test
|
||||
run: |
|
||||
pkg-config --define-prefix --cflags --libs taglib # for debugging
|
||||
go test -shuffle=on -tags netgo -race ./... -v
|
||||
go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v
|
||||
|
||||
- name: Test ndpgen
|
||||
run: |
|
||||
@@ -221,7 +221,7 @@ jobs:
|
||||
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Build Binaries
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -235,7 +235,7 @@ jobs:
|
||||
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
|
||||
|
||||
- name: Upload Binaries
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: navidrome-${{ env.PLATFORM }}
|
||||
path: ./output
|
||||
@@ -244,7 +244,7 @@ jobs:
|
||||
- name: Build and push image by digest
|
||||
id: push-image
|
||||
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -266,7 +266,7 @@ jobs:
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM }}
|
||||
@@ -288,7 +288,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
@@ -322,7 +322,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
@@ -338,7 +338,7 @@ jobs:
|
||||
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Create manifest list and push to Docker Hub
|
||||
uses: nick-fields/retry@v3
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 5
|
||||
max_attempts: 3
|
||||
@@ -374,7 +374,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: ./binaries
|
||||
pattern: navidrome-windows*
|
||||
@@ -393,7 +393,7 @@ jobs:
|
||||
du -h binaries/msi/*.msi
|
||||
|
||||
- name: Upload MSI files
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: navidrome-windows-installers
|
||||
path: binaries/msi/*.msi
|
||||
@@ -411,7 +411,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: ./binaries
|
||||
pattern: navidrome-*
|
||||
@@ -424,7 +424,7 @@ jobs:
|
||||
run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
version: '~> v2'
|
||||
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
|
||||
@@ -437,7 +437,7 @@ jobs:
|
||||
rm ./dist/*.tar.gz ./dist/*.zip
|
||||
|
||||
- name: Upload all-packages artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: packages
|
||||
path: dist/navidrome_0*
|
||||
@@ -460,13 +460,13 @@ jobs:
|
||||
item: ${{ fromJson(needs.release.outputs.package_list) }}
|
||||
steps:
|
||||
- name: Download all-packages artifact
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: packages
|
||||
path: ./dist
|
||||
|
||||
- name: Upload all-packages artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: navidrome_linux_${{ matrix.item }}
|
||||
path: dist/navidrome_0*_linux_${{ matrix.item }}
|
||||
|
||||
+2
-1
@@ -37,4 +37,5 @@ AGENTS.md
|
||||
*.wasm
|
||||
*.ndp
|
||||
openspec/
|
||||
go.work*
|
||||
go.work*
|
||||
.worktrees/
|
||||
@@ -2,6 +2,7 @@ version: "2"
|
||||
run:
|
||||
build-tags:
|
||||
- netgo
|
||||
- sqlite_fts5
|
||||
linters:
|
||||
enable:
|
||||
- asasalint
|
||||
@@ -39,6 +40,11 @@ linters:
|
||||
enable:
|
||||
- nilness
|
||||
exclusions:
|
||||
rules:
|
||||
- linters:
|
||||
- gosec
|
||||
path: _test\.go
|
||||
text: "G703"
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ Before submitting a pull request, ensure that you go through the following:
|
||||
### Commit Conventions
|
||||
Each commit message must adhere to the following format:
|
||||
```
|
||||
<type>(scope): <description> - <issue number>
|
||||
<type>(scope): <description>
|
||||
|
||||
[optional body]
|
||||
```
|
||||
|
||||
+2
-2
@@ -28,7 +28,7 @@ COPY --from=xx-build /out/ /usr/bin/
|
||||
### Get TagLib
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS taglib-build
|
||||
ARG TARGETPLATFORM
|
||||
ARG CROSS_TAGLIB_VERSION=2.1.1-2
|
||||
ARG CROSS_TAGLIB_VERSION=2.2.0-1
|
||||
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
|
||||
|
||||
# wget in busybox can't follow redirects
|
||||
@@ -109,7 +109,7 @@ RUN --mount=type=bind,source=. \
|
||||
export EXT=".exe"
|
||||
fi
|
||||
|
||||
go build -tags=netgo -ldflags="${LD_EXTRA} -w -s \
|
||||
go build -tags=netgo,sqlite_fts5 -ldflags="${LD_EXTRA} -w -s \
|
||||
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
|
||||
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
|
||||
-o /out/navidrome${EXT} .
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
||||
NODE_VERSION=$(shell cat .nvmrc)
|
||||
GO_BUILD_TAGS=netgo,sqlite_fts5
|
||||
|
||||
# Set global environment variables, required for most targets
|
||||
export CGO_CFLAGS_ALLOW=--define-prefix
|
||||
@@ -19,8 +20,8 @@ PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
||||
DOCKER_TAG ?= deluan/navidrome:develop
|
||||
|
||||
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
|
||||
CROSS_TAGLIB_VERSION ?= 2.1.1-2
|
||||
GOLANGCI_LINT_VERSION ?= v2.10.0
|
||||
CROSS_TAGLIB_VERSION ?= 2.2.1-1
|
||||
GOLANGCI_LINT_VERSION ?= v2.11.1
|
||||
|
||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||
|
||||
@@ -46,12 +47,12 @@ stop: ##@Development Stop development servers (UI and backend)
|
||||
.PHONY: stop
|
||||
|
||||
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
|
||||
go tool ginkgo watch -tags=netgo -notify ./...
|
||||
go tool ginkgo watch -tags=$(GO_BUILD_TAGS) -notify ./...
|
||||
.PHONY: watch
|
||||
|
||||
PKG ?= ./...
|
||||
test: ##@Development Run Go tests. Use PKG variable to specify packages to test, e.g. make test PKG=./server
|
||||
go test -tags netgo $(PKG)
|
||||
go test -tags $(GO_BUILD_TAGS) $(PKG)
|
||||
.PHONY: test
|
||||
|
||||
test-ndpgen: ##@Development Run tests for ndpgen plugin
|
||||
@@ -62,7 +63,7 @@ testall: test test-ndpgen test-i18n test-js ##@Development Run Go and JS tests
|
||||
.PHONY: testall
|
||||
|
||||
test-race: ##@Development Run Go tests with race detector
|
||||
go test -tags netgo -race -shuffle=on $(PKG)
|
||||
go test -tags $(GO_BUILD_TAGS) -race -shuffle=on $(PKG)
|
||||
.PHONY: test-race
|
||||
|
||||
test-js: ##@Development Run JS tests
|
||||
@@ -108,7 +109,7 @@ format: ##@Development Format code
|
||||
.PHONY: format
|
||||
|
||||
wire: check_go_env ##@Development Update Dependency Injection
|
||||
go tool wire gen -tags=netgo ./...
|
||||
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
|
||||
.PHONY: wire
|
||||
|
||||
gen: check_go_env ##@Development Run go generate for code generation
|
||||
@@ -144,14 +145,14 @@ setup-git: ##@Development Setup Git hooks (pre-commit and pre-push)
|
||||
.PHONY: setup-git
|
||||
|
||||
build: check_go_env buildjs ##@Build Build the project
|
||||
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
|
||||
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=$(GO_BUILD_TAGS)
|
||||
.PHONY: build
|
||||
|
||||
buildall: deprecated build
|
||||
.PHONY: buildall
|
||||
|
||||
debug-build: check_go_env buildjs ##@Build Build the project (with remote debug on)
|
||||
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
|
||||
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=$(GO_BUILD_TAGS)
|
||||
.PHONY: debug-build
|
||||
|
||||
buildjs: check_node_env ui/build/index.html ##@Build Build only frontend
|
||||
@@ -201,8 +202,8 @@ docker-msi: ##@Cross_Compilation Build MSI installer for Windows
|
||||
@du -h binaries/msi/*.msi
|
||||
.PHONY: docker-msi
|
||||
|
||||
run-docker: ##@Development Run a Navidrome Docker image. Usage: make run-docker tag=<tag>
|
||||
@if [ -z "$(tag)" ]; then echo "Usage: make run-docker tag=<tag>"; exit 1; fi
|
||||
docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run tag=<tag>
|
||||
@if [ -z "$(tag)" ]; then echo "Usage: make docker-run tag=<tag>"; exit 1; fi
|
||||
@TAG_DIR="tmp/$$(echo '$(tag)' | tr '/:' '_')"; mkdir -p "$$TAG_DIR"; \
|
||||
VOLUMES="-v $(PWD)/$$TAG_DIR:/data"; \
|
||||
if [ -f navidrome.toml ]; then \
|
||||
@@ -213,7 +214,7 @@ run-docker: ##@Development Run a Navidrome Docker image. Usage: make run-docker
|
||||
fi; \
|
||||
fi; \
|
||||
echo "Running: docker run --rm -p 4533:4533 $$VOLUMES $(tag)"; docker run --rm -p 4533:4533 $$VOLUMES $(tag)
|
||||
.PHONY: run-docker
|
||||
.PHONY: docker-run
|
||||
|
||||
package: docker-build ##@Cross_Compilation Create binaries and packages for ALL supported platforms
|
||||
@if [ -z `which goreleaser` ]; then echo "Please install goreleaser first: https://goreleaser.com/install/"; exit 1; fi
|
||||
@@ -232,6 +233,39 @@ get-music: ##@Development Download some free music from Navidrome's demo instanc
|
||||
.PHONY: get-music
|
||||
|
||||
|
||||
##########################################
|
||||
#### Worktrees
|
||||
|
||||
WORKTREES_DIR := .worktrees
|
||||
|
||||
wt: check_go_env ##@Worktrees Create and setup a git worktree. Usage: make wt name=feature-name [go=1]
|
||||
@if [ -z "${name}" ]; then echo "Usage: make wt name=<branch-name> [go=1]"; exit 1; fi
|
||||
@mkdir -p $(WORKTREES_DIR)
|
||||
@echo "Creating worktree for branch '${name}'..."
|
||||
@git worktree add $(WORKTREES_DIR)/${name} -b ${name} 2>/dev/null || \
|
||||
git worktree add $(WORKTREES_DIR)/${name} ${name}
|
||||
@if [ -n "${go}" ]; then \
|
||||
./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name} --go-only; \
|
||||
else \
|
||||
./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name}; \
|
||||
fi
|
||||
@echo "\nWorktree ready at $(WORKTREES_DIR)/${name}"
|
||||
@echo " cd $(WORKTREES_DIR)/${name}"
|
||||
.PHONY: wt
|
||||
|
||||
rm-wt: ##@Worktrees Remove a git worktree. Usage: make rm-wt name=feature-name
|
||||
@if [ -z "${name}" ]; then echo "Usage: make rm-wt name=<branch-name>"; exit 1; fi
|
||||
@if [ ! -d "$(WORKTREES_DIR)/${name}" ]; then echo "Worktree '${name}' not found in $(WORKTREES_DIR)/"; exit 1; fi
|
||||
@echo "Removing worktree '${name}'..."
|
||||
@git worktree remove --force $(WORKTREES_DIR)/${name}
|
||||
@echo "Worktree '${name}' removed."
|
||||
@echo "Note: branch '${name}' still exists. Delete it with: git branch -D ${name}"
|
||||
.PHONY: rm-wt
|
||||
|
||||
ls-wt: ##@Worktrees List all active git worktrees
|
||||
@git worktree list
|
||||
.PHONY: ls-wt
|
||||
|
||||
##########################################
|
||||
#### Miscellaneous
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
@@ -84,8 +84,8 @@ func (c *client) getJWT(ctx context.Context) (string, error) {
|
||||
}
|
||||
|
||||
// Calculate TTL with a 1-minute buffer for clock skew and network delays
|
||||
expiresAt := token.Expiration()
|
||||
if expiresAt.IsZero() {
|
||||
expiresAt, ok := token.Expiration()
|
||||
if !ok || expiresAt.IsZero() {
|
||||
return "", errors.New("deezer: JWT token has no expiration time")
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -179,7 +179,8 @@ var _ = Describe("JWT Authentication", func() {
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
// Verify token has no expiration
|
||||
Expect(testToken.Expiration().IsZero()).To(BeTrue())
|
||||
_, hasExp := testToken.Expiration()
|
||||
Expect(hasExp).To(BeFalse())
|
||||
|
||||
testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/storage/local"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
@@ -43,10 +44,34 @@ func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
|
||||
}
|
||||
|
||||
func (e extractor) Version() string {
|
||||
return "go-taglib (TagLib 2.1.1 WASM)"
|
||||
bi, ok := debug.ReadBuildInfo()
|
||||
if ok {
|
||||
for _, dep := range bi.Deps {
|
||||
if dep.Path == "go.senan.xyz/taglib" {
|
||||
if dep.Replace != nil {
|
||||
return dep.Replace.Version
|
||||
}
|
||||
return dep.Version
|
||||
}
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
||||
func (e extractor) extractMetadata(filePath string) (info *metadata.Info, err error) {
|
||||
// Recover from panics in the WASM runtime that can occur during any taglib
|
||||
// operation (opening, reading tags, or reading properties). This catches crashes
|
||||
// from malformed files or WASM runtime issues (e.g., wazero mmap failures on
|
||||
// hardened systems with MemoryDenyWriteExecute=true).
|
||||
debug.SetPanicOnFault(true)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error("gotaglib: WASM runtime panic reading file. Skipping", "filePath", filePath, "panic", r)
|
||||
debug.PrintStack()
|
||||
err = fmt.Errorf("WASM runtime panic: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
f, close, err := e.openFile(filePath)
|
||||
if err != nil {
|
||||
log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err)
|
||||
@@ -65,6 +90,7 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
||||
Channels: int(props.Channels),
|
||||
SampleRate: int(props.SampleRate),
|
||||
BitDepth: int(props.BitsPerSample),
|
||||
Codec: props.Codec,
|
||||
}
|
||||
|
||||
// Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys)
|
||||
@@ -99,16 +125,6 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
||||
// openFile opens the file at filePath using the extractor's filesystem.
|
||||
// It returns a TagLib File handle and a cleanup function to close resources.
|
||||
func (e extractor) openFile(filePath string) (f *taglib.File, closeFunc func(), err error) {
|
||||
// Recover from panics in the WASM runtime (e.g., wazero failing to mmap executable memory
|
||||
// on hardened systems like NixOS with MemoryDenyWriteExecute=true)
|
||||
debug.SetPanicOnFault(true)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error("WASM runtime panic: This may be caused by a hardened system that blocks executable memory mapping.", "file", filePath, "panic", r)
|
||||
err = fmt.Errorf("WASM runtime panic (hardened system?): %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
// Open the file from the filesystem
|
||||
file, err := e.fs.Open(filePath)
|
||||
if err != nil {
|
||||
@@ -279,4 +295,7 @@ func init() {
|
||||
local.RegisterExtractor("taglib", func(fsys fs.FS, baseDir string) local.Extractor {
|
||||
return &extractor{fsys}
|
||||
})
|
||||
conf.AddHook(func() {
|
||||
log.Debug("go-taglib version", "version", extractor{}.Version())
|
||||
})
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
const apiBaseUrl = "https://api.spotify.com/v1/"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("spotify: not found")
|
||||
)
|
||||
|
||||
type httpDoer interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func newClient(id, secret string, hc httpDoer) *client {
|
||||
return &client{id, secret, hc}
|
||||
}
|
||||
|
||||
type client struct {
|
||||
id string
|
||||
secret string
|
||||
hc httpDoer
|
||||
}
|
||||
|
||||
func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
|
||||
token, err := c.authorize(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("type", "artist")
|
||||
params.Add("q", name)
|
||||
params.Add("offset", "0")
|
||||
params.Add("limit", strconv.Itoa(limit))
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", apiBaseUrl+"search", nil)
|
||||
req.URL.RawQuery = params.Encode()
|
||||
req.Header.Add("Authorization", "Bearer "+token)
|
||||
|
||||
var results SearchResults
|
||||
err = c.makeRequest(req, &results)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(results.Artists.Items) == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return results.Artists.Items, err
|
||||
}
|
||||
|
||||
func (c *client) authorize(ctx context.Context) (string, error) {
|
||||
payload := url.Values{}
|
||||
payload.Add("grant_type", "client_credentials")
|
||||
|
||||
encodePayload := payload.Encode()
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", "https://accounts.spotify.com/api/token", strings.NewReader(encodePayload))
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Add("Content-Length", strconv.Itoa(len(encodePayload)))
|
||||
auth := c.id + ":" + c.secret
|
||||
req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(auth)))
|
||||
|
||||
response := map[string]any{}
|
||||
err := c.makeRequest(req, &response)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if v, ok := response["access_token"]; ok {
|
||||
return v.(string), nil
|
||||
}
|
||||
log.Error(ctx, "Invalid spotify response", "resp", response)
|
||||
return "", errors.New("invalid response")
|
||||
}
|
||||
|
||||
func (c *client) makeRequest(req *http.Request, response any) error {
|
||||
log.Trace(req.Context(), fmt.Sprintf("Sending Spotify %s request", req.Method), "url", req.URL)
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return c.parseError(data)
|
||||
}
|
||||
|
||||
return json.Unmarshal(data, response)
|
||||
}
|
||||
|
||||
func (c *client) parseError(data []byte) error {
|
||||
var e Error
|
||||
err := json.Unmarshal(data, &e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("spotify error(%s): %s", e.Code, e.Message)
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("client", func() {
|
||||
var httpClient *fakeHttpClient
|
||||
var client *client
|
||||
|
||||
BeforeEach(func() {
|
||||
httpClient = &fakeHttpClient{}
|
||||
client = newClient("SPOTIFY_ID", "SPOTIFY_SECRET", httpClient)
|
||||
})
|
||||
|
||||
Describe("ArtistImages", func() {
|
||||
It("returns artist images from a successful request", func() {
|
||||
f, _ := os.Open("tests/fixtures/spotify.search.artist.json")
|
||||
httpClient.mock("https://api.spotify.com/v1/search", http.Response{Body: f, StatusCode: 200})
|
||||
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
|
||||
})
|
||||
|
||||
artists, err := client.searchArtists(context.TODO(), "U2", 10)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(artists).To(HaveLen(20))
|
||||
Expect(artists[0].Popularity).To(Equal(82))
|
||||
|
||||
images := artists[0].Images
|
||||
Expect(images).To(HaveLen(3))
|
||||
Expect(images[0].Width).To(Equal(640))
|
||||
Expect(images[1].Width).To(Equal(320))
|
||||
Expect(images[2].Width).To(Equal(160))
|
||||
})
|
||||
|
||||
It("fails if artist was not found", func() {
|
||||
httpClient.mock("https://api.spotify.com/v1/search", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{
|
||||
"artists" : {
|
||||
"href" : "https://api.spotify.com/v1/search?query=dasdasdas%2Cdna&type=artist&offset=0&limit=20",
|
||||
"items" : [ ], "limit" : 20, "next" : null, "offset" : 0, "previous" : null, "total" : 0
|
||||
}}`)),
|
||||
})
|
||||
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
|
||||
})
|
||||
|
||||
_, err := client.searchArtists(context.TODO(), "U2", 10)
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
})
|
||||
|
||||
It("fails if not able to authorize", func() {
|
||||
f, _ := os.Open("tests/fixtures/spotify.search.artist.json")
|
||||
httpClient.mock("https://api.spotify.com/v1/search", http.Response{Body: f, StatusCode: 200})
|
||||
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||
StatusCode: 400,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":"invalid_client","error_description":"Invalid client"}`)),
|
||||
})
|
||||
|
||||
_, err := client.searchArtists(context.TODO(), "U2", 10)
|
||||
Expect(err).To(MatchError("spotify error(invalid_client): Invalid client"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("authorize", func() {
|
||||
It("returns an access_token on successful authorization", func() {
|
||||
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
|
||||
})
|
||||
|
||||
token, err := client.authorize(context.TODO())
|
||||
Expect(err).To(BeNil())
|
||||
Expect(token).To(Equal("NEW_ACCESS_TOKEN"))
|
||||
auth := httpClient.lastRequest.Header.Get("Authorization")
|
||||
Expect(auth).To(Equal("Basic U1BPVElGWV9JRDpTUE9USUZZX1NFQ1JFVA=="))
|
||||
})
|
||||
|
||||
It("fails on unsuccessful authorization", func() {
|
||||
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||
StatusCode: 400,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":"invalid_client","error_description":"Invalid client"}`)),
|
||||
})
|
||||
|
||||
_, err := client.authorize(context.TODO())
|
||||
Expect(err).To(MatchError("spotify error(invalid_client): Invalid client"))
|
||||
})
|
||||
|
||||
It("fails on invalid JSON response", func() {
|
||||
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{NOT_VALID}`)),
|
||||
})
|
||||
|
||||
_, err := client.authorize(context.TODO())
|
||||
Expect(err).To(MatchError("invalid character 'N' looking for beginning of object key string"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type fakeHttpClient struct {
|
||||
responses map[string]*http.Response
|
||||
lastRequest *http.Request
|
||||
}
|
||||
|
||||
func (c *fakeHttpClient) mock(url string, response http.Response) {
|
||||
if c.responses == nil {
|
||||
c.responses = make(map[string]*http.Response)
|
||||
}
|
||||
c.responses[url] = &response
|
||||
}
|
||||
|
||||
func (c *fakeHttpClient) Do(req *http.Request) (*http.Response, error) {
|
||||
c.lastRequest = req
|
||||
u := req.URL
|
||||
u.RawQuery = ""
|
||||
if resp, ok := c.responses[u.String()]; ok {
|
||||
return resp, nil
|
||||
}
|
||||
panic("URL not mocked: " + u.String())
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package spotify
|
||||
|
||||
type SearchResults struct {
|
||||
Artists ArtistsResult `json:"artists"`
|
||||
}
|
||||
|
||||
type ArtistsResult struct {
|
||||
HRef string `json:"href"`
|
||||
Items []Artist `json:"items"`
|
||||
}
|
||||
|
||||
type Artist struct {
|
||||
Genres []string `json:"genres"`
|
||||
HRef string `json:"href"`
|
||||
ID string `json:"id"`
|
||||
Popularity int `json:"popularity"`
|
||||
Images []Image `json:"images"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type Image struct {
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
type Error struct {
|
||||
Code string `json:"error"`
|
||||
Message string `json:"error_description"`
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Responses", func() {
|
||||
Describe("Search type=artist", func() {
|
||||
It("parses the artist search result correctly ", func() {
|
||||
var resp SearchResults
|
||||
body, _ := os.ReadFile("tests/fixtures/spotify.search.artist.json")
|
||||
err := json.Unmarshal(body, &resp)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(resp.Artists.Items).To(HaveLen(20))
|
||||
u2 := resp.Artists.Items[0]
|
||||
Expect(u2.Name).To(Equal("U2"))
|
||||
Expect(u2.Genres).To(ContainElements("irish rock", "permanent wave", "rock"))
|
||||
Expect(u2.ID).To(Equal("51Blml2LZPmy7TTiAg47vQ"))
|
||||
Expect(u2.HRef).To(Equal("https://api.spotify.com/v1/artists/51Blml2LZPmy7TTiAg47vQ"))
|
||||
Expect(u2.Images[0].URL).To(Equal("https://i.scdn.co/image/e22d5c0c8139b8439440a69854ed66efae91112d"))
|
||||
Expect(u2.Images[0].Width).To(Equal(640))
|
||||
Expect(u2.Images[0].Height).To(Equal(640))
|
||||
Expect(u2.Images[1].URL).To(Equal("https://i.scdn.co/image/40d6c5c14355cfc127b70da221233315497ec91d"))
|
||||
Expect(u2.Images[1].Width).To(Equal(320))
|
||||
Expect(u2.Images[1].Height).To(Equal(320))
|
||||
Expect(u2.Images[2].URL).To(Equal("https://i.scdn.co/image/7293d6752ae8a64e34adee5086858e408185b534"))
|
||||
Expect(u2.Images[2].Width).To(Equal(160))
|
||||
Expect(u2.Images[2].Height).To(Equal(160))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Error", func() {
|
||||
It("parses the error response correctly", func() {
|
||||
var errorResp Error
|
||||
body := []byte(`{"error":"invalid_client","error_description":"Invalid client"}`)
|
||||
err := json.Unmarshal(body, &errorResp)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(errorResp.Code).To(Equal("invalid_client"))
|
||||
Expect(errorResp.Message).To(Equal("Invalid client"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,96 +0,0 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"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/utils/cache"
|
||||
"github.com/xrash/smetrics"
|
||||
)
|
||||
|
||||
const spotifyAgentName = "spotify"
|
||||
|
||||
type spotifyAgent struct {
|
||||
ds model.DataStore
|
||||
id string
|
||||
secret string
|
||||
client *client
|
||||
}
|
||||
|
||||
func spotifyConstructor(ds model.DataStore) agents.Interface {
|
||||
if conf.Server.Spotify.ID == "" || conf.Server.Spotify.Secret == "" {
|
||||
return nil
|
||||
}
|
||||
l := &spotifyAgent{
|
||||
ds: ds,
|
||||
id: conf.Server.Spotify.ID,
|
||||
secret: conf.Server.Spotify.Secret,
|
||||
}
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
l.client = newClient(l.id, l.secret, chc)
|
||||
return l
|
||||
}
|
||||
|
||||
func (s *spotifyAgent) AgentName() string {
|
||||
return spotifyAgentName
|
||||
}
|
||||
|
||||
func (s *spotifyAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
|
||||
a, err := s.searchArtist(ctx, name)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var res []agents.ExternalImage
|
||||
for _, img := range a.Images {
|
||||
res = append(res, agents.ExternalImage{
|
||||
URL: img.URL,
|
||||
Size: img.Width,
|
||||
})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *spotifyAgent) searchArtist(ctx context.Context, name string) (*Artist, error) {
|
||||
artists, err := s.client.searchArtists(ctx, name, 40)
|
||||
if err != nil || len(artists) == 0 {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
name = strings.ToLower(name)
|
||||
|
||||
// Sort results, prioritizing artists with images, with similar names and with high popularity, in this order
|
||||
sort.Slice(artists, func(i, j int) bool {
|
||||
ai := fmt.Sprintf("%-5t-%03d-%04d", len(artists[i].Images) == 0, smetrics.WagnerFischer(name, strings.ToLower(artists[i].Name), 1, 1, 2), 1000-artists[i].Popularity)
|
||||
aj := fmt.Sprintf("%-5t-%03d-%04d", len(artists[j].Images) == 0, smetrics.WagnerFischer(name, strings.ToLower(artists[j].Name), 1, 1, 2), 1000-artists[j].Popularity)
|
||||
return ai < aj
|
||||
})
|
||||
|
||||
// If the first one has the same name, that's the one
|
||||
if strings.ToLower(artists[0].Name) != name {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return &artists[0], err
|
||||
}
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
agents.Register(spotifyAgentName, spotifyConstructor)
|
||||
})
|
||||
}
|
||||
+2
-2
@@ -27,7 +27,6 @@ import (
|
||||
_ "github.com/navidrome/navidrome/adapters/gotaglib"
|
||||
_ "github.com/navidrome/navidrome/adapters/lastfm"
|
||||
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
|
||||
_ "github.com/navidrome/navidrome/adapters/spotify"
|
||||
_ "github.com/navidrome/navidrome/adapters/taglib"
|
||||
)
|
||||
|
||||
@@ -196,7 +195,8 @@ func runInitialScan(ctx context.Context) func() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scanNeeded := conf.Server.Scanner.ScanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
|
||||
scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup
|
||||
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
|
||||
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
||||
if scanNeeded {
|
||||
s := CreateScanner(ctx)
|
||||
|
||||
+2
-1
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -74,7 +75,7 @@ func runScanner(ctx context.Context) {
|
||||
sqlDB := db.Db()
|
||||
defer db.Db().Close()
|
||||
ds := persistence.New(sqlDB)
|
||||
pls := core.NewPlaylists(ds)
|
||||
pls := playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
// Parse targets from command line or file
|
||||
var scanTargets []model.ScanTarget
|
||||
|
||||
@@ -248,6 +248,7 @@ ExecStart={{.Path|cmdEscape}}{{range .Arguments}} {{.|cmd}}{{end}}
|
||||
TimeoutStopSec=20
|
||||
RestartSec=120
|
||||
EnvironmentFile=-/etc/sysconfig/{{.Name}}
|
||||
Environment="ND_SYSTEMD_PRIORITY_LOGGING=1"
|
||||
|
||||
DevicePolicy=closed
|
||||
NoNewPrivileges=yes
|
||||
|
||||
+29
-17
@@ -1,6 +1,6 @@
|
||||
// Code generated by Wire. DO NOT EDIT.
|
||||
|
||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo"
|
||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo sqlite_fts5"
|
||||
//go:build !wireinject
|
||||
// +build !wireinject
|
||||
|
||||
@@ -16,9 +16,12 @@ import (
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
@@ -36,7 +39,6 @@ import (
|
||||
_ "github.com/navidrome/navidrome/adapters/gotaglib"
|
||||
_ "github.com/navidrome/navidrome/adapters/lastfm"
|
||||
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
|
||||
_ "github.com/navidrome/navidrome/adapters/spotify"
|
||||
_ "github.com/navidrome/navidrome/adapters/taglib"
|
||||
)
|
||||
|
||||
@@ -61,7 +63,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
share := core.NewShare(dataStore)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
imageUploadService := core.NewImageUploadService()
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||
insights := metrics.GetInstance(dataStore)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
@@ -72,12 +75,13 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
smartPlaylistEvaluator := playlists.NewSmartPlaylistEvaluator(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, smartPlaylistEvaluator, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
|
||||
user := core.NewUser(dataStore, manager)
|
||||
maintenance := core.NewMaintenance(dataStore)
|
||||
router := nativeapi.New(dataStore, share, playlists, insights, library, user, maintenance, manager)
|
||||
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, imageUploadService)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -92,17 +96,21 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
transcodingCache := core.GetTranscodingCache()
|
||||
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
transcodingCache := stream.GetTranscodingCache()
|
||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
share := core.NewShare(dataStore)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
players := core.NewPlayers(dataStore)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
imageUploadService := core.NewImageUploadService()
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||
smartPlaylistEvaluator := playlists.NewSmartPlaylistEvaluator(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, smartPlaylistEvaluator, metricsMetrics)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
|
||||
lyricsLyrics := lyrics.NewLyrics(manager)
|
||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -117,8 +125,8 @@ func CreatePublicRouter() *public.Router {
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
transcodingCache := core.GetTranscodingCache()
|
||||
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
transcodingCache := stream.GetTranscodingCache()
|
||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
share := core.NewShare(dataStore)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
|
||||
@@ -165,8 +173,10 @@ func CreateScanner(ctx context.Context) model.Scanner {
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
imageUploadService := core.NewImageUploadService()
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||
smartPlaylistEvaluator := playlists.NewSmartPlaylistEvaluator(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, smartPlaylistEvaluator, metricsMetrics)
|
||||
return modelScanner
|
||||
}
|
||||
|
||||
@@ -182,8 +192,10 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
imageUploadService := core.NewImageUploadService()
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||
smartPlaylistEvaluator := playlists.NewSmartPlaylistEvaluator(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, smartPlaylistEvaluator, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||
return watcher
|
||||
}
|
||||
@@ -206,7 +218,7 @@ func getPluginManager() *plugins.Manager {
|
||||
|
||||
// wire_injectors.go:
|
||||
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
|
||||
|
||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||
manager := getPluginManager()
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
@@ -44,6 +45,7 @@ var allProviders = wire.NewSet(
|
||||
plugins.GetManager,
|
||||
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
|
||||
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
|
||||
wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)),
|
||||
wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
|
||||
wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)),
|
||||
wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)),
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
package buildtags
|
||||
|
||||
// This file is left intentionally empty. It is used to make sure the package is not empty, in the case all
|
||||
// required build tags are disabled.
|
||||
@@ -0,0 +1,6 @@
|
||||
// Package buildtags provides compile-time enforcement of required build tags.
|
||||
//
|
||||
// Each file in this package is guarded by a build constraint and exports a variable
|
||||
// that main.go references. If a required tag is missing during compilation, the build
|
||||
// fails with an "undefined" error, directing the developer to use `make build`.
|
||||
package buildtags
|
||||
@@ -2,10 +2,6 @@
|
||||
|
||||
package buildtags
|
||||
|
||||
// NOTICE: This file was created to force the inclusion of the `netgo` tag when compiling the project.
|
||||
// If the tag is not included, the compilation will fail because this variable won't be defined, and the `main.go`
|
||||
// file requires it.
|
||||
|
||||
// Why this tag is required? See https://github.com/navidrome/navidrome/issues/700
|
||||
// The `netgo` tag is required when compiling the project. See https://github.com/navidrome/navidrome/issues/700
|
||||
|
||||
var NETGO = true
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build sqlite_fts5
|
||||
|
||||
package buildtags
|
||||
|
||||
// FTS5 is required for full-text search. Without this tag, the SQLite driver
|
||||
// won't include FTS5 support, causing runtime failures on migrations and search queries.
|
||||
|
||||
var SQLITE_FTS5 = true
|
||||
+109
-24
@@ -16,8 +16,8 @@ import (
|
||||
"github.com/kr/pretty"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/scheduler"
|
||||
"github.com/navidrome/navidrome/utils/run"
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
@@ -46,6 +46,7 @@ type configOptions struct {
|
||||
EnableTranscodingCancellation bool
|
||||
EnableDownloads bool
|
||||
EnableExternalServices bool
|
||||
EnableM3UExternalAlbumArt bool
|
||||
EnableInsightsCollector bool
|
||||
EnableMediaFileCoverArt bool
|
||||
TranscodingCacheSize string
|
||||
@@ -58,7 +59,7 @@ type configOptions struct {
|
||||
SmartPlaylistRefreshDelay time.Duration
|
||||
AutoTranscodeDownload bool
|
||||
DefaultDownsamplingFormat string
|
||||
SearchFullString bool
|
||||
Search searchOptions `json:",omitzero"`
|
||||
SimilarSongsMatchThreshold int
|
||||
RecentlyAddedByModTime bool
|
||||
PreferSortTags bool
|
||||
@@ -68,13 +69,16 @@ type configOptions struct {
|
||||
MPVPath string
|
||||
MPVCmdTemplate string
|
||||
CoverArtPriority string
|
||||
CoverJpegQuality int
|
||||
CoverArtQuality int
|
||||
ArtistArtPriority string
|
||||
ArtistImageFolder string
|
||||
DiscArtPriority string
|
||||
LyricsPriority string
|
||||
EnableGravatar bool
|
||||
EnableFavourites bool
|
||||
EnableStarRating bool
|
||||
EnableUserEditing bool
|
||||
EnableCoverArtUpload bool
|
||||
EnableSharing bool
|
||||
ShareURL string
|
||||
DefaultShareExpiration time.Duration
|
||||
@@ -82,6 +86,7 @@ type configOptions struct {
|
||||
DefaultTheme string
|
||||
DefaultLanguage string
|
||||
DefaultUIVolume int
|
||||
UISearchDebounceMs int
|
||||
EnableReplayGain bool
|
||||
EnableCoverAnimation bool
|
||||
EnableNowPlaying bool
|
||||
@@ -101,7 +106,6 @@ type configOptions struct {
|
||||
Inspect inspectOptions `json:",omitzero"`
|
||||
Subsonic subsonicOptions `json:",omitzero"`
|
||||
LastFM lastfmOptions `json:",omitzero"`
|
||||
Spotify spotifyOptions `json:",omitzero"`
|
||||
Deezer deezerOptions `json:",omitzero"`
|
||||
ListenBrainz listenBrainzOptions `json:",omitzero"`
|
||||
EnableScrobbleHistory bool
|
||||
@@ -129,7 +133,6 @@ type configOptions struct {
|
||||
DevExternalScanner bool
|
||||
DevScannerThreads uint
|
||||
DevSelectiveWatcher bool
|
||||
DevLegacyEmbedImage bool
|
||||
DevInsightsInitialDelay time.Duration
|
||||
DevEnablePlayerInsights bool
|
||||
DevEnablePluginsInsights bool
|
||||
@@ -137,6 +140,8 @@ type configOptions struct {
|
||||
DevExternalArtistFetchMultiplier float64
|
||||
DevOptimizeDB bool
|
||||
DevPreserveUnicodeInExternalCalls bool
|
||||
DevEnableMediaFileProbe bool
|
||||
DevJpegCoverArt bool
|
||||
}
|
||||
|
||||
type scannerOptions struct {
|
||||
@@ -154,6 +159,7 @@ type scannerOptions struct {
|
||||
|
||||
type subsonicOptions struct {
|
||||
AppendSubtitle bool
|
||||
AppendAlbumVersion bool
|
||||
ArtistParticipations bool
|
||||
DefaultReportRealPath bool
|
||||
EnableAverageRating bool
|
||||
@@ -181,11 +187,6 @@ type lastfmOptions struct {
|
||||
Languages []string // Computed from Language, split by comma
|
||||
}
|
||||
|
||||
type spotifyOptions struct {
|
||||
ID string
|
||||
Secret string //nolint:gosec
|
||||
}
|
||||
|
||||
type deezerOptions struct {
|
||||
Enabled bool
|
||||
Language string
|
||||
@@ -249,6 +250,12 @@ type pluginsOptions struct {
|
||||
type extAuthOptions struct {
|
||||
TrustedSources string
|
||||
UserHeader string
|
||||
LogoutURL string
|
||||
}
|
||||
|
||||
type searchOptions struct {
|
||||
Backend string
|
||||
FullString bool
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -273,6 +280,7 @@ func Load(noConfigDump bool) {
|
||||
mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
||||
mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality")
|
||||
|
||||
err := viper.Unmarshal(&Server)
|
||||
if err != nil {
|
||||
@@ -295,6 +303,12 @@ func Load(noConfigDump bool) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating artwork path:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if Server.Plugins.Enabled {
|
||||
if Server.Plugins.Folder == "" {
|
||||
Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins")
|
||||
@@ -327,6 +341,12 @@ func Load(noConfigDump bool) {
|
||||
os.Exit(1)
|
||||
}
|
||||
log.SetOutput(out)
|
||||
} else if os.Getenv("ND_SYSTEMD_PRIORITY_LOGGING") != "" && os.Getenv("JOURNAL_STREAM") != "" {
|
||||
// When running under systemd, prepend syslog priority prefixes so
|
||||
// journald assigns the correct severity to each log line.
|
||||
// Note that we have an additional environment variable, as JOURNAL_STREAM
|
||||
// can be present in a systemd environment even if not running as a systemd service
|
||||
log.EnableJournalFormat()
|
||||
}
|
||||
|
||||
log.SetLevelString(Server.LogLevel)
|
||||
@@ -339,11 +359,14 @@ func Load(noConfigDump bool) {
|
||||
validateBackupSchedule,
|
||||
validatePlaylistsPath,
|
||||
validatePurgeMissingOption,
|
||||
validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
|
||||
)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
Server.Search.Backend = normalizeSearchBackend(Server.Search.Backend)
|
||||
|
||||
if Server.BaseURL != "" {
|
||||
u, err := url.Parse(Server.BaseURL)
|
||||
if err != nil {
|
||||
@@ -389,12 +412,18 @@ func Load(noConfigDump bool) {
|
||||
// Parse Deezer.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
|
||||
Server.Deezer.Languages = parseLanguages(Server.Deezer.Language)
|
||||
|
||||
// Deprecated options
|
||||
logDeprecatedOptions("Scanner.GenreSeparators", "")
|
||||
logDeprecatedOptions("Scanner.GroupAlbumReleases", "")
|
||||
logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
|
||||
logDeprecatedOptions("SearchFullString", "Search.FullString")
|
||||
logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
||||
logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality")
|
||||
|
||||
// Removed options
|
||||
logRemovedOptions("Spotify.ID", "Spotify.Secret")
|
||||
|
||||
// Call init hooks
|
||||
for _, hook := range hooks {
|
||||
@@ -420,6 +449,23 @@ func logDeprecatedOptions(oldName, newName string) {
|
||||
}
|
||||
}
|
||||
|
||||
// logRemovedOptions checks if the option is set, and if yes, outputs a warning message saying the option is
|
||||
// not available anymore
|
||||
func logRemovedOptions(options ...string) {
|
||||
for _, option := range options {
|
||||
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_"))
|
||||
logWarning := func(option string) {
|
||||
log.Warn(fmt.Sprintf("Option '%s' is not available anymore and will be ignored. Please remove it from your config", option))
|
||||
}
|
||||
if viper.InConfig(option) {
|
||||
logWarning(option)
|
||||
}
|
||||
if os.Getenv(envVar) != "" {
|
||||
logWarning(envVar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mapDeprecatedOption is used to provide backwards compatibility for deprecated options. It should be called after
|
||||
// the config has been read by viper, but before unmarshalling it into the Config struct.
|
||||
func mapDeprecatedOption(legacyName, newName string) {
|
||||
@@ -456,8 +502,8 @@ func parseIniFileConfiguration() {
|
||||
func disableExternalServices() {
|
||||
log.Info("All external integrations are DISABLED!")
|
||||
Server.EnableInsightsCollector = false
|
||||
Server.EnableM3UExternalAlbumArt = false
|
||||
Server.LastFM.Enabled = false
|
||||
Server.Spotify.ID = ""
|
||||
Server.Deezer.Enabled = false
|
||||
Server.ListenBrainz.Enabled = false
|
||||
Server.Agents = ""
|
||||
@@ -526,19 +572,51 @@ func validateBackupSchedule() error {
|
||||
}
|
||||
|
||||
func validateSchedule(schedule, field string) (string, error) {
|
||||
if _, err := time.ParseDuration(schedule); err == nil {
|
||||
schedule = "@every " + schedule
|
||||
}
|
||||
c := cron.New()
|
||||
id, err := c.AddFunc(schedule, func() {})
|
||||
_, err := scheduler.ParseCrontab(schedule)
|
||||
if err != nil {
|
||||
log.Error(fmt.Sprintf("Invalid %s. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", field), "schedule", schedule, err)
|
||||
} else {
|
||||
c.Remove(id)
|
||||
}
|
||||
return schedule, err
|
||||
}
|
||||
|
||||
// validateURL checks if the provided URL is valid and has either http or https scheme.
|
||||
// It returns a function that can be used as a hook to validate URLs in the config.
|
||||
func validateURL(optionName, optionURL string) func() error {
|
||||
return func() error {
|
||||
if optionURL == "" {
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(optionURL)
|
||||
if err != nil {
|
||||
log.Error(fmt.Sprintf("Invalid %s: it could not be parsed", optionName), "url", optionURL, "err", err)
|
||||
return err
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
err := fmt.Errorf("invalid scheme for %s: '%s'. Only 'http' and 'https' are allowed", optionName, u.Scheme)
|
||||
log.Error(err.Error())
|
||||
return err
|
||||
}
|
||||
// Require an absolute URL with a non-empty host and no opaque component.
|
||||
if u.Host == "" || u.Opaque != "" {
|
||||
err := fmt.Errorf("invalid %s: '%s'. A full http(s) URL with a non-empty host is required", optionName, optionURL)
|
||||
log.Error(err.Error())
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSearchBackend(value string) string {
|
||||
v := strings.ToLower(strings.TrimSpace(value))
|
||||
switch v {
|
||||
case "fts", "legacy":
|
||||
return v
|
||||
default:
|
||||
log.Error("Invalid Search.Backend value, falling back to 'fts'", "value", value)
|
||||
return "fts"
|
||||
}
|
||||
}
|
||||
|
||||
// AddHook is used to register initialization code that should run as soon as the config is loaded
|
||||
func AddHook(hook func()) {
|
||||
hooks = append(hooks, hook)
|
||||
@@ -582,10 +660,12 @@ func setViperDefaults() {
|
||||
viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second)
|
||||
viper.SetDefault("enabledownloads", true)
|
||||
viper.SetDefault("enableexternalservices", true)
|
||||
viper.SetDefault("enablem3uexternalalbumart", false)
|
||||
viper.SetDefault("enablemediafilecoverart", true)
|
||||
viper.SetDefault("autotranscodedownload", false)
|
||||
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
|
||||
viper.SetDefault("searchfullstring", false)
|
||||
viper.SetDefault("search.fullstring", false)
|
||||
viper.SetDefault("search.backend", "fts")
|
||||
viper.SetDefault("similarsongsmatchthreshold", 85)
|
||||
viper.SetDefault("recentlyaddedbymodtime", false)
|
||||
viper.SetDefault("prefersorttags", false)
|
||||
@@ -594,8 +674,9 @@ func setViperDefaults() {
|
||||
viper.SetDefault("ffmpegpath", "")
|
||||
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s")
|
||||
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
|
||||
viper.SetDefault("coverjpegquality", 75)
|
||||
viper.SetDefault("coverartquality", 75)
|
||||
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
||||
viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded")
|
||||
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
|
||||
viper.SetDefault("enablegravatar", false)
|
||||
viper.SetDefault("enablefavourites", true)
|
||||
@@ -604,9 +685,11 @@ func setViperDefaults() {
|
||||
viper.SetDefault("defaulttheme", "Dark")
|
||||
viper.SetDefault("defaultlanguage", "")
|
||||
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
|
||||
viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs)
|
||||
viper.SetDefault("enablereplaygain", true)
|
||||
viper.SetDefault("enablecoveranimation", true)
|
||||
viper.SetDefault("enablenowplaying", true)
|
||||
viper.SetDefault("enablecoverartupload", true)
|
||||
viper.SetDefault("enablesharing", false)
|
||||
viper.SetDefault("shareurl", "")
|
||||
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
|
||||
@@ -619,6 +702,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("passwordencryptionkey", "")
|
||||
viper.SetDefault("extauth.userheader", "Remote-User")
|
||||
viper.SetDefault("extauth.trustedsources", "")
|
||||
viper.SetDefault("extauth.logouturl", "")
|
||||
viper.SetDefault("prometheus.enabled", false)
|
||||
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
|
||||
viper.SetDefault("prometheus.password", "")
|
||||
@@ -637,19 +721,18 @@ func setViperDefaults() {
|
||||
viper.SetDefault("scanner.followsymlinks", true)
|
||||
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
|
||||
viper.SetDefault("subsonic.appendsubtitle", true)
|
||||
viper.SetDefault("subsonic.appendalbumversion", true)
|
||||
viper.SetDefault("subsonic.artistparticipations", false)
|
||||
viper.SetDefault("subsonic.defaultreportrealpath", false)
|
||||
viper.SetDefault("subsonic.enableaveragerating", true)
|
||||
viper.SetDefault("subsonic.legacyclients", "DSub")
|
||||
viper.SetDefault("subsonic.minimalclients", "SubMusic")
|
||||
viper.SetDefault("agents", "lastfm,spotify,deezer")
|
||||
viper.SetDefault("agents", "deezer,lastfm,listenbrainz")
|
||||
viper.SetDefault("lastfm.enabled", true)
|
||||
viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage)
|
||||
viper.SetDefault("lastfm.apikey", "")
|
||||
viper.SetDefault("lastfm.secret", "")
|
||||
viper.SetDefault("lastfm.scrobblefirstartistonly", false)
|
||||
viper.SetDefault("spotify.id", "")
|
||||
viper.SetDefault("spotify.secret", "")
|
||||
viper.SetDefault("deezer.enabled", true)
|
||||
viper.SetDefault("deezer.language", consts.DefaultInfoLanguage)
|
||||
viper.SetDefault("listenbrainz.enabled", true)
|
||||
@@ -684,7 +767,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("devuishowconfig", true)
|
||||
viper.SetDefault("devneweventstream", true)
|
||||
viper.SetDefault("devoffsetoptimize", 50000)
|
||||
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/3))
|
||||
viper.SetDefault("devartworkmaxrequests", max(4, runtime.NumCPU()))
|
||||
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
||||
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
|
||||
@@ -699,6 +782,8 @@ func setViperDefaults() {
|
||||
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
|
||||
viper.SetDefault("devoptimizedb", true)
|
||||
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
|
||||
viper.SetDefault("devenablemediafileprobe", true)
|
||||
viper.SetDefault("devjpegcoverart", false)
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -52,6 +52,62 @@ var _ = Describe("Configuration", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ValidateURL", func() {
|
||||
It("accepts a valid http URL", func() {
|
||||
fn := conf.ValidateURL("TestOption", "http://example.com/path")
|
||||
Expect(fn()).To(Succeed())
|
||||
})
|
||||
|
||||
It("accepts a valid https URL", func() {
|
||||
fn := conf.ValidateURL("TestOption", "https://example.com/path")
|
||||
Expect(fn()).To(Succeed())
|
||||
})
|
||||
|
||||
It("rejects a URL with no scheme", func() {
|
||||
fn := conf.ValidateURL("TestOption", "example.com/path")
|
||||
Expect(fn()).To(MatchError(ContainSubstring("invalid scheme")))
|
||||
})
|
||||
|
||||
It("rejects a URL with an unsupported scheme", func() {
|
||||
fn := conf.ValidateURL("TestOption", "javascript://example.com/path")
|
||||
Expect(fn()).To(MatchError(ContainSubstring("invalid scheme")))
|
||||
})
|
||||
|
||||
It("accepts an empty URL (optional config)", func() {
|
||||
fn := conf.ValidateURL("TestOption", "")
|
||||
Expect(fn()).To(Succeed())
|
||||
})
|
||||
|
||||
It("includes the option name in the error message", func() {
|
||||
fn := conf.ValidateURL("MyOption", "ftp://example.com")
|
||||
Expect(fn()).To(MatchError(ContainSubstring("MyOption")))
|
||||
})
|
||||
|
||||
It("rejects a URL that cannot be parsed", func() {
|
||||
fn := conf.ValidateURL("TestOption", "://invalid")
|
||||
Expect(fn()).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("rejects a URL without a host", func() {
|
||||
fn := conf.ValidateURL("TestOption", "http:///path")
|
||||
Expect(fn()).To(MatchError(ContainSubstring("non-empty host is required")))
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTable("NormalizeSearchBackend",
|
||||
func(input, expected string) {
|
||||
Expect(conf.NormalizeSearchBackend(input)).To(Equal(expected))
|
||||
},
|
||||
Entry("accepts 'fts'", "fts", "fts"),
|
||||
Entry("accepts 'legacy'", "legacy", "legacy"),
|
||||
Entry("normalizes 'FTS' to lowercase", "FTS", "fts"),
|
||||
Entry("normalizes 'Legacy' to lowercase", "Legacy", "legacy"),
|
||||
Entry("trims whitespace", " fts ", "fts"),
|
||||
Entry("falls back to 'fts' for 'fts5'", "fts5", "fts"),
|
||||
Entry("falls back to 'fts' for unrecognized values", "invalid", "fts"),
|
||||
Entry("falls back to 'fts' for empty string", "", "fts"),
|
||||
)
|
||||
|
||||
DescribeTable("should load configuration from",
|
||||
func(format string) {
|
||||
filename := filepath.Join("testdata", "cfg."+format)
|
||||
|
||||
@@ -7,3 +7,7 @@ func ResetConf() {
|
||||
var SetViperDefaults = setViperDefaults
|
||||
|
||||
var ParseLanguages = parseLanguages
|
||||
|
||||
var ValidateURL = validateURL
|
||||
|
||||
var NormalizeSearchBackend = normalizeSearchBackend
|
||||
+25
-5
@@ -65,12 +65,13 @@ const (
|
||||
|
||||
I18nFolder = "i18n"
|
||||
ScanIgnoreFile = ".ndignore"
|
||||
ArtworkFolder = "artwork"
|
||||
|
||||
PlaceholderArtistArt = "artist-placeholder.webp"
|
||||
PlaceholderAlbumArt = "album-placeholder.webp"
|
||||
PlaceholderAvatar = "logo-192x192.png"
|
||||
UICoverArtSize = 300
|
||||
DefaultUIVolume = 100
|
||||
PlaceholderArtistArt = "artist-placeholder.webp"
|
||||
PlaceholderAlbumArt = "album-placeholder.webp"
|
||||
PlaceholderAvatar = "logo-192x192.png"
|
||||
DefaultUIVolume = 100
|
||||
DefaultUISearchDebounceMs = 200
|
||||
|
||||
DefaultHttpClientTimeOut = 10 * time.Second
|
||||
|
||||
@@ -83,6 +84,12 @@ const (
|
||||
Zwsp = string('\u200b')
|
||||
)
|
||||
|
||||
const (
|
||||
UICoverArtSize = 600
|
||||
)
|
||||
|
||||
var CacheWarmerImageSizes = []int{UICoverArtSize}
|
||||
|
||||
// Prometheus options
|
||||
const (
|
||||
PrometheusDefaultPath = "/metrics"
|
||||
@@ -101,6 +108,13 @@ const (
|
||||
DefaultCacheCleanUpInterval = 10 * time.Minute
|
||||
)
|
||||
|
||||
// Entity types
|
||||
const (
|
||||
EntityArtist = "artist"
|
||||
EntityPlaylist = "playlist"
|
||||
EntityRadio = "radio"
|
||||
)
|
||||
|
||||
const (
|
||||
AlbumPlayCountModeAbsolute = "absolute"
|
||||
AlbumPlayCountModeNormalized = "normalized"
|
||||
@@ -153,6 +167,12 @@ var (
|
||||
DefaultBitRate: 256,
|
||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
|
||||
},
|
||||
{
|
||||
Name: "flac audio",
|
||||
TargetFormat: "flac",
|
||||
DefaultBitRate: 0,
|
||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -7,6 +7,6 @@ A new agent must comply with these simple implementation rules:
|
||||
2) Implement one or more of the `*Retriever()` interfaces. That's where the agent's logic resides.
|
||||
3) Register itself (in its `init()` function).
|
||||
|
||||
For an agent to be used it needs to be listed in the `Agents` config option (default is `"lastfm,spotify"`). The order dictates the priority of the agents
|
||||
For an agent to be used it needs to be listed in the `Agents` config option (default is `"deezer,lastfm"`). The order dictates the priority of the agents
|
||||
|
||||
For a simple Agent example, look at the [local_agent](local_agent.go) agent source code.
|
||||
+4
-3
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
@@ -22,13 +23,13 @@ type Archiver interface {
|
||||
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||
}
|
||||
|
||||
func NewArchiver(ms MediaStreamer, ds model.DataStore, shares Share) Archiver {
|
||||
func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share) Archiver {
|
||||
return &archiver{ds: ds, ms: ms, shares: shares}
|
||||
}
|
||||
|
||||
type archiver struct {
|
||||
ds model.DataStore
|
||||
ms MediaStreamer
|
||||
ms stream.MediaStreamer
|
||||
shares Share
|
||||
}
|
||||
|
||||
@@ -176,7 +177,7 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
||||
|
||||
var r io.ReadCloser
|
||||
if format != "raw" && format != "" {
|
||||
r, err = a.ms.DoStream(ctx, &mf, format, bitrate, 0)
|
||||
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
|
||||
} else {
|
||||
r, err = os.Open(path)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -44,7 +45,7 @@ var _ = Describe("Archiver", func() {
|
||||
}}).Return(mfs, nil)
|
||||
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
|
||||
@@ -73,7 +74,7 @@ var _ = Describe("Archiver", func() {
|
||||
}}).Return(mfs, nil)
|
||||
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out)
|
||||
@@ -104,7 +105,7 @@ var _ = Describe("Archiver", func() {
|
||||
}
|
||||
|
||||
sh.On("Load", mock.Anything, "1").Return(share, nil)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipShare(context.Background(), "1", out)
|
||||
@@ -136,7 +137,7 @@ var _ = Describe("Archiver", func() {
|
||||
plRepo := &mockPlaylistRepository{}
|
||||
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
|
||||
ds.On("Playlist", mock.Anything).Return(plRepo)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)
|
||||
@@ -214,15 +215,15 @@ func (m *mockPlaylistRepository) GetWithTracks(id string, refreshSmartPlaylists,
|
||||
|
||||
type mockMediaStreamer struct {
|
||||
mock.Mock
|
||||
core.MediaStreamer
|
||||
stream.MediaStreamer
|
||||
}
|
||||
|
||||
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*core.Stream, error) {
|
||||
args := m.Called(ctx, mf, reqFormat, reqBitRate, reqOffset)
|
||||
func (m *mockMediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) {
|
||||
args := m.Called(ctx, mf, req)
|
||||
if args.Error(1) != nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return &core.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
|
||||
return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
|
||||
}
|
||||
|
||||
type mockShare struct {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
)
|
||||
|
||||
// isAnimatedGIF checks for multiple image descriptor blocks (0x2C) in a GIF file.
|
||||
// Animated GIFs use GIF89a and contain multiple image blocks.
|
||||
func isAnimatedGIF(data []byte) bool {
|
||||
// GIF header: "GIF87a" or "GIF89a"
|
||||
if !bytes.HasPrefix(data, []byte("GIF")) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Skip header (6 bytes) + logical screen descriptor (7 bytes)
|
||||
pos := 13
|
||||
if pos >= len(data) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Skip Global Color Table if present (bit 7 of packed byte at offset 10)
|
||||
if len(data) > 10 && data[10]&0x80 != 0 {
|
||||
// GCT size = 3 * 2^(N+1) where N = bits 0-2 of packed byte
|
||||
gctSize := 3 * (1 << ((data[10] & 0x07) + 1))
|
||||
pos += gctSize
|
||||
}
|
||||
|
||||
frameCount := 0
|
||||
for pos < len(data) {
|
||||
switch data[pos] {
|
||||
case 0x2C: // Image Descriptor - marks a frame
|
||||
frameCount++
|
||||
if frameCount > 1 {
|
||||
return true
|
||||
}
|
||||
pos++ // skip introducer
|
||||
if pos+8 >= len(data) {
|
||||
return false
|
||||
}
|
||||
pos += 8 // skip x, y, w, h (each 2 bytes)
|
||||
packed := data[pos]
|
||||
pos++ // skip packed byte
|
||||
// Skip Local Color Table if present
|
||||
if packed&0x80 != 0 {
|
||||
lctSize := 3 * (1 << ((packed & 0x07) + 1))
|
||||
pos += lctSize
|
||||
}
|
||||
// Skip LZW minimum code size
|
||||
pos++
|
||||
// Skip sub-blocks
|
||||
pos = skipGIFSubBlocks(data, pos)
|
||||
case 0x21: // Extension block
|
||||
pos++ // skip introducer
|
||||
if pos >= len(data) {
|
||||
return false
|
||||
}
|
||||
pos++ // skip extension label
|
||||
// Skip sub-blocks
|
||||
pos = skipGIFSubBlocks(data, pos)
|
||||
case 0x3B: // Trailer
|
||||
return false
|
||||
default:
|
||||
// Unknown block, bail
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// skipGIFSubBlocks advances past a sequence of GIF sub-blocks (terminated by a zero-length block).
|
||||
func skipGIFSubBlocks(data []byte, pos int) int {
|
||||
for pos < len(data) {
|
||||
blockSize := int(data[pos])
|
||||
pos++ // skip size byte
|
||||
if blockSize == 0 {
|
||||
break
|
||||
}
|
||||
pos += blockSize
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
// isAnimatedWebP checks for ANMF (animation frame) chunks in a WebP RIFF container.
|
||||
func isAnimatedWebP(data []byte) bool {
|
||||
// WebP header: "RIFF" + 4 bytes size + "WEBP"
|
||||
if !bytes.HasPrefix(data, []byte("RIFF")) || len(data) < 12 {
|
||||
return false
|
||||
}
|
||||
if !bytes.Equal(data[8:12], []byte("WEBP")) {
|
||||
return false
|
||||
}
|
||||
// Scan for ANMF chunk identifier
|
||||
return bytes.Contains(data[12:], []byte("ANMF"))
|
||||
}
|
||||
|
||||
// isAnimatedPNG checks for the acTL (animation control) chunk in a PNG file.
|
||||
// APNG files contain an acTL chunk that is not present in static PNGs.
|
||||
func isAnimatedPNG(data []byte) bool {
|
||||
// PNG signature: 8 bytes
|
||||
pngSig := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
|
||||
if !bytes.HasPrefix(data, pngSig) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Scan chunks for "acTL" (animation control)
|
||||
pos := uint64(8)
|
||||
dataLen := uint64(len(data))
|
||||
for pos+8 <= dataLen {
|
||||
chunkLen := uint64(binary.BigEndian.Uint32(data[pos : pos+4]))
|
||||
chunkType := string(data[pos+4 : pos+8])
|
||||
|
||||
if chunkType == "acTL" {
|
||||
return true
|
||||
}
|
||||
// Move to next chunk: 4 (length) + 4 (type) + chunkLen (data) + 4 (CRC)
|
||||
pos += 12 + chunkLen
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/gif"
|
||||
"image/png"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Animation detection", func() {
|
||||
Describe("isAnimatedGIF", func() {
|
||||
It("detects an animated GIF with multiple frames", func() {
|
||||
Expect(isAnimatedGIF(createAnimatedGIF(2))).To(BeTrue())
|
||||
})
|
||||
|
||||
It("detects an animated GIF with many frames", func() {
|
||||
Expect(isAnimatedGIF(createAnimatedGIF(5))).To(BeTrue())
|
||||
})
|
||||
|
||||
It("does not flag a static GIF (single frame)", func() {
|
||||
Expect(isAnimatedGIF(createAnimatedGIF(1))).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false for non-GIF data", func() {
|
||||
Expect(isAnimatedGIF(nil)).To(BeFalse())
|
||||
Expect(isAnimatedGIF([]byte{0xFF, 0xD8})).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("isAnimatedWebP", func() {
|
||||
It("detects an animated WebP with ANMF chunk", func() {
|
||||
Expect(isAnimatedWebP(createAnimatedWebPBytes())).To(BeTrue())
|
||||
})
|
||||
|
||||
It("does not flag a static WebP (no ANMF chunk)", func() {
|
||||
Expect(isAnimatedWebP(createStaticWebPBytes())).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false for non-WebP data", func() {
|
||||
Expect(isAnimatedWebP(nil)).To(BeFalse())
|
||||
Expect(isAnimatedWebP([]byte{0xFF, 0xD8})).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("isAnimatedPNG", func() {
|
||||
It("detects an APNG with acTL chunk", func() {
|
||||
Expect(isAnimatedPNG(createAPNGBytes())).To(BeTrue())
|
||||
})
|
||||
|
||||
It("does not flag a static PNG (no acTL chunk)", func() {
|
||||
Expect(isAnimatedPNG(createStaticPNGBytes())).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false for non-PNG data", func() {
|
||||
Expect(isAnimatedPNG(nil)).To(BeFalse())
|
||||
Expect(isAnimatedPNG([]byte{0xFF, 0xD8})).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// createAnimatedGIF creates a minimal animated GIF with the given number of frames.
|
||||
func createAnimatedGIF(frames int) []byte {
|
||||
g := &gif.GIF{
|
||||
LoopCount: 0,
|
||||
}
|
||||
for range frames {
|
||||
img := image.NewPaletted(image.Rect(0, 0, 2, 2), color.Palette{color.Black, color.White})
|
||||
g.Image = append(g.Image, img)
|
||||
g.Delay = append(g.Delay, 10)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
err := gif.EncodeAll(&buf, g)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// writeUint32LE appends a little-endian uint32 to the buffer.
|
||||
func writeUint32LE(buf *bytes.Buffer, v uint32) {
|
||||
b := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(b, v)
|
||||
buf.Write(b)
|
||||
}
|
||||
|
||||
// writeUint32BE appends a big-endian uint32 to the buffer.
|
||||
func writeUint32BE(buf *bytes.Buffer, v uint32) {
|
||||
b := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(b, v)
|
||||
buf.Write(b)
|
||||
}
|
||||
|
||||
// createAnimatedWebPBytes creates a minimal RIFF/WEBP container with an ANMF chunk.
|
||||
func createAnimatedWebPBytes() []byte {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("RIFF")
|
||||
writeUint32LE(&buf, 100) // file size placeholder
|
||||
buf.WriteString("WEBP")
|
||||
// VP8X chunk (extended format, required for animation)
|
||||
buf.WriteString("VP8X")
|
||||
writeUint32LE(&buf, 10)
|
||||
buf.Write(make([]byte, 10))
|
||||
// ANIM chunk (animation parameters)
|
||||
buf.WriteString("ANIM")
|
||||
writeUint32LE(&buf, 6)
|
||||
buf.Write(make([]byte, 6))
|
||||
// ANMF chunk (animation frame)
|
||||
buf.WriteString("ANMF")
|
||||
writeUint32LE(&buf, 16)
|
||||
buf.Write(make([]byte, 16))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// createStaticWebPBytes creates a minimal RIFF/WEBP container without ANMF chunks.
|
||||
func createStaticWebPBytes() []byte {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("RIFF")
|
||||
writeUint32LE(&buf, 20) // file size
|
||||
buf.WriteString("WEBP")
|
||||
// VP8 chunk (simple lossy format)
|
||||
buf.WriteString("VP8 ")
|
||||
writeUint32LE(&buf, 4)
|
||||
buf.Write(make([]byte, 4))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// createAPNGBytes creates a minimal PNG with an acTL chunk (making it APNG).
|
||||
func createAPNGBytes() []byte {
|
||||
// Start with a real PNG
|
||||
staticPNG := createStaticPNGBytes()
|
||||
|
||||
// Insert an acTL chunk after the IHDR chunk.
|
||||
// PNG structure: signature (8) + IHDR chunk (4 len + 4 type + 13 data + 4 crc = 25)
|
||||
ihdrEnd := 8 + 25
|
||||
var buf bytes.Buffer
|
||||
buf.Write(staticPNG[:ihdrEnd])
|
||||
// Write acTL chunk: length=8, type="acTL", data=num_frames(4)+num_plays(4), CRC=4
|
||||
writeUint32BE(&buf, 8) // chunk data length
|
||||
buf.WriteString("acTL")
|
||||
writeUint32BE(&buf, 2) // num_frames
|
||||
writeUint32BE(&buf, 0) // num_plays (0 = infinite)
|
||||
writeUint32BE(&buf, 0) // CRC placeholder
|
||||
buf.Write(staticPNG[ihdrEnd:])
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// createStaticPNGBytes creates a minimal valid static PNG.
|
||||
func createStaticPNGBytes() []byte {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
||||
var buf bytes.Buffer
|
||||
err := png.Encode(&buf, img)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
@@ -122,6 +122,10 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
|
||||
artReader, err = newMediafileArtworkReader(ctx, a, artID)
|
||||
case model.KindPlaylistArtwork:
|
||||
artReader, err = newPlaylistArtworkReader(ctx, a, artID)
|
||||
case model.KindDiscArtwork:
|
||||
artReader, err = newDiscArtworkReader(ctx, a, artID)
|
||||
case model.KindRadioArtwork:
|
||||
artReader, err = newRadioArtworkReader(ctx, a, artID)
|
||||
default:
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
|
||||
@@ -7,9 +7,12 @@ import (
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "github.com/gen2brain/webp"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -25,7 +28,7 @@ var _ = Describe("Artwork", func() {
|
||||
var ffmpeg *tests.MockFFmpeg
|
||||
var folderRepo *fakeFolderRepo
|
||||
ctx := log.NewContext(context.TODO())
|
||||
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers model.Album
|
||||
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers, alSingleDisc model.Album
|
||||
var arMultipleCovers model.Artist
|
||||
var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile
|
||||
|
||||
@@ -41,8 +44,9 @@ var _ = Describe("Artwork", func() {
|
||||
}
|
||||
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}
|
||||
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}}
|
||||
alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}}
|
||||
alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "", 2: ""}}
|
||||
alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}}
|
||||
alSingleDisc = model.Album{ID: "888", Name: "Single disc", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}}
|
||||
arMultipleCovers = model.Artist{ID: "777", Name: "All options"}
|
||||
alMultipleCovers = model.Album{
|
||||
ID: "666",
|
||||
@@ -190,6 +194,7 @@ var _ = Describe("Artwork", func() {
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alOnlyEmbed,
|
||||
alOnlyExternal,
|
||||
alSingleDisc,
|
||||
})
|
||||
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
mfWithEmbed,
|
||||
@@ -233,8 +238,137 @@ var _ = Describe("Artwork", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("al-444_0"))
|
||||
})
|
||||
It("falls back to disc cover art when media file has a disc number on a multi-disc album", func() {
|
||||
mfWithDisc := model.MediaFile{ID: "46", Path: "tests/fixtures/test.ogg", AlbumID: "444", DiscNumber: 2}
|
||||
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfWithDisc)).To(Succeed())
|
||||
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithDisc.ID))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should fall back to disc art, which itself falls back to album art
|
||||
Expect(path).To(Equal("dc-444:2_0"))
|
||||
})
|
||||
It("falls back to album cover art for single-disc albums even with a disc number", func() {
|
||||
mfOnSingleDisc := model.MediaFile{ID: "47", Path: "tests/fixtures/test.ogg", AlbumID: "888", DiscNumber: 1}
|
||||
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfOnSingleDisc)).To(Succeed())
|
||||
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfOnSingleDisc.ID))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Single-disc album should skip disc art and go straight to album art
|
||||
Expect(path).To(Equal("al-888_0"))
|
||||
})
|
||||
})
|
||||
})
|
||||
Describe("playlistArtworkReader", func() {
|
||||
Describe("findPlaylistSidecarPath", func() {
|
||||
It("discovers sidecar image next to playlist file", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u")
|
||||
imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg")
|
||||
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
|
||||
|
||||
result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath)
|
||||
Expect(result).To(Equal(imgPath))
|
||||
})
|
||||
|
||||
It("returns empty string when no sidecar image exists", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u")
|
||||
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
|
||||
|
||||
result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath)
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns empty string when playlist has no path", func() {
|
||||
result := findPlaylistSidecarPath(GinkgoT().Context(), "")
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("finds sidecar with different case base name", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
plsPath := filepath.Join(tmpDir, "myplaylist.m3u")
|
||||
imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg")
|
||||
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
|
||||
|
||||
result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath)
|
||||
Expect(result).To(Equal(imgPath))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fromPlaylistExternalImage", func() {
|
||||
It("opens local path from ExternalImageURL", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(tmpDir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("external image data"), 0600)).To(Succeed())
|
||||
|
||||
reader := &playlistArtworkReader{
|
||||
pl: model.Playlist{ExternalImageURL: imgPath},
|
||||
}
|
||||
r, path, err := reader.fromPlaylistExternalImage(ctx)()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
data, _ := io.ReadAll(r)
|
||||
Expect(string(data)).To(Equal("external image data"))
|
||||
r.Close()
|
||||
})
|
||||
|
||||
It("returns nil when ExternalImageURL is empty", func() {
|
||||
reader := &playlistArtworkReader{
|
||||
pl: model.Playlist{ExternalImageURL: ""},
|
||||
}
|
||||
r, path, err := reader.fromPlaylistExternalImage(ctx)()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns error when local file does not exist", func() {
|
||||
reader := &playlistArtworkReader{
|
||||
pl: model.Playlist{ExternalImageURL: "/non/existent/path/cover.jpg"},
|
||||
}
|
||||
r, _, err := reader.fromPlaylistExternalImage(ctx)()
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
})
|
||||
|
||||
It("skips HTTP URL when EnableM3UExternalAlbumArt is false", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
|
||||
reader := &playlistArtworkReader{
|
||||
pl: model.Playlist{ExternalImageURL: "https://example.com/cover.jpg"},
|
||||
}
|
||||
r, path, err := reader.fromPlaylistExternalImage(ctx)()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("still opens local path when EnableM3UExternalAlbumArt is false", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(tmpDir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("local image"), 0600)).To(Succeed())
|
||||
|
||||
reader := &playlistArtworkReader{
|
||||
pl: model.Playlist{ExternalImageURL: imgPath},
|
||||
}
|
||||
r, path, err := reader.fromPlaylistExternalImage(ctx)()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("resizedArtworkReader", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
@@ -246,24 +380,24 @@ var _ = Describe("Artwork", func() {
|
||||
})
|
||||
})
|
||||
When("Square is false", func() {
|
||||
It("returns a PNG if original image is a PNG", func() {
|
||||
It("returns WebP even if original image is a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(format).To(Equal("webp"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(15))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||
})
|
||||
It("returns a JPEG if original image is not a PNG", func() {
|
||||
It("returns WebP if original image is not a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(format).To(Equal("webp"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
@@ -273,9 +407,9 @@ var _ = Describe("Artwork", func() {
|
||||
var alCover model.Album
|
||||
|
||||
DescribeTable("resize",
|
||||
func(format string, landscape bool, size int) {
|
||||
coverFileName := "cover." + format
|
||||
dirName := createImage(format, landscape, size)
|
||||
func(srcFormat string, expectedFormat string, landscape bool, size int) {
|
||||
coverFileName := "cover." + srcFormat
|
||||
dirName := createImage(srcFormat, landscape, size)
|
||||
alCover = model.Album{
|
||||
ID: "444",
|
||||
Name: "Only external",
|
||||
@@ -292,16 +426,70 @@ var _ = Describe("Artwork", func() {
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(format).To(Equal(expectedFormat))
|
||||
Expect(img.Bounds().Size().X).To(Equal(size))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(size))
|
||||
},
|
||||
Entry("portrait png image", "png", false, 200),
|
||||
Entry("landscape png image", "png", true, 200),
|
||||
Entry("portrait jpg image", "jpg", false, 200),
|
||||
Entry("landscape jpg image", "jpg", true, 200),
|
||||
Entry("portrait png image", "png", "webp", false, 200),
|
||||
Entry("landscape png image", "png", "webp", true, 200),
|
||||
Entry("portrait jpg image", "jpg", "webp", false, 200),
|
||||
Entry("landscape jpg image", "jpg", "webp", true, 200),
|
||||
)
|
||||
})
|
||||
When("DevJpegCoverArt is true and square is false", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DevJpegCoverArt = true
|
||||
})
|
||||
It("returns JPEG even if original image is a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(15))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||
})
|
||||
It("returns JPEG if original image is a JPG", func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
})
|
||||
When("DevJpegCoverArt is true and square is true", func() {
|
||||
var alCover model.Album
|
||||
|
||||
BeforeEach(func() {
|
||||
conf.Server.DevJpegCoverArt = true
|
||||
})
|
||||
It("returns PNG for square mode", func() {
|
||||
dirName := createImage("png", false, 200)
|
||||
alCover = model.Album{
|
||||
ID: "444",
|
||||
Name: "Only external",
|
||||
FolderIDs: []string{"tmp"},
|
||||
}
|
||||
folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{"cover.png"}}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{alCover})
|
||||
|
||||
conf.Server.CoverArtPriority = "cover.png"
|
||||
r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), 200, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
})
|
||||
When("Requested size is larger than original", func() {
|
||||
It("clamps size to original dimensions", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func BenchmarkImageDecode(b *testing.B) {
|
||||
sizes := []int{300, 1000, 3000}
|
||||
formats := []struct {
|
||||
name string
|
||||
gen func(tb testing.TB, w, h int) []byte
|
||||
}{
|
||||
{"jpeg", func(tb testing.TB, w, h int) []byte { return generateJPEG(tb, w, h, 75) }},
|
||||
{"png", func(tb testing.TB, w, h int) []byte { return generatePNG(tb, w, h) }},
|
||||
}
|
||||
|
||||
for _, format := range formats {
|
||||
for _, size := range sizes {
|
||||
data := format.gen(b, size, size)
|
||||
b.Run(fmt.Sprintf("%s/%dx%d", format.name, size, size), func(b *testing.B) {
|
||||
b.SetBytes(int64(len(data)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
)
|
||||
|
||||
// setupE2EBenchmark creates an artwork instance with a real album cover image on disk,
|
||||
// backed by either a real file cache or disabled cache depending on cacheSize.
|
||||
// Note: This benchmarks artwork.Get() directly (not the full HTTP handler), which covers
|
||||
// the critical path (source selection, decode, resize, encode, cache). This is a deliberate
|
||||
// spec deviation — the full HTTP round-trip benchmark requires significant infrastructure
|
||||
// (DB, scanner, fake filesystem) and can be added later if HTTP overhead proves significant.
|
||||
//
|
||||
// Depends on fakeFolderRepo defined in reader_artist_test.go (same package, compiled together).
|
||||
func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID, func()) {
|
||||
b.Helper()
|
||||
cleanup := configtest.SetupConfig()
|
||||
b.Cleanup(cleanup)
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "artwork-bench-*")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a realistic cover image on disk
|
||||
coverPath := filepath.Join(tmpDir, "cover.jpg")
|
||||
coverImg := generateGradientImage(1000, 1000)
|
||||
f, err := os.Create(coverPath)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if err := jpeg.Encode(f, coverImg, &jpeg.Options{Quality: 90}); err != nil {
|
||||
f.Close()
|
||||
b.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
// Configure cache
|
||||
conf.Server.ImageCacheSize = cacheSize
|
||||
conf.Server.CacheFolder = tmpDir
|
||||
conf.Server.CoverArtQuality = 75
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
|
||||
// Set up mock data store with album pointing to our cover.
|
||||
// Set UpdatedAt so CoverArtID().LastUpdate is consistent across calls.
|
||||
album := model.Album{
|
||||
ID: "bench-album-1",
|
||||
Name: "Benchmark Album",
|
||||
FolderIDs: []string{"f1"},
|
||||
UpdatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
folderRepo := &fakeFolderRepo{
|
||||
result: []model.Folder{{
|
||||
Path: tmpDir,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}},
|
||||
}
|
||||
ds := &tests.MockDataStore{
|
||||
MockedTranscoding: &tests.MockTranscodingRepo{},
|
||||
MockedFolder: folderRepo,
|
||||
}
|
||||
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{album})
|
||||
|
||||
artID := album.CoverArtID()
|
||||
|
||||
imgCache := cache.NewFileCache("BenchImage", cacheSize, "bench-images", 0,
|
||||
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
|
||||
r, _, err := arg.(artworkReader).Reader(ctx)
|
||||
return r, err
|
||||
})
|
||||
|
||||
// Wait for cache init if enabled
|
||||
if cacheSize != "0" {
|
||||
for !imgCache.Available(context.Background()) && !imgCache.Disabled(context.Background()) {
|
||||
runtime.Gosched() // Yield to allow background init goroutine to run
|
||||
}
|
||||
}
|
||||
|
||||
ffmpeg := tests.NewMockFFmpeg("fallback content")
|
||||
aw := NewArtwork(ds, imgCache, ffmpeg, nil)
|
||||
|
||||
cleanupAll := func() {
|
||||
os.RemoveAll(tmpDir)
|
||||
}
|
||||
return aw, artID, cleanupAll
|
||||
}
|
||||
|
||||
func BenchmarkArtworkGetE2E(b *testing.B) {
|
||||
cacheConfigs := []struct {
|
||||
name string
|
||||
cacheSize string
|
||||
}{
|
||||
{"no_cache", "0"},
|
||||
{"with_cache", "100MB"},
|
||||
}
|
||||
sizes := []int{0, 300}
|
||||
|
||||
for _, cc := range cacheConfigs {
|
||||
for _, size := range sizes {
|
||||
b.Run(fmt.Sprintf("%s/size_%d", cc.name, size), func(b *testing.B) {
|
||||
aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize)
|
||||
defer cleanup()
|
||||
|
||||
// Warm the cache on first call if cache is enabled
|
||||
if cc.cacheSize != "0" {
|
||||
r, _, err := aw.Get(context.Background(), artID, size, size > 0)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
_, _ = io.ReadAll(r)
|
||||
r.Close()
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
r, _, err := aw.Get(context.Background(), artID, size, size > 0)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
_, _ = io.ReadAll(r)
|
||||
r.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkArtworkGetE2EConcurrent(b *testing.B) {
|
||||
cacheConfigs := []struct {
|
||||
name string
|
||||
cacheSize string
|
||||
}{
|
||||
{"no_cache", "0"},
|
||||
{"with_cache", "100MB"},
|
||||
}
|
||||
concurrencyLevels := []int{10, 50}
|
||||
|
||||
for _, cc := range cacheConfigs {
|
||||
for _, n := range concurrencyLevels {
|
||||
b.Run(fmt.Sprintf("%s/goroutines_%d", cc.name, n), func(b *testing.B) {
|
||||
aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize)
|
||||
defer cleanup()
|
||||
|
||||
// Warm cache
|
||||
if cc.cacheSize != "0" {
|
||||
r, _, _ := aw.Get(context.Background(), artID, 300, true)
|
||||
if r != nil {
|
||||
_, _ = io.ReadAll(r)
|
||||
r.Close()
|
||||
}
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(n)
|
||||
for g := 0; g < n; g++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
r, _, err := aw.Get(context.Background(), artID, 300, true)
|
||||
if err != nil {
|
||||
b.Error(err)
|
||||
return
|
||||
}
|
||||
_, _ = io.ReadAll(r)
|
||||
r.Close()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func BenchmarkImageEncode(b *testing.B) {
|
||||
img := generateGradientImage(300, 300)
|
||||
|
||||
jpegQualities := []int{60, 75, 90}
|
||||
for _, q := range jpegQualities {
|
||||
b.Run(fmt.Sprintf("jpeg/q%d/300x300", q), func(b *testing.B) {
|
||||
var buf bytes.Buffer
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
buf.Reset()
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: q}); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
b.ReportMetric(float64(buf.Len()), "bytes")
|
||||
})
|
||||
}
|
||||
|
||||
b.Run("png/300x300", func(b *testing.B) {
|
||||
var buf bytes.Buffer
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
buf.Reset()
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
b.ReportMetric(float64(buf.Len()), "bytes")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// generateJPEG creates a JPEG image of the given dimensions with a gradient pattern.
|
||||
// The gradient ensures the image has realistic entropy (not trivially compressible).
|
||||
func generateJPEG(t testing.TB, width, height, quality int) []byte {
|
||||
t.Helper()
|
||||
img := generateGradientImage(width, height)
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// generatePNG creates a PNG image of the given dimensions with a gradient pattern.
|
||||
func generatePNG(t testing.TB, width, height int) []byte {
|
||||
t.Helper()
|
||||
img := generateGradientImage(width, height)
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// generateGradientImage creates an RGBA image with a diagonal gradient pattern.
|
||||
func generateGradientImage(width, height int) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
r := uint8((x * 255) / width)
|
||||
g := uint8((y * 255) / height)
|
||||
b := uint8(((x + y) * 255) / (width + height))
|
||||
img.Set(x, y, color.RGBA{R: r, G: g, B: b, A: 255})
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
)
|
||||
|
||||
func BenchmarkResizeFullPipeline(b *testing.B) {
|
||||
cleanup := configtest.SetupConfig()
|
||||
b.Cleanup(cleanup)
|
||||
conf.Server.CoverArtQuality = 75
|
||||
|
||||
sourceSizes := []int{1000, 3000}
|
||||
targetSize := 300
|
||||
|
||||
for _, srcSize := range sourceSizes {
|
||||
jpegData := generateJPEG(b, srcSize, srcSize, 90)
|
||||
|
||||
b.Run(fmt.Sprintf("jpeg/%dx%d_to_%d", srcSize, srcSize, targetSize), func(b *testing.B) {
|
||||
b.SetBytes(int64(len(jpegData)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
result, _, err := resizeStaticImage(jpegData, targetSize, false)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if result == nil {
|
||||
b.Fatal("expected non-nil resized image")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
b.Run(fmt.Sprintf("jpeg/%dx%d_to_%d_square", srcSize, srcSize, targetSize), func(b *testing.B) {
|
||||
b.SetBytes(int64(len(jpegData)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
result, _, err := resizeStaticImage(jpegData, targetSize, true)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if result == nil {
|
||||
b.Fatal("expected non-nil resized image")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"go.senan.xyz/taglib"
|
||||
)
|
||||
|
||||
func BenchmarkTagExtraction(b *testing.B) {
|
||||
// Ensure working directory is the project root (tests.Init not called with -run='^$')
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
b.Fatal("runtime.Caller failed")
|
||||
}
|
||||
appPath, _ := filepath.Abs(filepath.Join(filepath.Dir(file), "..", ".."))
|
||||
|
||||
// Use existing test fixture with embedded artwork
|
||||
testFile := filepath.Join(appPath, "tests/fixtures/artist/an-album/test.mp3")
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
f, err := taglib.OpenReadOnly(testFile, taglib.WithReadStyle(taglib.ReadStyleFast))
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
images := f.Properties().Images
|
||||
if len(images) == 0 {
|
||||
b.Fatal("no images found in test file")
|
||||
}
|
||||
data, err := f.Image(0)
|
||||
if err != nil || len(data) == 0 {
|
||||
b.Fatal("failed to extract image data")
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func (a *cacheWarmer) waitSignal(ctx context.Context, timeout time.Duration) {
|
||||
func (a *cacheWarmer) processBatch(ctx context.Context, batch []model.ArtworkID) {
|
||||
log.Trace(ctx, "PreCaching a new batch of artwork", "batchSize", len(batch))
|
||||
input := pl.FromSlice(ctx, batch)
|
||||
errs := pl.Sink(ctx, 2, input, a.doCacheImage)
|
||||
errs := pl.Sink(ctx, 4, input, a.doCacheImage)
|
||||
for err := range errs {
|
||||
log.Debug(ctx, "Error warming cache", err)
|
||||
}
|
||||
@@ -142,13 +142,13 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("caching id='%s': %w", id, err)
|
||||
}
|
||||
defer r.Close()
|
||||
_, err = io.Copy(io.Discard, r)
|
||||
if err != nil {
|
||||
for _, size := range consts.CacheWarmerImageSizes {
|
||||
r, _, err := a.artwork.Get(ctx, id, size, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err)
|
||||
}
|
||||
_, err = io.Copy(io.Discard, r)
|
||||
r.Close()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -6,11 +6,13 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@@ -173,20 +175,42 @@ var _ = Describe("CacheWarmer", func() {
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("pre-caches UICoverArtSize", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
|
||||
Eventually(func() []int {
|
||||
return aw.getCachedSizes()
|
||||
}).Should(ContainElements(consts.UICoverArtSize))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockArtwork struct {
|
||||
err error
|
||||
err error
|
||||
mu sync.Mutex
|
||||
cachedSizes []int
|
||||
}
|
||||
|
||||
func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) {
|
||||
if m.err != nil {
|
||||
return nil, time.Time{}, m.err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.cachedSizes = append(m.cachedSizes, size)
|
||||
m.mu.Unlock()
|
||||
return io.NopCloser(strings.NewReader("test")), time.Now(), nil
|
||||
}
|
||||
|
||||
func (m *mockArtwork) getCachedSizes() []int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
result := make([]int, len(m.cachedSizes))
|
||||
copy(result, m.cachedSizes)
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) {
|
||||
return m.Get(ctx, model.ArtworkID{}, size, square)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
@@ -12,12 +13,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/maruel/natural"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/natural"
|
||||
)
|
||||
|
||||
type albumArtworkReader struct {
|
||||
@@ -57,10 +59,11 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar
|
||||
}
|
||||
|
||||
func (a *albumArtworkReader) Key() string {
|
||||
var hash [16]byte
|
||||
hashInput := conf.Server.CoverArtPriority
|
||||
if conf.Server.EnableExternalServices {
|
||||
hash = md5.Sum([]byte(conf.Server.Agents + conf.Server.CoverArtPriority))
|
||||
hashInput += conf.Server.Agents
|
||||
}
|
||||
hash := md5.Sum([]byte(hashInput))
|
||||
return fmt.Sprintf(
|
||||
"%s.%x.%t",
|
||||
a.cacheKey.Key(),
|
||||
@@ -103,6 +106,28 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
folderIDSet := make(map[string]bool, len(folderIDs))
|
||||
for _, id := range folderIDs {
|
||||
folderIDSet[id] = true
|
||||
}
|
||||
|
||||
// For multi-disc albums (2+ folders), check if all folders share a common parent
|
||||
// that is not already included. This finds cover art in the album root folder
|
||||
// (e.g., "Artist/Album/cover.jpg" when tracks are in "Artist/Album/CD1/" and "Artist/Album/CD2/").
|
||||
// We skip single-folder albums to avoid pulling images from the artist folder.
|
||||
if commonParentID := commonParentFolder(folders, folderIDSet); commonParentID != "" {
|
||||
parentFolder, err := ds.Folder(ctx).Get(commonParentID)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID)
|
||||
} else if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if parentFolder != nil {
|
||||
folders = append(folders, *parentFolder)
|
||||
}
|
||||
}
|
||||
|
||||
var paths []string
|
||||
var imgFiles []string
|
||||
var updatedAt time.Time
|
||||
@@ -125,6 +150,24 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
|
||||
return paths, imgFiles, &updatedAt, nil
|
||||
}
|
||||
|
||||
// commonParentFolder returns the shared parent folder ID when all folders have the
|
||||
// same parent and that parent is not already in folderIDSet. Returns "" otherwise.
|
||||
func commonParentFolder(folders []model.Folder, folderIDSet map[string]bool) string {
|
||||
if len(folders) < 2 {
|
||||
return ""
|
||||
}
|
||||
parentID := folders[0].ParentID
|
||||
if parentID == "" || folderIDSet[parentID] {
|
||||
return ""
|
||||
}
|
||||
for _, f := range folders[1:] {
|
||||
if f.ParentID != parentID {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return parentID
|
||||
}
|
||||
|
||||
// compareImageFiles compares two image file paths for sorting.
|
||||
// It extracts the base filename (without extension) and compares case-insensitively.
|
||||
// This ensures that "cover.jpg" sorts before "cover.1.jpg" since "cover" < "cover.1".
|
||||
|
||||
@@ -2,6 +2,7 @@ package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
@@ -116,5 +117,181 @@ var _ = Describe("Album Artwork Reader", func() {
|
||||
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
|
||||
Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Folder.jpg")))
|
||||
})
|
||||
|
||||
It("includes images from parent folder for multi-disc albums", func() {
|
||||
// Simulates: Artist/Album/cover.jpg with tracks in Artist/Album/CD1/ and Artist/Album/CD2/
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: "Artist/Album",
|
||||
Name: "CD1",
|
||||
ParentID: "parentFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
{
|
||||
ID: "folder2",
|
||||
Path: "Artist/Album",
|
||||
Name: "CD2",
|
||||
ParentID: "parentFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
}
|
||||
repo.parentResult = &model.Folder{
|
||||
ID: "parentFolder",
|
||||
Path: "Artist",
|
||||
Name: "Album",
|
||||
ImagesUpdatedAt: expectedAt,
|
||||
ImageFiles: []string{"cover.jpg", "back.jpg"},
|
||||
}
|
||||
|
||||
_, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(*imagesUpdatedAt).To(Equal(expectedAt))
|
||||
Expect(imgFiles).To(HaveLen(2))
|
||||
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/back.jpg")))
|
||||
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
|
||||
})
|
||||
|
||||
It("does not query parent when parent ID is already in album folders", func() {
|
||||
// When the parent folder is already one of the album's folders, skip it
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: "Artist",
|
||||
Name: "Album",
|
||||
ParentID: "folder2",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
},
|
||||
{
|
||||
ID: "folder2",
|
||||
Path: "",
|
||||
Name: "Artist",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(HaveLen(1))
|
||||
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
|
||||
// Get should not have been called (parent already in folder set)
|
||||
Expect(repo.getCallCount).To(Equal(0))
|
||||
})
|
||||
|
||||
It("does not query parent when folders have different parents", func() {
|
||||
// When album folders span different parents, don't search any parent
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: "Artist1/Album",
|
||||
Name: "part1",
|
||||
ParentID: "parentA",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
},
|
||||
{
|
||||
ID: "folder2",
|
||||
Path: "Artist2/Album",
|
||||
Name: "part2",
|
||||
ParentID: "parentB",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(HaveLen(1))
|
||||
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist1/Album/part1/cover.jpg")))
|
||||
// Get should not have been called (different parents)
|
||||
Expect(repo.getCallCount).To(Equal(0))
|
||||
})
|
||||
|
||||
It("does not query parent for single-folder albums", func() {
|
||||
// A single-folder album's parent is typically the artist folder,
|
||||
// which should not be searched for cover art
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: "Artist",
|
||||
Name: "Album",
|
||||
ParentID: "artistFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
},
|
||||
}
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(HaveLen(1))
|
||||
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
|
||||
// Get should not have been called (single folder, no parent lookup)
|
||||
Expect(repo.getCallCount).To(Equal(0))
|
||||
})
|
||||
|
||||
It("propagates non-ErrNotFound errors from parent folder lookup", func() {
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: "Artist/Album",
|
||||
Name: "CD1",
|
||||
ParentID: "parentFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
},
|
||||
{
|
||||
ID: "folder2",
|
||||
Path: "Artist/Album",
|
||||
Name: "CD2",
|
||||
ParentID: "parentFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
}
|
||||
repo.getErr = errors.New("db connection failed")
|
||||
|
||||
_, _, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).To(MatchError("db connection failed"))
|
||||
Expect(repo.getCallCount).To(Equal(1))
|
||||
})
|
||||
|
||||
It("continues gracefully when parent folder is not found", func() {
|
||||
// Parent folder may have been deleted; should log a warning and continue
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: "Artist/Album",
|
||||
Name: "CD1",
|
||||
ParentID: "missingParent",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
},
|
||||
{
|
||||
ID: "folder2",
|
||||
Path: "Artist/Album",
|
||||
Name: "CD2",
|
||||
ParentID: "missingParent",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
}
|
||||
// parentResult is nil, so Get will return ErrNotFound
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(HaveLen(1))
|
||||
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/CD1/cover.jpg")))
|
||||
Expect(repo.getCallCount).To(Equal(1))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -29,11 +29,12 @@ const (
|
||||
|
||||
type artistReader struct {
|
||||
cacheKey
|
||||
a *artwork
|
||||
provider external.Provider
|
||||
artist model.Artist
|
||||
artistFolder string
|
||||
imgFiles []string
|
||||
a *artwork
|
||||
provider external.Provider
|
||||
artist model.Artist
|
||||
artistFolder string
|
||||
imgFiles []string
|
||||
imgFolderImgPath string // cached path from ArtistImageFolder lookup
|
||||
}
|
||||
|
||||
func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
|
||||
@@ -71,15 +72,26 @@ func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.A
|
||||
//a.cacheKey.lastUpdate = ar.ExternalInfoUpdatedAt
|
||||
|
||||
a.cacheKey.lastUpdate = *imagesUpdatedAt
|
||||
if ar.UpdatedAt != nil && ar.UpdatedAt.After(a.cacheKey.lastUpdate) {
|
||||
a.cacheKey.lastUpdate = *ar.UpdatedAt
|
||||
}
|
||||
if artistFolderLastUpdate.After(a.cacheKey.lastUpdate) {
|
||||
a.cacheKey.lastUpdate = artistFolderLastUpdate
|
||||
}
|
||||
if conf.Server.ArtistImageFolder != "" && strings.Contains(strings.ToLower(conf.Server.ArtistArtPriority), "image-folder") {
|
||||
a.imgFolderImgPath = findImageInArtistFolder(conf.Server.ArtistImageFolder, ar.MbzArtistID, ar.Name)
|
||||
if a.imgFolderImgPath != "" {
|
||||
if info, err := os.Stat(a.imgFolderImgPath); err == nil && info.ModTime().After(a.cacheKey.lastUpdate) {
|
||||
a.cacheKey.lastUpdate = info.ModTime()
|
||||
}
|
||||
}
|
||||
}
|
||||
a.cacheKey.artID = artID
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *artistReader) Key() string {
|
||||
hash := md5.Sum([]byte(conf.Server.Agents + conf.Server.Spotify.ID))
|
||||
hash := md5.Sum([]byte(conf.Server.Agents))
|
||||
return fmt.Sprintf(
|
||||
"%s.%t.%x",
|
||||
a.cacheKey.Key(),
|
||||
@@ -93,10 +105,15 @@ func (a *artistReader) LastUpdated() time.Time {
|
||||
}
|
||||
|
||||
func (a *artistReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
var ff = a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority)
|
||||
ff := []sourceFunc{a.fromArtistUploadedImage()}
|
||||
ff = append(ff, a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority)...)
|
||||
return selectImageReader(ctx, a.artID, ff...)
|
||||
}
|
||||
|
||||
func (a *artistReader) fromArtistUploadedImage() sourceFunc {
|
||||
return fromLocalFile(a.artist.UploadedImagePath())
|
||||
}
|
||||
|
||||
func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority string) []sourceFunc {
|
||||
var ff []sourceFunc
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
|
||||
@@ -104,6 +121,8 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin
|
||||
switch {
|
||||
case pattern == "external":
|
||||
ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.provider))
|
||||
case pattern == "image-folder":
|
||||
ff = append(ff, a.fromArtistImageFolder(ctx))
|
||||
case strings.HasPrefix(pattern, "album/"):
|
||||
ff = append(ff, fromExternalFile(ctx, a.imgFiles, strings.TrimPrefix(pattern, "album/")))
|
||||
default:
|
||||
@@ -196,3 +215,51 @@ func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albu
|
||||
}
|
||||
return folderPath, folders[0].ImagesUpdatedAt, nil
|
||||
}
|
||||
|
||||
func (a *artistReader) fromArtistImageFolder(ctx context.Context) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
folder := conf.Server.ArtistImageFolder
|
||||
if folder == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
// Use cached path from newArtistArtworkReader if available,
|
||||
// avoiding a second directory scan.
|
||||
path := a.imgFolderImgPath
|
||||
if path == "" {
|
||||
path = findImageInArtistFolder(folder, a.artist.MbzArtistID, a.artist.Name)
|
||||
}
|
||||
if path == "" {
|
||||
return nil, "", fmt.Errorf("no image found for artist %q in %s", a.artist.Name, folder)
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return f, path, nil
|
||||
}
|
||||
}
|
||||
|
||||
// findImageInArtistFolder scans a folder for an image file matching the artist's MBID or name
|
||||
// (case-insensitive). Returns the full path, or empty string if not found.
|
||||
func findImageInArtistFolder(folder, mbzArtistID, artistName string) string {
|
||||
entries, err := os.ReadDir(folder)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, candidate := range []string{mbzArtistID, artistName} {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
base := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
if strings.EqualFold(base, candidate) && model.IsImageFile(name) {
|
||||
return filepath.Join(folder, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@@ -413,18 +415,283 @@ var _ = Describe("artistArtworkReader", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fromArtistUploadedImage", func() {
|
||||
var (
|
||||
tempDir string
|
||||
reader *artistReader
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tempDir = GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = tempDir
|
||||
|
||||
// Create the artwork/artist directory
|
||||
Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "artist"), 0755)).To(Succeed())
|
||||
|
||||
reader = &artistReader{}
|
||||
})
|
||||
|
||||
When("artist has an uploaded image", func() {
|
||||
It("returns the uploaded image", func() {
|
||||
imgPath := filepath.Join(tempDir, "artwork", "artist", "ar-1_test.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("uploaded artist image"), 0600)).To(Succeed())
|
||||
|
||||
reader.artist = model.Artist{ID: "ar-1", UploadedImage: "ar-1_test.jpg"}
|
||||
sf := reader.fromArtistUploadedImage()
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("uploaded artist image"))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("artist has no uploaded image", func() {
|
||||
It("returns nil reader (falls through)", func() {
|
||||
reader.artist = model.Artist{ID: "ar-1"}
|
||||
sf := reader.fromArtistUploadedImage()
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fromArtistImageFolder", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
tempDir string
|
||||
ar *artistReader
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tempDir = GinkgoT().TempDir()
|
||||
ar = &artistReader{}
|
||||
})
|
||||
|
||||
When("ArtistImageFolder is not configured", func() {
|
||||
It("returns nil (skips)", func() {
|
||||
conf.Server.ArtistImageFolder = ""
|
||||
ar.artist = model.Artist{Name: "Test Artist"}
|
||||
sf := ar.fromArtistImageFolder(ctx)
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
When("image exists matching MBID", func() {
|
||||
It("finds the image by MBID", func() {
|
||||
conf.Server.ArtistImageFolder = tempDir
|
||||
mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e"
|
||||
imgPath := filepath.Join(tempDir, mbid+".jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("mbid image"), 0600)).To(Succeed())
|
||||
|
||||
ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid}
|
||||
sf := ar.fromArtistImageFolder(ctx)
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("mbid image"))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("MBID match is case-insensitive", func() {
|
||||
It("finds the image regardless of case", func() {
|
||||
conf.Server.ArtistImageFolder = tempDir
|
||||
mbid := "F27EC8DB-AF05-4F36-916E-3D57F91ECF5E"
|
||||
imgPath := filepath.Join(tempDir, "f27ec8db-af05-4f36-916e-3d57f91ecf5e.png")
|
||||
Expect(os.WriteFile(imgPath, []byte("mbid case image"), 0600)).To(Succeed())
|
||||
|
||||
ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid}
|
||||
sf := ar.fromArtistImageFolder(ctx)
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("no MBID file exists but artist name file does", func() {
|
||||
It("falls back to artist name match", func() {
|
||||
conf.Server.ArtistImageFolder = tempDir
|
||||
imgPath := filepath.Join(tempDir, "Test Artist.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("name image"), 0600)).To(Succeed())
|
||||
|
||||
ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: "nonexistent-mbid"}
|
||||
sf := ar.fromArtistImageFolder(ctx)
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("name image"))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("artist name match is case-insensitive", func() {
|
||||
It("matches regardless of case", func() {
|
||||
conf.Server.ArtistImageFolder = tempDir
|
||||
imgPath := filepath.Join(tempDir, "test artist.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("case insensitive"), 0600)).To(Succeed())
|
||||
|
||||
ar.artist = model.Artist{Name: "Test Artist"}
|
||||
sf := ar.fromArtistImageFolder(ctx)
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("both MBID and name files exist", func() {
|
||||
It("prefers MBID over name match", func() {
|
||||
conf.Server.ArtistImageFolder = tempDir
|
||||
mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e"
|
||||
mbidPath := filepath.Join(tempDir, mbid+".jpg")
|
||||
namePath := filepath.Join(tempDir, "Test Artist.jpg")
|
||||
Expect(os.WriteFile(mbidPath, []byte("mbid image"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(namePath, []byte("name image"), 0600)).To(Succeed())
|
||||
|
||||
ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid}
|
||||
sf := ar.fromArtistImageFolder(ctx)
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(mbidPath))
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("mbid image"))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("no matching image found", func() {
|
||||
It("returns an error", func() {
|
||||
conf.Server.ArtistImageFolder = tempDir
|
||||
// Create an unrelated file
|
||||
Expect(os.WriteFile(filepath.Join(tempDir, "other.jpg"), []byte("other"), 0600)).To(Succeed())
|
||||
|
||||
ar.artist = model.Artist{Name: "Test Artist"}
|
||||
sf := ar.fromArtistImageFolder(ctx)
|
||||
r, _, err := sf()
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
Expect(err.Error()).To(ContainSubstring("no image found"))
|
||||
})
|
||||
})
|
||||
|
||||
When("cached imgFolderImgPath is set", func() {
|
||||
It("uses cached path instead of scanning", func() {
|
||||
conf.Server.ArtistImageFolder = tempDir
|
||||
imgPath := filepath.Join(tempDir, "cached.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("cached image"), 0600)).To(Succeed())
|
||||
|
||||
ar.artist = model.Artist{Name: "Test Artist"}
|
||||
ar.imgFolderImgPath = imgPath
|
||||
sf := ar.fromArtistImageFolder(ctx)
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("cached image"))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("findImageInArtistFolder", func() {
|
||||
var tempDir string
|
||||
|
||||
BeforeEach(func() {
|
||||
tempDir = GinkgoT().TempDir()
|
||||
})
|
||||
|
||||
When("matching file exists by MBID", func() {
|
||||
It("returns the file path", func() {
|
||||
mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e"
|
||||
imgPath := filepath.Join(tempDir, mbid+".jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed())
|
||||
|
||||
path := findImageInArtistFolder(tempDir, mbid, "Test")
|
||||
Expect(path).To(Equal(imgPath))
|
||||
})
|
||||
})
|
||||
|
||||
When("matching file exists by name", func() {
|
||||
It("returns the file path", func() {
|
||||
imgPath := filepath.Join(tempDir, "Test Artist.png")
|
||||
Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed())
|
||||
|
||||
path := findImageInArtistFolder(tempDir, "", "Test Artist")
|
||||
Expect(path).To(Equal(imgPath))
|
||||
})
|
||||
})
|
||||
|
||||
When("no matching file exists", func() {
|
||||
It("returns empty string", func() {
|
||||
path := findImageInArtistFolder(tempDir, "", "Unknown Artist")
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
When("folder does not exist", func() {
|
||||
It("returns empty string", func() {
|
||||
path := findImageInArtistFolder("/nonexistent/path", "", "Test")
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type fakeFolderRepo struct {
|
||||
model.FolderRepository
|
||||
result []model.Folder
|
||||
err error
|
||||
result []model.Folder
|
||||
parentResult *model.Folder
|
||||
getErr error
|
||||
getCallCount int
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) {
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) Get(id string) (*model.Folder, error) {
|
||||
f.getCallCount++
|
||||
if f.getErr != nil {
|
||||
return nil, f.getErr
|
||||
}
|
||||
if f.parentResult != nil {
|
||||
return f.parentResult, nil
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
type fakeDataStore struct {
|
||||
model.DataStore
|
||||
folderRepo *fakeFolderRepo
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type discArtworkReader struct {
|
||||
cacheKey
|
||||
a *artwork
|
||||
album model.Album
|
||||
discNumber int
|
||||
imgFiles []string
|
||||
discFolders map[string]bool
|
||||
isMultiFolder bool
|
||||
firstTrackPath string
|
||||
updatedAt *time.Time
|
||||
}
|
||||
|
||||
func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID) (*discArtworkReader, error) {
|
||||
albumID, discNumber, err := model.ParseDiscArtworkID(artID.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err)
|
||||
}
|
||||
|
||||
al, err := a.ds.Album(ctx).Get(albumID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, a.ds, *al)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Query mediafiles for this album + disc to find folder associations and first track
|
||||
mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Sort: "track_number",
|
||||
Order: "ASC",
|
||||
Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build disc folder set and find first track
|
||||
discFolders := make(map[string]bool)
|
||||
var firstTrackPath string
|
||||
allFolderIDs := make(map[string]bool)
|
||||
for _, mf := range mfs {
|
||||
allFolderIDs[mf.FolderID] = true
|
||||
if firstTrackPath == "" {
|
||||
firstTrackPath = mf.Path
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve folder IDs to absolute paths
|
||||
if len(allFolderIDs) > 0 {
|
||||
folderIDs := make([]string, 0, len(allFolderIDs))
|
||||
for id := range allFolderIDs {
|
||||
folderIDs = append(folderIDs, id)
|
||||
}
|
||||
folders, err := a.ds.Folder(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"folder.id": folderIDs},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, f := range folders {
|
||||
discFolders[f.AbsolutePath()] = true
|
||||
}
|
||||
}
|
||||
|
||||
isMultiFolder := len(al.FolderIDs) > 1
|
||||
|
||||
r := &discArtworkReader{
|
||||
a: a,
|
||||
album: *al,
|
||||
discNumber: discNumber,
|
||||
imgFiles: imgFiles,
|
||||
discFolders: discFolders,
|
||||
isMultiFolder: isMultiFolder,
|
||||
firstTrackPath: core.AbsolutePath(ctx, a.ds, al.LibraryID, firstTrackPath),
|
||||
updatedAt: imagesUpdatedAt,
|
||||
}
|
||||
r.cacheKey.artID = artID
|
||||
if r.updatedAt != nil && r.updatedAt.After(al.UpdatedAt) {
|
||||
r.cacheKey.lastUpdate = *r.updatedAt
|
||||
} else {
|
||||
r.cacheKey.lastUpdate = al.UpdatedAt
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (d *discArtworkReader) Key() string {
|
||||
hash := md5.Sum([]byte(conf.Server.DiscArtPriority))
|
||||
return fmt.Sprintf(
|
||||
"%s.%x",
|
||||
d.cacheKey.Key(),
|
||||
hash,
|
||||
)
|
||||
}
|
||||
|
||||
func (d *discArtworkReader) LastUpdated() time.Time {
|
||||
return d.album.UpdatedAt
|
||||
}
|
||||
|
||||
func (d *discArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
var ff = d.fromDiscArtPriority(ctx, d.a.ffmpeg, conf.Server.DiscArtPriority)
|
||||
// Fallback to album cover art
|
||||
albumArtID := model.NewArtworkID(model.KindAlbumArtwork, d.album.ID, &d.album.UpdatedAt)
|
||||
ff = append(ff, fromAlbum(ctx, d.a, albumArtID))
|
||||
return selectImageReader(ctx, d.cacheKey.artID, ff...)
|
||||
}
|
||||
|
||||
func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc {
|
||||
var ff []sourceFunc
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "embedded":
|
||||
ff = append(ff, fromTag(ctx, d.firstTrackPath), fromFFmpegTag(ctx, ffmpeg, d.firstTrackPath))
|
||||
case pattern == "external":
|
||||
// Not supported for disc art, silently ignore
|
||||
case pattern == "discsubtitle":
|
||||
if subtitle := strings.TrimSpace(d.album.Discs[d.discNumber]); subtitle != "" {
|
||||
ff = append(ff, d.fromDiscSubtitle(ctx, subtitle))
|
||||
}
|
||||
case len(d.imgFiles) > 0:
|
||||
ff = append(ff, d.fromExternalFile(ctx, pattern))
|
||||
}
|
||||
}
|
||||
return ff
|
||||
}
|
||||
|
||||
// fromDiscSubtitle returns a sourceFunc that matches image files whose stem
|
||||
// (filename without extension) equals the disc subtitle (case-insensitive).
|
||||
func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
for _, file := range d.imgFiles {
|
||||
_, name := filepath.Split(file)
|
||||
stem := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
if !strings.EqualFold(stem, subtitle) {
|
||||
continue
|
||||
}
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not open disc art file", "file", file, err)
|
||||
continue
|
||||
}
|
||||
return f, file, nil
|
||||
}
|
||||
return nil, "", fmt.Errorf("disc %d: no image file matching subtitle %q", d.discNumber, subtitle)
|
||||
}
|
||||
}
|
||||
|
||||
// extractDiscNumber extracts a disc number from a filename based on a glob pattern.
|
||||
// It finds the portion of the filename that the wildcard matched and parses leading
|
||||
// digits as the disc number. Returns (0, false) if the pattern doesn't match or
|
||||
// no leading digits are found in the wildcard portion.
|
||||
func extractDiscNumber(pattern, filename string) (int, bool) {
|
||||
filename = strings.ToLower(filename)
|
||||
pattern = strings.ToLower(pattern)
|
||||
|
||||
matched, err := filepath.Match(pattern, filename)
|
||||
if err != nil || !matched {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Find the prefix before the first '*' in the pattern
|
||||
starIdx := strings.IndexByte(pattern, '*')
|
||||
if starIdx < 0 {
|
||||
return 0, false
|
||||
}
|
||||
prefix := pattern[:starIdx]
|
||||
|
||||
// Strip the prefix from the filename to get the wildcard-matched portion
|
||||
if !strings.HasPrefix(filename, prefix) {
|
||||
return 0, false
|
||||
}
|
||||
remainder := filename[len(prefix):]
|
||||
|
||||
// Extract leading ASCII digits from the remainder
|
||||
var digits []byte
|
||||
for _, r := range remainder {
|
||||
if r >= '0' && r <= '9' {
|
||||
digits = append(digits, byte(r))
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(digits) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
num, err := strconv.Atoi(string(digits))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return num, true
|
||||
}
|
||||
|
||||
// fromExternalFile returns a sourceFunc that matches image files against a glob
|
||||
// pattern with disc-number-aware filtering.
|
||||
//
|
||||
// Matching rules:
|
||||
// - If a disc number can be extracted from the filename, the file matches only if
|
||||
// the number equals the target disc number.
|
||||
// - If no number is found and this is a multi-folder album, the file matches if
|
||||
// it's in a folder containing tracks for this disc.
|
||||
// - If no number is found and this is a single-folder album, the file is skipped
|
||||
// (ambiguous).
|
||||
func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
for _, file := range d.imgFiles {
|
||||
_, name := filepath.Split(file)
|
||||
match, err := filepath.Match(pattern, strings.ToLower(name))
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error matching disc art file to pattern", "pattern", pattern, "file", file)
|
||||
continue
|
||||
}
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to extract disc number from filename
|
||||
num, hasNum := extractDiscNumber(pattern, name)
|
||||
if hasNum {
|
||||
// File has a disc number — must match target disc
|
||||
if num != d.discNumber {
|
||||
continue
|
||||
}
|
||||
} else if d.isMultiFolder {
|
||||
// No number, multi-folder: match by folder association
|
||||
dir := filepath.Dir(file)
|
||||
if !d.discFolders[dir] {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// No number, single-folder: ambiguous, skip
|
||||
continue
|
||||
}
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not open disc art file", "file", file, err)
|
||||
continue
|
||||
}
|
||||
return f, file, nil
|
||||
}
|
||||
return nil, "", fmt.Errorf("disc %d: pattern '%s' not matched by files", d.discNumber, pattern)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Disc Artwork Reader", func() {
|
||||
Describe("extractDiscNumber", func() {
|
||||
DescribeTable("extracts disc number from filename based on glob pattern",
|
||||
func(pattern, filename string, expectedNum int, expectedOk bool) {
|
||||
num, ok := extractDiscNumber(pattern, filename)
|
||||
Expect(ok).To(Equal(expectedOk))
|
||||
if expectedOk {
|
||||
Expect(num).To(Equal(expectedNum))
|
||||
}
|
||||
},
|
||||
// Standard disc patterns
|
||||
Entry("disc1.jpg", "disc*.*", "disc1.jpg", 1, true),
|
||||
Entry("disc2.png", "disc*.*", "disc2.png", 2, true),
|
||||
Entry("disc01.jpg", "disc*.*", "disc01.jpg", 1, true),
|
||||
Entry("disc02.png", "disc*.*", "disc02.png", 2, true),
|
||||
Entry("disc10.jpg", "disc*.*", "disc10.jpg", 10, true),
|
||||
|
||||
// CD patterns
|
||||
Entry("cd1.jpg", "cd*.*", "cd1.jpg", 1, true),
|
||||
Entry("cd02.png", "cd*.*", "cd02.png", 2, true),
|
||||
|
||||
// No number in filename
|
||||
Entry("disc.jpg has no number", "disc*.*", "disc.jpg", 0, false),
|
||||
Entry("cd.jpg has no number", "cd*.*", "cd.jpg", 0, false),
|
||||
|
||||
// Extra text after number
|
||||
Entry("disc2-bonus.jpg", "disc*.*", "disc2-bonus.jpg", 2, true),
|
||||
Entry("disc01_front.png", "disc*.*", "disc01_front.png", 1, true),
|
||||
|
||||
// Case insensitive (filename already lowered by caller)
|
||||
Entry("Disc1.jpg lowered", "disc*.*", "disc1.jpg", 1, true),
|
||||
|
||||
// Pattern doesn't match
|
||||
Entry("cover.jpg doesn't match disc*.*", "disc*.*", "cover.jpg", 0, false),
|
||||
|
||||
// Pattern with no wildcard before dot
|
||||
Entry("front1.jpg with front*.*", "front*.*", "front1.jpg", 1, true),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("fromExternalFile", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
tmpDir string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
tmpDir = GinkgoT().TempDir()
|
||||
})
|
||||
|
||||
createFile := func(path string) string {
|
||||
fullPath := filepath.Join(tmpDir, filepath.FromSlash(path))
|
||||
Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed())
|
||||
Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed())
|
||||
return fullPath
|
||||
}
|
||||
|
||||
It("matches file with disc number in single-folder album", func() {
|
||||
f1 := createFile("album/disc1.jpg")
|
||||
f2 := createFile("album/disc2.jpg")
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1, f2},
|
||||
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, "disc*.*")
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(f1))
|
||||
})
|
||||
|
||||
It("skips file without number in single-folder album", func() {
|
||||
f1 := createFile("album/disc.jpg")
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1},
|
||||
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, "disc*.*")
|
||||
r, _, _ := sf()
|
||||
Expect(r).To(BeNil())
|
||||
})
|
||||
|
||||
It("matches file without number in multi-folder album by folder", func() {
|
||||
f1 := createFile("album/cd1/disc.jpg")
|
||||
f2 := createFile("album/cd2/disc.jpg")
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1, f2},
|
||||
discFolders: map[string]bool{filepath.Join(tmpDir, "album", "cd1"): true},
|
||||
isMultiFolder: true,
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, "disc*.*")
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(f1))
|
||||
})
|
||||
|
||||
It("prefers disc number over folder when number is present", func() {
|
||||
// disc2.jpg in cd1 folder should match disc 2, not disc 1
|
||||
f1 := createFile("album/cd1/disc2.jpg")
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 2,
|
||||
imgFiles: []string{f1},
|
||||
discFolders: map[string]bool{filepath.Join(tmpDir, "album", "cd1"): true},
|
||||
isMultiFolder: true,
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, "disc*.*")
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(f1))
|
||||
})
|
||||
|
||||
It("does not match disc2.jpg when looking for disc 1", func() {
|
||||
f1 := createFile("album/disc2.jpg")
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1},
|
||||
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, "disc*.*")
|
||||
r, _, _ := sf()
|
||||
Expect(r).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fromDiscSubtitle", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
tmpDir string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
tmpDir = GinkgoT().TempDir()
|
||||
})
|
||||
|
||||
createFile := func(path string) string {
|
||||
fullPath := filepath.Join(tmpDir, filepath.FromSlash(path))
|
||||
Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed())
|
||||
Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed())
|
||||
return fullPath
|
||||
}
|
||||
|
||||
It("matches image file whose stem equals the disc subtitle (case-insensitive)", func() {
|
||||
f1 := createFile("album/The Blue Disc.jpg")
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1},
|
||||
}
|
||||
|
||||
sf := reader.fromDiscSubtitle(ctx, "The Blue Disc")
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(f1))
|
||||
})
|
||||
|
||||
It("matches case-insensitively", func() {
|
||||
f1 := createFile("album/bonus tracks.png")
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 2,
|
||||
imgFiles: []string{f1},
|
||||
}
|
||||
|
||||
sf := reader.fromDiscSubtitle(ctx, "Bonus Tracks")
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(f1))
|
||||
})
|
||||
|
||||
It("returns error when no matching file found", func() {
|
||||
f1 := createFile("album/cover.jpg")
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1},
|
||||
}
|
||||
|
||||
sf := reader.fromDiscSubtitle(ctx, "The Blue Disc")
|
||||
_, _, err := sf()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("matches first file when multiple extensions exist", func() {
|
||||
f1 := createFile("album/The Blue Disc.jpg")
|
||||
f2 := createFile("album/The Blue Disc.png")
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1, f2},
|
||||
}
|
||||
|
||||
sf := reader.fromDiscSubtitle(ctx, "The Blue Disc")
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(f1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("discArtworkReader", func() {
|
||||
Describe("fromDiscArtPriority", func() {
|
||||
var reader *discArtworkReader
|
||||
|
||||
BeforeEach(func() {
|
||||
reader = &discArtworkReader{
|
||||
discNumber: 2,
|
||||
isMultiFolder: true,
|
||||
discFolders: map[string]bool{"/music/album/cd2": true},
|
||||
imgFiles: []string{
|
||||
"/music/album/cd1/disc.jpg",
|
||||
"/music/album/cd2/disc.jpg",
|
||||
"/music/album/cd2/disc2.jpg",
|
||||
},
|
||||
firstTrackPath: "/music/album/cd2/track1.flac",
|
||||
}
|
||||
})
|
||||
|
||||
It("returns source funcs for glob patterns", func() {
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*")
|
||||
Expect(ff).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("returns source funcs for embedded pattern", func() {
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "embedded")
|
||||
Expect(ff).To(HaveLen(2)) // fromTag + fromFFmpegTag
|
||||
})
|
||||
|
||||
It("handles multiple comma-separated patterns", func() {
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*, cd*.*, embedded")
|
||||
Expect(ff).To(HaveLen(4)) // disc*.* + cd*.* + fromTag + fromFFmpegTag
|
||||
})
|
||||
|
||||
It("ignores 'external' pattern silently", func() {
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "external")
|
||||
Expect(ff).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("returns no source funcs when imgFiles is empty and pattern is not embedded", func() {
|
||||
reader.imgFiles = nil
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*")
|
||||
Expect(ff).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("returns source func for discsubtitle pattern", func() {
|
||||
reader.album = model.Album{Discs: model.Discs{2: "Bonus Tracks"}}
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle")
|
||||
Expect(ff).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("returns no source func for discsubtitle when disc has no subtitle", func() {
|
||||
reader.album = model.Album{Discs: model.Discs{2: ""}}
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle")
|
||||
Expect(ff).To(HaveLen(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -26,16 +26,22 @@ func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID mode
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, _, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, *al)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &mediafileArtworkReader{
|
||||
a: artwork,
|
||||
mediafile: *mf,
|
||||
album: *al,
|
||||
}
|
||||
a.cacheKey.artID = artID
|
||||
if al.UpdatedAt.After(mf.UpdatedAt) {
|
||||
a.cacheKey.lastUpdate = mf.UpdatedAt
|
||||
if al.UpdatedAt.After(a.cacheKey.lastUpdate) {
|
||||
a.cacheKey.lastUpdate = al.UpdatedAt
|
||||
} else {
|
||||
a.cacheKey.lastUpdate = mf.UpdatedAt
|
||||
}
|
||||
if imagesUpdatedAt != nil && imagesUpdatedAt.After(a.cacheKey.lastUpdate) {
|
||||
a.cacheKey.lastUpdate = *imagesUpdatedAt
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
@@ -60,6 +66,12 @@ func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, str
|
||||
fromFFmpegTag(ctx, a.a.ffmpeg, path),
|
||||
}
|
||||
}
|
||||
ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.AlbumCoverArtID()))
|
||||
// For multi-disc albums, fall back to disc artwork first; for single-disc albums,
|
||||
// skip disc resolution (it would just fall through to album art anyway).
|
||||
if len(a.album.Discs) > 1 {
|
||||
ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.DiscCoverArtID()))
|
||||
} else {
|
||||
ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.AlbumCoverArtID()))
|
||||
}
|
||||
return selectImageReader(ctx, a.artID, ff...)
|
||||
}
|
||||
@@ -8,12 +8,17 @@ import (
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
type playlistArtworkReader struct {
|
||||
@@ -35,6 +40,24 @@ func newPlaylistArtworkReader(ctx context.Context, artwork *artwork, artID model
|
||||
}
|
||||
a.cacheKey.artID = artID
|
||||
a.cacheKey.lastUpdate = pl.UpdatedAt
|
||||
|
||||
// Check sidecar and ExternalImageURL local file ModTimes for cache invalidation.
|
||||
// If either is newer than the playlist's UpdatedAt, use that instead so the
|
||||
// cache is busted when a user replaces a sidecar image or local file reference.
|
||||
for _, path := range []string{
|
||||
findPlaylistSidecarPath(ctx, pl.Path),
|
||||
pl.ExternalImageURL,
|
||||
} {
|
||||
if path == "" || strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
continue
|
||||
}
|
||||
if info, err := os.Stat(path); err == nil {
|
||||
if info.ModTime().After(a.cacheKey.lastUpdate) {
|
||||
a.cacheKey.lastUpdate = info.ModTime()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -43,11 +66,81 @@ func (a *playlistArtworkReader) LastUpdated() time.Time {
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
ff := []sourceFunc{
|
||||
return selectImageReader(ctx, a.artID,
|
||||
a.fromPlaylistUploadedImage(),
|
||||
a.fromPlaylistSidecar(ctx),
|
||||
a.fromPlaylistExternalImage(ctx),
|
||||
a.fromGeneratedTiledCover(ctx),
|
||||
fromAlbumPlaceholder(),
|
||||
)
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) fromPlaylistUploadedImage() sourceFunc {
|
||||
return fromLocalFile(a.pl.UploadedImagePath())
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) fromPlaylistSidecar(ctx context.Context) sourceFunc {
|
||||
return fromLocalFile(findPlaylistSidecarPath(ctx, a.pl.Path))
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) fromPlaylistExternalImage(ctx context.Context) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
imgURL := a.pl.ExternalImageURL
|
||||
if imgURL == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
parsed, err := url.Parse(imgURL)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if parsed.Scheme == "http" || parsed.Scheme == "https" {
|
||||
if !conf.Server.EnableM3UExternalAlbumArt {
|
||||
return nil, "", nil
|
||||
}
|
||||
return fromURL(ctx, parsed)
|
||||
}
|
||||
return fromLocalFile(imgURL)()
|
||||
}
|
||||
return selectImageReader(ctx, a.artID, ff...)
|
||||
}
|
||||
|
||||
// fromLocalFile returns a sourceFunc that opens the given local path.
|
||||
// Returns (nil, "", nil) if path is empty — signalling "not found, try next source".
|
||||
func fromLocalFile(path string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
if path == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return f, path, nil
|
||||
}
|
||||
}
|
||||
|
||||
// findPlaylistSidecarPath scans the directory of the playlist file for a sidecar
|
||||
// image file with the same base name (case-insensitive). Returns empty string if
|
||||
// no matching image is found or if plsPath is empty.
|
||||
func findPlaylistSidecarPath(ctx context.Context, plsPath string) string {
|
||||
if plsPath == "" {
|
||||
return ""
|
||||
}
|
||||
dir := filepath.Dir(plsPath)
|
||||
base := strings.TrimSuffix(filepath.Base(plsPath), filepath.Ext(plsPath))
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not read directory for playlist sidecar", "dir", dir, err)
|
||||
return ""
|
||||
}
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
nameBase := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
if !entry.IsDir() && strings.EqualFold(nameBase, base) && model.IsImageFile(name) {
|
||||
return filepath.Join(dir, name)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) fromGeneratedTiledCover(ctx context.Context) sourceFunc {
|
||||
@@ -107,7 +200,7 @@ func (a *playlistArtworkReader) createTile(_ context.Context, r io.ReadCloser) (
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return imaging.Fill(img, tileSize/2, tileSize/2, imaging.Center, imaging.Lanczos), nil
|
||||
return fillCenter(img, tileSize/2, tileSize/2), nil
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) createTiledImage(_ context.Context, tiles []image.Image) (io.ReadCloser, error) {
|
||||
@@ -145,3 +238,32 @@ func rect(pos int) image.Rectangle {
|
||||
r.Max.Y = r.Min.Y + tileSize/2
|
||||
return r
|
||||
}
|
||||
|
||||
// fillCenter crops the source image from the center and scales it to fill dstW x dstH exactly,
|
||||
// equivalent to imaging.Fill with Center anchor.
|
||||
func fillCenter(src image.Image, dstW, dstH int) image.Image {
|
||||
srcBounds := src.Bounds()
|
||||
srcW := srcBounds.Dx()
|
||||
srcH := srcBounds.Dy()
|
||||
|
||||
// Calculate crop rectangle (center crop to match destination aspect ratio)
|
||||
srcAspect := float64(srcW) / float64(srcH)
|
||||
dstAspect := float64(dstW) / float64(dstH)
|
||||
|
||||
var cropRect image.Rectangle
|
||||
if srcAspect > dstAspect {
|
||||
// Source is wider — crop horizontally
|
||||
cropW := int(float64(srcH) * dstAspect)
|
||||
cropX := (srcW - cropW) / 2
|
||||
cropRect = image.Rect(srcBounds.Min.X+cropX, srcBounds.Min.Y, srcBounds.Min.X+cropX+cropW, srcBounds.Max.Y)
|
||||
} else {
|
||||
// Source is taller — crop vertically
|
||||
cropH := int(float64(srcW) / dstAspect)
|
||||
cropY := (srcH - cropH) / 2
|
||||
cropRect = image.Rect(srcBounds.Min.X, srcBounds.Min.Y+cropY, srcBounds.Max.X, srcBounds.Min.Y+cropY+cropH)
|
||||
}
|
||||
|
||||
dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, cropRect, draw.Src, nil)
|
||||
return dst
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type radioArtworkReader struct {
|
||||
cacheKey
|
||||
a *artwork
|
||||
radio model.Radio
|
||||
}
|
||||
|
||||
func newRadioArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*radioArtworkReader, error) {
|
||||
r, err := artwork.ds.Radio(ctx).Get(artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &radioArtworkReader{a: artwork, radio: *r}
|
||||
a.cacheKey.artID = artID
|
||||
a.cacheKey.lastUpdate = r.UpdatedAt
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *radioArtworkReader) LastUpdated() time.Time {
|
||||
return a.lastUpdate
|
||||
}
|
||||
|
||||
func (a *radioArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
return selectImageReader(ctx, a.artID,
|
||||
a.fromRadioUploadedImage(),
|
||||
)
|
||||
}
|
||||
|
||||
func (a *radioArtworkReader) fromRadioUploadedImage() sourceFunc {
|
||||
return fromLocalFile(a.radio.UploadedImagePath())
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("radioArtworkReader", func() {
|
||||
var (
|
||||
tempDir string
|
||||
reader *radioArtworkReader
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tempDir = GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = tempDir
|
||||
|
||||
Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "radio"), 0755)).To(Succeed())
|
||||
|
||||
reader = &radioArtworkReader{}
|
||||
})
|
||||
|
||||
Describe("fromRadioUploadedImage", func() {
|
||||
When("radio has an uploaded image", func() {
|
||||
It("returns the uploaded image", func() {
|
||||
imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed())
|
||||
|
||||
reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"}
|
||||
sf := reader.fromRadioUploadedImage()
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("radio has no uploaded image", func() {
|
||||
It("returns nil reader (falls through)", func() {
|
||||
reader.radio = model.Radio{ID: "rd-1"}
|
||||
sf := reader.fromRadioUploadedImage()
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Reader", func() {
|
||||
When("radio has an uploaded image", func() {
|
||||
It("returns the image reader", func() {
|
||||
imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed())
|
||||
|
||||
reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"}
|
||||
reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"}
|
||||
r, _, err := reader.Reader(context.Background())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("radio has no uploaded image", func() {
|
||||
It("returns ErrUnavailable", func() {
|
||||
reader.radio = model.Radio{ID: "rd-1"}
|
||||
reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"}
|
||||
r, _, err := reader.Reader(context.Background())
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(r).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,17 +5,26 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/gen2brain/webp"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
var bufPool = sync.Pool{
|
||||
New: func() any {
|
||||
return new(bytes.Buffer)
|
||||
},
|
||||
}
|
||||
|
||||
type resizedArtworkReader struct {
|
||||
artID model.ArtworkID
|
||||
cacheKey string
|
||||
@@ -46,7 +55,7 @@ func (a *resizedArtworkReader) Key() string {
|
||||
if a.square {
|
||||
return baseKey + ".square"
|
||||
}
|
||||
return fmt.Sprintf("%s.%d", baseKey, conf.Server.CoverJpegQuality)
|
||||
return fmt.Sprintf("%s.%d", baseKey, conf.Server.CoverArtQuality)
|
||||
}
|
||||
|
||||
func (a *resizedArtworkReader) LastUpdated() time.Time {
|
||||
@@ -61,7 +70,7 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin
|
||||
}
|
||||
defer orig.Close()
|
||||
|
||||
resized, origSize, err := resizeImage(orig, a.size, a.square)
|
||||
resized, origSize, err := a.resizeImage(ctx, orig)
|
||||
if resized == nil {
|
||||
log.Trace(ctx, "Image smaller than requested size", "artID", a.artID, "original", origSize, "resized", a.size, "square", a.square)
|
||||
} else {
|
||||
@@ -75,11 +84,42 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin
|
||||
orig, _, err = a.a.Get(ctx, a.artID, 0, false)
|
||||
return orig, "", err
|
||||
}
|
||||
// Preserve ReadCloser semantics if the resized reader already supports Close
|
||||
// (e.g., ffmpeg pipe), otherwise wrap with NopCloser
|
||||
if rc, ok := resized.(io.ReadCloser); ok {
|
||||
return rc, fmt.Sprintf("%s@%d", a.artID, a.size), nil
|
||||
}
|
||||
return io.NopCloser(resized), fmt.Sprintf("%s@%d", a.artID, a.size), nil
|
||||
}
|
||||
|
||||
func resizeImage(reader io.Reader, size int, square bool) (io.Reader, int, error) {
|
||||
original, format, err := image.Decode(reader)
|
||||
func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader) (io.Reader, int, error) {
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("reading image data: %w", err)
|
||||
}
|
||||
|
||||
// Preserve animation for animated images (skip for square thumbnails)
|
||||
if !a.square {
|
||||
if isAnimatedGIF(data) {
|
||||
if a.a.ffmpeg.IsAvailable() {
|
||||
// Animated GIF: convert to animated WebP via ffmpeg (with optional resize)
|
||||
r, err := a.a.ffmpeg.ConvertAnimatedImage(ctx, bytes.NewReader(data), a.size, conf.Server.CoverArtQuality)
|
||||
if err == nil {
|
||||
return r, 0, nil
|
||||
}
|
||||
log.Warn(ctx, "Could not convert animated GIF, falling back to static", err)
|
||||
}
|
||||
} else if isAnimatedWebP(data) || isAnimatedPNG(data) {
|
||||
// Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these)
|
||||
return bytes.NewReader(data), 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
return resizeStaticImage(data, a.size, a.square)
|
||||
}
|
||||
|
||||
func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) {
|
||||
original, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -96,26 +136,45 @@ func resizeImage(reader io.Reader, size int, square bool) (io.Reader, int, error
|
||||
return nil, originalSize, nil
|
||||
}
|
||||
|
||||
var resized image.Image
|
||||
if originalSize >= size {
|
||||
resized = imaging.Fit(original, size, size, imaging.Lanczos)
|
||||
} else {
|
||||
if bounds.Max.Y < bounds.Max.X {
|
||||
resized = imaging.Resize(original, size, 0, imaging.Lanczos)
|
||||
} else {
|
||||
resized = imaging.Resize(original, 0, size, imaging.Lanczos)
|
||||
}
|
||||
}
|
||||
if square {
|
||||
bg := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
resized = imaging.OverlayCenter(bg, resized, 1)
|
||||
}
|
||||
// Calculate aspect-fit dimensions
|
||||
srcW, srcH := bounds.Dx(), bounds.Dy()
|
||||
scale := float64(size) / float64(max(srcW, srcH))
|
||||
dstW := int(float64(srcW) * scale)
|
||||
dstH := int(float64(srcH) * scale)
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
if format == "png" || square {
|
||||
err = png.Encode(buf, resized)
|
||||
var dst *image.NRGBA
|
||||
var dstRect image.Rectangle
|
||||
if square {
|
||||
// Square canvas with image centered (transparent padding via zero-initialized NRGBA)
|
||||
dst = image.NewNRGBA(image.Rect(0, 0, size, size))
|
||||
offsetX := (size - dstW) / 2
|
||||
offsetY := (size - dstH) / 2
|
||||
dstRect = image.Rect(offsetX, offsetY, offsetX+dstW, offsetY+dstH)
|
||||
} else {
|
||||
err = jpeg.Encode(buf, resized, &jpeg.Options{Quality: conf.Server.CoverJpegQuality})
|
||||
// Tight-fit canvas
|
||||
dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
dstRect = dst.Bounds()
|
||||
}
|
||||
return buf, originalSize, err
|
||||
xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil)
|
||||
|
||||
buf := bufPool.Get().(*bytes.Buffer)
|
||||
buf.Reset()
|
||||
if conf.Server.DevJpegCoverArt {
|
||||
if square {
|
||||
err = png.Encode(buf, dst)
|
||||
} else {
|
||||
err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality})
|
||||
}
|
||||
} else {
|
||||
err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality})
|
||||
}
|
||||
if err != nil {
|
||||
bufPool.Put(buf)
|
||||
return nil, originalSize, err
|
||||
}
|
||||
// Copy bytes before returning buffer to pool (pool may reuse the buffer)
|
||||
encoded := make([]byte, buf.Len())
|
||||
copy(encoded, buf.Bytes())
|
||||
bufPool.Put(buf)
|
||||
return bytes.NewReader(encoded), originalSize, nil
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("resizeImage", func() {
|
||||
var mockFF *tests.MockFFmpeg
|
||||
var r *resizedArtworkReader
|
||||
|
||||
BeforeEach(func() {
|
||||
mockFF = tests.NewMockFFmpeg("converted-animated-data")
|
||||
r = &resizedArtworkReader{
|
||||
size: 300,
|
||||
square: false,
|
||||
a: &artwork{ffmpeg: mockFF},
|
||||
}
|
||||
})
|
||||
|
||||
Describe("animated GIF handling", func() {
|
||||
It("converts animated GIF via ffmpeg when available", func() {
|
||||
data := createAnimatedGIF(3)
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
// Should have been processed by ffmpeg (mock returns "converted-animated-data")
|
||||
output, err := io.ReadAll(result)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output).To(Equal(data)) // MockFFmpeg echoes input back
|
||||
})
|
||||
|
||||
It("falls back to static resize when ffmpeg fails for animated GIF", func() {
|
||||
mockFF.Error = errors.New("ffmpeg failed")
|
||||
// Use size smaller than image so static resize actually produces output
|
||||
r.size = 1
|
||||
data := createAnimatedGIF(3)
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
// Should fall through to static resize successfully (no ffmpeg error propagated)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
// Verify it's a static image (WebP encoded), not the ffmpeg error
|
||||
output, err := io.ReadAll(result)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(output)).To(BeNumerically(">", 0))
|
||||
})
|
||||
|
||||
It("skips animation for square thumbnails even with animated GIF", func() {
|
||||
r.square = true
|
||||
data := createAnimatedGIF(3)
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
// Should fall through to static resize (not ffmpeg conversion)
|
||||
// The minimal test GIF may or may not resize successfully,
|
||||
// but ffmpeg should NOT have been called for animated conversion
|
||||
_ = result
|
||||
_ = err
|
||||
// Verify by checking the mock wasn't used for animated conversion:
|
||||
// If ffmpeg was called, it would return mock data, not static resize result
|
||||
})
|
||||
})
|
||||
|
||||
Describe("animated WebP handling", func() {
|
||||
It("returns animated WebP data as-is when not square", func() {
|
||||
data := createAnimatedWebPBytes()
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
// Should return original data unchanged
|
||||
output, err := io.ReadAll(result)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output).To(Equal(data))
|
||||
})
|
||||
|
||||
It("does not passthrough animated WebP for square thumbnails", func() {
|
||||
r.square = true
|
||||
data := createAnimatedWebPBytes()
|
||||
// Should fall through to static resize, which will fail on fake WebP data
|
||||
_, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
// Static decode will fail on our minimal test WebP bytes (not a real image)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("animated PNG handling", func() {
|
||||
It("returns animated PNG data as-is when not square", func() {
|
||||
data := createAPNGBytes()
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
// Should return original data unchanged
|
||||
output, err := io.ReadAll(result)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output).To(Equal(data))
|
||||
})
|
||||
|
||||
It("does not passthrough animated PNG for square thumbnails", func() {
|
||||
r.square = true
|
||||
data := createAPNGBytes()
|
||||
// Should fall through to static resize
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
// Static PNG decode should succeed on our APNG (it's a valid PNG)
|
||||
if err == nil {
|
||||
Expect(result).ToNot(BeNil())
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("static image handling", func() {
|
||||
It("resizes a static PNG normally", func() {
|
||||
data := createStaticPNGBytes()
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
// Static PNG is 2x2, size 300 is larger, so should return nil (no upscale)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ReadCloser preservation", func() {
|
||||
It("preserves Close semantics from ffmpeg ReadCloser", func() {
|
||||
// Create a trackable ReadCloser
|
||||
tracker := &closeTracker{Reader: bytes.NewReader([]byte("test data"))}
|
||||
mockFF2 := &mockFFmpegWithCloser{tracker: tracker}
|
||||
r.a = &artwork{ffmpeg: mockFF2}
|
||||
|
||||
data := createAnimatedGIF(3)
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// The result should be an io.ReadCloser (the tracker)
|
||||
rc, ok := result.(io.ReadCloser)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(rc.Close()).ToNot(HaveOccurred())
|
||||
Expect(tracker.closed).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// closeTracker is an io.ReadCloser that tracks whether Close was called.
|
||||
type closeTracker struct {
|
||||
io.Reader
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (c *closeTracker) Close() error {
|
||||
c.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// mockFFmpegWithCloser is a minimal FFmpeg mock that returns a specific ReadCloser
|
||||
// for ConvertAnimatedImage, allowing us to verify Close propagation.
|
||||
type mockFFmpegWithCloser struct {
|
||||
ffmpeg.FFmpeg
|
||||
tracker *closeTracker
|
||||
}
|
||||
|
||||
func (m *mockFFmpegWithCloser) IsAvailable() bool { return true }
|
||||
func (m *mockFFmpegWithCloser) ConvertAnimatedImage(_ context.Context, _ io.Reader, _ int, _ int) (io.ReadCloser, error) {
|
||||
return m.tracker, nil
|
||||
}
|
||||
@@ -15,8 +15,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
@@ -86,58 +84,6 @@ var picTypeRegexes = []*regexp.Regexp{
|
||||
}
|
||||
|
||||
func fromTag(ctx context.Context, path string) sourceFunc {
|
||||
if conf.Server.DevLegacyEmbedImage {
|
||||
return fromTagLegacy(ctx, path)
|
||||
}
|
||||
return fromTagGoTaglib(ctx, path)
|
||||
}
|
||||
|
||||
func fromTagLegacy(ctx context.Context, path string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
if path == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
m, err := tag.ReadFrom(f)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
types := m.PictureTypes()
|
||||
if len(types) == 0 {
|
||||
return nil, "", fmt.Errorf("no embedded image found in %s", path)
|
||||
}
|
||||
|
||||
var picture *tag.Picture
|
||||
for _, regex := range picTypeRegexes {
|
||||
for _, t := range types {
|
||||
if regex.MatchString(t) {
|
||||
log.Trace(ctx, "Found embedded image", "type", t, "path", path)
|
||||
picture = m.Pictures(t)
|
||||
break
|
||||
}
|
||||
}
|
||||
if picture != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if picture == nil {
|
||||
log.Trace(ctx, "Could not find a front image. Getting the first one", "type", types[0], "path", path)
|
||||
picture = m.Picture()
|
||||
}
|
||||
if picture == nil {
|
||||
return nil, "", fmt.Errorf("could not load embedded image from %s", path)
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(picture.Data)), path, nil
|
||||
}
|
||||
}
|
||||
|
||||
func fromTagGoTaglib(ctx context.Context, path string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
if path == "" {
|
||||
return nil, "", nil
|
||||
|
||||
+35
-36
@@ -4,12 +4,11 @@ import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"maps"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -46,38 +45,30 @@ func Init(ds model.DataStore) {
|
||||
})
|
||||
}
|
||||
|
||||
func createBaseClaims() map[string]any {
|
||||
tokenClaims := map[string]any{}
|
||||
tokenClaims[jwt.IssuerKey] = consts.JWTIssuer
|
||||
return tokenClaims
|
||||
}
|
||||
|
||||
func CreatePublicToken(claims map[string]any) (string, error) {
|
||||
tokenClaims := createBaseClaims()
|
||||
maps.Copy(tokenClaims, claims)
|
||||
_, token, err := TokenAuth.Encode(tokenClaims)
|
||||
|
||||
func CreatePublicToken(claims Claims) (string, error) {
|
||||
claims.Issuer = consts.JWTIssuer
|
||||
_, token, err := TokenAuth.Encode(claims.ToMap())
|
||||
return token, err
|
||||
}
|
||||
|
||||
func CreateExpiringPublicToken(exp time.Time, claims map[string]any) (string, error) {
|
||||
tokenClaims := createBaseClaims()
|
||||
func CreateExpiringPublicToken(exp time.Time, claims Claims) (string, error) {
|
||||
claims.Issuer = consts.JWTIssuer
|
||||
if !exp.IsZero() {
|
||||
tokenClaims[jwt.ExpirationKey] = exp.UTC().Unix()
|
||||
claims.ExpiresAt = exp
|
||||
}
|
||||
maps.Copy(tokenClaims, claims)
|
||||
_, token, err := TokenAuth.Encode(tokenClaims)
|
||||
|
||||
_, token, err := TokenAuth.Encode(claims.ToMap())
|
||||
return token, err
|
||||
}
|
||||
|
||||
func CreateToken(u *model.User) (string, error) {
|
||||
claims := createBaseClaims()
|
||||
claims[jwt.SubjectKey] = u.UserName
|
||||
claims[jwt.IssuedAtKey] = time.Now().UTC().Unix()
|
||||
claims["uid"] = u.ID
|
||||
claims["adm"] = u.IsAdmin
|
||||
token, _, err := TokenAuth.Encode(claims)
|
||||
claims := Claims{
|
||||
Issuer: consts.JWTIssuer,
|
||||
Subject: u.UserName,
|
||||
IssuedAt: time.Now(),
|
||||
UserID: u.ID,
|
||||
IsAdmin: u.IsAdmin,
|
||||
}
|
||||
token, _, err := TokenAuth.Encode(claims.ToMap())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -86,23 +77,18 @@ func CreateToken(u *model.User) (string, error) {
|
||||
}
|
||||
|
||||
func TouchToken(token jwt.Token) (string, error) {
|
||||
claims, err := token.AsMap(context.Background())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
claims[jwt.ExpirationKey] = time.Now().UTC().Add(conf.Server.SessionTimeout).Unix()
|
||||
_, newToken, err := TokenAuth.Encode(claims)
|
||||
|
||||
claims := ClaimsFromToken(token).
|
||||
WithExpiresAt(time.Now().UTC().Add(conf.Server.SessionTimeout))
|
||||
_, newToken, err := TokenAuth.Encode(claims.ToMap())
|
||||
return newToken, err
|
||||
}
|
||||
|
||||
func Validate(tokenStr string) (map[string]any, error) {
|
||||
func Validate(tokenStr string) (Claims, error) {
|
||||
token, err := jwtauth.VerifyToken(TokenAuth, tokenStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return Claims{}, err
|
||||
}
|
||||
return token.AsMap(context.Background())
|
||||
return ClaimsFromToken(token), nil
|
||||
}
|
||||
|
||||
func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
|
||||
@@ -134,6 +120,19 @@ func createNewSecret(ctx context.Context, ds model.DataStore) string {
|
||||
return secret
|
||||
}
|
||||
|
||||
// EncodeToken creates a signed JWT from an arbitrary claims map.
|
||||
// It sets the issuer claim automatically.
|
||||
func EncodeToken(claims map[string]any) (string, error) {
|
||||
claims[jwt.IssuerKey] = consts.JWTIssuer
|
||||
_, token, err := TokenAuth.Encode(claims)
|
||||
return token, err
|
||||
}
|
||||
|
||||
// DecodeAndVerifyToken verifies a JWT string and returns the parsed token.
|
||||
func DecodeAndVerifyToken(tokenStr string) (jwt.Token, error) {
|
||||
return jwtauth.VerifyToken(TokenAuth, tokenStr)
|
||||
}
|
||||
|
||||
func getEncKey() []byte {
|
||||
key := cmp.Or(
|
||||
conf.Server.PasswordEncryptionKey,
|
||||
|
||||
@@ -54,7 +54,7 @@ var _ = Describe("Auth", func() {
|
||||
|
||||
decodedClaims, err := auth.Validate(tokenStr)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(decodedClaims["iss"]).To(Equal("issuer"))
|
||||
Expect(decodedClaims.Issuer).To(Equal("issuer"))
|
||||
})
|
||||
|
||||
It("returns ErrExpired if the `exp` field is in the past", func() {
|
||||
@@ -82,11 +82,11 @@ var _ = Describe("Auth", func() {
|
||||
claims, err := auth.Validate(tokenStr)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
Expect(claims["iss"]).To(Equal(consts.JWTIssuer))
|
||||
Expect(claims["sub"]).To(Equal("johndoe"))
|
||||
Expect(claims["uid"]).To(Equal("123"))
|
||||
Expect(claims["adm"]).To(Equal(true))
|
||||
Expect(claims["exp"]).To(BeTemporally(">", time.Now()))
|
||||
Expect(claims.Issuer).To(Equal(consts.JWTIssuer))
|
||||
Expect(claims.Subject).To(Equal("johndoe"))
|
||||
Expect(claims.UserID).To(Equal("123"))
|
||||
Expect(claims.IsAdmin).To(Equal(true))
|
||||
Expect(claims.ExpiresAt).To(BeTemporally(">", time.Now()))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -104,8 +104,7 @@ var _ = Describe("Auth", func() {
|
||||
|
||||
decodedClaims, err := auth.Validate(touched)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
exp := decodedClaims["exp"].(time.Time)
|
||||
Expect(exp.Sub(yesterday)).To(BeNumerically(">=", oneDay))
|
||||
Expect(decodedClaims.ExpiresAt.Sub(yesterday)).To(BeNumerically(">=", oneDay))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
)
|
||||
|
||||
// Claims represents the typed JWT claims used throughout Navidrome,
|
||||
// replacing the untyped map[string]any approach.
|
||||
type Claims struct {
|
||||
// Standard JWT claims
|
||||
Issuer string
|
||||
Subject string // username for session tokens
|
||||
IssuedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
|
||||
// Custom claims
|
||||
UserID string // "uid"
|
||||
IsAdmin bool // "adm"
|
||||
ID string // "id" - artwork/mediafile ID
|
||||
Format string // "f" - audio format
|
||||
BitRate int // "b" - audio bitrate
|
||||
}
|
||||
|
||||
// ToMap converts Claims to a map[string]any for use with TokenAuth.Encode().
|
||||
// Only non-zero fields are included.
|
||||
func (c Claims) ToMap() map[string]any {
|
||||
m := make(map[string]any)
|
||||
if c.Issuer != "" {
|
||||
m[jwt.IssuerKey] = c.Issuer
|
||||
}
|
||||
if c.Subject != "" {
|
||||
m[jwt.SubjectKey] = c.Subject
|
||||
}
|
||||
if !c.IssuedAt.IsZero() {
|
||||
m[jwt.IssuedAtKey] = c.IssuedAt.UTC().Unix()
|
||||
}
|
||||
if !c.ExpiresAt.IsZero() {
|
||||
m[jwt.ExpirationKey] = c.ExpiresAt.UTC().Unix()
|
||||
}
|
||||
if c.UserID != "" {
|
||||
m["uid"] = c.UserID
|
||||
}
|
||||
if c.IsAdmin {
|
||||
m["adm"] = c.IsAdmin
|
||||
}
|
||||
if c.ID != "" {
|
||||
m["id"] = c.ID
|
||||
}
|
||||
if c.Format != "" {
|
||||
m["f"] = c.Format
|
||||
}
|
||||
if c.BitRate != 0 {
|
||||
m["b"] = c.BitRate
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (c Claims) WithExpiresAt(t time.Time) Claims {
|
||||
c.ExpiresAt = t
|
||||
return c
|
||||
}
|
||||
|
||||
// ClaimsFromToken extracts Claims directly from a jwt.Token using token.Get().
|
||||
func ClaimsFromToken(token jwt.Token) Claims {
|
||||
var c Claims
|
||||
c.Issuer, _ = token.Issuer()
|
||||
c.Subject, _ = token.Subject()
|
||||
c.IssuedAt, _ = token.IssuedAt()
|
||||
c.ExpiresAt, _ = token.Expiration()
|
||||
|
||||
var uid string
|
||||
if err := token.Get("uid", &uid); err == nil {
|
||||
c.UserID = uid
|
||||
}
|
||||
var adm bool
|
||||
if err := token.Get("adm", &adm); err == nil {
|
||||
c.IsAdmin = adm
|
||||
}
|
||||
var id string
|
||||
if err := token.Get("id", &id); err == nil {
|
||||
c.ID = id
|
||||
}
|
||||
var f string
|
||||
if err := token.Get("f", &f); err == nil {
|
||||
c.Format = f
|
||||
}
|
||||
if err := token.Get("b", &c.BitRate); err != nil {
|
||||
var bf float64
|
||||
if err := token.Get("b", &bf); err == nil {
|
||||
c.BitRate = int(bf)
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Claims", func() {
|
||||
Describe("ToMap", func() {
|
||||
It("includes only non-zero fields", func() {
|
||||
c := auth.Claims{
|
||||
Issuer: "ND",
|
||||
Subject: "johndoe",
|
||||
UserID: "123",
|
||||
IsAdmin: true,
|
||||
}
|
||||
m := c.ToMap()
|
||||
Expect(m).To(HaveKeyWithValue("iss", "ND"))
|
||||
Expect(m).To(HaveKeyWithValue("sub", "johndoe"))
|
||||
Expect(m).To(HaveKeyWithValue("uid", "123"))
|
||||
Expect(m).To(HaveKeyWithValue("adm", true))
|
||||
Expect(m).NotTo(HaveKey("exp"))
|
||||
Expect(m).NotTo(HaveKey("iat"))
|
||||
Expect(m).NotTo(HaveKey("id"))
|
||||
Expect(m).NotTo(HaveKey("f"))
|
||||
Expect(m).NotTo(HaveKey("b"))
|
||||
})
|
||||
|
||||
It("includes expiration and issued-at when set", func() {
|
||||
now := time.Now()
|
||||
c := auth.Claims{
|
||||
IssuedAt: now,
|
||||
ExpiresAt: now.Add(time.Hour),
|
||||
}
|
||||
m := c.ToMap()
|
||||
Expect(m).To(HaveKey("iat"))
|
||||
Expect(m).To(HaveKey("exp"))
|
||||
})
|
||||
|
||||
It("includes custom claims for public tokens", func() {
|
||||
c := auth.Claims{
|
||||
ID: "al-123",
|
||||
Format: "mp3",
|
||||
BitRate: 192,
|
||||
}
|
||||
m := c.ToMap()
|
||||
Expect(m).To(HaveKeyWithValue("id", "al-123"))
|
||||
Expect(m).To(HaveKeyWithValue("f", "mp3"))
|
||||
Expect(m).To(HaveKeyWithValue("b", 192))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ClaimsFromToken", func() {
|
||||
It("round-trips session claims through encode/decode", func() {
|
||||
tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
now := time.Now().Truncate(time.Second)
|
||||
original := auth.Claims{
|
||||
Issuer: "ND",
|
||||
Subject: "johndoe",
|
||||
UserID: "123",
|
||||
IsAdmin: true,
|
||||
}
|
||||
m := original.ToMap()
|
||||
m["iat"] = now.UTC().Unix()
|
||||
token, _, err := tokenAuth.Encode(m)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
c := auth.ClaimsFromToken(token)
|
||||
Expect(c.Issuer).To(Equal("ND"))
|
||||
Expect(c.Subject).To(Equal("johndoe"))
|
||||
Expect(c.UserID).To(Equal("123"))
|
||||
Expect(c.IsAdmin).To(BeTrue())
|
||||
Expect(c.IssuedAt.UTC()).To(Equal(now.UTC()))
|
||||
})
|
||||
|
||||
It("round-trips public token claims through encode/decode", func() {
|
||||
tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
original := auth.Claims{
|
||||
Issuer: "ND",
|
||||
ID: "al-456",
|
||||
Format: "opus",
|
||||
BitRate: 128,
|
||||
}
|
||||
token, _, err := tokenAuth.Encode(original.ToMap())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
c := auth.ClaimsFromToken(token)
|
||||
Expect(c.Issuer).To(Equal("ND"))
|
||||
Expect(c.ID).To(Equal("al-456"))
|
||||
Expect(c.Format).To(Equal("opus"))
|
||||
Expect(c.BitRate).To(Equal(128))
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
Vendored
+11
-5
@@ -374,13 +374,19 @@ func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e.callGetImage(ctx, e.ag, &artist)
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "ArtistImage call canceled", ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
// Use already-stored image URL if available, avoiding expensive external API calls.
|
||||
// If the info is expired, the background refresh (via UpdateArtistInfo/artistQueue) will update it.
|
||||
imageUrl := artist.ArtistImageUrl()
|
||||
if imageUrl == "" {
|
||||
// No cached URL — must fetch from external source synchronously
|
||||
e.callGetImage(ctx, e.ag, &artist)
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "ArtistImage call canceled", ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
imageUrl = artist.ArtistImageUrl()
|
||||
}
|
||||
|
||||
imageUrl := artist.ArtistImageUrl()
|
||||
if imageUrl == "" {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
-1
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
_ "github.com/navidrome/navidrome/adapters/lastfm"
|
||||
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
|
||||
_ "github.com/navidrome/navidrome/adapters/spotify"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
|
||||
+323
-15
@@ -1,24 +1,52 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
// TranscodeOptions contains all parameters for a transcoding operation.
|
||||
type TranscodeOptions struct {
|
||||
Command string // DB command template (used to detect custom vs default)
|
||||
Format string // Target format (mp3, opus, aac, flac)
|
||||
FilePath string
|
||||
BitRate int // kbps, 0 = codec default
|
||||
SampleRate int // 0 = no constraint
|
||||
Channels int // 0 = no constraint
|
||||
BitDepth int // 0 = no constraint; valid values: 16, 24, 32
|
||||
Offset int // seconds
|
||||
}
|
||||
|
||||
// AudioProbeResult contains authoritative audio stream properties from ffprobe.
|
||||
type AudioProbeResult struct {
|
||||
Codec string `json:"codec"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
BitRate int `json:"bitRate"`
|
||||
SampleRate int `json:"sampleRate"`
|
||||
BitDepth int `json:"bitDepth"`
|
||||
Channels int `json:"channels"`
|
||||
}
|
||||
|
||||
type FFmpeg interface {
|
||||
Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error)
|
||||
Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error)
|
||||
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
|
||||
ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error)
|
||||
Probe(ctx context.Context, files []string) (string, error)
|
||||
ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error)
|
||||
CmdPath() (string, error)
|
||||
IsAvailable() bool
|
||||
Version() string
|
||||
@@ -29,29 +57,50 @@ func New() FFmpeg {
|
||||
}
|
||||
|
||||
const (
|
||||
extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
|
||||
probeCmd = "ffmpeg %s -f ffmetadata"
|
||||
extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
|
||||
probeCmd = "ffmpeg %s -f ffmetadata"
|
||||
probeAudioStreamCmd = "ffprobe -v quiet -select_streams a:0 -print_format json -show_streams -show_format %s"
|
||||
)
|
||||
|
||||
type ffmpeg struct{}
|
||||
|
||||
func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) {
|
||||
func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error) {
|
||||
if _, err := ffmpegCmd(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// First make sure the file exists
|
||||
if err := fileExists(path); err != nil {
|
||||
if err := fileExists(opts.FilePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := createFFmpegCommand(command, path, maxBitRate, offset)
|
||||
var args []string
|
||||
if isDefaultCommand(opts.Format, opts.Command) {
|
||||
args = buildDynamicArgs(opts)
|
||||
} else {
|
||||
args = buildTemplateArgs(opts)
|
||||
}
|
||||
return e.start(ctx, args)
|
||||
}
|
||||
|
||||
func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) {
|
||||
cmdPath, err := ffmpegCmd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
args := []string{cmdPath, "-i", "pipe:0"}
|
||||
if maxSize > 0 {
|
||||
vf := fmt.Sprintf("scale='min(%d,iw)':'min(%d,ih)':force_original_aspect_ratio=decrease", maxSize, maxSize)
|
||||
args = append(args, "-vf", vf)
|
||||
}
|
||||
args = append(args, "-loop", "0", "-c:v", "libwebp_anim",
|
||||
"-quality", strconv.Itoa(quality), "-f", "webp", "-")
|
||||
|
||||
return e.start(ctx, args, reader)
|
||||
}
|
||||
|
||||
func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) {
|
||||
if _, err := ffmpegCmd(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// First make sure the file exists
|
||||
if err := fileExists(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -81,6 +130,91 @@ func (e *ffmpeg) Probe(ctx context.Context, files []string) (string, error) {
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
func (e *ffmpeg) ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) {
|
||||
if _, err := ffmpegCmd(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := fileExists(filePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := createFFmpegCommand(probeAudioStreamCmd, filePath, 0, 0)
|
||||
log.Trace(ctx, "Executing ffprobe command", "args", args)
|
||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("running ffprobe on %q: %w", filePath, err)
|
||||
}
|
||||
return parseProbeOutput(output)
|
||||
}
|
||||
|
||||
type probeOutput struct {
|
||||
Streams []probeStream `json:"streams"`
|
||||
Format probeFormat `json:"format"`
|
||||
}
|
||||
|
||||
type probeFormat struct {
|
||||
BitRate string `json:"bit_rate"`
|
||||
}
|
||||
|
||||
type probeStream struct {
|
||||
CodecName string `json:"codec_name"`
|
||||
CodecType string `json:"codec_type"`
|
||||
Profile string `json:"profile"`
|
||||
SampleRate string `json:"sample_rate"`
|
||||
BitRate string `json:"bit_rate"`
|
||||
Channels int `json:"channels"`
|
||||
BitsPerSample int `json:"bits_per_sample"`
|
||||
BitsPerRawSample string `json:"bits_per_raw_sample"`
|
||||
}
|
||||
|
||||
func parseProbeOutput(data []byte) (*AudioProbeResult, error) {
|
||||
var output probeOutput
|
||||
if err := json.Unmarshal(data, &output); err != nil {
|
||||
return nil, fmt.Errorf("parsing ffprobe output: %w", err)
|
||||
}
|
||||
|
||||
for _, s := range output.Streams {
|
||||
if s.CodecType != "audio" {
|
||||
continue
|
||||
}
|
||||
bitDepth := s.BitsPerSample
|
||||
if bitDepth == 0 && s.BitsPerRawSample != "" {
|
||||
bitDepth, _ = strconv.Atoi(s.BitsPerRawSample)
|
||||
}
|
||||
result := &AudioProbeResult{
|
||||
Codec: s.CodecName,
|
||||
Channels: s.Channels,
|
||||
BitDepth: bitDepth,
|
||||
}
|
||||
|
||||
// Profile: "unknown" → empty
|
||||
if s.Profile != "" && !strings.EqualFold(s.Profile, "unknown") {
|
||||
result.Profile = s.Profile
|
||||
}
|
||||
|
||||
// Sample rate: string → int
|
||||
if s.SampleRate != "" {
|
||||
result.SampleRate, _ = strconv.Atoi(s.SampleRate)
|
||||
}
|
||||
|
||||
// Bit rate: bps string → kbps int
|
||||
if s.BitRate != "" {
|
||||
bps, _ := strconv.Atoi(s.BitRate)
|
||||
result.BitRate = bps / 1000
|
||||
}
|
||||
|
||||
// Fallback to format-level bit_rate (needed for FLAC, Opus, etc.)
|
||||
if result.BitRate == 0 && output.Format.BitRate != "" {
|
||||
bps, _ := strconv.Atoi(output.Format.BitRate)
|
||||
result.BitRate = bps / 1000
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no audio stream found in ffprobe output")
|
||||
}
|
||||
|
||||
func (e *ffmpeg) CmdPath() (string, error) {
|
||||
return ffmpegCmd()
|
||||
}
|
||||
@@ -108,9 +242,12 @@ func (e *ffmpeg) Version() string {
|
||||
return parts[2]
|
||||
}
|
||||
|
||||
func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error) {
|
||||
func (e *ffmpeg) start(ctx context.Context, args []string, input ...io.Reader) (io.ReadCloser, error) {
|
||||
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
|
||||
j := &ffCmd{args: args}
|
||||
if len(input) > 0 {
|
||||
j.input = input[0]
|
||||
}
|
||||
j.PipeReader, j.out = io.Pipe()
|
||||
err := j.start(ctx)
|
||||
if err != nil {
|
||||
@@ -122,18 +259,25 @@ func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error
|
||||
|
||||
type ffCmd struct {
|
||||
*io.PipeReader
|
||||
out *io.PipeWriter
|
||||
args []string
|
||||
cmd *exec.Cmd
|
||||
out *io.PipeWriter
|
||||
args []string
|
||||
cmd *exec.Cmd
|
||||
input io.Reader // optional stdin source
|
||||
stderr *bytes.Buffer
|
||||
}
|
||||
|
||||
func (j *ffCmd) start(ctx context.Context) error {
|
||||
cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
|
||||
cmd.Stdout = j.out
|
||||
if j.input != nil {
|
||||
cmd.Stdin = j.input
|
||||
}
|
||||
j.stderr = &bytes.Buffer{}
|
||||
stderrWriter := &limitedWriter{buf: j.stderr, limit: 4096}
|
||||
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stderr = io.MultiWriter(os.Stderr, stderrWriter)
|
||||
} else {
|
||||
cmd.Stderr = io.Discard
|
||||
cmd.Stderr = stderrWriter
|
||||
}
|
||||
j.cmd = cmd
|
||||
|
||||
@@ -147,7 +291,11 @@ func (j *ffCmd) wait() {
|
||||
if err := j.cmd.Wait(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
_ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()))
|
||||
errMsg := fmt.Sprintf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode())
|
||||
if stderrOutput := strings.TrimSpace(j.stderr.String()); stderrOutput != "" {
|
||||
errMsg += ": " + stderrOutput
|
||||
}
|
||||
_ = j.out.CloseWithError(errors.New(errMsg))
|
||||
} else {
|
||||
_ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err))
|
||||
}
|
||||
@@ -156,6 +304,156 @@ func (j *ffCmd) wait() {
|
||||
_ = j.out.Close()
|
||||
}
|
||||
|
||||
// limitedWriter wraps a bytes.Buffer and stops writing once the limit is reached.
|
||||
// Writes that would exceed the limit are silently discarded to prevent unbounded memory usage.
|
||||
type limitedWriter struct {
|
||||
buf *bytes.Buffer
|
||||
limit int
|
||||
}
|
||||
|
||||
func (w *limitedWriter) Write(p []byte) (int, error) {
|
||||
n := len(p)
|
||||
remaining := w.limit - w.buf.Len()
|
||||
if remaining <= 0 {
|
||||
return n, nil // Discard but report success to avoid breaking the writer
|
||||
}
|
||||
if len(p) > remaining {
|
||||
p = p[:remaining]
|
||||
}
|
||||
w.buf.Write(p)
|
||||
return n, nil // Always report full write to avoid ErrShortWrite from io.MultiWriter
|
||||
}
|
||||
|
||||
// formatCodecMap maps target format to ffmpeg codec flag.
|
||||
var formatCodecMap = map[string]string{
|
||||
"mp3": "libmp3lame",
|
||||
"opus": "libopus",
|
||||
"aac": "aac",
|
||||
"flac": "flac",
|
||||
}
|
||||
|
||||
// formatOutputMap maps target format to ffmpeg output format flag (-f).
|
||||
var formatOutputMap = map[string]string{
|
||||
"mp3": "mp3",
|
||||
"opus": "opus",
|
||||
"aac": "adts",
|
||||
"flac": "flac",
|
||||
}
|
||||
|
||||
// defaultCommands is used to detect whether a user has customized their transcoding command.
|
||||
var defaultCommands = func() map[string]string {
|
||||
m := make(map[string]string, len(consts.DefaultTranscodings))
|
||||
for _, t := range consts.DefaultTranscodings {
|
||||
m[t.TargetFormat] = t.Command
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// isDefaultCommand returns true if the command matches the known default for this format.
|
||||
func isDefaultCommand(format, command string) bool {
|
||||
return defaultCommands[format] == command
|
||||
}
|
||||
|
||||
// buildDynamicArgs programmatically constructs ffmpeg arguments for known formats,
|
||||
// including all transcoding parameters (bitrate, sample rate, channels).
|
||||
func buildDynamicArgs(opts TranscodeOptions) []string {
|
||||
cmdPath, _ := ffmpegCmd()
|
||||
args := []string{cmdPath, "-i", opts.FilePath}
|
||||
|
||||
if opts.Offset > 0 {
|
||||
args = append(args, "-ss", strconv.Itoa(opts.Offset))
|
||||
}
|
||||
|
||||
args = append(args, "-map", "0:a:0")
|
||||
|
||||
if codec, ok := formatCodecMap[opts.Format]; ok {
|
||||
args = append(args, "-c:a", codec)
|
||||
}
|
||||
|
||||
if opts.BitRate > 0 {
|
||||
args = append(args, "-b:a", strconv.Itoa(opts.BitRate)+"k")
|
||||
}
|
||||
if opts.SampleRate > 0 {
|
||||
args = append(args, "-ar", strconv.Itoa(opts.SampleRate))
|
||||
}
|
||||
if opts.Channels > 0 {
|
||||
args = append(args, "-ac", strconv.Itoa(opts.Channels))
|
||||
}
|
||||
// Only pass -sample_fmt for lossless output formats where bit depth matters.
|
||||
// Lossy codecs (mp3, aac, opus) handle sample format conversion internally,
|
||||
// and passing interleaved formats like "s16" causes silent failures.
|
||||
if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) {
|
||||
args = append(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth))
|
||||
}
|
||||
|
||||
args = append(args, "-v", "0")
|
||||
|
||||
if outputFmt, ok := formatOutputMap[opts.Format]; ok {
|
||||
args = append(args, "-f", outputFmt)
|
||||
}
|
||||
|
||||
args = append(args, "-")
|
||||
return args
|
||||
}
|
||||
|
||||
// buildTemplateArgs handles user-customized command templates, with dynamic injection
|
||||
// of sample rate, channels, and bit depth when requested by the transcode decision.
|
||||
// Note: these flags are injected unconditionally when non-zero, even if the template
|
||||
// already includes them. FFmpeg uses the last occurrence of duplicate flags.
|
||||
func buildTemplateArgs(opts TranscodeOptions) []string {
|
||||
args := createFFmpegCommand(opts.Command, opts.FilePath, opts.BitRate, opts.Offset)
|
||||
|
||||
// Dynamically inject -ar, -ac, and -sample_fmt before the output target
|
||||
if opts.SampleRate > 0 {
|
||||
args = injectBeforeOutput(args, "-ar", strconv.Itoa(opts.SampleRate))
|
||||
}
|
||||
if opts.Channels > 0 {
|
||||
args = injectBeforeOutput(args, "-ac", strconv.Itoa(opts.Channels))
|
||||
}
|
||||
if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) {
|
||||
args = injectBeforeOutput(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth))
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// injectBeforeOutput inserts a flag and value before the trailing "-" (stdout output).
|
||||
func injectBeforeOutput(args []string, flag, value string) []string {
|
||||
if len(args) > 0 && args[len(args)-1] == "-" {
|
||||
result := make([]string, 0, len(args)+2)
|
||||
result = append(result, args[:len(args)-1]...)
|
||||
result = append(result, flag, value, "-")
|
||||
return result
|
||||
}
|
||||
return append(args, flag, value)
|
||||
}
|
||||
|
||||
// isLosslessOutputFormat returns true if the format is a lossless audio format
|
||||
// where preserving bit depth via -sample_fmt is meaningful.
|
||||
// Note: this covers only formats ffmpeg can produce as output. For the full set of
|
||||
// lossless formats used in transcoding decisions, see core/stream/codec.go:isLosslessFormat.
|
||||
func isLosslessOutputFormat(format string) bool {
|
||||
switch strings.ToLower(format) {
|
||||
case "flac", "alac", "wav", "aiff":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// bitDepthToSampleFmt converts a bit depth value to the ffmpeg sample_fmt string.
|
||||
// FLAC only supports s16 and s32; for 24-bit sources, s32 is the correct format
|
||||
// (ffmpeg packs 24-bit samples into 32-bit containers).
|
||||
func bitDepthToSampleFmt(bitDepth int) string {
|
||||
switch bitDepth {
|
||||
case 16:
|
||||
return "s16"
|
||||
case 32:
|
||||
return "s32"
|
||||
default:
|
||||
// 24-bit and other depths: use s32 (the next valid container size)
|
||||
return "s32"
|
||||
}
|
||||
}
|
||||
|
||||
// Path will always be an absolute path
|
||||
func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
|
||||
var args []string
|
||||
@@ -196,10 +494,20 @@ func fixCmd(cmd string) []string {
|
||||
if s == "ffmpeg" || s == "ffmpeg.exe" {
|
||||
split[i] = cmdPath
|
||||
}
|
||||
if s == "ffprobe" || s == "ffprobe.exe" {
|
||||
split[i] = ffprobePath(cmdPath)
|
||||
}
|
||||
}
|
||||
return split
|
||||
}
|
||||
|
||||
// ffprobePath derives the ffprobe binary path from the resolved ffmpeg path.
|
||||
func ffprobePath(ffmpegCmd string) string {
|
||||
dir := filepath.Dir(ffmpegCmd)
|
||||
base := filepath.Base(ffmpegCmd)
|
||||
return filepath.Join(dir, strings.Replace(base, "ffmpeg", "ffprobe", 1))
|
||||
}
|
||||
|
||||
func ffmpegCmd() (string, error) {
|
||||
ffOnce.Do(func() {
|
||||
if conf.Server.FFmpegPath != "" {
|
||||
|
||||
+532
-5
@@ -2,19 +2,27 @@ package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
sync "sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestFFmpeg(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
// Inline test init to avoid import cycle with tests package
|
||||
//nolint:dogsled
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
appPath, _ := filepath.Abs(filepath.Join(filepath.Dir(file), "..", ".."))
|
||||
confPath := filepath.Join(appPath, "tests", "navidrome-test.toml")
|
||||
_ = os.Chdir(appPath)
|
||||
conf.LoadFromFile(confPath)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "FFmpeg Suite")
|
||||
@@ -70,6 +78,472 @@ var _ = Describe("ffmpeg", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("isDefaultCommand", func() {
|
||||
It("returns true for known default mp3 command", func() {
|
||||
Expect(isDefaultCommand("mp3", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue())
|
||||
})
|
||||
It("returns true for known default opus command", func() {
|
||||
Expect(isDefaultCommand("opus", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue())
|
||||
})
|
||||
It("returns true for known default aac command", func() {
|
||||
Expect(isDefaultCommand("aac", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue())
|
||||
})
|
||||
It("returns true for known default flac command", func() {
|
||||
Expect(isDefaultCommand("flac", "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue())
|
||||
})
|
||||
It("returns false for a custom command", func() {
|
||||
Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse())
|
||||
})
|
||||
It("returns false for unknown format", func() {
|
||||
Expect(isDefaultCommand("wav", "ffmpeg -i %s -f wav -")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("buildDynamicArgs", func() {
|
||||
It("builds mp3 args with bitrate, samplerate, and channels", func() {
|
||||
args := buildDynamicArgs(TranscodeOptions{
|
||||
Format: "mp3",
|
||||
FilePath: "/music/file.flac",
|
||||
BitRate: 256,
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
})
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.flac",
|
||||
"-map", "0:a:0",
|
||||
"-c:a", "libmp3lame",
|
||||
"-b:a", "256k",
|
||||
"-ar", "48000",
|
||||
"-ac", "2",
|
||||
"-v", "0",
|
||||
"-f", "mp3",
|
||||
"-",
|
||||
}))
|
||||
})
|
||||
|
||||
It("builds flac args without bitrate", func() {
|
||||
args := buildDynamicArgs(TranscodeOptions{
|
||||
Format: "flac",
|
||||
FilePath: "/music/file.dsf",
|
||||
SampleRate: 48000,
|
||||
})
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.dsf",
|
||||
"-map", "0:a:0",
|
||||
"-c:a", "flac",
|
||||
"-ar", "48000",
|
||||
"-v", "0",
|
||||
"-f", "flac",
|
||||
"-",
|
||||
}))
|
||||
})
|
||||
|
||||
It("builds opus args with bitrate only", func() {
|
||||
args := buildDynamicArgs(TranscodeOptions{
|
||||
Format: "opus",
|
||||
FilePath: "/music/file.flac",
|
||||
BitRate: 128,
|
||||
})
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.flac",
|
||||
"-map", "0:a:0",
|
||||
"-c:a", "libopus",
|
||||
"-b:a", "128k",
|
||||
"-v", "0",
|
||||
"-f", "opus",
|
||||
"-",
|
||||
}))
|
||||
})
|
||||
|
||||
It("includes offset when specified", func() {
|
||||
args := buildDynamicArgs(TranscodeOptions{
|
||||
Format: "mp3",
|
||||
FilePath: "/music/file.mp3",
|
||||
BitRate: 192,
|
||||
Offset: 30,
|
||||
})
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.mp3",
|
||||
"-ss", "30",
|
||||
"-map", "0:a:0",
|
||||
"-c:a", "libmp3lame",
|
||||
"-b:a", "192k",
|
||||
"-v", "0",
|
||||
"-f", "mp3",
|
||||
"-",
|
||||
}))
|
||||
})
|
||||
|
||||
It("builds aac args with ADTS output", func() {
|
||||
args := buildDynamicArgs(TranscodeOptions{
|
||||
Format: "aac",
|
||||
FilePath: "/music/file.flac",
|
||||
BitRate: 256,
|
||||
})
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.flac",
|
||||
"-map", "0:a:0",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "256k",
|
||||
"-v", "0",
|
||||
"-f", "adts",
|
||||
"-",
|
||||
}))
|
||||
})
|
||||
|
||||
It("builds flac args with bit depth", func() {
|
||||
args := buildDynamicArgs(TranscodeOptions{
|
||||
Format: "flac",
|
||||
FilePath: "/music/file.dsf",
|
||||
BitDepth: 24,
|
||||
})
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.dsf",
|
||||
"-map", "0:a:0",
|
||||
"-c:a", "flac",
|
||||
"-sample_fmt", "s32",
|
||||
"-v", "0",
|
||||
"-f", "flac",
|
||||
"-",
|
||||
}))
|
||||
})
|
||||
|
||||
It("omits -sample_fmt when bit depth is 0", func() {
|
||||
args := buildDynamicArgs(TranscodeOptions{
|
||||
Format: "flac",
|
||||
FilePath: "/music/file.flac",
|
||||
BitDepth: 0,
|
||||
})
|
||||
Expect(args).ToNot(ContainElement("-sample_fmt"))
|
||||
})
|
||||
|
||||
It("omits -sample_fmt when bit depth is too low (DSD)", func() {
|
||||
args := buildDynamicArgs(TranscodeOptions{
|
||||
Format: "flac",
|
||||
FilePath: "/music/file.dsf",
|
||||
BitDepth: 1,
|
||||
})
|
||||
Expect(args).ToNot(ContainElement("-sample_fmt"))
|
||||
})
|
||||
|
||||
DescribeTable("omits -sample_fmt for lossy formats even when bit depth >= 16",
|
||||
func(format string, bitRate int) {
|
||||
args := buildDynamicArgs(TranscodeOptions{
|
||||
Format: format,
|
||||
FilePath: "/music/file.flac",
|
||||
BitRate: bitRate,
|
||||
BitDepth: 16,
|
||||
})
|
||||
Expect(args).ToNot(ContainElement("-sample_fmt"))
|
||||
},
|
||||
Entry("mp3", "mp3", 256),
|
||||
Entry("aac", "aac", 256),
|
||||
Entry("opus", "opus", 128),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("bitDepthToSampleFmt", func() {
|
||||
It("converts 16-bit", func() {
|
||||
Expect(bitDepthToSampleFmt(16)).To(Equal("s16"))
|
||||
})
|
||||
It("converts 24-bit to s32 (FLAC only supports s16/s32)", func() {
|
||||
Expect(bitDepthToSampleFmt(24)).To(Equal("s32"))
|
||||
})
|
||||
It("converts 32-bit", func() {
|
||||
Expect(bitDepthToSampleFmt(32)).To(Equal("s32"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("buildTemplateArgs", func() {
|
||||
It("injects -ar and -ac into custom template", func() {
|
||||
args := buildTemplateArgs(TranscodeOptions{
|
||||
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
|
||||
FilePath: "/music/file.flac",
|
||||
BitRate: 192,
|
||||
SampleRate: 44100,
|
||||
Channels: 2,
|
||||
})
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.flac",
|
||||
"-b:a", "192k", "-v", "0", "-f", "mp3",
|
||||
"-ar", "44100", "-ac", "2",
|
||||
"-",
|
||||
}))
|
||||
})
|
||||
|
||||
It("injects only -ar when channels is 0", func() {
|
||||
args := buildTemplateArgs(TranscodeOptions{
|
||||
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
|
||||
FilePath: "/music/file.flac",
|
||||
BitRate: 192,
|
||||
SampleRate: 48000,
|
||||
})
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.flac",
|
||||
"-b:a", "192k", "-v", "0", "-f", "mp3",
|
||||
"-ar", "48000",
|
||||
"-",
|
||||
}))
|
||||
})
|
||||
|
||||
It("does not inject anything when sample rate and channels are 0", func() {
|
||||
args := buildTemplateArgs(TranscodeOptions{
|
||||
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
|
||||
FilePath: "/music/file.flac",
|
||||
BitRate: 192,
|
||||
})
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.flac",
|
||||
"-b:a", "192k", "-v", "0", "-f", "mp3",
|
||||
"-",
|
||||
}))
|
||||
})
|
||||
|
||||
It("injects -sample_fmt for lossless output format with bit depth", func() {
|
||||
args := buildTemplateArgs(TranscodeOptions{
|
||||
Command: "ffmpeg -i %s -v 0 -c:a flac -f flac -",
|
||||
Format: "flac",
|
||||
FilePath: "/music/file.dsf",
|
||||
BitDepth: 24,
|
||||
})
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.dsf",
|
||||
"-v", "0", "-c:a", "flac", "-f", "flac",
|
||||
"-sample_fmt", "s32",
|
||||
"-",
|
||||
}))
|
||||
})
|
||||
|
||||
It("does not inject -sample_fmt for lossy output format even with bit depth", func() {
|
||||
args := buildTemplateArgs(TranscodeOptions{
|
||||
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
|
||||
Format: "mp3",
|
||||
FilePath: "/music/file.flac",
|
||||
BitRate: 192,
|
||||
BitDepth: 16,
|
||||
})
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.flac",
|
||||
"-b:a", "192k", "-v", "0", "-f", "mp3",
|
||||
"-",
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("injectBeforeOutput", func() {
|
||||
It("inserts flag before trailing dash", func() {
|
||||
args := injectBeforeOutput([]string{"ffmpeg", "-i", "file.mp3", "-f", "mp3", "-"}, "-ar", "48000")
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "file.mp3", "-f", "mp3", "-ar", "48000", "-"}))
|
||||
})
|
||||
|
||||
It("appends when no trailing dash", func() {
|
||||
args := injectBeforeOutput([]string{"ffmpeg", "-i", "file.mp3"}, "-ar", "48000")
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "file.mp3", "-ar", "48000"}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("parseProbeOutput", func() {
|
||||
It("parses MP3 with embedded artwork (real ffprobe output)", func() {
|
||||
// Real: MP3 file with mjpeg artwork stream after audio
|
||||
data := []byte(`{"streams":[` +
|
||||
`{"index":0,"codec_name":"mp3","codec_long_name":"MP3 (MPEG audio layer 3)","codec_type":"audio",` +
|
||||
`"sample_fmt":"fltp","sample_rate":"44100","channels":2,"channel_layout":"stereo",` +
|
||||
`"bits_per_sample":0,"bit_rate":"198314","tags":{"encoder":"LAME3.99r"}},` +
|
||||
`{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline","width":400,"height":400}]}`)
|
||||
result, err := parseProbeOutput(data)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Codec).To(Equal("mp3"))
|
||||
Expect(result.Profile).To(BeEmpty()) // MP3 has no profile field
|
||||
Expect(result.SampleRate).To(Equal(44100))
|
||||
Expect(result.Channels).To(Equal(2))
|
||||
Expect(result.BitRate).To(Equal(198)) // 198314 bps -> 198 kbps
|
||||
Expect(result.BitDepth).To(Equal(0)) // lossy codec
|
||||
})
|
||||
|
||||
It("parses AAC-LC in m4a container (real ffprobe output)", func() {
|
||||
// Real: AAC LC file with profile and artwork
|
||||
data := []byte(`{"streams":[` +
|
||||
`{"index":0,"codec_name":"aac","codec_long_name":"AAC (Advanced Audio Coding)",` +
|
||||
`"profile":"LC","codec_type":"audio","sample_fmt":"fltp","sample_rate":"44100",` +
|
||||
`"channels":2,"channel_layout":"stereo","bits_per_sample":0,"bit_rate":"279958"},` +
|
||||
`{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`)
|
||||
result, err := parseProbeOutput(data)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Codec).To(Equal("aac"))
|
||||
Expect(result.Profile).To(Equal("LC"))
|
||||
Expect(result.SampleRate).To(Equal(44100))
|
||||
Expect(result.Channels).To(Equal(2))
|
||||
Expect(result.BitRate).To(Equal(279)) // 279958 bps -> 279 kbps
|
||||
})
|
||||
|
||||
It("parses HE-AACv2 in mp4 container with video stream (real ffprobe output)", func() {
|
||||
// Real: Fraunhofer HE-AACv2 sample (LFE-SBRstereo.mp4), video stream before audio
|
||||
data := []byte(`{"streams":[` +
|
||||
`{"index":0,"codec_name":"h264","codec_type":"video","profile":"Main"},` +
|
||||
`{"index":1,"codec_name":"aac","codec_long_name":"AAC (Advanced Audio Coding)",` +
|
||||
`"profile":"HE-AACv2","codec_type":"audio","sample_fmt":"fltp",` +
|
||||
`"sample_rate":"48000","channels":2,"channel_layout":"stereo",` +
|
||||
`"bits_per_sample":0,"bit_rate":"55999"}]}`)
|
||||
result, err := parseProbeOutput(data)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Codec).To(Equal("aac"))
|
||||
Expect(result.Profile).To(Equal("HE-AACv2"))
|
||||
Expect(result.SampleRate).To(Equal(48000))
|
||||
Expect(result.Channels).To(Equal(2))
|
||||
Expect(result.BitRate).To(Equal(55)) // 55999 bps -> 55 kbps
|
||||
})
|
||||
|
||||
It("parses FLAC using bits_per_raw_sample and format-level bit_rate (real ffprobe output)", func() {
|
||||
// Real: FLAC reports bit depth in bits_per_raw_sample, not bits_per_sample.
|
||||
// Stream-level bit_rate is absent; format-level bit_rate is used as fallback.
|
||||
data := []byte(`{"streams":[` +
|
||||
`{"index":0,"codec_name":"flac","codec_long_name":"FLAC (Free Lossless Audio Codec)",` +
|
||||
`"codec_type":"audio","sample_fmt":"s16","sample_rate":"44100","channels":2,` +
|
||||
`"channel_layout":"stereo","bits_per_sample":0,"bits_per_raw_sample":"16"},` +
|
||||
`{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}],` +
|
||||
`"format":{"bit_rate":"906900"}}`)
|
||||
result, err := parseProbeOutput(data)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Codec).To(Equal("flac"))
|
||||
Expect(result.SampleRate).To(Equal(44100))
|
||||
Expect(result.BitDepth).To(Equal(16)) // from bits_per_raw_sample
|
||||
Expect(result.BitRate).To(Equal(906)) // format-level: 906900 bps -> 906 kbps
|
||||
Expect(result.Profile).To(BeEmpty()) // no profile field in real output
|
||||
})
|
||||
|
||||
It("parses Opus with format-level bit_rate fallback (real ffprobe output)", func() {
|
||||
// Real: Opus stream-level bit_rate is absent; format-level is used as fallback.
|
||||
data := []byte(`{"streams":[` +
|
||||
`{"index":0,"codec_name":"opus","codec_long_name":"Opus (Opus Interactive Audio Codec)",` +
|
||||
`"codec_type":"audio","sample_fmt":"fltp","sample_rate":"48000","channels":2,` +
|
||||
`"channel_layout":"stereo","bits_per_sample":0}],` +
|
||||
`"format":{"bit_rate":"128000"}}`)
|
||||
result, err := parseProbeOutput(data)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Codec).To(Equal("opus"))
|
||||
Expect(result.SampleRate).To(Equal(48000))
|
||||
Expect(result.Channels).To(Equal(2))
|
||||
Expect(result.BitRate).To(Equal(128)) // format-level: 128000 bps -> 128 kbps
|
||||
Expect(result.BitDepth).To(Equal(0))
|
||||
})
|
||||
|
||||
It("parses WAV/PCM with bits_per_sample (real ffprobe output)", func() {
|
||||
// Real: WAV uses bits_per_sample directly
|
||||
data := []byte(`{"streams":[` +
|
||||
`{"index":0,"codec_name":"pcm_s16le","codec_long_name":"PCM signed 16-bit little-endian",` +
|
||||
`"codec_type":"audio","sample_fmt":"s16","sample_rate":"44100","channels":2,` +
|
||||
`"bits_per_sample":16,"bit_rate":"1411200"}]}`)
|
||||
result, err := parseProbeOutput(data)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Codec).To(Equal("pcm_s16le"))
|
||||
Expect(result.SampleRate).To(Equal(44100))
|
||||
Expect(result.Channels).To(Equal(2))
|
||||
Expect(result.BitDepth).To(Equal(16))
|
||||
Expect(result.BitRate).To(Equal(1411))
|
||||
})
|
||||
|
||||
It("parses ALAC in m4a container (real ffprobe output)", func() {
|
||||
// Real: Beatles - You Can't Do That (2023 Mix), ALAC 16-bit
|
||||
data := []byte(`{"streams":[` +
|
||||
`{"index":0,"codec_name":"alac","codec_long_name":"ALAC (Apple Lossless Audio Codec)",` +
|
||||
`"codec_type":"audio","sample_fmt":"s16p","sample_rate":"44100","channels":2,` +
|
||||
`"channel_layout":"stereo","bits_per_sample":0,"bit_rate":"1011003",` +
|
||||
`"bits_per_raw_sample":"16"},` +
|
||||
`{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`)
|
||||
result, err := parseProbeOutput(data)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Codec).To(Equal("alac"))
|
||||
Expect(result.BitDepth).To(Equal(16)) // from bits_per_raw_sample
|
||||
Expect(result.SampleRate).To(Equal(44100))
|
||||
Expect(result.Channels).To(Equal(2))
|
||||
Expect(result.BitRate).To(Equal(1011)) // 1011003 bps -> 1011 kbps
|
||||
})
|
||||
|
||||
It("skips video-only streams", func() {
|
||||
data := []byte(`{"streams":[{"index":0,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`)
|
||||
_, err := parseProbeOutput(data)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("no audio stream"))
|
||||
})
|
||||
|
||||
It("returns error for empty streams array", func() {
|
||||
data := []byte(`{"streams":[]}`)
|
||||
_, err := parseProbeOutput(data)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error for invalid JSON", func() {
|
||||
data := []byte(`not json`)
|
||||
_, err := parseProbeOutput(data)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("parses HiRes multichannel FLAC with format-level bit_rate (real ffprobe output)", func() {
|
||||
// Real: Pink Floyd - 192kHz/24-bit/7.1 surround FLAC
|
||||
data := []byte(`{"streams":[` +
|
||||
`{"index":0,"codec_name":"flac","codec_long_name":"FLAC (Free Lossless Audio Codec)",` +
|
||||
`"codec_type":"audio","sample_fmt":"s32","sample_rate":"192000","channels":8,` +
|
||||
`"channel_layout":"7.1","bits_per_sample":0,"bits_per_raw_sample":"24"},` +
|
||||
`{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Progressive"}],` +
|
||||
`"format":{"bit_rate":"18432000"}}`)
|
||||
result, err := parseProbeOutput(data)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Codec).To(Equal("flac"))
|
||||
Expect(result.SampleRate).To(Equal(192000))
|
||||
Expect(result.BitDepth).To(Equal(24))
|
||||
Expect(result.Channels).To(Equal(8))
|
||||
Expect(result.BitRate).To(Equal(18432)) // format-level: 18432000 bps -> 18432 kbps
|
||||
})
|
||||
|
||||
It("parses DSD/DSF file (real ffprobe output)", func() {
|
||||
// Real: Yes - Owner of a Lonely Heart, DSD64 DSF
|
||||
data := []byte(`{"streams":[` +
|
||||
`{"index":0,"codec_name":"dsd_lsbf_planar",` +
|
||||
`"codec_long_name":"DSD (Direct Stream Digital), least significant bit first, planar",` +
|
||||
`"codec_type":"audio","sample_fmt":"fltp","sample_rate":"352800","channels":2,` +
|
||||
`"channel_layout":"stereo","bits_per_sample":8,"bit_rate":"5644800"},` +
|
||||
`{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`)
|
||||
result, err := parseProbeOutput(data)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Codec).To(Equal("dsd_lsbf_planar"))
|
||||
Expect(result.BitDepth).To(Equal(8)) // DSD reports 8 bits_per_sample
|
||||
Expect(result.SampleRate).To(Equal(352800)) // DSD64 sample rate
|
||||
Expect(result.Channels).To(Equal(2))
|
||||
Expect(result.BitRate).To(Equal(5644)) // 5644800 bps -> 5644 kbps
|
||||
})
|
||||
|
||||
It("prefers stream-level bit_rate over format-level when both are present", func() {
|
||||
// ALAC/DSD: stream has bit_rate, format also has bit_rate — stream wins
|
||||
data := []byte(`{"streams":[` +
|
||||
`{"index":0,"codec_name":"alac","codec_type":"audio","sample_fmt":"s16p",` +
|
||||
`"sample_rate":"44100","channels":2,"bits_per_sample":0,` +
|
||||
`"bit_rate":"1011003","bits_per_raw_sample":"16"}],` +
|
||||
`"format":{"bit_rate":"1050000"}}`)
|
||||
result, err := parseProbeOutput(data)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.BitRate).To(Equal(1011)) // stream-level: 1011003 bps -> 1011 kbps (not format's 1050)
|
||||
})
|
||||
|
||||
It("returns BitRate 0 when neither stream nor format has bit_rate", func() {
|
||||
data := []byte(`{"streams":[` +
|
||||
`{"index":0,"codec_name":"flac","codec_type":"audio","sample_fmt":"s16",` +
|
||||
`"sample_rate":"44100","channels":2,"bits_per_sample":0,"bits_per_raw_sample":"16"}],` +
|
||||
`"format":{}}`)
|
||||
result, err := parseProbeOutput(data)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.BitRate).To(Equal(0))
|
||||
})
|
||||
|
||||
It("clears 'unknown' profile to empty string", func() {
|
||||
data := []byte(`{"streams":[{"index":0,"codec_name":"flac",` +
|
||||
`"codec_type":"audio","profile":"unknown","sample_rate":"44100",` +
|
||||
`"channels":2,"bits_per_sample":0}]}`)
|
||||
result, err := parseProbeOutput(data)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Profile).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("FFmpeg", func() {
|
||||
Context("when FFmpeg is available", func() {
|
||||
var ff FFmpeg
|
||||
@@ -93,7 +567,12 @@ var _ = Describe("ffmpeg", func() {
|
||||
command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -f mp3 -"
|
||||
|
||||
// The input file is not used here, but we need to provide a valid path to the Transcode function
|
||||
stream, err := ff.Transcode(ctx, command, "tests/fixtures/test.mp3", 128, 0)
|
||||
stream, err := ff.Transcode(ctx, TranscodeOptions{
|
||||
Command: command,
|
||||
Format: "mp3",
|
||||
FilePath: "tests/fixtures/test.mp3",
|
||||
BitRate: 128,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer stream.Close()
|
||||
|
||||
@@ -115,11 +594,56 @@ var _ = Describe("ffmpeg", func() {
|
||||
cancel() // Cancel immediately
|
||||
|
||||
// This should fail immediately
|
||||
_, err := ff.Transcode(ctx, "ffmpeg -i %s -f mp3 -", "tests/fixtures/test.mp3", 128, 0)
|
||||
_, err := ff.Transcode(ctx, TranscodeOptions{
|
||||
Command: "ffmpeg -i %s -f mp3 -",
|
||||
Format: "mp3",
|
||||
FilePath: "tests/fixtures/test.mp3",
|
||||
BitRate: 128,
|
||||
})
|
||||
Expect(err).To(MatchError(context.Canceled))
|
||||
})
|
||||
})
|
||||
|
||||
Context("stderr capture", func() {
|
||||
BeforeEach(func() {
|
||||
if runtime.GOOS == "windows" {
|
||||
Skip("stderr capture tests use /bin/sh, skipping on Windows")
|
||||
}
|
||||
})
|
||||
|
||||
It("should include stderr in error when process fails", func() {
|
||||
ff := &ffmpeg{}
|
||||
ctx := GinkgoT().Context()
|
||||
|
||||
// Directly call start() with a bash command that writes to stderr and fails
|
||||
args := []string{"/bin/sh", "-c", "echo 'codec not found: libopus' >&2; exit 1"}
|
||||
stream, err := ff.start(ctx, args)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer stream.Close()
|
||||
|
||||
buf := make([]byte, 1024)
|
||||
_, err = stream.Read(buf)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("codec not found: libopus"))
|
||||
})
|
||||
|
||||
It("should not include stderr in error when process succeeds", func() {
|
||||
ff := &ffmpeg{}
|
||||
ctx := GinkgoT().Context()
|
||||
|
||||
// Command that writes to stderr but exits successfully
|
||||
args := []string{"/bin/sh", "-c", "echo 'warning: something' >&2; printf 'output'"}
|
||||
stream, err := ff.start(ctx, args)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer stream.Close()
|
||||
|
||||
buf := make([]byte, 1024)
|
||||
n, err := stream.Read(buf)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(buf[:n])).To(Equal("output"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with mock process behavior", func() {
|
||||
var longRunningCmd string
|
||||
BeforeEach(func() {
|
||||
@@ -142,7 +666,10 @@ var _ = Describe("ffmpeg", func() {
|
||||
defer cancel()
|
||||
|
||||
// Start a process that will run for a while
|
||||
stream, err := ff.Transcode(ctx, longRunningCmd, "tests/fixtures/test.mp3", 0, 0)
|
||||
stream, err := ff.Transcode(ctx, TranscodeOptions{
|
||||
Command: longRunningCmd,
|
||||
FilePath: "tests/fixtures/test.mp3",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer stream.Close()
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
type ImageUploadService interface {
|
||||
SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error)
|
||||
RemoveImage(ctx context.Context, path string) error
|
||||
}
|
||||
|
||||
type imageUploadService struct{}
|
||||
|
||||
func NewImageUploadService() ImageUploadService {
|
||||
return &imageUploadService{}
|
||||
}
|
||||
|
||||
func (s *imageUploadService) SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (string, error) {
|
||||
filename := imageFilename(entityID, name, ext)
|
||||
absPath := model.UploadedImagePath(entityType, filename)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
|
||||
return "", fmt.Errorf("creating image directory: %w", err)
|
||||
}
|
||||
|
||||
// Remove old image if it exists
|
||||
if oldPath != "" {
|
||||
if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) {
|
||||
log.Warn(ctx, "Failed to remove old image", "path", oldPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save new image
|
||||
f, err := os.Create(absPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating image file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := io.Copy(f, reader); err != nil {
|
||||
return "", fmt.Errorf("writing image file: %w", err)
|
||||
}
|
||||
|
||||
return filename, nil
|
||||
}
|
||||
|
||||
func (s *imageUploadService) RemoveImage(ctx context.Context, path string) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("removing image %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func imageFilename(id, name, ext string) string {
|
||||
clean := utils.CleanFileName(name)
|
||||
if clean == "" {
|
||||
return id + ext
|
||||
}
|
||||
return id + "_" + clean + ext
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ImageUploadService", func() {
|
||||
var svc core.ImageUploadService
|
||||
var tmpDir string
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tmpDir = GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = tmpDir
|
||||
svc = core.NewImageUploadService()
|
||||
})
|
||||
|
||||
Describe("SetImage", func() {
|
||||
It("creates directory and saves image file", func() {
|
||||
ctx := context.Background()
|
||||
reader := strings.NewReader("fake image data")
|
||||
filename, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", reader, ".jpg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(filename).To(Equal("ar-1_pink_floyd.jpg"))
|
||||
|
||||
absPath := filepath.Join(tmpDir, "artwork", "artist", "ar-1_pink_floyd.jpg")
|
||||
data, err := os.ReadFile(absPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("fake image data"))
|
||||
})
|
||||
|
||||
It("falls back to ID-only filename when name cleans to empty", func() {
|
||||
ctx := context.Background()
|
||||
reader := strings.NewReader("data")
|
||||
filename, err := svc.SetImage(ctx, consts.EntityPlaylist, "pl-1", "!!!", "", reader, ".png")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(filename).To(Equal("pl-1.png"))
|
||||
})
|
||||
|
||||
It("removes old image when replacing", func() {
|
||||
ctx := context.Background()
|
||||
oldDir := filepath.Join(tmpDir, "artwork", "artist")
|
||||
Expect(os.MkdirAll(oldDir, 0755)).To(Succeed())
|
||||
oldFile := filepath.Join(oldDir, "ar-1_old.png")
|
||||
Expect(os.WriteFile(oldFile, []byte("old"), 0600)).To(Succeed())
|
||||
|
||||
reader := strings.NewReader("new image")
|
||||
_, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "New Name", oldFile, reader, ".jpg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(oldFile).ToNot(BeAnExistingFile())
|
||||
|
||||
newPath := filepath.Join(oldDir, "ar-1_new_name.jpg")
|
||||
Expect(newPath).To(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("ignores missing old file without error", func() {
|
||||
ctx := context.Background()
|
||||
reader := strings.NewReader("data")
|
||||
_, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Name", "/nonexistent/path.jpg", reader, ".jpg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RemoveImage", func() {
|
||||
It("removes the file at the given path", func() {
|
||||
ctx := context.Background()
|
||||
dir := filepath.Join(tmpDir, "artwork", "artist")
|
||||
Expect(os.MkdirAll(dir, 0755)).To(Succeed())
|
||||
path := filepath.Join(dir, "ar-1_test.jpg")
|
||||
Expect(os.WriteFile(path, []byte("img"), 0600)).To(Succeed())
|
||||
|
||||
err := svc.RemoveImage(ctx, path)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).ToNot(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("succeeds when file does not exist", func() {
|
||||
ctx := context.Background()
|
||||
err := svc.RemoveImage(ctx, "/nonexistent/file.jpg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("succeeds with empty path", func() {
|
||||
ctx := context.Background()
|
||||
err := svc.RemoveImage(ctx, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
+28
-6
@@ -9,23 +9,45 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
func GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
// Lyrics can fetch lyrics for a media file.
|
||||
type Lyrics interface {
|
||||
GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error)
|
||||
}
|
||||
|
||||
// PluginLoader discovers and loads lyrics provider plugins.
|
||||
type PluginLoader interface {
|
||||
LoadLyricsProvider(name string) (Lyrics, bool)
|
||||
}
|
||||
|
||||
type lyricsService struct {
|
||||
pluginLoader PluginLoader
|
||||
}
|
||||
|
||||
// NewLyrics creates a new lyrics service. pluginLoader may be nil if no plugin
|
||||
// system is available.
|
||||
func NewLyrics(pluginLoader PluginLoader) Lyrics {
|
||||
return &lyricsService{pluginLoader: pluginLoader}
|
||||
}
|
||||
|
||||
// GetLyrics returns lyrics for the given media file, trying sources in the
|
||||
// order specified by conf.Server.LyricsPriority.
|
||||
func (l *lyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
var lyricsList model.LyricList
|
||||
var err error
|
||||
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.LyricsPriority), ",") {
|
||||
for pattern := range strings.SplitSeq(conf.Server.LyricsPriority, ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "embedded":
|
||||
case strings.EqualFold(pattern, "embedded"):
|
||||
lyricsList, err = fromEmbedded(ctx, mf)
|
||||
case strings.HasPrefix(pattern, "."):
|
||||
lyricsList, err = fromExternalFile(ctx, mf, pattern)
|
||||
lyricsList, err = fromExternalFile(ctx, mf, strings.ToLower(pattern))
|
||||
default:
|
||||
log.Error(ctx, "Invalid lyric pattern", "pattern", pattern)
|
||||
lyricsList, err = l.fromPlugin(ctx, mf, pattern)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error(ctx, "error parsing lyrics", "source", pattern, err)
|
||||
log.Error(ctx, "error getting lyrics", "source", pattern, err)
|
||||
}
|
||||
|
||||
if len(lyricsList) > 0 {
|
||||
|
||||
+105
-3
@@ -3,6 +3,7 @@ package lyrics_test
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@@ -72,7 +73,8 @@ var _ = Describe("sources", func() {
|
||||
|
||||
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) {
|
||||
conf.Server.LyricsPriority = priority
|
||||
list, err := lyrics.GetLyrics(ctx, &mf)
|
||||
svc := lyrics.NewLyrics(nil)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(expected))
|
||||
},
|
||||
@@ -107,7 +109,8 @@ var _ = Describe("sources", func() {
|
||||
It("should fallback to embedded if an error happens when parsing file", func() {
|
||||
conf.Server.LyricsPriority = ".mp3,embedded"
|
||||
|
||||
list, err := lyrics.GetLyrics(ctx, &mf)
|
||||
svc := lyrics.NewLyrics(nil)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics))
|
||||
})
|
||||
@@ -115,10 +118,109 @@ var _ = Describe("sources", func() {
|
||||
It("should return nothing if error happens when trying to parse file", func() {
|
||||
conf.Server.LyricsPriority = ".mp3"
|
||||
|
||||
list, err := lyrics.GetLyrics(ctx, &mf)
|
||||
svc := lyrics.NewLyrics(nil)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("plugin sources", func() {
|
||||
var mockLoader *mockPluginLoader
|
||||
|
||||
BeforeEach(func() {
|
||||
mockLoader = &mockPluginLoader{}
|
||||
})
|
||||
|
||||
It("should return lyrics from a plugin", func() {
|
||||
conf.Server.LyricsPriority = "test-lyrics-plugin"
|
||||
mockLoader.lyrics = unsyncedLyrics
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(unsyncedLyrics))
|
||||
})
|
||||
|
||||
It("should try plugin after embedded returns nothing", func() {
|
||||
conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
|
||||
mf.Lyrics = "" // No embedded lyrics
|
||||
mockLoader.lyrics = unsyncedLyrics
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(unsyncedLyrics))
|
||||
})
|
||||
|
||||
It("should skip plugin if embedded has lyrics", func() {
|
||||
conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
|
||||
mockLoader.lyrics = unsyncedLyrics
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics)) // embedded wins
|
||||
})
|
||||
|
||||
It("should skip unknown plugin names gracefully", func() {
|
||||
conf.Server.LyricsPriority = "nonexistent-plugin,embedded"
|
||||
mockLoader.notFound = true
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
|
||||
})
|
||||
|
||||
It("should preserve plugin name case from config", func() {
|
||||
conf.Server.LyricsPriority = "MyLyricsPlugin"
|
||||
mockLoader.pluginName = "MyLyricsPlugin"
|
||||
mockLoader.lyrics = unsyncedLyrics
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(unsyncedLyrics))
|
||||
})
|
||||
|
||||
It("should handle plugin error gracefully", func() {
|
||||
conf.Server.LyricsPriority = "test-lyrics-plugin,embedded"
|
||||
mockLoader.err = fmt.Errorf("plugin error")
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockPluginLoader struct {
|
||||
lyrics model.LyricList
|
||||
err error
|
||||
notFound bool
|
||||
pluginName string // expected plugin name (exact match, like real manager)
|
||||
}
|
||||
|
||||
func (m *mockPluginLoader) PluginNames(_ string) []string {
|
||||
if m.notFound {
|
||||
return nil
|
||||
}
|
||||
return []string{"test-lyrics-plugin"}
|
||||
}
|
||||
|
||||
func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) {
|
||||
if m.notFound {
|
||||
return nil, false
|
||||
}
|
||||
// If pluginName is set, require exact match (like the real plugin manager)
|
||||
if m.pluginName != "" && name != m.pluginName {
|
||||
return nil, false
|
||||
}
|
||||
return &mockLyricsProvider{lyrics: m.lyrics, err: m.err}, true
|
||||
}
|
||||
|
||||
type mockLyricsProvider struct {
|
||||
lyrics model.LyricList
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockLyricsProvider) GetLyrics(_ context.Context, _ *model.MediaFile) (model.LyricList, error) {
|
||||
return m.lyrics, m.err
|
||||
}
|
||||
@@ -49,3 +49,27 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (
|
||||
|
||||
return model.LyricList{*lyrics}, nil
|
||||
}
|
||||
|
||||
// fromPlugin attempts to load lyrics from a plugin with the given name.
|
||||
func (l *lyricsService) fromPlugin(ctx context.Context, mf *model.MediaFile, pluginName string) (model.LyricList, error) {
|
||||
if l.pluginLoader == nil {
|
||||
log.Debug(ctx, "Invalid lyric source", "source", pluginName)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
provider, ok := l.pluginLoader.LoadLyricsProvider(pluginName)
|
||||
if !ok {
|
||||
log.Warn(ctx, "Lyrics plugin not found", "plugin", pluginName)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
lyricsList, err := provider.GetLyrics(ctx, mf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(lyricsList) > 0 {
|
||||
log.Trace(ctx, "Retrieved lyrics from plugin", "plugin", pluginName, "count", len(lyricsList))
|
||||
}
|
||||
return lyricsList, nil
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
)
|
||||
|
||||
type MediaStreamer interface {
|
||||
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, offset int) (*Stream, error)
|
||||
DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error)
|
||||
}
|
||||
|
||||
type TranscodingCache cache.FileCache
|
||||
|
||||
func NewMediaStreamer(ds model.DataStore, t ffmpeg.FFmpeg, cache TranscodingCache) MediaStreamer {
|
||||
return &mediaStreamer{ds: ds, transcoder: t, cache: cache}
|
||||
}
|
||||
|
||||
type mediaStreamer struct {
|
||||
ds model.DataStore
|
||||
transcoder ffmpeg.FFmpeg
|
||||
cache cache.FileCache
|
||||
}
|
||||
|
||||
type streamJob struct {
|
||||
ms *mediaStreamer
|
||||
mf *model.MediaFile
|
||||
filePath string
|
||||
format string
|
||||
bitRate int
|
||||
offset int
|
||||
}
|
||||
|
||||
func (j *streamJob) Key() string {
|
||||
return fmt.Sprintf("%s.%s.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format, j.offset)
|
||||
}
|
||||
|
||||
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
|
||||
mf, err := ms.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ms.DoStream(ctx, mf, reqFormat, reqBitRate, reqOffset)
|
||||
}
|
||||
|
||||
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
|
||||
var format string
|
||||
var bitRate int
|
||||
var cached bool
|
||||
defer func() {
|
||||
log.Info(ctx, "Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached,
|
||||
"bitRate", bitRate, "user", userName(ctx), "transcoding", format != "raw",
|
||||
"originalFormat", mf.Suffix, "originalBitRate", mf.BitRate)
|
||||
}()
|
||||
|
||||
format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate)
|
||||
s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
|
||||
filePath := mf.AbsolutePath()
|
||||
|
||||
if format == "raw" {
|
||||
log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", filePath,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
||||
"selectedBitrate", bitRate, "selectedFormat", format)
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.ReadCloser = f
|
||||
s.Seeker = f
|
||||
s.format = mf.Suffix
|
||||
return s, nil
|
||||
}
|
||||
|
||||
job := &streamJob{
|
||||
ms: ms,
|
||||
mf: mf,
|
||||
filePath: filePath,
|
||||
format: format,
|
||||
bitRate: bitRate,
|
||||
offset: reqOffset,
|
||||
}
|
||||
r, err := ms.cache.Get(ctx, job)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err)
|
||||
return nil, err
|
||||
}
|
||||
cached = r.Cached
|
||||
|
||||
s.ReadCloser = r
|
||||
s.Seeker = r.Seeker
|
||||
|
||||
log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", filePath,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
||||
"selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
type Stream struct {
|
||||
ctx context.Context
|
||||
mf *model.MediaFile
|
||||
bitRate int
|
||||
format string
|
||||
io.ReadCloser
|
||||
io.Seeker
|
||||
}
|
||||
|
||||
func (s *Stream) Seekable() bool { return s.Seeker != nil }
|
||||
func (s *Stream) Duration() float32 { return s.mf.Duration }
|
||||
func (s *Stream) ContentType() string { return mime.TypeByExtension("." + s.format) }
|
||||
func (s *Stream) Name() string { return s.mf.Title + "." + s.format }
|
||||
func (s *Stream) ModTime() time.Time { return s.mf.UpdatedAt }
|
||||
func (s *Stream) EstimatedContentLength() int {
|
||||
return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
|
||||
}
|
||||
|
||||
// TODO This function deserves some love (refactoring)
|
||||
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) {
|
||||
format = "raw"
|
||||
if reqFormat == "raw" {
|
||||
return format, 0
|
||||
}
|
||||
if reqFormat == mf.Suffix && reqBitRate == 0 {
|
||||
bitRate = mf.BitRate
|
||||
return format, bitRate
|
||||
}
|
||||
trc, hasDefault := request.TranscodingFrom(ctx)
|
||||
var cFormat string
|
||||
var cBitRate int
|
||||
if reqFormat != "" {
|
||||
cFormat = reqFormat
|
||||
} else {
|
||||
if hasDefault {
|
||||
cFormat = trc.TargetFormat
|
||||
cBitRate = trc.DefaultBitRate
|
||||
if p, ok := request.PlayerFrom(ctx); ok {
|
||||
cBitRate = p.MaxBitRate
|
||||
}
|
||||
} else if reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "" {
|
||||
// If no format is specified and no transcoding associated to the player, but a bitrate is specified,
|
||||
// and there is no transcoding set for the player, we use the default downsampling format.
|
||||
// But only if the requested bitRate is lower than the original bitRate.
|
||||
log.Debug("Default Downsampling", "Using default downsampling format", conf.Server.DefaultDownsamplingFormat)
|
||||
cFormat = conf.Server.DefaultDownsamplingFormat
|
||||
}
|
||||
}
|
||||
if reqBitRate > 0 {
|
||||
cBitRate = reqBitRate
|
||||
}
|
||||
if cBitRate == 0 && cFormat == "" {
|
||||
return format, bitRate
|
||||
}
|
||||
t, err := ds.Transcoding(ctx).FindByFormat(cFormat)
|
||||
if err == nil {
|
||||
format = t.TargetFormat
|
||||
if cBitRate != 0 {
|
||||
bitRate = cBitRate
|
||||
} else {
|
||||
bitRate = t.DefaultBitRate
|
||||
}
|
||||
}
|
||||
if format == mf.Suffix && bitRate >= mf.BitRate {
|
||||
format = "raw"
|
||||
bitRate = 0
|
||||
}
|
||||
return format, bitRate
|
||||
}
|
||||
|
||||
var (
|
||||
onceTranscodingCache sync.Once
|
||||
instanceTranscodingCache TranscodingCache
|
||||
)
|
||||
|
||||
func GetTranscodingCache() TranscodingCache {
|
||||
onceTranscodingCache.Do(func() {
|
||||
instanceTranscodingCache = NewTranscodingCache()
|
||||
})
|
||||
return instanceTranscodingCache
|
||||
}
|
||||
|
||||
func NewTranscodingCache() TranscodingCache {
|
||||
return cache.NewFileCache("Transcoding", conf.Server.TranscodingCacheSize,
|
||||
consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems,
|
||||
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
|
||||
job := arg.(*streamJob)
|
||||
t, err := job.ms.ds.Transcoding(ctx).FindByFormat(job.format)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
|
||||
// Choose the appropriate context based on EnableTranscodingCancellation configuration.
|
||||
// This is where we decide whether transcoding processes should be cancellable or not.
|
||||
var transcodingCtx context.Context
|
||||
if conf.Server.EnableTranscodingCancellation {
|
||||
// Use the request context directly, allowing cancellation when client disconnects
|
||||
transcodingCtx = ctx
|
||||
} else {
|
||||
// Use background context with request values preserved.
|
||||
// This prevents cancellation but maintains request metadata (user, client, etc.)
|
||||
transcodingCtx = request.AddValues(context.Background(), ctx)
|
||||
}
|
||||
|
||||
out, err := job.ms.transcoder.Transcode(transcodingCtx, t.Command, job.filePath, job.bitRate, job.offset)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("MediaStreamer", func() {
|
||||
var ds model.DataStore
|
||||
ctx := log.NewContext(context.Background())
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
|
||||
})
|
||||
|
||||
Context("selectTranscodingOptions", func() {
|
||||
mf := &model.MediaFile{}
|
||||
Context("player is not configured", func() {
|
||||
It("returns raw if raw is requested", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns raw if a transcoder does not exists", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns the requested format if a transcoder exists", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
||||
})
|
||||
It("returns raw if requested format is the same as the original and it is not necessary to downsample", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 112
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns the requested format if requested BitRate is lower than original", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 320
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(192))
|
||||
})
|
||||
It("returns raw if requested format is the same as the original, but requested BitRate is 0", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 320
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
Expect(bitRate).To(Equal(320))
|
||||
})
|
||||
Context("Downsampling", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DefaultDownsamplingFormat = "opus"
|
||||
mf.Suffix = "FLAC"
|
||||
mf.BitRate = 960
|
||||
})
|
||||
It("returns the DefaultDownsamplingFormat if a maxBitrate is requested but not the format", func() {
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 128)
|
||||
Expect(format).To(Equal("opus"))
|
||||
Expect(bitRate).To(Equal(128))
|
||||
})
|
||||
It("returns raw if maxBitrate is equal or greater than original", func() {
|
||||
// This happens with DSub (and maybe other clients?). See https://github.com/navidrome/navidrome/issues/2066
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 960)
|
||||
Expect(format).To(Equal("raw"))
|
||||
Expect(bitRate).To(Equal(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("player has format configured", func() {
|
||||
BeforeEach(func() {
|
||||
t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
|
||||
ctx = request.WithTranscoding(ctx, t)
|
||||
})
|
||||
It("returns raw if raw is requested", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns configured format/bitrate as default", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(96))
|
||||
})
|
||||
It("returns requested format", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
||||
})
|
||||
It("returns requested bitrate", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(80))
|
||||
})
|
||||
It("returns raw if selected bitrate and format is the same as original", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 192
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
|
||||
Expect(format).To(Equal("raw"))
|
||||
Expect(bitRate).To(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
Context("player has maxBitRate configured", func() {
|
||||
BeforeEach(func() {
|
||||
t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
|
||||
p := model.Player{ID: "player1", TranscodingId: t.ID, MaxBitRate: 192}
|
||||
ctx = request.WithTranscoding(ctx, t)
|
||||
ctx = request.WithPlayer(ctx, p)
|
||||
})
|
||||
It("returns raw if raw is requested", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns configured format/bitrate as default", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(192))
|
||||
})
|
||||
It("returns requested format", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
||||
})
|
||||
It("returns requested bitrate", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 160)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(160))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -199,7 +199,6 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
data.Config.EnableSharing = conf.Server.EnableSharing
|
||||
data.Config.EnableStarRating = conf.Server.EnableStarRating
|
||||
data.Config.EnableLastFM = conf.Server.LastFM.Enabled && conf.Server.LastFM.ApiKey != "" && conf.Server.LastFM.Secret != ""
|
||||
data.Config.EnableSpotify = conf.Server.Spotify.ID != "" && conf.Server.Spotify.Secret != ""
|
||||
data.Config.EnableListenBrainz = conf.Server.ListenBrainz.Enabled
|
||||
data.Config.EnableDeezer = conf.Server.Deezer.Enabled
|
||||
data.Config.EnableMediaFileCoverArt = conf.Server.EnableMediaFileCoverArt
|
||||
@@ -208,7 +207,8 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
data.Config.TranscodingCacheSize = conf.Server.TranscodingCacheSize
|
||||
data.Config.ImageCacheSize = conf.Server.ImageCacheSize
|
||||
data.Config.SessionTimeout = uint64(math.Trunc(conf.Server.SessionTimeout.Seconds()))
|
||||
data.Config.SearchFullString = conf.Server.SearchFullString
|
||||
data.Config.SearchFullString = conf.Server.Search.FullString
|
||||
data.Config.SearchBackend = conf.Server.Search.Backend
|
||||
data.Config.RecentlyAddedByModTime = conf.Server.RecentlyAddedByModTime
|
||||
data.Config.PreferSortTags = conf.Server.PreferSortTags
|
||||
data.Config.BackupSchedule = conf.Server.Backup.Schedule
|
||||
|
||||
@@ -61,13 +61,13 @@ type Data struct {
|
||||
EnableListenBrainz bool `json:"enableListenBrainz,omitempty"`
|
||||
EnableDeezer bool `json:"enableDeezer,omitempty"`
|
||||
EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"`
|
||||
EnableSpotify bool `json:"enableSpotify,omitempty"`
|
||||
EnableJukebox bool `json:"enableJukebox,omitempty"`
|
||||
EnablePrometheus bool `json:"enablePrometheus,omitempty"`
|
||||
EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"`
|
||||
EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
|
||||
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
|
||||
SearchFullString bool `json:"searchFullString,omitempty"`
|
||||
SearchBackend string `json:"searchBackend,omitempty"`
|
||||
RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"`
|
||||
PreferSortTags bool `json:"preferSortTags,omitempty"`
|
||||
BackupSchedule string `json:"backupSchedule,omitempty"`
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/kballard/go-shellquote"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils/shellquote"
|
||||
)
|
||||
|
||||
func start(ctx context.Context, args []string) (Executor, error) {
|
||||
|
||||
@@ -188,7 +188,7 @@ var _ = Describe("MPV", func() {
|
||||
|
||||
It("returns empty slice for empty template", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{}))
|
||||
Expect(args).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// SmartPlaylistEvaluator evaluates smart playlists in the background.
|
||||
// Call Enqueue to queue a playlist for evaluation. The evaluation happens
|
||||
// asynchronously in a background goroutine.
|
||||
type SmartPlaylistEvaluator interface {
|
||||
Enqueue(playlistID string)
|
||||
}
|
||||
|
||||
func NewSmartPlaylistEvaluator(ds model.DataStore) SmartPlaylistEvaluator {
|
||||
e := &smartPlaylistEvaluator{
|
||||
ds: ds,
|
||||
buffer: make(map[string]struct{}),
|
||||
wakeSignal: make(chan struct{}, 1),
|
||||
}
|
||||
go e.run()
|
||||
return e
|
||||
}
|
||||
|
||||
type smartPlaylistEvaluator struct {
|
||||
ds model.DataStore
|
||||
buffer map[string]struct{}
|
||||
mutex sync.Mutex
|
||||
wakeSignal chan struct{}
|
||||
}
|
||||
|
||||
func (e *smartPlaylistEvaluator) Enqueue(playlistID string) {
|
||||
e.mutex.Lock()
|
||||
defer e.mutex.Unlock()
|
||||
e.buffer[playlistID] = struct{}{}
|
||||
e.sendWakeSignal()
|
||||
}
|
||||
|
||||
func (e *smartPlaylistEvaluator) sendWakeSignal() {
|
||||
select {
|
||||
case e.wakeSignal <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (e *smartPlaylistEvaluator) run() {
|
||||
for {
|
||||
e.waitSignal(10 * time.Second)
|
||||
|
||||
e.mutex.Lock()
|
||||
if len(e.buffer) == 0 {
|
||||
e.mutex.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
batch := slices.Collect(maps.Keys(e.buffer))
|
||||
e.buffer = make(map[string]struct{})
|
||||
e.mutex.Unlock()
|
||||
|
||||
e.processBatch(batch)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *smartPlaylistEvaluator) waitSignal(timeout time.Duration) {
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-e.wakeSignal:
|
||||
}
|
||||
}
|
||||
|
||||
func (e *smartPlaylistEvaluator) processBatch(batch []string) {
|
||||
// Use admin context so userFilter() returns all playlists.
|
||||
// Evaluate() internally uses pls.OwnerID for annotation JOINs.
|
||||
ctx := auth.WithAdminUser(context.TODO(), e.ds)
|
||||
|
||||
log.Debug(ctx, "Evaluating smart playlists in background", "count", len(batch))
|
||||
for _, id := range batch {
|
||||
start := time.Now()
|
||||
err := e.ds.Playlist(ctx).Evaluate(id)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error evaluating smart playlist in background", "id", id, err)
|
||||
continue
|
||||
}
|
||||
log.Debug(ctx, "Smart playlist evaluation complete", "id", id, "elapsed", time.Since(start))
|
||||
}
|
||||
}
|
||||
|
||||
// NoopSmartPlaylistEvaluator returns an evaluator that does nothing.
|
||||
// Used in CLI scan and test contexts.
|
||||
func NoopSmartPlaylistEvaluator() SmartPlaylistEvaluator {
|
||||
return &noopSmartPlaylistEvaluator{}
|
||||
}
|
||||
|
||||
type noopSmartPlaylistEvaluator struct{}
|
||||
|
||||
func (n *noopSmartPlaylistEvaluator) Enqueue(string) {}
|
||||
@@ -0,0 +1,17 @@
|
||||
package playlists_test
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
)
|
||||
|
||||
var _ = Describe("SmartPlaylistEvaluator", func() {
|
||||
Describe("NoopSmartPlaylistEvaluator", func() {
|
||||
It("does not panic when enqueuing", func() {
|
||||
noop := playlists.NoopSmartPlaylistEvaluator()
|
||||
Expect(func() { noop.Enqueue("some-id") }).ToNot(Panic())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/ioutils"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
|
||||
pls, err := s.parsePlaylist(ctx, filename, folder)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
|
||||
return nil, err
|
||||
}
|
||||
log.Debug(ctx, "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(folder.AbsolutePath(), filename), err)
|
||||
}
|
||||
return pls, err
|
||||
}
|
||||
|
||||
func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) {
|
||||
owner, _ := request.UserFrom(ctx)
|
||||
pls := &model.Playlist{
|
||||
OwnerID: owner.ID,
|
||||
Public: false,
|
||||
Sync: false,
|
||||
}
|
||||
err := s.parseM3U(ctx, pls, nil, reader)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing playlist", err)
|
||||
return nil, err
|
||||
}
|
||||
err = s.ds.Playlist(ctx).Put(pls)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error saving playlist", err)
|
||||
return nil, err
|
||||
}
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, folder *model.Folder) (*model.Playlist, error) {
|
||||
pls, err := s.newSyncedPlaylist(folder.AbsolutePath(), playlistFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := os.Open(pls.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := ioutils.UTF8Reader(file)
|
||||
extension := strings.ToLower(filepath.Ext(playlistFile))
|
||||
switch extension {
|
||||
case ".nsp":
|
||||
err = s.parseNSP(ctx, pls, reader)
|
||||
default:
|
||||
err = s.parseM3U(ctx, pls, folder, reader)
|
||||
}
|
||||
return pls, err
|
||||
}
|
||||
|
||||
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
|
||||
owner, _ := request.UserFrom(ctx)
|
||||
|
||||
// Try to find existing playlist by path. Since filesystem normalization differs across
|
||||
// platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match
|
||||
// playlists that may have been imported on a different platform.
|
||||
pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
// Try alternate normalization form
|
||||
altPath := norm.NFD.String(newPls.Path)
|
||||
if altPath == newPls.Path {
|
||||
altPath = norm.NFC.String(newPls.Path)
|
||||
}
|
||||
if altPath != newPls.Path {
|
||||
pls, err = s.ds.Playlist(ctx).FindByPath(altPath)
|
||||
}
|
||||
}
|
||||
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.UploadedImage = pls.UploadedImage // Preserve manual upload
|
||||
newPls.EvaluatedAt = &time.Time{}
|
||||
} else {
|
||||
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
||||
newPls.OwnerID = owner.ID
|
||||
// For NSP files, Public may already be set from the file; for M3U, use server default
|
||||
if !newPls.IsSmartPlaylist() {
|
||||
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
}
|
||||
}
|
||||
return s.ds.Playlist(ctx).Put(newPls)
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package core_test
|
||||
package playlists_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -10,6 +12,7 @@ import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -19,18 +22,18 @@ import (
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
var _ = Describe("Playlists", func() {
|
||||
var _ = Describe("Playlists - Import", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var ps core.Playlists
|
||||
var mockPlsRepo mockedPlaylistRepo
|
||||
var ps playlists.Playlists
|
||||
var mockPlsRepo *tests.MockPlaylistRepo
|
||||
var mockLibRepo *tests.MockLibraryRepo
|
||||
ctx := context.Background()
|
||||
|
||||
BeforeEach(func() {
|
||||
mockPlsRepo = mockedPlaylistRepo{}
|
||||
mockPlsRepo = tests.CreateMockPlaylistRepo()
|
||||
mockLibRepo = &tests.MockLibraryRepo{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedPlaylist: &mockPlsRepo,
|
||||
MockedPlaylist: mockPlsRepo,
|
||||
MockedLibrary: mockLibRepo,
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
@@ -39,7 +42,8 @@ var _ = Describe("Playlists", func() {
|
||||
Describe("ImportFile", func() {
|
||||
var folder *model.Folder
|
||||
BeforeEach(func() {
|
||||
ps = core.NewPlaylists(ds)
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
ds.MockedMediaFile = &mockedMediaFileRepo{}
|
||||
libPath, _ := os.Getwd()
|
||||
// Set up library with the actual library path that matches the folder
|
||||
@@ -61,7 +65,7 @@ var _ = Describe("Playlists", func() {
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
|
||||
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/playlists/test.ogg"))
|
||||
Expect(mockPlsRepo.last).To(Equal(pls))
|
||||
Expect(mockPlsRepo.Last).To(Equal(pls))
|
||||
})
|
||||
|
||||
It("parses playlists using LF ending", func() {
|
||||
@@ -93,13 +97,220 @@ var _ = Describe("Playlists", func() {
|
||||
Expect(pls.Tracks).To(HaveLen(1))
|
||||
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
|
||||
})
|
||||
|
||||
It("parses #EXTALBUMARTURL with HTTP URL", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
|
||||
pls, err := ps.ImportFile(ctx, folder, "pls-with-art-url.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(Equal("https://example.com/cover.jpg"))
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("parses #EXTALBUMARTURL with absolute local path", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(tmpDir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
|
||||
|
||||
m3u := fmt.Sprintf("#EXTALBUMARTURL:%s\ntest.mp3\ntest.ogg\n", imgPath)
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}}
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(Equal(imgPath))
|
||||
})
|
||||
|
||||
It("parses #EXTALBUMARTURL with relative local path", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
Expect(os.WriteFile(filepath.Join(tmpDir, "cover.jpg"), []byte("fake image"), 0600)).To(Succeed())
|
||||
|
||||
m3u := "#EXTALBUMARTURL:cover.jpg\ntest.mp3\n"
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(Equal(filepath.Join(tmpDir, "cover.jpg")))
|
||||
})
|
||||
|
||||
It("parses #EXTALBUMARTURL with file:// URL", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(tmpDir, "my cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
|
||||
|
||||
m3u := fmt.Sprintf("#EXTALBUMARTURL:file://%s\ntest.mp3\n", strings.ReplaceAll(imgPath, " ", "%20"))
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(Equal(imgPath))
|
||||
})
|
||||
|
||||
It("preserves + in file:// URLs (PathUnescape, not QueryUnescape)", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(tmpDir, "A+B.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
|
||||
|
||||
m3u := fmt.Sprintf("#EXTALBUMARTURL:file://%s\ntest.mp3\n", imgPath)
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(Equal(imgPath))
|
||||
})
|
||||
|
||||
It("rejects #EXTALBUMARTURL with absolute path outside library boundaries", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
|
||||
m3u := "#EXTALBUMARTURL:/etc/passwd\ntest.mp3\n"
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("rejects #EXTALBUMARTURL with file:// URL outside library boundaries", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
|
||||
m3u := "#EXTALBUMARTURL:file:///etc/passwd\ntest.mp3\n"
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("rejects #EXTALBUMARTURL with relative path escaping library", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
|
||||
m3u := "#EXTALBUMARTURL:../../etc/passwd\ntest.mp3\n"
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("ignores HTTP #EXTALBUMARTURL when EnableM3UExternalAlbumArt is false", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
m3u := "#EXTALBUMARTURL:https://example.com/cover.jpg\ntest.mp3\n"
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("updates ExternalImageURL on re-scan even when UploadedImage is set", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
m3u := "#EXTALBUMARTURL:https://example.com/new-cover.jpg\ntest.mp3\n"
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
|
||||
|
||||
existingPls := &model.Playlist{
|
||||
ID: "existing-id",
|
||||
Name: "Existing Playlist",
|
||||
Path: plsFile,
|
||||
Sync: true,
|
||||
UploadedImage: "existing-id.jpg",
|
||||
ExternalImageURL: "https://example.com/old-cover.jpg",
|
||||
}
|
||||
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.UploadedImage).To(Equal("existing-id.jpg"))
|
||||
Expect(pls.ExternalImageURL).To(Equal("https://example.com/new-cover.jpg"))
|
||||
})
|
||||
|
||||
It("clears ExternalImageURL on re-scan when directive is removed", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
m3u := "test.mp3\n"
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
|
||||
|
||||
existingPls := &model.Playlist{
|
||||
ID: "existing-id",
|
||||
Name: "Existing Playlist",
|
||||
Path: plsFile,
|
||||
Sync: true,
|
||||
ExternalImageURL: "https://example.com/old-cover.jpg",
|
||||
}
|
||||
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("NSP", func() {
|
||||
It("parses well-formed playlists", func() {
|
||||
pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.last).To(Equal(pls))
|
||||
Expect(mockPlsRepo.Last).To(Equal(pls))
|
||||
Expect(pls.OwnerID).To(Equal("123"))
|
||||
Expect(pls.Name).To(Equal("Recently Played"))
|
||||
Expect(pls.Comment).To(Equal("Recently played tracks"))
|
||||
@@ -125,7 +336,6 @@ var _ = Describe("Playlists", func() {
|
||||
Expect(pls.Public).To(BeFalse())
|
||||
})
|
||||
It("uses server default when public field is absent", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultPlaylistPublicVisibility = true
|
||||
|
||||
pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
|
||||
@@ -149,7 +359,7 @@ var _ = Describe("Playlists", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}}
|
||||
ps = core.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
// Create the playlist file on disk with the filesystem's normalization form
|
||||
plsFile := tmpDir + "/" + filesystemName + ".m3u"
|
||||
@@ -163,7 +373,7 @@ var _ = Describe("Playlists", func() {
|
||||
Path: storedPath,
|
||||
Sync: true,
|
||||
}
|
||||
mockPlsRepo.data = map[string]*model.Playlist{storedPath: existingPls}
|
||||
mockPlsRepo.PathMap = map[string]*model.Playlist{storedPath: existingPls}
|
||||
|
||||
// Import using the filesystem's normalization form
|
||||
plsFolder := &model.Folder{
|
||||
@@ -209,7 +419,7 @@ var _ = Describe("Playlists", func() {
|
||||
"def.mp3", // This is playlists/def.mp3 relative to plsDir
|
||||
},
|
||||
}
|
||||
ps = core.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
})
|
||||
|
||||
It("handles relative paths that reference files in other libraries", func() {
|
||||
@@ -365,7 +575,7 @@ var _ = Describe("Playlists", func() {
|
||||
},
|
||||
}
|
||||
// Recreate playlists service to pick up new mock
|
||||
ps = core.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
// Create playlist in music library that references both tracks
|
||||
plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3"
|
||||
@@ -408,7 +618,7 @@ var _ = Describe("Playlists", func() {
|
||||
BeforeEach(func() {
|
||||
repo = &mockedMediaFileFromListRepo{}
|
||||
ds.MockedMediaFile = repo
|
||||
ps = core.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
})
|
||||
@@ -439,7 +649,7 @@ var _ = Describe("Playlists", func() {
|
||||
Expect(pls.Tracks[1].Path).To(Equal("tests/test.ogg"))
|
||||
Expect(pls.Tracks[2].Path).To(Equal("downloads/newfile.flac"))
|
||||
Expect(pls.Tracks[3].Path).To(Equal("tests/01 Invisible (RED) Edit Version.mp3"))
|
||||
Expect(mockPlsRepo.last).To(Equal(pls))
|
||||
Expect(mockPlsRepo.Last).To(Equal(pls))
|
||||
})
|
||||
|
||||
It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
|
||||
@@ -460,7 +670,7 @@ var _ = Describe("Playlists", func() {
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("returns only tracks that exist in the database and in the same other as the m3u", func() {
|
||||
It("returns only tracks that exist in the database and in the same order as the m3u", func() {
|
||||
repo.data = []string{
|
||||
"album1/test1.mp3",
|
||||
"album2/test2.mp3",
|
||||
@@ -495,6 +705,24 @@ var _ = Describe("Playlists", func() {
|
||||
Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
|
||||
})
|
||||
|
||||
It("parses #EXTALBUMARTURL with HTTP URL via ImportM3U", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
|
||||
repo.data = []string{"tests/test.mp3"}
|
||||
m3u := "#EXTALBUMARTURL:https://example.com/cover.jpg\n/music/tests/test.mp3\n"
|
||||
pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(Equal("https://example.com/cover.jpg"))
|
||||
})
|
||||
|
||||
It("ignores relative #EXTALBUMARTURL when imported via API (no folder context)", func() {
|
||||
repo.data = []string{"tests/test.mp3"}
|
||||
m3u := "#EXTALBUMARTURL:cover.jpg\n/music/tests/test.mp3\n"
|
||||
pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(BeEmpty())
|
||||
})
|
||||
|
||||
// Fullwidth characters (e.g., ABCD) are not handled by SQLite's NOCASE collation,
|
||||
// so we need exact matching for non-ASCII characters.
|
||||
It("matches fullwidth characters exactly (SQLite NOCASE limitation)", func() {
|
||||
@@ -570,7 +798,7 @@ var _ = Describe("Playlists", func() {
|
||||
|
||||
})
|
||||
|
||||
Describe("InPlaylistsPath", func() {
|
||||
Describe("InPath", func() {
|
||||
var folder model.Folder
|
||||
|
||||
BeforeEach(func() {
|
||||
@@ -584,27 +812,27 @@ var _ = Describe("Playlists", func() {
|
||||
|
||||
It("returns true if PlaylistsPath is empty", func() {
|
||||
conf.Server.PlaylistsPath = ""
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
|
||||
Expect(playlists.InPath(folder)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns true if PlaylistsPath is any (**/**)", func() {
|
||||
conf.Server.PlaylistsPath = "**/**"
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
|
||||
Expect(playlists.InPath(folder)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns true if folder is in PlaylistsPath", func() {
|
||||
conf.Server.PlaylistsPath = "other/**:playlists/**"
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
|
||||
Expect(playlists.InPath(folder)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false if folder is not in PlaylistsPath", func() {
|
||||
conf.Server.PlaylistsPath = "other"
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
|
||||
Expect(playlists.InPath(folder)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() {
|
||||
conf.Server.PlaylistsPath = "."
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
|
||||
Expect(playlists.InPath(folder)).To(BeFalse())
|
||||
|
||||
folder2 := model.Folder{
|
||||
LibraryPath: "/music",
|
||||
@@ -612,7 +840,7 @@ var _ = Describe("Playlists", func() {
|
||||
Name: ".",
|
||||
}
|
||||
|
||||
Expect(core.InPlaylistsPath(folder2)).To(BeTrue())
|
||||
Expect(playlists.InPath(folder2)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -693,23 +921,3 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi
|
||||
}
|
||||
return mfs, nil
|
||||
}
|
||||
|
||||
type mockedPlaylistRepo struct {
|
||||
last *model.Playlist
|
||||
data map[string]*model.Playlist // keyed by path
|
||||
model.PlaylistRepository
|
||||
}
|
||||
|
||||
func (r *mockedPlaylistRepo) FindByPath(path string) (*model.Playlist, error) {
|
||||
if r.data != nil {
|
||||
if pls, ok := r.data[path]; ok {
|
||||
return pls, nil
|
||||
}
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (r *mockedPlaylistRepo) Put(pls *model.Playlist) error {
|
||||
r.last = pls
|
||||
return nil
|
||||
}
|
||||
@@ -1,183 +1,29 @@
|
||||
package core
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/RaveNoX/go-jsoncommentstrip"
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/ioutils"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
type Playlists interface {
|
||||
ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
|
||||
Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
|
||||
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
|
||||
}
|
||||
|
||||
type playlists struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func NewPlaylists(ds model.DataStore) Playlists {
|
||||
return &playlists{ds: ds}
|
||||
}
|
||||
|
||||
func InPlaylistsPath(folder model.Folder) bool {
|
||||
if conf.Server.PlaylistsPath == "" {
|
||||
return true
|
||||
}
|
||||
rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
|
||||
for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
||||
if match, _ := doublestar.Match(path, rel); match {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
|
||||
pls, err := s.parsePlaylist(ctx, filename, folder)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), 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(folder.AbsolutePath(), filename), err)
|
||||
}
|
||||
return pls, err
|
||||
}
|
||||
|
||||
func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) {
|
||||
owner, _ := request.UserFrom(ctx)
|
||||
pls := &model.Playlist{
|
||||
OwnerID: owner.ID,
|
||||
Public: false,
|
||||
Sync: false,
|
||||
}
|
||||
err := s.parseM3U(ctx, pls, nil, reader)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing playlist", err)
|
||||
return nil, err
|
||||
}
|
||||
err = s.ds.Playlist(ctx).Put(pls)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error saving playlist", err)
|
||||
return nil, err
|
||||
}
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, folder *model.Folder) (*model.Playlist, error) {
|
||||
pls, err := s.newSyncedPlaylist(folder.AbsolutePath(), playlistFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := os.Open(pls.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := ioutils.UTF8Reader(file)
|
||||
extension := strings.ToLower(filepath.Ext(playlistFile))
|
||||
switch extension {
|
||||
case ".nsp":
|
||||
err = s.parseNSP(ctx, pls, reader)
|
||||
default:
|
||||
err = s.parseM3U(ctx, pls, folder, reader)
|
||||
}
|
||||
return pls, err
|
||||
}
|
||||
|
||||
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 getPositionFromOffset(data []byte, offset int64) (line, column int) {
|
||||
line = 1
|
||||
for _, b := range data[:offset] {
|
||||
if b == '\n' {
|
||||
line++
|
||||
column = 1
|
||||
} else {
|
||||
column++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.Reader) error {
|
||||
nsp := &nspFile{}
|
||||
reader = io.LimitReader(reader, 100*1024) // Limit to 100KB
|
||||
reader = jsoncommentstrip.NewReader(reader)
|
||||
input, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading SmartPlaylist: %w", err)
|
||||
}
|
||||
err = json.Unmarshal(input, nsp)
|
||||
if err != nil {
|
||||
var syntaxErr *json.SyntaxError
|
||||
if errors.As(err, &syntaxErr) {
|
||||
line, col := getPositionFromOffset(input, syntaxErr.Offset)
|
||||
return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err)
|
||||
}
|
||||
return fmt.Errorf("JSON parsing error in SmartPlaylist: %w", err)
|
||||
}
|
||||
pls.Rules = &nsp.Criteria
|
||||
if nsp.Name != "" {
|
||||
pls.Name = nsp.Name
|
||||
}
|
||||
if nsp.Comment != "" {
|
||||
pls.Comment = nsp.Comment
|
||||
}
|
||||
if nsp.Public != nil {
|
||||
pls.Public = *nsp.Public
|
||||
} else {
|
||||
pls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error {
|
||||
mediaFileRepository := s.ds.MediaFile(ctx)
|
||||
resolver, err := newPathResolver(ctx, s.ds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var mfs model.MediaFiles
|
||||
// Chunk size of 100 lines, as each line can generate up to 4 lookup candidates
|
||||
// (NFC/NFD × raw/lowercase), and SQLite has a max expression tree depth of 1000.
|
||||
@@ -185,8 +31,12 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
||||
filteredLines := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "#PLAYLIST:") {
|
||||
pls.Name = line[len("#PLAYLIST:"):]
|
||||
if after, ok := strings.CutPrefix(line, "#PLAYLIST:"); ok {
|
||||
pls.Name = after
|
||||
continue
|
||||
}
|
||||
if after, ok := strings.CutPrefix(line, "#EXTALBUMARTURL:"); ok {
|
||||
pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher)
|
||||
continue
|
||||
}
|
||||
// Skip empty lines and extended info
|
||||
@@ -195,14 +45,14 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
||||
}
|
||||
if after, ok := strings.CutPrefix(line, "file://"); ok {
|
||||
line = after
|
||||
line, _ = url.QueryUnescape(line)
|
||||
line, _ = url.PathUnescape(line)
|
||||
}
|
||||
if !model.IsAudioFile(line) {
|
||||
continue
|
||||
}
|
||||
filteredLines = append(filteredLines, line)
|
||||
}
|
||||
resolvedPaths, err := s.resolvePaths(ctx, folder, filteredLines)
|
||||
resolvedPaths, err := resolver.resolvePaths(ctx, folder, filteredLines)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err)
|
||||
continue
|
||||
@@ -258,7 +108,9 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
||||
existing[key] = idx
|
||||
}
|
||||
|
||||
// Find media files in the order of the resolved paths, to keep playlist order
|
||||
// Find media files in the order of the resolved paths, to keep playlist order.
|
||||
// Both `existing` keys and `resolvedPaths` use the library-qualified format "libraryID:relativePath",
|
||||
// so normalizing the full string produces matching keys (digits and ':' are ASCII-invariant).
|
||||
for _, path := range resolvedPaths {
|
||||
key := strings.ToLower(norm.NFC.String(path))
|
||||
idx, ok := existing[key]
|
||||
@@ -398,15 +250,10 @@ func (r *pathResolver) findInLibraries(absolutePath string) pathResolution {
|
||||
// resolvePaths converts playlist file paths to library-qualified paths (format: "libraryID:relativePath").
|
||||
// For relative paths, it resolves them to absolute paths first, then determines which
|
||||
// library they belong to. This allows playlists to reference files across library boundaries.
|
||||
func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
|
||||
resolver, err := newPathResolver(ctx, s.ds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
|
||||
results := make([]string, 0, len(lines))
|
||||
for idx, line := range lines {
|
||||
resolution := resolver.resolvePath(line, folder)
|
||||
resolution := r.resolvePath(line, folder)
|
||||
|
||||
if !resolution.valid {
|
||||
log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
|
||||
@@ -426,122 +273,52 @@ func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, line
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
|
||||
owner, _ := request.UserFrom(ctx)
|
||||
|
||||
// Try to find existing playlist by path. Since filesystem normalization differs across
|
||||
// platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match
|
||||
// playlists that may have been imported on a different platform.
|
||||
pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
// Try alternate normalization form
|
||||
altPath := norm.NFD.String(newPls.Path)
|
||||
if altPath == newPls.Path {
|
||||
altPath = norm.NFC.String(newPls.Path)
|
||||
}
|
||||
if altPath != newPls.Path {
|
||||
pls, err = s.ds.Playlist(ctx).FindByPath(altPath)
|
||||
}
|
||||
}
|
||||
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
|
||||
// resolveImageURL resolves an #EXTALBUMARTURL value to a storable string.
|
||||
// HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt).
|
||||
// Local paths (file://, absolute, or relative) are resolved to an absolute path
|
||||
// and validated against known library boundaries via matcher.
|
||||
func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
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
|
||||
// For NSP files, Public may already be set from the file; for M3U, use server default
|
||||
if !newPls.IsSmartPlaylist() {
|
||||
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
// HTTP(S) URLs — store as-is, but only if external album art is enabled
|
||||
if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
|
||||
if !conf.Server.EnableM3UExternalAlbumArt {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
return s.ds.Playlist(ctx).Put(newPls)
|
||||
|
||||
// Resolve to local absolute path
|
||||
localPath, ok := resolveLocalPath(value, folder)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Validate path is within a known library
|
||||
if libID, _ := matcher.findLibraryForPath(localPath); libID == 0 {
|
||||
return ""
|
||||
}
|
||||
return localPath
|
||||
}
|
||||
|
||||
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.WithTxImmediate(func(tx model.DataStore) error {
|
||||
var pls *model.Playlist
|
||||
var err error
|
||||
repo := tx.Playlist(ctx)
|
||||
tracks := repo.Tracks(playlistID, true)
|
||||
if tracks == nil {
|
||||
return fmt.Errorf("%w: playlist '%s'", model.ErrNotFound, playlistID)
|
||||
}
|
||||
if needsTrackRefresh {
|
||||
pls, err = repo.GetWithTracks(playlistID, true, false)
|
||||
pls.RemoveTracks(idxToRemove)
|
||||
pls.AddMediaFilesByID(idsToAdd)
|
||||
} else {
|
||||
if len(idsToAdd) > 0 {
|
||||
_, err = tracks.Add(idsToAdd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if needsInfoUpdate {
|
||||
pls, err = repo.Get(playlistID)
|
||||
}
|
||||
}
|
||||
// resolveLocalPath converts a file://, absolute, or relative path to a clean absolute path.
|
||||
// Returns ("", false) if the path cannot be resolved.
|
||||
func resolveLocalPath(value string, folder *model.Folder) (string, bool) {
|
||||
if after, ok := strings.CutPrefix(value, "file://"); ok {
|
||||
decoded, err := url.PathUnescape(after)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", false
|
||||
}
|
||||
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 = tracks.DeleteAll(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return repo.Put(pls)
|
||||
})
|
||||
}
|
||||
|
||||
type nspFile struct {
|
||||
criteria.Criteria
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment"`
|
||||
Public *bool `json:"public"`
|
||||
}
|
||||
|
||||
func (i *nspFile) UnmarshalJSON(data []byte) error {
|
||||
m := map[string]any{}
|
||||
err := json.Unmarshal(data, &m)
|
||||
if err != nil {
|
||||
return err
|
||||
return filepath.Clean(decoded), true
|
||||
}
|
||||
i.Name, _ = m["name"].(string)
|
||||
i.Comment, _ = m["comment"].(string)
|
||||
if public, ok := m["public"].(bool); ok {
|
||||
i.Public = &public
|
||||
if filepath.IsAbs(value) {
|
||||
return filepath.Clean(value), true
|
||||
}
|
||||
return json.Unmarshal(data, &i.Criteria)
|
||||
if folder == nil {
|
||||
return "", false
|
||||
}
|
||||
return filepath.Clean(filepath.Join(folder.AbsolutePath(), value)), true
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package core
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -214,38 +214,38 @@ var _ = Describe("pathResolver", func() {
|
||||
})
|
||||
|
||||
Describe("resolvePath", func() {
|
||||
It("resolves absolute paths", func() {
|
||||
resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
|
||||
Context("basic", func() {
|
||||
It("resolves absolute paths", func() {
|
||||
resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(1))
|
||||
Expect(resolution.libraryPath).To(Equal("/music"))
|
||||
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(1))
|
||||
Expect(resolution.libraryPath).To(Equal("/music"))
|
||||
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
|
||||
})
|
||||
|
||||
It("resolves relative paths when folder is provided", func() {
|
||||
folder := &model.Folder{
|
||||
Path: "playlists",
|
||||
LibraryPath: "/music",
|
||||
LibraryID: 1,
|
||||
}
|
||||
|
||||
resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(1))
|
||||
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
|
||||
})
|
||||
|
||||
It("returns invalid resolution for paths outside any library", func() {
|
||||
resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
|
||||
|
||||
Expect(resolution.valid).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
It("resolves relative paths when folder is provided", func() {
|
||||
folder := &model.Folder{
|
||||
Path: "playlists",
|
||||
LibraryPath: "/music",
|
||||
LibraryID: 1,
|
||||
}
|
||||
|
||||
resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(1))
|
||||
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
|
||||
})
|
||||
|
||||
It("returns invalid resolution for paths outside any library", func() {
|
||||
resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
|
||||
|
||||
Expect(resolution.valid).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("resolvePath", func() {
|
||||
Context("With absolute paths", func() {
|
||||
Context("cross-library", func() {
|
||||
It("resolves path within a library", func() {
|
||||
resolution := resolver.resolvePath("/music/track.mp3", nil)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/utils/jsoncommentstrip"
|
||||
)
|
||||
|
||||
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 getPositionFromOffset(data []byte, offset int64) (line, column int) {
|
||||
line = 1
|
||||
for _, b := range data[:offset] {
|
||||
if b == '\n' {
|
||||
line++
|
||||
column = 1
|
||||
} else {
|
||||
column++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.Reader) error {
|
||||
nsp := &nspFile{}
|
||||
reader = io.LimitReader(reader, 100*1024) // Limit to 100KB
|
||||
reader = jsoncommentstrip.NewReader(reader)
|
||||
input, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading SmartPlaylist: %w", err)
|
||||
}
|
||||
err = json.Unmarshal(input, nsp)
|
||||
if err != nil {
|
||||
var syntaxErr *json.SyntaxError
|
||||
if errors.As(err, &syntaxErr) {
|
||||
line, col := getPositionFromOffset(input, syntaxErr.Offset)
|
||||
return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err)
|
||||
}
|
||||
return fmt.Errorf("JSON parsing error in SmartPlaylist: %w", err)
|
||||
}
|
||||
pls.Rules = &nsp.Criteria
|
||||
if nsp.Name != "" {
|
||||
pls.Name = nsp.Name
|
||||
}
|
||||
if nsp.Comment != "" {
|
||||
pls.Comment = nsp.Comment
|
||||
}
|
||||
if nsp.Public != nil {
|
||||
pls.Public = *nsp.Public
|
||||
} else {
|
||||
pls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type nspFile struct {
|
||||
criteria.Criteria
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment"`
|
||||
Public *bool `json:"public"`
|
||||
}
|
||||
|
||||
func (i *nspFile) UnmarshalJSON(data []byte) error {
|
||||
m := map[string]any{}
|
||||
err := json.Unmarshal(data, &m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.Name, _ = m["name"].(string)
|
||||
i.Comment, _ = m["comment"].(string)
|
||||
if public, ok := m["public"].(bool); ok {
|
||||
i.Public = &public
|
||||
}
|
||||
return json.Unmarshal(data, &i.Criteria)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("parseNSP", func() {
|
||||
var s *playlists
|
||||
ctx := context.Background()
|
||||
|
||||
BeforeEach(func() {
|
||||
s = &playlists{}
|
||||
})
|
||||
|
||||
It("parses a well-formed NSP with all fields", func() {
|
||||
nsp := `{
|
||||
"name": "My Smart Playlist",
|
||||
"comment": "A test playlist",
|
||||
"public": true,
|
||||
"all": [{"is": {"loved": true}}],
|
||||
"sort": "title",
|
||||
"order": "asc",
|
||||
"limit": 50
|
||||
}`
|
||||
pls := &model.Playlist{Name: "default-name"}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("My Smart Playlist"))
|
||||
Expect(pls.Comment).To(Equal("A test playlist"))
|
||||
Expect(pls.Public).To(BeTrue())
|
||||
Expect(pls.Rules).ToNot(BeNil())
|
||||
Expect(pls.Rules.Sort).To(Equal("title"))
|
||||
Expect(pls.Rules.Order).To(Equal("asc"))
|
||||
Expect(pls.Rules.Limit).To(Equal(50))
|
||||
Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
|
||||
})
|
||||
|
||||
It("keeps existing name when NSP has no name field", func() {
|
||||
nsp := `{"all": [{"is": {"loved": true}}]}`
|
||||
pls := &model.Playlist{Name: "Original Name"}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("Original Name"))
|
||||
})
|
||||
|
||||
It("keeps existing comment when NSP has no comment field", func() {
|
||||
nsp := `{"all": [{"is": {"loved": true}}]}`
|
||||
pls := &model.Playlist{Comment: "Original Comment"}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Comment).To(Equal("Original Comment"))
|
||||
})
|
||||
|
||||
It("strips JSON comments before parsing", func() {
|
||||
nsp := `{
|
||||
// Line comment
|
||||
"name": "Commented Playlist",
|
||||
/* Block comment */
|
||||
"all": [{"is": {"loved": true}}]
|
||||
}`
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("Commented Playlist"))
|
||||
})
|
||||
|
||||
It("uses server default when public field is absent", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultPlaylistPublicVisibility = true
|
||||
|
||||
nsp := `{"all": [{"is": {"loved": true}}]}`
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Public).To(BeTrue())
|
||||
})
|
||||
|
||||
It("honors explicit public: false over server default", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultPlaylistPublicVisibility = true
|
||||
|
||||
nsp := `{"public": false, "all": [{"is": {"loved": true}}]}`
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Public).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns a syntax error with line and column info", func() {
|
||||
nsp := "{\n \"name\": \"Bad\",\n \"all\": [INVALID]\n}"
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("JSON syntax error in SmartPlaylist"))
|
||||
Expect(err.Error()).To(MatchRegexp(`line \d+, column \d+`))
|
||||
})
|
||||
|
||||
It("returns a parsing error for completely invalid JSON", func() {
|
||||
nsp := `not json at all`
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("SmartPlaylist"))
|
||||
})
|
||||
|
||||
It("gracefully handles non-string name field", func() {
|
||||
nsp := `{"name": 123, "all": [{"is": {"loved": true}}]}`
|
||||
pls := &model.Playlist{Name: "Original"}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Type assertion in UnmarshalJSON fails silently; name stays as original
|
||||
Expect(pls.Name).To(Equal("Original"))
|
||||
})
|
||||
|
||||
It("parses limitPercent from NSP", func() {
|
||||
nsp := `{
|
||||
"all": [{"is": {"loved": true}}],
|
||||
"sort": "playCount",
|
||||
"order": "desc",
|
||||
"limitPercent": 25
|
||||
}`
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Rules).ToNot(BeNil())
|
||||
Expect(pls.Rules.LimitPercent).To(Equal(25))
|
||||
Expect(pls.Rules.Limit).To(Equal(0))
|
||||
})
|
||||
|
||||
It("parses criteria with multiple rules", func() {
|
||||
nsp := `{
|
||||
"all": [
|
||||
{"is": {"loved": true}},
|
||||
{"contains": {"title": "rock"}}
|
||||
],
|
||||
"sort": "lastPlayed",
|
||||
"order": "desc",
|
||||
"limit": 100
|
||||
}`
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Rules).ToNot(BeNil())
|
||||
Expect(pls.Rules.Sort).To(Equal("lastPlayed"))
|
||||
Expect(pls.Rules.Order).To(Equal("desc"))
|
||||
Expect(pls.Rules.Limit).To(Equal(100))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("getPositionFromOffset", func() {
|
||||
It("returns correct position on first line", func() {
|
||||
data := []byte("hello world")
|
||||
line, col := getPositionFromOffset(data, 5)
|
||||
Expect(line).To(Equal(1))
|
||||
Expect(col).To(Equal(5))
|
||||
})
|
||||
|
||||
It("returns correct position after newlines", func() {
|
||||
data := []byte("line1\nline2\nline3")
|
||||
// Offsets: l(0) i(1) n(2) e(3) 1(4) \n(5) l(6) i(7) n(8)
|
||||
line, col := getPositionFromOffset(data, 8)
|
||||
Expect(line).To(Equal(2))
|
||||
Expect(col).To(Equal(3))
|
||||
})
|
||||
|
||||
It("returns correct position at start of new line", func() {
|
||||
data := []byte("line1\nline2")
|
||||
// After \n at offset 5, col resets to 1; offset 6 is 'l' -> col=1
|
||||
line, col := getPositionFromOffset(data, 6)
|
||||
Expect(line).To(Equal(2))
|
||||
Expect(col).To(Equal(1))
|
||||
})
|
||||
|
||||
It("handles multiple newlines", func() {
|
||||
data := []byte("a\nb\nc\nd")
|
||||
// a(0) \n(1) b(2) \n(3) c(4) \n(5) d(6)
|
||||
line, col := getPositionFromOffset(data, 6)
|
||||
Expect(line).To(Equal(4))
|
||||
Expect(col).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("newSyncedPlaylist", func() {
|
||||
var s *playlists
|
||||
|
||||
BeforeEach(func() {
|
||||
s = &playlists{}
|
||||
})
|
||||
|
||||
It("creates a synced playlist with correct attributes", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
Expect(os.WriteFile(filepath.Join(tmpDir, "test.m3u"), []byte("content"), 0600)).To(Succeed())
|
||||
|
||||
pls, err := s.newSyncedPlaylist(tmpDir, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("test"))
|
||||
Expect(pls.Comment).To(Equal("Auto-imported from 'test.m3u'"))
|
||||
Expect(pls.Public).To(BeFalse())
|
||||
Expect(pls.Path).To(Equal(filepath.Join(tmpDir, "test.m3u")))
|
||||
Expect(pls.Sync).To(BeTrue())
|
||||
Expect(pls.UpdatedAt).ToNot(BeZero())
|
||||
})
|
||||
|
||||
It("strips extension from filename to derive name", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
Expect(os.WriteFile(filepath.Join(tmpDir, "My Favorites.nsp"), []byte("{}"), 0600)).To(Succeed())
|
||||
|
||||
pls, err := s.newSyncedPlaylist(tmpDir, "My Favorites.nsp")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("My Favorites"))
|
||||
})
|
||||
|
||||
It("returns error for non-existent file", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
_, err := s.newSyncedPlaylist(tmpDir, "nonexistent.m3u")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,321 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
"github.com/deluan/rest"
|
||||
"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"
|
||||
)
|
||||
|
||||
type Playlists interface {
|
||||
// Reads
|
||||
GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error)
|
||||
Get(ctx context.Context, id string) (*model.Playlist, error)
|
||||
GetWithTracks(ctx context.Context, id string) (*model.Playlist, error)
|
||||
GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error)
|
||||
|
||||
// Mutations
|
||||
Create(ctx context.Context, playlistId string, name string, ids []string) (string, error)
|
||||
Delete(ctx context.Context, id string) error
|
||||
Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
|
||||
|
||||
// Track management
|
||||
AddTracks(ctx context.Context, playlistID string, ids []string) (int, error)
|
||||
AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error)
|
||||
AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error)
|
||||
AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error)
|
||||
RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error
|
||||
ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error
|
||||
|
||||
// Cover art
|
||||
SetImage(ctx context.Context, playlistID string, reader io.Reader, ext string) error
|
||||
RemoveImage(ctx context.Context, playlistID string) error
|
||||
|
||||
// Import
|
||||
ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
|
||||
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
|
||||
|
||||
// REST adapters (follows Share/Library pattern)
|
||||
NewRepository(ctx context.Context) rest.Repository
|
||||
TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository
|
||||
}
|
||||
|
||||
// ImageUploadService is a local interface satisfied by core.ImageUploadService.
|
||||
// Defined here to avoid an import cycle between core and core/playlists.
|
||||
type ImageUploadService interface {
|
||||
SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error)
|
||||
RemoveImage(ctx context.Context, path string) error
|
||||
}
|
||||
|
||||
type playlists struct {
|
||||
ds model.DataStore
|
||||
imgUpload ImageUploadService
|
||||
}
|
||||
|
||||
func NewPlaylists(ds model.DataStore, imgUpload ImageUploadService) Playlists {
|
||||
return &playlists{ds: ds, imgUpload: imgUpload}
|
||||
}
|
||||
|
||||
func InPath(folder model.Folder) bool {
|
||||
if conf.Server.PlaylistsPath == "" {
|
||||
return true
|
||||
}
|
||||
rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
|
||||
for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
||||
if match, _ := doublestar.Match(path, rel); match {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- Read operations ---
|
||||
|
||||
func (s *playlists) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) {
|
||||
return s.ds.Playlist(ctx).GetAll(options...)
|
||||
}
|
||||
|
||||
func (s *playlists) Get(ctx context.Context, id string) (*model.Playlist, error) {
|
||||
return s.ds.Playlist(ctx).Get(id)
|
||||
}
|
||||
|
||||
func (s *playlists) GetWithTracks(ctx context.Context, id string) (*model.Playlist, error) {
|
||||
return s.ds.Playlist(ctx).GetWithTracks(id, true, false)
|
||||
}
|
||||
|
||||
func (s *playlists) GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) {
|
||||
return s.ds.Playlist(ctx).GetPlaylists(mediaFileId)
|
||||
}
|
||||
|
||||
// --- Mutation operations ---
|
||||
|
||||
// Create creates a new playlist (when name is provided) or replaces tracks on an existing
|
||||
// playlist (when playlistId is provided). This matches the Subsonic createPlaylist semantics.
|
||||
func (s *playlists) Create(ctx context.Context, playlistId string, name string, ids []string) (string, error) {
|
||||
usr, _ := request.UserFrom(ctx)
|
||||
err := s.ds.WithTxImmediate(func(tx model.DataStore) error {
|
||||
var pls *model.Playlist
|
||||
var err error
|
||||
|
||||
if playlistId != "" {
|
||||
pls, err = tx.Playlist(ctx).Get(playlistId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pls.IsSmartPlaylist() {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
if !usr.IsAdmin && pls.OwnerID != usr.ID {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
} else {
|
||||
pls = &model.Playlist{Name: name}
|
||||
pls.OwnerID = usr.ID
|
||||
}
|
||||
pls.Tracks = nil
|
||||
pls.AddMediaFilesByID(ids)
|
||||
|
||||
err = tx.Playlist(ctx).Put(pls)
|
||||
playlistId = pls.ID
|
||||
return err
|
||||
})
|
||||
return playlistId, err
|
||||
}
|
||||
|
||||
func (s *playlists) Delete(ctx context.Context, id string) error {
|
||||
pls, err := s.checkWritable(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clean up custom cover image file if one exists
|
||||
if path := pls.UploadedImagePath(); path != "" {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
log.Warn(ctx, "Failed to remove playlist image on delete", "path", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return s.ds.Playlist(ctx).Delete(id)
|
||||
}
|
||||
|
||||
func (s *playlists) Update(ctx context.Context, playlistID string,
|
||||
name *string, comment *string, public *bool,
|
||||
idsToAdd []string, idxToRemove []int) error {
|
||||
var pls *model.Playlist
|
||||
var err error
|
||||
hasTrackChanges := len(idsToAdd) > 0 || len(idxToRemove) > 0
|
||||
if hasTrackChanges {
|
||||
pls, err = s.checkTracksEditable(ctx, playlistID)
|
||||
} else {
|
||||
pls, err = s.checkWritable(ctx, playlistID)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
|
||||
repo := tx.Playlist(ctx)
|
||||
|
||||
if len(idxToRemove) > 0 {
|
||||
tracksRepo := repo.Tracks(playlistID, false)
|
||||
// Convert 0-based indices to 1-based position IDs and delete them directly,
|
||||
// avoiding the need to load all tracks into memory.
|
||||
positions := make([]string, len(idxToRemove))
|
||||
for i, idx := range idxToRemove {
|
||||
positions[i] = strconv.Itoa(idx + 1)
|
||||
}
|
||||
if err := tracksRepo.Delete(positions...); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(idsToAdd) > 0 {
|
||||
if _, err := tracksRepo.Add(idsToAdd); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.updateMetadata(ctx, tx, pls, name, comment, public)
|
||||
}
|
||||
|
||||
if len(idsToAdd) > 0 {
|
||||
if _, err := repo.Tracks(playlistID, false).Add(idsToAdd); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if name == nil && comment == nil && public == nil {
|
||||
return nil
|
||||
}
|
||||
// Reuse the playlist from checkWritable (no tracks loaded, so Put only refreshes counters)
|
||||
return s.updateMetadata(ctx, tx, pls, name, comment, public)
|
||||
})
|
||||
}
|
||||
|
||||
// --- Permission helpers ---
|
||||
|
||||
// checkWritable fetches the playlist and verifies the current user can modify it.
|
||||
func (s *playlists) checkWritable(ctx context.Context, id string) (*model.Playlist, error) {
|
||||
pls, err := s.ds.Playlist(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usr, _ := request.UserFrom(ctx)
|
||||
if !usr.IsAdmin && pls.OwnerID != usr.ID {
|
||||
return nil, model.ErrNotAuthorized
|
||||
}
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
// checkTracksEditable verifies the user can modify tracks (ownership + not smart playlist).
|
||||
func (s *playlists) checkTracksEditable(ctx context.Context, playlistID string) (*model.Playlist, error) {
|
||||
pls, err := s.checkWritable(ctx, playlistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pls.IsSmartPlaylist() {
|
||||
return nil, model.ErrNotAuthorized
|
||||
}
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
// updateMetadata applies optional metadata changes to a playlist and persists it.
|
||||
// Accepts a DataStore parameter so it can be used inside transactions.
|
||||
// The caller is responsible for permission checks.
|
||||
func (s *playlists) updateMetadata(ctx context.Context, ds model.DataStore, pls *model.Playlist, name *string, comment *string, public *bool) error {
|
||||
if name != nil {
|
||||
pls.Name = *name
|
||||
}
|
||||
if comment != nil {
|
||||
pls.Comment = *comment
|
||||
}
|
||||
if public != nil {
|
||||
pls.Public = *public
|
||||
}
|
||||
return ds.Playlist(ctx).Put(pls)
|
||||
}
|
||||
|
||||
// --- Track management operations ---
|
||||
|
||||
func (s *playlists) AddTracks(ctx context.Context, playlistID string, ids []string) (int, error) {
|
||||
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.ds.Playlist(ctx).Tracks(playlistID, false).Add(ids)
|
||||
}
|
||||
|
||||
func (s *playlists) AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) {
|
||||
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddAlbums(albumIds)
|
||||
}
|
||||
|
||||
func (s *playlists) AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error) {
|
||||
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddArtists(artistIds)
|
||||
}
|
||||
|
||||
func (s *playlists) AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error) {
|
||||
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddDiscs(discs)
|
||||
}
|
||||
|
||||
func (s *playlists) RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error {
|
||||
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.ds.WithTx(func(tx model.DataStore) error {
|
||||
return tx.Playlist(ctx).Tracks(playlistID, false).Delete(trackIds...)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error {
|
||||
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.ds.WithTx(func(tx model.DataStore) error {
|
||||
return tx.Playlist(ctx).Tracks(playlistID, false).Reorder(pos, newPos)
|
||||
})
|
||||
}
|
||||
|
||||
// --- Cover art operations ---
|
||||
|
||||
func (s *playlists) SetImage(ctx context.Context, playlistID string, reader io.Reader, ext string) error {
|
||||
pls, err := s.checkWritable(ctx, playlistID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
oldPath := pls.UploadedImagePath()
|
||||
filename, err := s.imgUpload.SetImage(ctx, consts.EntityPlaylist, pls.ID, pls.Name, oldPath, reader, ext)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pls.UploadedImage = filename
|
||||
return s.ds.Playlist(ctx).Put(pls)
|
||||
}
|
||||
|
||||
func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error {
|
||||
pls, err := s.checkWritable(ctx, playlistID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.imgUpload.RemoveImage(ctx, pls.UploadedImagePath()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pls.UploadedImage = ""
|
||||
return s.ds.Playlist(ctx).Put(pls)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package playlists_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestPlaylists(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Playlists Suite")
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package playlists_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Playlists", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var ps playlists.Playlists
|
||||
var mockPlsRepo *tests.MockPlaylistRepo
|
||||
ctx := context.Background()
|
||||
|
||||
BeforeEach(func() {
|
||||
mockPlsRepo = tests.CreateMockPlaylistRepo()
|
||||
ds = &tests.MockDataStore{
|
||||
MockedPlaylist: mockPlsRepo,
|
||||
MockedLibrary: &tests.MockLibraryRepo{},
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
var mockTracks *tests.MockPlaylistTrackRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 3}
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
})
|
||||
|
||||
It("allows owner to delete their playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.Delete(ctx, "pls-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
|
||||
})
|
||||
|
||||
It("allows admin to delete any playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||
err := ps.Delete(ctx, "pls-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
|
||||
})
|
||||
|
||||
It("denies non-owner, non-admin from deleting", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
err := ps.Delete(ctx, "pls-1")
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
Expect(mockPlsRepo.Deleted).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns error when playlist not found", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.Delete(ctx, "nonexistent")
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Create", func() {
|
||||
BeforeEach(func() {
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "Existing", OwnerID: "user-1"},
|
||||
"pls-2": {ID: "pls-2", Name: "Other's", OwnerID: "other-user"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
}
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
})
|
||||
|
||||
It("creates a new playlist with owner set from context", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
id, err := ps.Create(ctx, "", "New Playlist", []string{"song-1", "song-2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(id).ToNot(BeEmpty())
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("New Playlist"))
|
||||
Expect(mockPlsRepo.Last.OwnerID).To(Equal("user-1"))
|
||||
})
|
||||
|
||||
It("replaces tracks on existing playlist when owner matches", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
id, err := ps.Create(ctx, "pls-1", "", []string{"song-3"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(id).To(Equal("pls-1"))
|
||||
Expect(mockPlsRepo.Last.Tracks).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("allows admin to replace tracks on any playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||
id, err := ps.Create(ctx, "pls-2", "", []string{"song-3"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(id).To(Equal("pls-2"))
|
||||
})
|
||||
|
||||
It("denies non-owner, non-admin from replacing tracks on existing playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.Create(ctx, "pls-2", "", []string{"song-3"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("returns error when existing playlistId not found", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.Create(ctx, "nonexistent", "", []string{"song-1"})
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("denies replacing tracks on a smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.Create(ctx, "pls-smart", "", []string{"song-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Update", func() {
|
||||
var mockTracks *tests.MockPlaylistTrackRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2}
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
})
|
||||
|
||||
It("allows owner to update their playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
newName := "Updated Name"
|
||||
err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("allows admin to update any playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||
newName := "Updated Name"
|
||||
err := ps.Update(ctx, "pls-other", &newName, nil, nil, nil, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("denies non-owner, non-admin from updating", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
newName := "Updated Name"
|
||||
err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("returns error when playlist not found", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
newName := "Updated Name"
|
||||
err := ps.Update(ctx, "nonexistent", &newName, nil, nil, nil, nil)
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("denies adding tracks to a smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.Update(ctx, "pls-smart", nil, nil, nil, []string{"song-1"}, nil)
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("denies removing tracks from a smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.Update(ctx, "pls-smart", nil, nil, nil, nil, []int{0})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("allows metadata updates on a smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
newName := "Updated Smart"
|
||||
err := ps.Update(ctx, "pls-smart", &newName, nil, nil, nil, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("AddTracks", func() {
|
||||
var mockTracks *tests.MockPlaylistTrackRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2}
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
})
|
||||
|
||||
It("allows owner to add tracks", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
count, err := ps.AddTracks(ctx, "pls-1", []string{"song-1", "song-2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(2))
|
||||
Expect(mockTracks.AddedIds).To(ConsistOf("song-1", "song-2"))
|
||||
})
|
||||
|
||||
It("allows admin to add tracks to any playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||
count, err := ps.AddTracks(ctx, "pls-other", []string{"song-1"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(2))
|
||||
})
|
||||
|
||||
It("denies non-owner, non-admin", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
_, err := ps.AddTracks(ctx, "pls-1", []string{"song-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("denies editing smart playlists", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.AddTracks(ctx, "pls-smart", []string{"song-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("returns error when playlist not found", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.AddTracks(ctx, "nonexistent", []string{"song-1"})
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RemoveTracks", func() {
|
||||
var mockTracks *tests.MockPlaylistTrackRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockTracks = &tests.MockPlaylistTrackRepo{}
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
})
|
||||
|
||||
It("allows owner to remove tracks", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1", "track-2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockTracks.DeletedIds).To(ConsistOf("track-1", "track-2"))
|
||||
})
|
||||
|
||||
It("denies on smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.RemoveTracks(ctx, "pls-smart", []string{"track-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("denies non-owner", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ReorderTrack", func() {
|
||||
var mockTracks *tests.MockPlaylistTrackRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockTracks = &tests.MockPlaylistTrackRepo{}
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
})
|
||||
|
||||
It("allows owner to reorder", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.ReorderTrack(ctx, "pls-1", 1, 3)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockTracks.Reordered).To(BeTrue())
|
||||
})
|
||||
|
||||
It("denies on smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.ReorderTrack(ctx, "pls-smart", 1, 3)
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("SetImage", func() {
|
||||
var tmpDir string
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tmpDir = GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = tmpDir
|
||||
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
||||
}
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
})
|
||||
|
||||
It("saves image file and updates UploadedImage", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
reader := strings.NewReader("fake image data")
|
||||
err := ps.SetImage(ctx, "pls-1", reader, ".jpg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(mockPlsRepo.Last.UploadedImage).To(Equal("pls-1_my_playlist.jpg"))
|
||||
absPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1_my_playlist.jpg")
|
||||
data, err := os.ReadFile(absPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("fake image data"))
|
||||
})
|
||||
|
||||
It("removes old image when replacing", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
|
||||
// Upload first image
|
||||
err := ps.SetImage(ctx, "pls-1", strings.NewReader("first"), ".png")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
oldPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1_my_playlist.png")
|
||||
Expect(oldPath).To(BeAnExistingFile())
|
||||
|
||||
// Upload replacement image
|
||||
err = ps.SetImage(ctx, "pls-1", strings.NewReader("second"), ".jpg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(oldPath).ToNot(BeAnExistingFile())
|
||||
newPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1_my_playlist.jpg")
|
||||
Expect(newPath).To(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("allows admin to set image on any playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||
err := ps.SetImage(ctx, "pls-other", strings.NewReader("data"), ".jpg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("denies non-owner", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
err := ps.SetImage(ctx, "pls-1", strings.NewReader("data"), ".jpg")
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("returns error when playlist not found", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.SetImage(ctx, "nonexistent", strings.NewReader("data"), ".jpg")
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RemoveImage", func() {
|
||||
var tmpDir string
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tmpDir = GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = tmpDir
|
||||
|
||||
// Create a real image file on disk
|
||||
imgDir := filepath.Join(tmpDir, "artwork", "playlist")
|
||||
Expect(os.MkdirAll(imgDir, 0755)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(imgDir, "pls-1.jpg"), []byte("img data"), 0600)).To(Succeed())
|
||||
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1", UploadedImage: "pls-1.jpg"},
|
||||
"pls-empty": {ID: "pls-empty", Name: "No Cover", OwnerID: "user-1"},
|
||||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
||||
}
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
})
|
||||
|
||||
It("removes file and clears UploadedImage", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.RemoveImage(ctx, "pls-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(mockPlsRepo.Last.UploadedImage).To(BeEmpty())
|
||||
absPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1.jpg")
|
||||
Expect(absPath).ToNot(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("succeeds even if playlist has no image", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.RemoveImage(ctx, "pls-empty")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.UploadedImage).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("denies non-owner", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
err := ps.RemoveImage(ctx, "pls-1")
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("returns error when playlist not found", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.RemoveImage(ctx, "nonexistent")
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
)
|
||||
|
||||
// --- REST adapter (follows Share/Library pattern) ---
|
||||
|
||||
func (s *playlists) NewRepository(ctx context.Context) rest.Repository {
|
||||
return &playlistRepositoryWrapper{
|
||||
ctx: ctx,
|
||||
PlaylistRepository: s.ds.Playlist(ctx),
|
||||
service: s,
|
||||
}
|
||||
}
|
||||
|
||||
// playlistRepositoryWrapper wraps the playlist repository as a thin REST-to-service adapter.
|
||||
// It satisfies rest.Repository through the embedded PlaylistRepository (via ResourceRepository),
|
||||
// and rest.Persistable by delegating to service methods for all mutations.
|
||||
type playlistRepositoryWrapper struct {
|
||||
model.PlaylistRepository
|
||||
ctx context.Context
|
||||
service *playlists
|
||||
}
|
||||
|
||||
func (r *playlistRepositoryWrapper) Save(entity any) (string, error) {
|
||||
return r.service.savePlaylist(r.ctx, entity.(*model.Playlist))
|
||||
}
|
||||
|
||||
func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error {
|
||||
return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...)
|
||||
}
|
||||
|
||||
func (r *playlistRepositoryWrapper) Delete(id string) error {
|
||||
err := r.service.Delete(r.ctx, id)
|
||||
switch {
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
return rest.ErrNotFound
|
||||
case errors.Is(err, model.ErrNotAuthorized):
|
||||
return rest.ErrPermissionDenied
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (s *playlists) TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository {
|
||||
repo := s.ds.Playlist(ctx)
|
||||
tracks := repo.Tracks(playlistId, refreshSmartPlaylist)
|
||||
if tracks == nil {
|
||||
return nil
|
||||
}
|
||||
return tracks.(rest.Repository)
|
||||
}
|
||||
|
||||
// savePlaylist creates a new playlist, assigning the owner from context.
|
||||
// Only Name, Comment, Public, and Rules are user-settable via the REST API.
|
||||
func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (string, error) {
|
||||
usr, _ := request.UserFrom(ctx)
|
||||
pls.OwnerID = usr.ID
|
||||
pls.ID = "" // Force new creation
|
||||
pls.Path = "" // Server-managed (M3U file path)
|
||||
pls.Sync = false // Server-managed (M3U sync flag)
|
||||
pls.UploadedImage = "" // Managed by image upload endpoint
|
||||
pls.ExternalImageURL = "" // Managed by M3U import / plugins only
|
||||
pls.EvaluatedAt = nil // Server-managed
|
||||
err := s.ds.Playlist(ctx).Put(pls)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return pls.ID, nil
|
||||
}
|
||||
|
||||
// updatePlaylistEntity updates playlist metadata with permission checks.
|
||||
// Used by the REST API wrapper.
|
||||
func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error {
|
||||
current, err := s.checkWritable(ctx, id)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
return rest.ErrNotFound
|
||||
case errors.Is(err, model.ErrNotAuthorized):
|
||||
return rest.ErrPermissionDenied
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
usr, _ := request.UserFrom(ctx)
|
||||
if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
// Apply ownership change (admin only)
|
||||
if entity.OwnerID != "" {
|
||||
current.OwnerID = entity.OwnerID
|
||||
}
|
||||
// Apply smart playlist rules update
|
||||
current.Rules = entity.Rules
|
||||
return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package playlists_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("REST Adapter", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var ps playlists.Playlists
|
||||
var mockPlsRepo *tests.MockPlaylistRepo
|
||||
ctx := context.Background()
|
||||
|
||||
BeforeEach(func() {
|
||||
mockPlsRepo = tests.CreateMockPlaylistRepo()
|
||||
ds = &tests.MockDataStore{
|
||||
MockedPlaylist: mockPlsRepo,
|
||||
MockedLibrary: &tests.MockLibraryRepo{},
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
})
|
||||
|
||||
Describe("NewRepository", func() {
|
||||
var repo rest.Persistable
|
||||
|
||||
BeforeEach(func() {
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
}
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
})
|
||||
|
||||
Describe("Save", func() {
|
||||
It("sets the owner from the context user", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "New Playlist"}
|
||||
id, err := repo.Save(pls)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(id).ToNot(BeEmpty())
|
||||
Expect(pls.OwnerID).To(Equal("user-1"))
|
||||
})
|
||||
|
||||
It("forces a new creation by clearing ID", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{ID: "should-be-cleared", Name: "New"}
|
||||
_, err := repo.Save(pls)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ID).ToNot(Equal("should-be-cleared"))
|
||||
})
|
||||
|
||||
It("clears server-managed fields to prevent injection via REST API", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
now := time.Now()
|
||||
pls := &model.Playlist{
|
||||
Name: "Legit Playlist",
|
||||
Comment: "A comment",
|
||||
Public: true,
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}},
|
||||
Path: "/some/path/playlist.m3u",
|
||||
Sync: true,
|
||||
UploadedImage: "injected-image-path",
|
||||
ExternalImageURL: "http://evil.example.com/ssrf",
|
||||
EvaluatedAt: &now,
|
||||
}
|
||||
_, err := repo.Save(pls)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
saved := mockPlsRepo.Last
|
||||
// User-settable fields are preserved
|
||||
Expect(saved.Name).To(Equal("Legit Playlist"))
|
||||
Expect(saved.Comment).To(Equal("A comment"))
|
||||
Expect(saved.Public).To(BeTrue())
|
||||
Expect(saved.Rules).ToNot(BeNil())
|
||||
// Server-managed fields are cleared
|
||||
Expect(saved.Path).To(BeEmpty())
|
||||
Expect(saved.Sync).To(BeFalse())
|
||||
Expect(saved.UploadedImage).To(BeEmpty())
|
||||
Expect(saved.ExternalImageURL).To(BeEmpty())
|
||||
Expect(saved.EvaluatedAt).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Update", func() {
|
||||
It("allows owner to update their playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "Updated"}
|
||||
err := repo.Update("pls-1", pls)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("allows admin to update any playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "Updated"}
|
||||
err := repo.Update("pls-1", pls)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("denies non-owner, non-admin", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "Updated"}
|
||||
err := repo.Update("pls-1", pls)
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
|
||||
It("denies regular user from changing ownership", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "Updated", OwnerID: "other-user"}
|
||||
err := repo.Update("pls-1", pls)
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
|
||||
It("updates smart playlist rules", func() {
|
||||
mockPlsRepo.Data["smart-1"] = &model.Playlist{
|
||||
ID: "smart-1",
|
||||
Name: "Smart Playlist",
|
||||
OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "old"}},
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
newRules := &criteria.Criteria{Expression: criteria.Contains{"title": "new"}}
|
||||
pls := &model.Playlist{Name: "Smart Playlist", Rules: newRules}
|
||||
err := repo.Update("smart-1", pls)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Rules).To(Equal(newRules))
|
||||
})
|
||||
|
||||
It("returns rest.ErrNotFound when playlist doesn't exist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "Updated"}
|
||||
err := repo.Update("nonexistent", pls)
|
||||
Expect(err).To(Equal(rest.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
It("delegates to service Delete with permission checks", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Delete("pls-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
|
||||
})
|
||||
|
||||
It("denies non-owner", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Delete("pls-1")
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
// ImageURL generates a public URL for artwork images.
|
||||
// It creates a signed token for the artwork ID and builds a complete public URL.
|
||||
func ImageURL(req *http.Request, artID model.ArtworkID, size int) string {
|
||||
token, _ := auth.CreatePublicToken(map[string]any{"id": artID.String()})
|
||||
token, _ := auth.CreatePublicToken(auth.Claims{ID: artID.String()})
|
||||
uri := path.Join(consts.URLPathPublicImages, token)
|
||||
params := url.Values{}
|
||||
if size > 0 {
|
||||
|
||||
+2
-2
@@ -7,11 +7,11 @@ import (
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/nanoid"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
@@ -73,7 +73,7 @@ type shareRepositoryWrapper struct {
|
||||
|
||||
func (r *shareRepositoryWrapper) newId() (string, error) {
|
||||
for {
|
||||
id, err := gonanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 10)
|
||||
id, err := nanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 10)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ func (s *localStorage) Start(ctx context.Context) (<-chan string, error) {
|
||||
if !s.watching.CompareAndSwap(false, true) {
|
||||
return nil, errors.New("watcher already started")
|
||||
}
|
||||
input := make(chan notify.EventInfo, 1)
|
||||
output := make(chan string, 1)
|
||||
input := make(chan notify.EventInfo, 500)
|
||||
output := make(chan string, 500)
|
||||
|
||||
started := make(chan struct{})
|
||||
go func() {
|
||||
|
||||
@@ -284,6 +284,9 @@ func (ffs *FakeFS) parseFile(filePath string) (*metadata.Info, error) {
|
||||
p.AudioProperties.BitDepth = getInt("bitdepth")
|
||||
p.AudioProperties.SampleRate = getInt("samplerate")
|
||||
p.AudioProperties.Channels = getInt("channels")
|
||||
if codec, ok := data["codec"].(string); ok {
|
||||
p.AudioProperties.Codec = codec
|
||||
}
|
||||
for k, v := range data {
|
||||
p.Tags[k] = []string{fmt.Sprintf("%v", v)}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// containerAliasGroups maps each container alias to a canonical group name.
|
||||
var containerAliasGroups = func() map[string]string {
|
||||
groups := [][]string{
|
||||
{"aac", "adts", "m4a", "mp4", "m4b", "m4p"},
|
||||
{"mpeg", "mp3", "mp2"},
|
||||
{"ogg", "oga", "opus"},
|
||||
{"aif", "aiff"},
|
||||
{"asf", "wma"},
|
||||
{"mpc", "mpp"},
|
||||
{"wv"},
|
||||
}
|
||||
m := make(map[string]string)
|
||||
for _, g := range groups {
|
||||
canonical := g[0]
|
||||
for _, name := range g {
|
||||
m[name] = canonical
|
||||
}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// codecAliasGroups maps each codec alias to a canonical group name.
|
||||
// Codecs within the same group are considered equivalent.
|
||||
var codecAliasGroups = func() map[string]string {
|
||||
groups := [][]string{
|
||||
{"aac", "adts"},
|
||||
{"ac3", "ac-3"},
|
||||
{"eac3", "e-ac3", "e-ac-3", "eac-3"},
|
||||
{"mpc7", "musepack7"},
|
||||
{"mpc8", "musepack8"},
|
||||
{"wma1", "wmav1"},
|
||||
{"wma2", "wmav2"},
|
||||
{"wmalossless", "wma9lossless"},
|
||||
{"wmapro", "wma9pro"},
|
||||
{"shn", "shorten"},
|
||||
{"mp4als", "als"},
|
||||
}
|
||||
m := make(map[string]string)
|
||||
for _, g := range groups {
|
||||
for _, name := range g {
|
||||
m[name] = g[0] // canonical = first entry
|
||||
}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// matchesWithAliases checks if a value matches any entry in candidates,
|
||||
// consulting the alias map for equivalent names.
|
||||
func matchesWithAliases(value string, candidates []string, aliases map[string]string) bool {
|
||||
value = strings.ToLower(value)
|
||||
canonical := aliases[value]
|
||||
for _, c := range candidates {
|
||||
c = strings.ToLower(c)
|
||||
if c == value {
|
||||
return true
|
||||
}
|
||||
if canonical != "" && aliases[c] == canonical {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesContainer checks if a file suffix matches any of the container names,
|
||||
// including common aliases.
|
||||
func matchesContainer(suffix string, containers []string) bool {
|
||||
return matchesWithAliases(suffix, containers, containerAliasGroups)
|
||||
}
|
||||
|
||||
// matchesCodec checks if a codec matches any of the codec names,
|
||||
// including common aliases.
|
||||
func matchesCodec(codec string, codecs []string) bool {
|
||||
return matchesWithAliases(codec, codecs, codecAliasGroups)
|
||||
}
|
||||
|
||||
// IsAACCodec returns true if the given codec or container name resolves to AAC.
|
||||
func IsAACCodec(name string) bool {
|
||||
return matchesCodec(name, []string{"aac"}) || matchesContainer(name, []string{"aac"})
|
||||
}
|
||||
|
||||
func containsIgnoreCase(slice []string, s string) bool {
|
||||
return slices.ContainsFunc(slice, func(item string) bool {
|
||||
return strings.EqualFold(item, s)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Aliases", func() {
|
||||
Describe("IsAACCodec", func() {
|
||||
It("returns true for AAC and its aliases", func() {
|
||||
Expect(IsAACCodec("aac")).To(BeTrue())
|
||||
Expect(IsAACCodec("AAC")).To(BeTrue())
|
||||
Expect(IsAACCodec("adts")).To(BeTrue())
|
||||
Expect(IsAACCodec("m4a")).To(BeTrue())
|
||||
Expect(IsAACCodec("mp4")).To(BeTrue())
|
||||
Expect(IsAACCodec("m4b")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false for non-AAC formats", func() {
|
||||
Expect(IsAACCodec("mp3")).To(BeFalse())
|
||||
Expect(IsAACCodec("opus")).To(BeFalse())
|
||||
Expect(IsAACCodec("flac")).To(BeFalse())
|
||||
Expect(IsAACCodec("ogg")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false for empty string", func() {
|
||||
Expect(IsAACCodec("")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
package stream
|
||||
|
||||
import "strings"
|
||||
|
||||
// normalizeProbeCodec maps ffprobe codec_name values to the simplified internal
|
||||
// codec names used throughout Navidrome (matching inferCodecFromSuffix output).
|
||||
// Most ffprobe names match directly; this handles the exceptions.
|
||||
func normalizeProbeCodec(codec string) string {
|
||||
c := strings.ToLower(codec)
|
||||
// DSD variants: dsd_lsbf_planar, dsd_msbf_planar, dsd_lsbf, dsd_msbf
|
||||
if strings.HasPrefix(c, "dsd") {
|
||||
return "dsd"
|
||||
}
|
||||
// PCM variants: pcm_s16le, pcm_s24le, pcm_s32be, pcm_f32le, etc.
|
||||
if strings.HasPrefix(c, "pcm_") {
|
||||
return "pcm"
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// isLosslessFormat returns true if the format is a known lossless audio codec/format.
|
||||
// Detection is based on codec name only, not bit depth — some lossy codecs (e.g. ADPCM)
|
||||
// report non-zero bits_per_sample in ffprobe, so bit depth alone is not a reliable signal.
|
||||
//
|
||||
// Note: core/ffmpeg has a separate isLosslessOutputFormat that covers only formats
|
||||
// ffmpeg can produce as output (a smaller set).
|
||||
func isLosslessFormat(format string) bool {
|
||||
switch strings.ToLower(format) {
|
||||
case "flac", "alac", "wav", "aiff", "ape", "wv", "wavpack", "tta", "tak", "shn", "dsd", "pcm":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeSourceSampleRate adjusts the source sample rate for codecs that store
|
||||
// it differently than PCM. Currently handles DSD (÷8):
|
||||
// DSD64=2822400→352800, DSD128=5644800→705600, etc.
|
||||
// For other codecs, returns the rate unchanged.
|
||||
func normalizeSourceSampleRate(sampleRate int, codec string) int {
|
||||
if strings.EqualFold(codec, "dsd") && sampleRate > 0 {
|
||||
return sampleRate / 8
|
||||
}
|
||||
return sampleRate
|
||||
}
|
||||
|
||||
// normalizeSourceBitDepth adjusts the source bit depth for codecs that use
|
||||
// non-standard bit depths. Currently handles DSD (1-bit → 24-bit PCM, which is
|
||||
// what ffmpeg produces). For other codecs, returns the depth unchanged.
|
||||
func normalizeSourceBitDepth(bitDepth int, codec string) int {
|
||||
if strings.EqualFold(codec, "dsd") && bitDepth == 1 {
|
||||
return 24
|
||||
}
|
||||
return bitDepth
|
||||
}
|
||||
|
||||
// codecFixedOutputSampleRate returns the mandatory output sample rate for codecs
|
||||
// that always resample regardless of input (e.g., Opus always outputs 48000Hz).
|
||||
// Returns 0 if the codec has no fixed output rate.
|
||||
func codecFixedOutputSampleRate(codec string) int {
|
||||
switch strings.ToLower(codec) {
|
||||
case "opus":
|
||||
return 48000
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// codecMaxSampleRate returns the hard maximum output sample rate for a codec.
|
||||
// Returns 0 if the codec has no hard limit.
|
||||
func codecMaxSampleRate(codec string) int {
|
||||
switch strings.ToLower(codec) {
|
||||
case "mp3":
|
||||
return 48000
|
||||
case "aac":
|
||||
return 96000
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Codec", func() {
|
||||
Describe("isLosslessFormat", func() {
|
||||
It("returns true for known lossless codecs", func() {
|
||||
Expect(isLosslessFormat("flac")).To(BeTrue())
|
||||
Expect(isLosslessFormat("alac")).To(BeTrue())
|
||||
Expect(isLosslessFormat("pcm")).To(BeTrue())
|
||||
Expect(isLosslessFormat("wav")).To(BeTrue())
|
||||
Expect(isLosslessFormat("dsd")).To(BeTrue())
|
||||
Expect(isLosslessFormat("ape")).To(BeTrue())
|
||||
Expect(isLosslessFormat("wv")).To(BeTrue())
|
||||
Expect(isLosslessFormat("wavpack")).To(BeTrue()) // ffprobe codec_name for WavPack
|
||||
})
|
||||
|
||||
It("returns false for lossy codecs", func() {
|
||||
Expect(isLosslessFormat("mp3")).To(BeFalse())
|
||||
Expect(isLosslessFormat("aac")).To(BeFalse())
|
||||
Expect(isLosslessFormat("opus")).To(BeFalse())
|
||||
Expect(isLosslessFormat("vorbis")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false for unknown codecs", func() {
|
||||
Expect(isLosslessFormat("unknown_codec")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("is case-insensitive", func() {
|
||||
Expect(isLosslessFormat("FLAC")).To(BeTrue())
|
||||
Expect(isLosslessFormat("Alac")).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("normalizeProbeCodec", func() {
|
||||
It("passes through common codec names unchanged", func() {
|
||||
Expect(normalizeProbeCodec("mp3")).To(Equal("mp3"))
|
||||
Expect(normalizeProbeCodec("aac")).To(Equal("aac"))
|
||||
Expect(normalizeProbeCodec("flac")).To(Equal("flac"))
|
||||
Expect(normalizeProbeCodec("opus")).To(Equal("opus"))
|
||||
Expect(normalizeProbeCodec("vorbis")).To(Equal("vorbis"))
|
||||
Expect(normalizeProbeCodec("alac")).To(Equal("alac"))
|
||||
Expect(normalizeProbeCodec("wmav2")).To(Equal("wmav2"))
|
||||
})
|
||||
|
||||
It("normalizes DSD variants to dsd", func() {
|
||||
Expect(normalizeProbeCodec("dsd_lsbf_planar")).To(Equal("dsd"))
|
||||
Expect(normalizeProbeCodec("dsd_msbf_planar")).To(Equal("dsd"))
|
||||
Expect(normalizeProbeCodec("dsd_lsbf")).To(Equal("dsd"))
|
||||
Expect(normalizeProbeCodec("dsd_msbf")).To(Equal("dsd"))
|
||||
})
|
||||
|
||||
It("normalizes PCM variants to pcm", func() {
|
||||
Expect(normalizeProbeCodec("pcm_s16le")).To(Equal("pcm"))
|
||||
Expect(normalizeProbeCodec("pcm_s24le")).To(Equal("pcm"))
|
||||
Expect(normalizeProbeCodec("pcm_s32be")).To(Equal("pcm"))
|
||||
Expect(normalizeProbeCodec("pcm_f32le")).To(Equal("pcm"))
|
||||
})
|
||||
|
||||
It("lowercases input", func() {
|
||||
Expect(normalizeProbeCodec("MP3")).To(Equal("mp3"))
|
||||
Expect(normalizeProbeCodec("AAC")).To(Equal("aac"))
|
||||
Expect(normalizeProbeCodec("DSD_LSBF_PLANAR")).To(Equal("dsd"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,449 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
)
|
||||
|
||||
const fallbackBitrate = 256 // kbps
|
||||
|
||||
// TranscodeDecider is the core service interface for making transcoding decisions
|
||||
type TranscodeDecider interface {
|
||||
MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts TranscodeOptions) (*TranscodeDecision, error)
|
||||
CreateTranscodeParams(decision *TranscodeDecision) (string, error)
|
||||
ResolveRequestFromToken(ctx context.Context, token string, mf *model.MediaFile, offset int) (Request, error)
|
||||
ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) Request
|
||||
}
|
||||
|
||||
func NewTranscodeDecider(ds model.DataStore, ff ffmpeg.FFmpeg) TranscodeDecider {
|
||||
return &deciderService{
|
||||
ds: ds,
|
||||
ff: ff,
|
||||
}
|
||||
}
|
||||
|
||||
type deciderService struct {
|
||||
ds model.DataStore
|
||||
ff ffmpeg.FFmpeg
|
||||
}
|
||||
|
||||
func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts TranscodeOptions) (*TranscodeDecision, error) {
|
||||
decision := &TranscodeDecision{
|
||||
MediaID: mf.ID,
|
||||
SourceUpdatedAt: mf.UpdatedAt,
|
||||
}
|
||||
|
||||
var probe *ffmpeg.AudioProbeResult
|
||||
if !opts.SkipProbe {
|
||||
var err error
|
||||
probe, err = s.ensureProbed(ctx, mf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Build source stream details (uses probe data if available)
|
||||
decision.SourceStream = buildSourceStream(mf, probe)
|
||||
src := &decision.SourceStream
|
||||
|
||||
// Check for server-side player transcoding override
|
||||
if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" {
|
||||
clientInfo = applyServerOverride(ctx, clientInfo, &trc)
|
||||
} else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 {
|
||||
if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate {
|
||||
modified := *clientInfo
|
||||
modified.MaxAudioBitrate = player.MaxBitRate
|
||||
clientInfo = &modified
|
||||
log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name)
|
||||
}
|
||||
}
|
||||
|
||||
log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", src.Container,
|
||||
"codec", src.Codec, "bitrate", src.Bitrate, "channels", src.Channels,
|
||||
"sampleRate", src.SampleRate, "lossless", src.IsLossless, "client", clientInfo.Name)
|
||||
|
||||
// Check global bitrate constraint first.
|
||||
if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate {
|
||||
log.Trace(ctx, "Global bitrate constraint exceeded, skipping direct play",
|
||||
"sourceBitrate", src.Bitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
|
||||
decision.TranscodeReasons = append(decision.TranscodeReasons, "audio bitrate not supported")
|
||||
// Skip direct play profiles entirely — global constraint fails
|
||||
} else {
|
||||
// Try direct play profiles, collecting reasons for each failure
|
||||
for _, profile := range clientInfo.DirectPlayProfiles {
|
||||
if reason := s.checkDirectPlayProfile(src, &profile, clientInfo); reason == "" {
|
||||
decision.CanDirectPlay = true
|
||||
decision.TranscodeReasons = nil // Clear any previously collected reasons
|
||||
break
|
||||
} else {
|
||||
decision.TranscodeReasons = append(decision.TranscodeReasons, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If direct play is possible, we're done
|
||||
if decision.CanDirectPlay {
|
||||
log.Debug(ctx, "Transcode decision: direct play", "mediaID", mf.ID, "container", src.Container, "codec", src.Codec)
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
// Try transcoding profiles (in order of preference)
|
||||
for _, profile := range clientInfo.TranscodingProfiles {
|
||||
if ts, transcodeFormat := s.computeTranscodedStream(ctx, src, &profile, clientInfo); ts != nil {
|
||||
decision.CanTranscode = true
|
||||
decision.TargetFormat = transcodeFormat
|
||||
decision.TargetBitrate = ts.Bitrate
|
||||
decision.TargetChannels = ts.Channels
|
||||
decision.TargetSampleRate = ts.SampleRate
|
||||
decision.TargetBitDepth = ts.BitDepth
|
||||
decision.TranscodeStream = ts
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if decision.CanTranscode {
|
||||
log.Debug(ctx, "Transcode decision: transcode", "mediaID", mf.ID,
|
||||
"targetFormat", decision.TargetFormat, "targetBitrate", decision.TargetBitrate,
|
||||
"targetChannels", decision.TargetChannels, "reasons", decision.TranscodeReasons)
|
||||
}
|
||||
|
||||
// If neither direct play nor transcode is possible
|
||||
if !decision.CanDirectPlay && !decision.CanTranscode {
|
||||
decision.ErrorReason = "no compatible playback profile found"
|
||||
log.Warn(ctx, "Transcode decision: no compatible profile", "mediaID", mf.ID,
|
||||
"container", src.Container, "codec", src.Codec, "reasons", decision.TranscodeReasons)
|
||||
}
|
||||
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func buildSourceStream(mf *model.MediaFile, probe *ffmpeg.AudioProbeResult) Details {
|
||||
sd := Details{
|
||||
Container: mf.Suffix,
|
||||
Duration: mf.Duration,
|
||||
Size: mf.Size,
|
||||
}
|
||||
|
||||
// Use pre-parsed probe result, or fall back to parsing stored probe data
|
||||
if probe == nil {
|
||||
probe, _ = parseProbeData(mf.ProbeData)
|
||||
}
|
||||
|
||||
// Use probe data if available for authoritative values
|
||||
if probe != nil {
|
||||
sd.Codec = normalizeProbeCodec(probe.Codec)
|
||||
sd.Profile = probe.Profile
|
||||
sd.Bitrate = probe.BitRate
|
||||
sd.SampleRate = probe.SampleRate
|
||||
sd.BitDepth = probe.BitDepth
|
||||
sd.Channels = probe.Channels
|
||||
} else {
|
||||
sd.Codec = mf.AudioCodec()
|
||||
sd.Bitrate = mf.BitRate
|
||||
sd.SampleRate = mf.SampleRate
|
||||
sd.BitDepth = mf.BitDepth
|
||||
sd.Channels = mf.Channels
|
||||
}
|
||||
sd.IsLossless = isLosslessFormat(sd.Codec)
|
||||
|
||||
return sd
|
||||
}
|
||||
|
||||
// applyServerOverride replaces the client-provided profiles with synthetic ones
|
||||
// matching the server-forced transcoding format and bitrate.
|
||||
func applyServerOverride(ctx context.Context, original *ClientInfo, trc *model.Transcoding) *ClientInfo {
|
||||
maxBitRate := trc.DefaultBitRate
|
||||
if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 {
|
||||
maxBitRate = player.MaxBitRate
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Applying server-side transcoding override",
|
||||
"targetFormat", trc.TargetFormat, "maxBitRate", maxBitRate,
|
||||
"client", original.Name)
|
||||
|
||||
return &ClientInfo{
|
||||
Name: original.Name,
|
||||
Platform: original.Platform,
|
||||
MaxAudioBitrate: maxBitRate,
|
||||
MaxTranscodingAudioBitrate: maxBitRate,
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{trc.TargetFormat}, AudioCodecs: []string{trc.TargetFormat}, Protocols: []string{ProtocolHTTP}},
|
||||
},
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: trc.TargetFormat, AudioCodec: trc.TargetFormat, Protocol: ProtocolHTTP},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func parseProbeData(data string) (*ffmpeg.AudioProbeResult, error) {
|
||||
if data == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var result ffmpeg.AudioProbeResult
|
||||
if err := json.Unmarshal([]byte(data), &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// checkDirectPlayProfile returns "" if the profile matches (direct play OK),
|
||||
// or a typed reason string if it doesn't match.
|
||||
func (s *deciderService) checkDirectPlayProfile(src *Details, profile *DirectPlayProfile, clientInfo *ClientInfo) string {
|
||||
// Check protocol (only http for now)
|
||||
if len(profile.Protocols) > 0 && !containsIgnoreCase(profile.Protocols, ProtocolHTTP) {
|
||||
return "protocol not supported"
|
||||
}
|
||||
|
||||
// Check container
|
||||
if len(profile.Containers) > 0 && !matchesContainer(src.Container, profile.Containers) {
|
||||
return "container not supported"
|
||||
}
|
||||
|
||||
// Check codec
|
||||
if len(profile.AudioCodecs) > 0 && !matchesCodec(src.Codec, profile.AudioCodecs) {
|
||||
return "audio codec not supported"
|
||||
}
|
||||
|
||||
// Check channels
|
||||
if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels {
|
||||
return "audio channels not supported"
|
||||
}
|
||||
|
||||
// Check codec-specific limitations
|
||||
for _, codecProfile := range clientInfo.CodecProfiles {
|
||||
if strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) && matchesCodec(src.Codec, []string{codecProfile.Name}) {
|
||||
if reason := checkLimitations(src, codecProfile.Limitations); reason != "" {
|
||||
return reason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// computeTranscodedStream attempts to build a valid transcoded stream for the given profile.
|
||||
// Returns the stream details and the internal transcoding format (which may differ from the
|
||||
// response container when a codec fallback occurs, e.g., "mp4"→"aac").
|
||||
// Returns nil, "" if the profile cannot produce a valid output.
|
||||
func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Details, profile *Profile, clientInfo *ClientInfo) (*Details, string) {
|
||||
// Check protocol (only http for now)
|
||||
if profile.Protocol != "" && !strings.EqualFold(profile.Protocol, ProtocolHTTP) {
|
||||
log.Trace(ctx, "Skipping transcoding profile: unsupported protocol", "protocol", profile.Protocol)
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
responseContainer, targetFormat := resolveTargetFormat(profile)
|
||||
if targetFormat == "" {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// Verify we have a transcoding command available (DB custom or built-in default)
|
||||
if LookupTranscodeCommand(ctx, s.ds, targetFormat) == "" {
|
||||
log.Trace(ctx, "Skipping transcoding profile: no transcoding command available", "targetFormat", targetFormat)
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
targetIsLossless := isLosslessFormat(targetFormat)
|
||||
|
||||
// Reject lossy to lossless conversion
|
||||
if !src.IsLossless && targetIsLossless {
|
||||
log.Trace(ctx, "Skipping transcoding profile: lossy to lossless not allowed", "targetFormat", targetFormat)
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
ts := &Details{
|
||||
Container: responseContainer,
|
||||
Codec: strings.ToLower(profile.AudioCodec),
|
||||
SampleRate: normalizeSourceSampleRate(src.SampleRate, src.Codec),
|
||||
Channels: src.Channels,
|
||||
BitDepth: normalizeSourceBitDepth(src.BitDepth, src.Codec),
|
||||
IsLossless: targetIsLossless,
|
||||
}
|
||||
if ts.Codec == "" {
|
||||
ts.Codec = targetFormat
|
||||
}
|
||||
|
||||
// Apply codec-intrinsic sample rate adjustments before codec profile limitations
|
||||
if fixedRate := codecFixedOutputSampleRate(ts.Codec); fixedRate > 0 {
|
||||
ts.SampleRate = fixedRate
|
||||
}
|
||||
if maxRate := codecMaxSampleRate(ts.Codec); maxRate > 0 && ts.SampleRate > maxRate {
|
||||
ts.SampleRate = maxRate
|
||||
}
|
||||
|
||||
// Determine target bitrate (all in kbps)
|
||||
if ok := s.computeBitrate(ctx, src, targetFormat, targetIsLossless, clientInfo, ts); !ok {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// Apply MaxAudioChannels from the transcoding profile
|
||||
if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels {
|
||||
ts.Channels = profile.MaxAudioChannels
|
||||
}
|
||||
|
||||
// Apply codec profile limitations to the TARGET codec
|
||||
if ok := s.applyCodecLimitations(ctx, src.Bitrate, targetFormat, targetIsLossless, clientInfo, ts); !ok {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
return ts, targetFormat
|
||||
}
|
||||
|
||||
// lookupDefaultBitrate returns the default bitrate for the given format.
|
||||
// It checks the DB first (for user-customized values), then falls back to
|
||||
// the built-in defaults, and finally to fallbackBitrate.
|
||||
func lookupDefaultBitrate(ctx context.Context, ds model.DataStore, format string) int {
|
||||
if t, err := ds.Transcoding(ctx).FindByFormat(format); err == nil && t.DefaultBitRate > 0 {
|
||||
return t.DefaultBitRate
|
||||
}
|
||||
for _, dt := range consts.DefaultTranscodings {
|
||||
if dt.TargetFormat == format && dt.DefaultBitRate > 0 {
|
||||
return dt.DefaultBitRate
|
||||
}
|
||||
}
|
||||
return fallbackBitrate
|
||||
}
|
||||
|
||||
// LookupTranscodeCommand returns the ffmpeg command for the given format.
|
||||
// It checks the DB first (for user-customized commands), then falls back to
|
||||
// the built-in default command. Returns "" if the format is unknown.
|
||||
func LookupTranscodeCommand(ctx context.Context, ds model.DataStore, format string) string {
|
||||
t, err := ds.Transcoding(ctx).FindByFormat(format)
|
||||
if err == nil && t.Command != "" {
|
||||
return t.Command
|
||||
}
|
||||
// Fall back to built-in defaults
|
||||
for _, dt := range consts.DefaultTranscodings {
|
||||
if dt.TargetFormat == format {
|
||||
return dt.Command
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveTargetFormat determines the response container and internal target format
|
||||
// from the profile's Container and AudioCodec fields. When an AudioCodec is specified
|
||||
// it is preferred as targetFormat (e.g. container "mp4" with audioCodec "aac" → targetFormat "aac").
|
||||
func resolveTargetFormat(profile *Profile) (responseContainer, targetFormat string) {
|
||||
responseContainer = strings.ToLower(profile.Container)
|
||||
targetFormat = responseContainer
|
||||
|
||||
// Prefer the audioCodec as targetFormat when provided (handles container-to-codec
|
||||
// mapping like "mp4" → "aac", "ogg" → "opus").
|
||||
if profile.AudioCodec != "" {
|
||||
targetFormat = strings.ToLower(profile.AudioCodec)
|
||||
}
|
||||
|
||||
// If neither container nor audioCodec is set, we can't resolve a format.
|
||||
if targetFormat == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// When no container was specified, use the targetFormat as container too.
|
||||
if responseContainer == "" {
|
||||
responseContainer = targetFormat
|
||||
}
|
||||
|
||||
return responseContainer, targetFormat
|
||||
}
|
||||
|
||||
// computeBitrate determines the target bitrate for the transcoded stream.
|
||||
// Returns false if the profile should be rejected.
|
||||
func (s *deciderService) computeBitrate(ctx context.Context, src *Details, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *Details) bool {
|
||||
if src.IsLossless {
|
||||
if !targetIsLossless {
|
||||
if clientInfo.MaxTranscodingAudioBitrate > 0 {
|
||||
ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate
|
||||
} else if clientInfo.MaxAudioBitrate > 0 {
|
||||
ts.Bitrate = clientInfo.MaxAudioBitrate
|
||||
} else {
|
||||
ts.Bitrate = lookupDefaultBitrate(ctx, s.ds, targetFormat)
|
||||
}
|
||||
} else {
|
||||
if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate {
|
||||
log.Trace(ctx, "Skipping transcoding profile: lossless target exceeds bitrate limit",
|
||||
"targetFormat", targetFormat, "sourceBitrate", src.Bitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
|
||||
return false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ts.Bitrate = src.Bitrate
|
||||
}
|
||||
|
||||
// Apply maxAudioBitrate as final cap
|
||||
if clientInfo.MaxAudioBitrate > 0 && ts.Bitrate > 0 && ts.Bitrate > clientInfo.MaxAudioBitrate {
|
||||
ts.Bitrate = clientInfo.MaxAudioBitrate
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// applyCodecLimitations applies codec profile limitations to the transcoded stream.
|
||||
// Returns false if the profile should be rejected.
|
||||
func (s *deciderService) applyCodecLimitations(ctx context.Context, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *Details) bool {
|
||||
targetCodec := ts.Codec
|
||||
for _, codecProfile := range clientInfo.CodecProfiles {
|
||||
if !strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) {
|
||||
continue
|
||||
}
|
||||
if !matchesCodec(targetCodec, []string{codecProfile.Name}) {
|
||||
continue
|
||||
}
|
||||
for _, lim := range codecProfile.Limitations {
|
||||
result := applyLimitation(sourceBitrate, &lim, ts)
|
||||
if strings.EqualFold(lim.Name, LimitationAudioBitrate) && targetIsLossless && result == adjustAdjusted {
|
||||
log.Trace(ctx, "Skipping transcoding profile: cannot adjust bitrate for lossless target",
|
||||
"targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name)
|
||||
return false
|
||||
}
|
||||
if result == adjustCannotFit {
|
||||
log.Trace(ctx, "Skipping transcoding profile: codec limitation cannot be satisfied",
|
||||
"targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name,
|
||||
"comparison", lim.Comparison, "values", lim.Values)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ensureProbed runs ffprobe if probe data is missing, persists it, and returns
|
||||
// the parsed result. Returns (nil, nil) when probing is skipped or data already exists
|
||||
// (in which case the caller should parse mf.ProbeData).
|
||||
func (s *deciderService) ensureProbed(ctx context.Context, mf *model.MediaFile) (*ffmpeg.AudioProbeResult, error) {
|
||||
if mf.ProbeData != "" {
|
||||
return nil, nil
|
||||
}
|
||||
if !conf.Server.DevEnableMediaFileProbe {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result, err := s.ff.ProbeAudioStream(ctx, mf.AbsolutePath())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("probing media file %s: %w", mf.ID, err)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshaling probe result for %s: %w", mf.ID, err)
|
||||
}
|
||||
mf.ProbeData = string(data)
|
||||
|
||||
if err := s.ds.MediaFile(ctx).UpdateProbeData(mf.ID, mf.ProbeData); err != nil {
|
||||
log.Error(ctx, "Failed to persist probe data", "mediaID", mf.ID, err)
|
||||
// Don't fail the decision — we have the data in memory
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Probed media file", "mediaID", mf.ID, "codec", result.Codec,
|
||||
"profile", result.Profile, "bitRate", result.BitRate,
|
||||
"sampleRate", result.SampleRate, "bitDepth", result.BitDepth, "channels", result.Channels)
|
||||
return result, nil
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,101 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// buildLegacyClientInfo translates legacy Subsonic stream/download parameters
|
||||
// into a ClientInfo for use with MakeDecision.
|
||||
// It does NOT read request.TranscodingFrom(ctx) — that is handled by
|
||||
// MakeDecision's applyServerOverride.
|
||||
func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *ClientInfo {
|
||||
ci := &ClientInfo{Name: "legacy"}
|
||||
|
||||
// Determine target format for transcoding
|
||||
var targetFormat string
|
||||
switch {
|
||||
case reqFormat != "":
|
||||
targetFormat = reqFormat
|
||||
case reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "":
|
||||
targetFormat = conf.Server.DefaultDownsamplingFormat
|
||||
}
|
||||
|
||||
if targetFormat != "" {
|
||||
// Add a direct play profile for the source format when no explicit
|
||||
// format was requested (bitrate-only downsampling) or when the
|
||||
// requested format matches the source. When the client explicitly
|
||||
// requests a different format, direct play must not match the
|
||||
// source — otherwise the source is returned untranscoded.
|
||||
if reqFormat == "" || strings.EqualFold(reqFormat, mf.Suffix) {
|
||||
ci.DirectPlayProfiles = []DirectPlayProfile{
|
||||
{Containers: []string{mf.Suffix}, AudioCodecs: []string{mf.AudioCodec()}, Protocols: []string{ProtocolHTTP}},
|
||||
}
|
||||
}
|
||||
ci.TranscodingProfiles = []Profile{
|
||||
{Container: targetFormat, AudioCodec: targetFormat, Protocol: ProtocolHTTP},
|
||||
}
|
||||
if reqBitRate > 0 {
|
||||
ci.MaxAudioBitrate = reqBitRate
|
||||
ci.MaxTranscodingAudioBitrate = reqBitRate
|
||||
}
|
||||
} else {
|
||||
// No transcoding requested — direct play everything
|
||||
ci.DirectPlayProfiles = []DirectPlayProfile{
|
||||
{Protocols: []string{ProtocolHTTP}},
|
||||
}
|
||||
}
|
||||
|
||||
return ci
|
||||
}
|
||||
|
||||
// ResolveRequest uses MakeDecision to resolve legacy Subsonic stream parameters
|
||||
// into a fully specified Request.
|
||||
func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) Request {
|
||||
var req Request
|
||||
req.Offset = offset
|
||||
|
||||
if reqFormat == "raw" {
|
||||
req.Format = "raw"
|
||||
return req
|
||||
}
|
||||
|
||||
clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate)
|
||||
decision, err := s.MakeDecision(ctx, mf, clientInfo, TranscodeOptions{SkipProbe: true})
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err)
|
||||
req.Format = "raw"
|
||||
return req
|
||||
}
|
||||
|
||||
if decision.CanDirectPlay {
|
||||
req.Format = "raw"
|
||||
return req
|
||||
}
|
||||
|
||||
if decision.CanTranscode {
|
||||
req.Format = decision.TargetFormat
|
||||
req.BitRate = decision.TargetBitrate
|
||||
req.SampleRate = decision.TargetSampleRate
|
||||
req.BitDepth = decision.TargetBitDepth
|
||||
req.Channels = decision.TargetChannels
|
||||
return req
|
||||
}
|
||||
|
||||
// No compatible profile for the requested format — retry with DefaultDownsamplingFormat
|
||||
// TODO: validate DefaultDownsamplingFormat at startup to warn about unsupported values
|
||||
fallbackFormat := conf.Server.DefaultDownsamplingFormat
|
||||
if reqFormat != "" && fallbackFormat != "" && !strings.EqualFold(reqFormat, fallbackFormat) {
|
||||
log.Warn(ctx, "Requested format not available, falling back to default downsampling format",
|
||||
"requestedFormat", reqFormat, "fallbackFormat", fallbackFormat, "id", mf.ID)
|
||||
return s.ResolveRequest(ctx, mf, fallbackFormat, reqBitRate, offset)
|
||||
}
|
||||
|
||||
// Ultimate fallback — raw
|
||||
req.Format = "raw"
|
||||
return req
|
||||
}
|
||||
Loaded 100 of 490 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user