mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Merge branch 'master' into feat/theme-catppuccin-latte
This commit is contained in:
commit
802fe38641
@ -13,17 +13,5 @@ RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/shar
|
||||
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.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 \
|
||||
&& tar -xzf /tmp/cross-taglib.tar.gz -C /usr --strip-components=1 \
|
||||
&& mv /usr/include/taglib/* /usr/include/ \
|
||||
&& rmdir /usr/include/taglib \
|
||||
&& rm /tmp/cross-taglib.tar.gz /usr/provenance.json
|
||||
|
||||
ENV CGO_CFLAGS_ALLOW="--define-prefix"
|
||||
|
||||
# [Optional] Uncomment this line to install global node packages.
|
||||
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
|
||||
|
||||
@ -4,11 +4,10 @@
|
||||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
|
||||
"VARIANT": "1.25",
|
||||
"VARIANT": "1.26",
|
||||
// Options
|
||||
"INSTALL_NODE": "true",
|
||||
"NODE_VERSION": "v24",
|
||||
"CROSS_TAGLIB_VERSION": "2.2.0-1"
|
||||
"NODE_VERSION": "v24"
|
||||
}
|
||||
},
|
||||
"workspaceMount": "",
|
||||
|
||||
23
.github/actions/download-taglib/action.yml
vendored
23
.github/actions/download-taglib/action.yml
vendored
@ -1,23 +0,0 @@
|
||||
name: 'Download TagLib'
|
||||
description: 'Downloads and extracts the TagLib library, adding it to PKG_CONFIG_PATH'
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version of TagLib to download'
|
||||
required: true
|
||||
platform:
|
||||
description: 'Platform to download TagLib for'
|
||||
default: 'linux-amd64'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Download TagLib
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p /tmp/taglib
|
||||
cd /tmp
|
||||
FILE=taglib-${{ inputs.platform }}.tar.gz
|
||||
wget https://github.com/navidrome/cross-taglib/releases/download/v${{ inputs.version }}/${FILE}
|
||||
tar -xzf ${FILE} -C taglib
|
||||
PKG_CONFIG_PREFIX=/tmp/taglib
|
||||
echo "PKG_CONFIG_PREFIX=${PKG_CONFIG_PREFIX}" >> $GITHUB_ENV
|
||||
echo "PKG_CONFIG_PATH=${PKG_CONFIG_PATH}:${PKG_CONFIG_PREFIX}/lib/pkgconfig" >> $GITHUB_ENV
|
||||
9
.github/actions/prepare-docker/action.yml
vendored
9
.github/actions/prepare-docker/action.yml
vendored
@ -53,13 +53,13 @@ runs:
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: inputs.hub_username != '' && inputs.hub_password != ''
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ inputs.hub_username }}
|
||||
password: ${{ inputs.hub_password }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@ -67,12 +67,13 @@ runs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Extract metadata for Docker image
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
github-token: ${{ inputs.github_token }}
|
||||
labels: |
|
||||
maintainer=deluan@navidrome.org
|
||||
images: |
|
||||
|
||||
13
.github/workflows/download-link-on-pr.yml
vendored
13
.github/workflows/download-link-on-pr.yml
vendored
@ -8,7 +8,7 @@ jobs:
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v3
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
# This snippet is public-domain, taken from
|
||||
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
|
||||
@ -19,8 +19,7 @@ jobs:
|
||||
const pull_user_id = ${{github.event.sender.id}};
|
||||
|
||||
const issue_number = await (async () => {
|
||||
const pulls = await github.pulls.list({owner, repo});
|
||||
for await (const {data} of github.paginate.iterator(pulls)) {
|
||||
for await (const {data} of github.paginate.iterator(github.rest.pulls.list, {owner, repo})) {
|
||||
for (const pull of data) {
|
||||
if (pull.head.sha === pull_head_sha && pull.user.id === pull_user_id) {
|
||||
return pull.number;
|
||||
@ -34,7 +33,7 @@ jobs:
|
||||
return core.error(`No matching pull request found`);
|
||||
}
|
||||
|
||||
const {data: {artifacts}} = await github.actions.listWorkflowRunArtifacts({owner, repo, run_id});
|
||||
const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id});
|
||||
if (!artifacts.length) {
|
||||
return core.error(`No artifacts found`);
|
||||
}
|
||||
@ -43,12 +42,12 @@ jobs:
|
||||
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
|
||||
}
|
||||
|
||||
const {data: comments} = await github.issues.listComments({repo, owner, issue_number});
|
||||
const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number});
|
||||
const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]');
|
||||
if (existing_comment) {
|
||||
core.info(`Updating comment ${existing_comment.id}`);
|
||||
await github.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
|
||||
await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
|
||||
} else {
|
||||
core.info(`Creating a comment`);
|
||||
await github.issues.createComment({repo, owner, issue_number, body});
|
||||
await github.rest.issues.createComment({repo, owner, issue_number, body});
|
||||
}
|
||||
|
||||
93
.github/workflows/pipeline.yml
vendored
93
.github/workflows/pipeline.yml
vendored
@ -14,8 +14,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CROSS_TAGLIB_VERSION: "2.2.0-1"
|
||||
CGO_CFLAGS_ALLOW: "--define-prefix"
|
||||
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
|
||||
|
||||
jobs:
|
||||
@ -66,10 +64,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download TagLib
|
||||
uses: ./.github/actions/download-taglib
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
version: ${{ env.CROSS_TAGLIB_VERSION }}
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
@ -106,18 +103,15 @@ jobs:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download TagLib
|
||||
uses: ./.github/actions/download-taglib
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
version: ${{ env.CROSS_TAGLIB_VERSION }}
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
pkg-config --define-prefix --cflags --libs taglib # for debugging
|
||||
go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v
|
||||
run: go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v
|
||||
|
||||
- name: Test ndpgen
|
||||
run: |
|
||||
@ -126,6 +120,79 @@ jobs:
|
||||
go build -o ndpgen .
|
||||
./ndpgen --help
|
||||
|
||||
go-windows:
|
||||
name: Test Go code (Windows)
|
||||
runs-on: windows-2022
|
||||
env:
|
||||
FFMPEG_VERSION: "7.1"
|
||||
FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: MINGW64
|
||||
install: mingw-w64-x86_64-gcc
|
||||
update: false
|
||||
|
||||
- name: Add mingw64 to PATH
|
||||
shell: bash
|
||||
run: echo "C:/msys64/mingw64/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Cache ffmpeg
|
||||
id: ffmpeg-cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: C:\ffmpeg
|
||||
key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64
|
||||
|
||||
- name: Download ffmpeg
|
||||
if: steps.ffmpeg-cache.outputs.cache-hit != 'true'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$asset = "ffmpeg-n${env:FFMPEG_VERSION}-latest-win64-gpl-${env:FFMPEG_VERSION}"
|
||||
$url = "https://github.com/${env:FFMPEG_REPOSITORY}/releases/download/latest/$asset.zip"
|
||||
Invoke-WebRequest -Uri $url -OutFile ffmpeg.zip
|
||||
Expand-Archive ffmpeg.zip -DestinationPath C:\ffmpeg-extracted
|
||||
New-Item -ItemType Directory -Force -Path C:\ffmpeg\bin | Out-Null
|
||||
Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffmpeg.exe" C:\ffmpeg\bin
|
||||
Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffprobe.exe" C:\ffmpeg\bin
|
||||
|
||||
- name: Add ffmpeg to PATH
|
||||
shell: bash
|
||||
run: echo "C:/ffmpeg/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Verify toolchain
|
||||
shell: pwsh
|
||||
run: |
|
||||
go version
|
||||
where.exe gcc
|
||||
gcc --version
|
||||
ffmpeg -version
|
||||
ffprobe -version
|
||||
|
||||
- name: Download dependencies
|
||||
shell: bash
|
||||
run: go mod download
|
||||
|
||||
- name: Test
|
||||
shell: bash
|
||||
env:
|
||||
CGO_ENABLED: "1"
|
||||
run: go test -shuffle=on -tags netgo,sqlite_fts5 ./... -v
|
||||
|
||||
- name: Test ndpgen
|
||||
shell: pwsh
|
||||
run: |
|
||||
cd plugins\cmd\ndpgen
|
||||
go test -shuffle=on -v
|
||||
go build -o ndpgen.exe .
|
||||
.\ndpgen.exe --help
|
||||
|
||||
js:
|
||||
name: Test JS code
|
||||
runs-on: ubuntu-latest
|
||||
@ -190,7 +257,7 @@ jobs:
|
||||
|
||||
build:
|
||||
name: Build
|
||||
needs: [js, go, go-lint, i18n-lint, git-version, check-push-enabled]
|
||||
needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled]
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
|
||||
@ -232,7 +299,6 @@ jobs:
|
||||
build-args: |
|
||||
GIT_SHA=${{ env.GIT_SHA }}
|
||||
GIT_TAG=${{ env.GIT_TAG }}
|
||||
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
|
||||
|
||||
- name: Upload Binaries
|
||||
uses: actions/upload-artifact@v7
|
||||
@ -253,7 +319,6 @@ jobs:
|
||||
build-args: |
|
||||
GIT_SHA=${{ env.GIT_SHA }}
|
||||
GIT_TAG=${{ env.GIT_TAG }}
|
||||
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
|
||||
outputs: |
|
||||
type=image,name=${{ steps.docker.outputs.hub_repository }},push-by-digest=true,name-canonical=true,push=${{ steps.docker.outputs.hub_enabled }}
|
||||
type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@ -28,7 +28,7 @@ jobs:
|
||||
This pull request has been automatically locked since there
|
||||
has not been any recent activity after it was closed.
|
||||
Please open a new issue for related bugs.
|
||||
- uses: actions/stale@v9
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
operations-per-run: 999
|
||||
days-before-issue-stale: 180
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -37,5 +37,6 @@ AGENTS.md
|
||||
*.wasm
|
||||
*.ndp
|
||||
openspec/
|
||||
.agents
|
||||
go.work*
|
||||
.worktrees/
|
||||
.worktrees/
|
||||
|
||||
@ -55,6 +55,7 @@ linters:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
- node_modules
|
||||
formatters:
|
||||
exclusions:
|
||||
generated: lax
|
||||
@ -62,3 +63,4 @@ formatters:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
- node_modules
|
||||
|
||||
83
Dockerfile
83
Dockerfile
@ -24,26 +24,6 @@ RUN cd /out && \
|
||||
FROM scratch AS xx
|
||||
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.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
|
||||
RUN <<EOT
|
||||
apk add --no-cache wget
|
||||
PLATFORM=$(echo ${TARGETPLATFORM} | tr '/' '-')
|
||||
FILE=taglib-${PLATFORM}.tar.gz
|
||||
|
||||
DOWNLOAD_URL=${CROSS_TAGLIB_RELEASES_URL}${FILE}
|
||||
wget ${DOWNLOAD_URL}
|
||||
|
||||
mkdir /taglib
|
||||
tar -xzf ${FILE} -C /taglib
|
||||
EOT
|
||||
|
||||
########################################################################################################################
|
||||
### Build Navidrome UI
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/node:lts-alpine AS ui
|
||||
@ -62,8 +42,47 @@ FROM scratch AS ui-bundle
|
||||
COPY --from=ui /build /build
|
||||
|
||||
########################################################################################################################
|
||||
### Build Navidrome binary
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.25-trixie AS base
|
||||
### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen)
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-alpine AS build-alpine
|
||||
COPY --from=xx / /
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
RUN apk add --no-cache clang lld file git
|
||||
RUN xx-apk add --no-cache gcc musl-dev zlib-dev
|
||||
RUN xx-verify --setup
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
RUN --mount=type=bind,source=. \
|
||||
--mount=type=cache,target=/root/.cache \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
ARG GIT_SHA
|
||||
ARG GIT_TAG
|
||||
|
||||
RUN --mount=type=bind,source=. \
|
||||
--mount=from=ui,source=/build,target=./ui/build,ro \
|
||||
--mount=type=cache,target=/root/.cache \
|
||||
--mount=type=cache,target=/go/pkg/mod <<EOT
|
||||
set -e
|
||||
xx-go --wrap
|
||||
export CGO_ENABLED=1
|
||||
# -latomic is required on 32-bit arm (arm/v6, arm/v7) so SQLite's 64-bit atomics resolve.
|
||||
go build -tags=netgo,sqlite_fts5 -ldflags="-w -s \
|
||||
-linkmode=external -extldflags '-latomic' \
|
||||
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
|
||||
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
|
||||
-o /out/navidrome .
|
||||
# Fail the build if the binary is accidentally statically linked: dlopen (and
|
||||
# therefore native libwebp detection) only works with a dynamic interpreter.
|
||||
file /out/navidrome | grep -q "dynamically linked" || { echo "ERROR: /out/navidrome is not dynamically linked"; file /out/navidrome; exit 1; }
|
||||
EOT
|
||||
|
||||
########################################################################################################################
|
||||
### Build Navidrome binary for standalone distribution (static glibc, cross-compiled)
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-trixie AS base
|
||||
RUN apt-get update && apt-get install -y clang lld
|
||||
COPY --from=xx / /
|
||||
WORKDIR /workspace
|
||||
@ -88,14 +107,11 @@ RUN --mount=type=bind,source=. \
|
||||
--mount=from=ui,source=/build,target=./ui/build,ro \
|
||||
--mount=from=osxcross,src=/osxcross/SDK,target=/xx-sdk,ro \
|
||||
--mount=type=cache,target=/root/.cache \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=from=taglib-build,target=/taglib,src=/taglib,ro <<EOT
|
||||
--mount=type=cache,target=/go/pkg/mod <<EOT
|
||||
|
||||
# Setup CGO cross-compilation environment
|
||||
xx-go --wrap
|
||||
export CGO_ENABLED=1
|
||||
export CGO_CFLAGS_ALLOW="--define-prefix"
|
||||
export PKG_CONFIG_PATH=/taglib/lib/pkgconfig
|
||||
cat $(go env GOENV)
|
||||
|
||||
# Only Darwin (macOS) requires clang (default), Windows requires gcc, everything else can use any compiler.
|
||||
@ -127,21 +143,28 @@ FROM public.ecr.aws/docker/library/alpine:3.20 AS final
|
||||
LABEL maintainer="deluan@navidrome.org"
|
||||
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
|
||||
|
||||
# Install ffmpeg and mpv
|
||||
RUN apk add -U --no-cache ffmpeg mpv sqlite
|
||||
# Install runtime dependencies
|
||||
# - libwebp + symlinks: enables native WebP encoding via purego/dlopen
|
||||
RUN apk add -U --no-cache ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \
|
||||
for lib in libwebp libwebpdemux libwebpmux; do \
|
||||
target=$(ls /usr/lib/$lib.so.* 2>/dev/null | head -1) && \
|
||||
[ -n "$target" ] && ln -sf "$target" /usr/lib/$lib.so; \
|
||||
done
|
||||
|
||||
# Copy navidrome binary
|
||||
COPY --from=build /out/navidrome /app/
|
||||
# Copy navidrome binary (musl build for Docker, enables native libwebp)
|
||||
COPY --from=build-alpine /out/navidrome /app/
|
||||
|
||||
VOLUME ["/data", "/music"]
|
||||
ENV ND_MUSICFOLDER=/music
|
||||
ENV ND_DATAFOLDER=/data
|
||||
ENV ND_CONFIGFILE=/data/navidrome.toml
|
||||
ENV ND_PORT=4533
|
||||
ENV ND_ENABLEWEBPENCODING=true
|
||||
RUN touch /.nddockerenv
|
||||
|
||||
EXPOSE ${ND_PORT}
|
||||
WORKDIR /app
|
||||
ENV PATH="/app:${PATH}"
|
||||
|
||||
ENTRYPOINT ["/app/navidrome"]
|
||||
|
||||
|
||||
17
Makefile
17
Makefile
@ -1,9 +1,10 @@
|
||||
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
||||
NODE_VERSION=$(shell cat .nvmrc)
|
||||
GO_BUILD_TAGS=netgo,sqlite_fts5
|
||||
|
||||
comma:=,
|
||||
GO_BUILD_TAGS=netgo,sqlite_fts5$(if $(EXTRA_BUILD_TAGS),$(comma)$(EXTRA_BUILD_TAGS))
|
||||
|
||||
# Set global environment variables, required for most targets
|
||||
export CGO_CFLAGS_ALLOW=--define-prefix
|
||||
export ND_ENABLEINSIGHTSCOLLECTOR=false
|
||||
|
||||
ifneq ("$(wildcard .git/HEAD)","")
|
||||
@ -19,9 +20,7 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin
|
||||
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.2.1-1
|
||||
GOLANGCI_LINT_VERSION ?= v2.11.1
|
||||
GOLANGCI_LINT_VERSION ?= v2.12.0
|
||||
|
||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||
|
||||
@ -76,8 +75,8 @@ test-i18n: ##@Development Validate all translations files
|
||||
|
||||
install-golangci-lint: ##@Development Install golangci-lint if not present
|
||||
@INSTALL=false; \
|
||||
if PATH=$$PATH:./bin which golangci-lint > /dev/null 2>&1; then \
|
||||
CURRENT_VERSION=$$(PATH=$$PATH:./bin golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \
|
||||
if PATH=./bin:$$PATH which golangci-lint > /dev/null 2>&1; then \
|
||||
CURRENT_VERSION=$$(PATH=./bin:$$PATH golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \
|
||||
REQUIRED_VERSION=$$(echo "$(GOLANGCI_LINT_VERSION)" | sed 's/^v//'); \
|
||||
if [ "$$CURRENT_VERSION" != "$$REQUIRED_VERSION" ]; then \
|
||||
echo "Found golangci-lint $$CURRENT_VERSION, but $$REQUIRED_VERSION is required. Reinstalling..."; \
|
||||
@ -94,7 +93,7 @@ install-golangci-lint: ##@Development Install golangci-lint if not present
|
||||
.PHONY: install-golangci-lint
|
||||
|
||||
lint: install-golangci-lint ##@Development Lint Go code
|
||||
PATH=$$PATH:./bin golangci-lint run --timeout 5m
|
||||
PATH=./bin:$$PATH golangci-lint run --timeout 5m
|
||||
.PHONY: lint
|
||||
|
||||
lintall: lint ##@Development Lint Go and JS code
|
||||
@ -177,7 +176,6 @@ docker-build: ##@Cross_Compilation Cross-compile for any supported platform (che
|
||||
--platform $(PLATFORMS) \
|
||||
--build-arg GIT_TAG=${GIT_TAG} \
|
||||
--build-arg GIT_SHA=${GIT_SHA} \
|
||||
--build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \
|
||||
--output "./binaries" --target binary .
|
||||
.PHONY: docker-build
|
||||
|
||||
@ -189,7 +187,6 @@ docker-image: ##@Cross_Compilation Build Docker image, tagged as `deluan/navidro
|
||||
--platform $(IMAGE_PLATFORMS) \
|
||||
--build-arg GIT_TAG=${GIT_TAG} \
|
||||
--build-arg GIT_SHA=${GIT_SHA} \
|
||||
--build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \
|
||||
--tag $(DOCKER_TAG) .
|
||||
.PHONY: docker-image
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
package deezer
|
||||
|
||||
import (
|
||||
bytes "bytes"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
@ -8,7 +8,6 @@ import (
|
||||
"github.com/djherbis/times"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -91,8 +90,7 @@ var _ = Describe("Extractor", func() {
|
||||
info.FileInfo = testFileInfo{FileInfo: fileInfo}
|
||||
|
||||
metadata := metadata.New(path, info)
|
||||
mf := metadata.ToMediaFile(1, "folderID")
|
||||
return &mf
|
||||
return new(metadata.ToMediaFile(1, "folderID"))
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
@ -109,7 +107,7 @@ var _ = Describe("Extractor", func() {
|
||||
Expect(mf.RGAlbumPeak).To(Equal(albumPeak))
|
||||
},
|
||||
Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil),
|
||||
Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)),
|
||||
Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", new(0.0), new(1.0), new(0.0), new(1.0)),
|
||||
)
|
||||
})
|
||||
|
||||
@ -120,8 +118,8 @@ var _ = Describe("Extractor", func() {
|
||||
DisplayTitle: "",
|
||||
Lang: code,
|
||||
Line: []model.Line{
|
||||
{Start: gg.P(int64(0)), Value: "This is"},
|
||||
{Start: gg.P(int64(2500)), Value: secondLine},
|
||||
{Start: new(int64(0)), Value: "This is"},
|
||||
{Start: new(int64(2500)), Value: secondLine},
|
||||
},
|
||||
Offset: nil,
|
||||
Synced: true,
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@ -127,6 +128,17 @@ var _ = Describe("Extractor", func() {
|
||||
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"}))
|
||||
// Still as of TagLib v2.2.1, TagLib only maps values in ID3, MP4, and ASF tags
|
||||
// to `originaldate`.
|
||||
if strings.HasSuffix(file, ".mp3") || strings.HasSuffix(file, ".wav") || strings.HasSuffix(file, ".aiff") || strings.HasSuffix(file, ".m4a") || strings.HasSuffix(file, ".wma") {
|
||||
Expect(m.Tags).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"}))
|
||||
}
|
||||
// MP3Tag sets `ORIGYEAR` in several formats for which it has no built-in mapping
|
||||
// for original release dates.
|
||||
Expect(m.Tags).To(Or(
|
||||
HaveKeyWithValue("origyear", []string{"1998-07-28"}),
|
||||
HaveKeyWithValue("----:com.apple.itunes:origyear", []string{"1998-07-28"}),
|
||||
))
|
||||
|
||||
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
|
||||
Expect(m.Tags).To(Or(
|
||||
@ -202,6 +214,7 @@ var _ = Describe("Extractor", func() {
|
||||
// Only run permission tests if we are not root
|
||||
RegularUserContext("when run without root privileges", func() {
|
||||
BeforeEach(func() {
|
||||
tests.SkipOnWindows("uses Unix file permission bits")
|
||||
// Use root fs for absolute paths in temp directory
|
||||
e = &extractor{fs: os.DirFS("/")}
|
||||
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
|
||||
|
||||
@ -416,6 +416,10 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
return err == nil && sk != ""
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
|
||||
|
||||
@ -77,6 +77,13 @@ func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
resp["status"] = key != ""
|
||||
linkToken, err := createLinkToken(u.ID)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Could not create LastFM link token", "userId", u.ID, err)
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
resp["linkToken"] = linkToken
|
||||
_ = rest.RespondWithJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
@ -97,11 +104,17 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "token not received")
|
||||
return
|
||||
}
|
||||
uid, err := p.String("uid")
|
||||
linkToken, err := p.String("uid")
|
||||
if err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received")
|
||||
return
|
||||
}
|
||||
uid, err := verifyLinkToken(linkToken)
|
||||
if err != nil {
|
||||
log.Warn(r.Context(), "Rejected LastFM callback with invalid link token", "requestId", middleware.GetReqID(r.Context()), err)
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "invalid link token")
|
||||
return
|
||||
}
|
||||
|
||||
// Need to add user to context, as this is a non-authenticated endpoint, so it does not
|
||||
// automatically contain any user info
|
||||
|
||||
218
adapters/lastfm/auth_router_test.go
Normal file
218
adapters/lastfm/auth_router_test.go
Normal file
@ -0,0 +1,218 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"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("auth_router", func() {
|
||||
var (
|
||||
ds *tests.MockDataStore
|
||||
userProps *tests.MockedUserPropsRepo
|
||||
httpClient *tests.FakeHttpClient
|
||||
router *Router
|
||||
)
|
||||
|
||||
const (
|
||||
victimID = "victim-user-id"
|
||||
attackerID = "attacker-user-id"
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
userProps = &tests.MockedUserPropsRepo{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedProperty: &tests.MockedPropertyRepo{},
|
||||
MockedUserProps: userProps,
|
||||
}
|
||||
auth.Init(ds)
|
||||
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
router = &Router{
|
||||
ds: ds,
|
||||
apiKey: "API_KEY",
|
||||
secret: "SECRET",
|
||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
}
|
||||
router.client = newClient(router.apiKey, router.secret, httpClient)
|
||||
router.Handler = router.routes()
|
||||
})
|
||||
|
||||
storedSessionKey := func(userID string) string {
|
||||
key, _ := userProps.Get(userID, sessionKeyProperty)
|
||||
return key
|
||||
}
|
||||
|
||||
stubGetSessionOK := func(sessionKey string) {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"` + sessionKey + `","subscriber":0}}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
}
|
||||
|
||||
Describe("getLinkStatus", func() {
|
||||
It("includes a signed linkToken for the authenticated user", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "/link", nil)
|
||||
ctx := request.WithUser(req.Context(), model.User{ID: victimID})
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.getLinkStatus(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
var body map[string]any
|
||||
Expect(json.Unmarshal(rec.Body.Bytes(), &body)).To(Succeed())
|
||||
Expect(body["apiKey"]).To(Equal("API_KEY"))
|
||||
Expect(body["status"]).To(Equal(false))
|
||||
token, ok := body["linkToken"].(string)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(token).ToNot(BeEmpty())
|
||||
|
||||
verified, err := verifyLinkToken(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(verified).To(Equal(victimID))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("callback", func() {
|
||||
It("stores the session key under the user encoded in the signed token", func() {
|
||||
stubGetSessionOK("LEGIT_SESSION")
|
||||
linkToken, err := createLinkToken(victimID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken+"&token=LASTFM_TOKEN", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
Expect(storedSessionKey(victimID)).To(Equal("LEGIT_SESSION"))
|
||||
})
|
||||
|
||||
It("rejects a raw (unsigned) uid value", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+victimID+"&token=LASTFM_TOKEN", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(storedSessionKey(victimID)).To(BeEmpty())
|
||||
Expect(httpClient.SavedRequest).To(BeNil())
|
||||
})
|
||||
|
||||
It("rejects an expired link token", func() {
|
||||
expiredToken, err := auth.EncodeToken(map[string]any{
|
||||
"uid": victimID,
|
||||
"scope": linkTokenScope,
|
||||
"exp": time.Now().Add(-1 * time.Minute).UTC().Unix(),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+expiredToken+"&token=LASTFM_TOKEN", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(storedSessionKey(victimID)).To(BeEmpty())
|
||||
Expect(httpClient.SavedRequest).To(BeNil())
|
||||
})
|
||||
|
||||
It("rejects a token with the wrong scope (e.g. a regular session JWT)", func() {
|
||||
sessionJWT, err := auth.CreateToken(&model.User{ID: attackerID, UserName: "attacker"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+sessionJWT+"&token=LASTFM_TOKEN", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(storedSessionKey(attackerID)).To(BeEmpty())
|
||||
Expect(httpClient.SavedRequest).To(BeNil())
|
||||
})
|
||||
|
||||
It("writes only under the user encoded in the token, regardless of query manipulation", func() {
|
||||
// An attacker holds a legitimate link token for their own account.
|
||||
// They attempt to call the callback hoping to overwrite the victim's
|
||||
// session key — but the handler must derive the user ID from the
|
||||
// signed token, not from any other input.
|
||||
stubGetSessionOK("ATTACKER_SESSION")
|
||||
attackerToken, err := createLinkToken(attackerID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+attackerToken+"&token=LASTFM_TOKEN&user="+victimID, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
Expect(storedSessionKey(attackerID)).To(Equal("ATTACKER_SESSION"))
|
||||
Expect(storedSessionKey(victimID)).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns 400 when uid is missing", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?token=LASTFM_TOKEN", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("returns 400 when token is missing", func() {
|
||||
linkToken, err := createLinkToken(victimID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("link token helpers", func() {
|
||||
It("round-trips a freshly issued token", func() {
|
||||
token, err := createLinkToken(victimID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
uid, err := verifyLinkToken(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(uid).To(Equal(victimID))
|
||||
})
|
||||
|
||||
It("rejects garbage", func() {
|
||||
_, err := verifyLinkToken("not-a-jwt")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("rejects a token whose scope claim is wrong", func() {
|
||||
wrongScopeToken, err := auth.EncodeToken(map[string]any{
|
||||
"uid": victimID,
|
||||
"scope": "some-other-scope",
|
||||
"exp": time.Now().Add(linkTokenTTL).UTC().Unix(),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = verifyLinkToken(wrongScopeToken)
|
||||
Expect(err).To(MatchError("invalid link token scope"))
|
||||
})
|
||||
|
||||
It("rejects a scoped token that has no expiration", func() {
|
||||
nonExpiringToken, err := auth.EncodeToken(map[string]any{
|
||||
"uid": victimID,
|
||||
"scope": linkTokenScope,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = verifyLinkToken(nonExpiringToken)
|
||||
Expect(err).To(MatchError("link token missing expiration"))
|
||||
})
|
||||
})
|
||||
})
|
||||
50
adapters/lastfm/link_token.go
Normal file
50
adapters/lastfm/link_token.go
Normal file
@ -0,0 +1,50 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
linkTokenScope = "lastfm-link"
|
||||
linkTokenTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
// createLinkToken issues a signed token binding the Last.fm callback to the
|
||||
// user who initiated the OAuth flow. It travels back through Last.fm via the
|
||||
// `cb` URL in place of the previously-trusted raw `uid` query parameter.
|
||||
func createLinkToken(userID string) (string, error) {
|
||||
claims := map[string]any{
|
||||
"uid": userID,
|
||||
"scope": linkTokenScope,
|
||||
"exp": time.Now().Add(linkTokenTTL).UTC().Unix(),
|
||||
}
|
||||
return auth.EncodeToken(claims)
|
||||
}
|
||||
|
||||
// verifyLinkToken validates a signed link token and returns the encoded user ID.
|
||||
// It enforces both the signature/expiry (via the underlying JWT verifier) and a
|
||||
// dedicated scope claim, preventing tokens minted for other purposes (e.g. a
|
||||
// regular session JWT) from being accepted here.
|
||||
func verifyLinkToken(tokenStr string) (string, error) {
|
||||
token, err := auth.DecodeAndVerifyToken(tokenStr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// jwtauth treats a token without `exp` as non-expiring; require it
|
||||
// explicitly so an accidental regression cannot mint permanent tokens.
|
||||
if exp, ok := token.Expiration(); !ok || exp.IsZero() {
|
||||
return "", errors.New("link token missing expiration")
|
||||
}
|
||||
var scope string
|
||||
if err := token.Get("scope", &scope); err != nil || scope != linkTokenScope {
|
||||
return "", errors.New("invalid link token scope")
|
||||
}
|
||||
var uid string
|
||||
if err := token.Get("uid", &uid); err != nil || uid == "" {
|
||||
return "", errors.New("invalid link token user ID")
|
||||
}
|
||||
return uid, nil
|
||||
}
|
||||
@ -212,6 +212,10 @@ func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id strin
|
||||
return songs, nil
|
||||
}
|
||||
|
||||
func (l *listenBrainzAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
if conf.Server.ListenBrainz.Enabled {
|
||||
|
||||
@ -1,274 +0,0 @@
|
||||
package taglib
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/djherbis/times"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type testFileInfo struct {
|
||||
fs.FileInfo
|
||||
}
|
||||
|
||||
func (t testFileInfo) BirthTime() time.Time {
|
||||
if ts := times.Get(t.FileInfo); ts.HasBirthTime() {
|
||||
return ts.BirthTime()
|
||||
}
|
||||
return t.FileInfo.ModTime()
|
||||
}
|
||||
|
||||
var _ = Describe("Extractor", func() {
|
||||
toP := func(name, sortName, mbid string) model.Participant {
|
||||
return model.Participant{
|
||||
Artist: model.Artist{Name: name, SortArtistName: sortName, MbzArtistID: mbid},
|
||||
}
|
||||
}
|
||||
|
||||
roles := []struct {
|
||||
model.Role
|
||||
model.ParticipantList
|
||||
}{
|
||||
{model.RoleComposer, model.ParticipantList{
|
||||
toP("coma a", "a, coma", "bf13b584-f27c-43db-8f42-32898d33d4e2"),
|
||||
toP("comb", "comb", "924039a2-09c6-4d29-9b4f-50cc54447d36"),
|
||||
}},
|
||||
{model.RoleLyricist, model.ParticipantList{
|
||||
toP("la a", "a, la", "c84f648f-68a6-40a2-a0cb-d135b25da3c2"),
|
||||
toP("lb", "lb", "0a7c582d-143a-4540-b4e9-77200835af65"),
|
||||
}},
|
||||
{model.RoleArranger, model.ParticipantList{
|
||||
toP("aa", "", "4605a1d4-8d15-42a3-bd00-9c20e42f71e6"),
|
||||
toP("ab", "", "002f0ff8-77bf-42cc-8216-61a9c43dc145"),
|
||||
}},
|
||||
{model.RoleConductor, model.ParticipantList{
|
||||
toP("cona", "", "af86879b-2141-42af-bad2-389a4dc91489"),
|
||||
toP("conb", "", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"),
|
||||
}},
|
||||
{model.RoleDirector, model.ParticipantList{
|
||||
toP("dia", "", "f943187f-73de-4794-be47-88c66f0fd0f4"),
|
||||
toP("dib", "", "bceb75da-1853-4b3d-b399-b27f0cafc389"),
|
||||
}},
|
||||
{model.RoleEngineer, model.ParticipantList{
|
||||
toP("ea", "", "f634bf6d-d66a-425d-888a-28ad39392759"),
|
||||
toP("eb", "", "243d64ae-d514-44e1-901a-b918d692baee"),
|
||||
}},
|
||||
{model.RoleProducer, model.ParticipantList{
|
||||
toP("pra", "", "d971c8d7-999c-4a5f-ac31-719721ab35d6"),
|
||||
toP("prb", "", "f0a09070-9324-434f-a599-6d25ded87b69"),
|
||||
}},
|
||||
{model.RoleRemixer, model.ParticipantList{
|
||||
toP("ra", "", "c7dc6095-9534-4c72-87cc-aea0103462cf"),
|
||||
toP("rb", "", "8ebeef51-c08c-4736-992f-c37870becedd"),
|
||||
}},
|
||||
{model.RoleDJMixer, model.ParticipantList{
|
||||
toP("dja", "", "d063f13b-7589-4efc-ab7f-c60e6db17247"),
|
||||
toP("djb", "", "3636670c-385f-4212-89c8-0ff51d6bc456"),
|
||||
}},
|
||||
{model.RoleMixer, model.ParticipantList{
|
||||
toP("ma", "", "53fb5a2d-7016-427e-a563-d91819a5f35a"),
|
||||
toP("mb", "", "64c13e65-f0da-4ab9-a300-71ee53b0376a"),
|
||||
}},
|
||||
}
|
||||
|
||||
var e *extractor
|
||||
|
||||
parseTestFile := func(path string) *model.MediaFile {
|
||||
mds, err := e.Parse(path)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
info, ok := mds[path]
|
||||
Expect(ok).To(BeTrue())
|
||||
|
||||
fileInfo, err := os.Stat(path)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
info.FileInfo = testFileInfo{FileInfo: fileInfo}
|
||||
|
||||
metadata := metadata.New(path, info)
|
||||
mf := metadata.ToMediaFile(1, "folderID")
|
||||
return &mf
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
e = &extractor{}
|
||||
})
|
||||
|
||||
Describe("ReplayGain", func() {
|
||||
DescribeTable("test replaygain end-to-end", func(file string, trackGain, trackPeak, albumGain, albumPeak *float64) {
|
||||
mf := parseTestFile("tests/fixtures/" + file)
|
||||
|
||||
Expect(mf.RGTrackGain).To(Equal(trackGain))
|
||||
Expect(mf.RGTrackPeak).To(Equal(trackPeak))
|
||||
Expect(mf.RGAlbumGain).To(Equal(albumGain))
|
||||
Expect(mf.RGAlbumPeak).To(Equal(albumPeak))
|
||||
},
|
||||
Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil),
|
||||
Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("lyrics", func() {
|
||||
makeLyrics := func(code, secondLine string) model.Lyrics {
|
||||
return model.Lyrics{
|
||||
DisplayArtist: "",
|
||||
DisplayTitle: "",
|
||||
Lang: code,
|
||||
Line: []model.Line{
|
||||
{Start: gg.P(int64(0)), Value: "This is"},
|
||||
{Start: gg.P(int64(2500)), Value: secondLine},
|
||||
},
|
||||
Offset: nil,
|
||||
Synced: true,
|
||||
}
|
||||
}
|
||||
|
||||
It("should fetch both synced and unsynced lyrics in mixed flac", func() {
|
||||
mf := parseTestFile("tests/fixtures/mixed-lyrics.flac")
|
||||
|
||||
lyrics, err := mf.StructuredLyrics()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(HaveLen(2))
|
||||
|
||||
Expect(lyrics[0].Synced).To(BeTrue())
|
||||
Expect(lyrics[1].Synced).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should handle mp3 with uslt and sylt", func() {
|
||||
mf := parseTestFile("tests/fixtures/test.mp3")
|
||||
|
||||
lyrics, err := mf.StructuredLyrics()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(HaveLen(4))
|
||||
|
||||
engSylt := makeLyrics("eng", "English SYLT")
|
||||
engUslt := makeLyrics("eng", "English")
|
||||
unsSylt := makeLyrics("xxx", "unspecified SYLT")
|
||||
unsUslt := makeLyrics("xxx", "unspecified")
|
||||
|
||||
Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt))
|
||||
})
|
||||
|
||||
DescribeTable("format-specific lyrics", func(file string, isId3 bool) {
|
||||
mf := parseTestFile("tests/fixtures/" + file)
|
||||
|
||||
lyrics, err := mf.StructuredLyrics()
|
||||
Expect(err).To(Not(HaveOccurred()))
|
||||
Expect(lyrics).To(HaveLen(2))
|
||||
|
||||
unspec := makeLyrics("xxx", "unspecified")
|
||||
eng := makeLyrics("xxx", "English")
|
||||
|
||||
if isId3 {
|
||||
eng.Lang = "eng"
|
||||
}
|
||||
|
||||
Expect(lyrics).To(Or(
|
||||
Equal(model.LyricList{unspec, eng}),
|
||||
Equal(model.LyricList{eng, unspec})))
|
||||
},
|
||||
Entry("flac", "test.flac", false),
|
||||
Entry("m4a", "test.m4a", false),
|
||||
Entry("ogg", "test.ogg", false),
|
||||
Entry("wma", "test.wma", false),
|
||||
Entry("wv", "test.wv", false),
|
||||
Entry("wav", "test.wav", true),
|
||||
Entry("aiff", "test.aiff", true),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("Participants", func() {
|
||||
DescribeTable("test tags consistent across formats", func(format string) {
|
||||
mf := parseTestFile("tests/fixtures/test." + format)
|
||||
|
||||
for _, data := range roles {
|
||||
role := data.Role
|
||||
artists := data.ParticipantList
|
||||
|
||||
actual := mf.Participants[role]
|
||||
Expect(actual).To(HaveLen(len(artists)))
|
||||
|
||||
for i := range artists {
|
||||
actualArtist := actual[i]
|
||||
expectedArtist := artists[i]
|
||||
|
||||
Expect(actualArtist.Name).To(Equal(expectedArtist.Name))
|
||||
Expect(actualArtist.SortArtistName).To(Equal(expectedArtist.SortArtistName))
|
||||
Expect(actualArtist.MbzArtistID).To(Equal(expectedArtist.MbzArtistID))
|
||||
}
|
||||
}
|
||||
|
||||
if format != "m4a" {
|
||||
performers := mf.Participants[model.RolePerformer]
|
||||
Expect(performers).To(HaveLen(8))
|
||||
|
||||
rules := map[string][]string{
|
||||
"pgaa": {"2fd0b311-9fa8-4ff9-be5d-f6f3d16b835e", "Guitar"},
|
||||
"pgbb": {"223d030b-bf97-4c2a-ad26-b7f7bbe25c93", "Guitar", ""},
|
||||
"pvaa": {"cb195f72-448f-41c8-b962-3f3c13d09d38", "Vocals"},
|
||||
"pvbb": {"60a1f832-8ca2-49f6-8660-84d57f07b520", "Vocals", "Flute"},
|
||||
"pfaa": {"51fb40c-0305-4bf9-a11b-2ee615277725", "", "Flute"},
|
||||
}
|
||||
|
||||
for name, rule := range rules {
|
||||
mbid := rule[0]
|
||||
for i := 1; i < len(rule); i++ {
|
||||
found := false
|
||||
|
||||
for _, mapped := range performers {
|
||||
if mapped.Name == name && mapped.MbzArtistID == mbid && mapped.SubRole == rule[i] {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
Expect(found).To(BeTrue(), "Could not find matching artist")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Entry("FLAC format", "flac"),
|
||||
Entry("M4a format", "m4a"),
|
||||
Entry("OGG format", "ogg"),
|
||||
Entry("WV format", "wv"),
|
||||
|
||||
Entry("MP3 format", "mp3"),
|
||||
Entry("WAV format", "wav"),
|
||||
Entry("AIFF format", "aiff"),
|
||||
)
|
||||
|
||||
It("should parse wma", func() {
|
||||
mf := parseTestFile("tests/fixtures/test.wma")
|
||||
|
||||
for _, data := range roles {
|
||||
role := data.Role
|
||||
artists := data.ParticipantList
|
||||
actual := mf.Participants[role]
|
||||
|
||||
// WMA has no Arranger role
|
||||
if role == model.RoleArranger {
|
||||
Expect(actual).To(HaveLen(0))
|
||||
continue
|
||||
}
|
||||
|
||||
Expect(actual).To(HaveLen(len(artists)), role.String())
|
||||
|
||||
// For some bizarre reason, the order is inverted. We also don't get
|
||||
// sort names or MBIDs
|
||||
for i := range artists {
|
||||
idx := len(artists) - 1 - i
|
||||
|
||||
actualArtist := actual[i]
|
||||
expectedArtist := artists[idx]
|
||||
|
||||
Expect(actualArtist.Name).To(Equal(expectedArtist.Name))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,9 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package taglib
|
||||
|
||||
import "C"
|
||||
|
||||
func getFilename(s string) *C.char {
|
||||
return C.CString(s)
|
||||
}
|
||||
@ -1,96 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package taglib
|
||||
|
||||
// From https://github.com/orofarne/gowchar
|
||||
|
||||
/*
|
||||
#include <wchar.h>
|
||||
|
||||
const size_t SIZEOF_WCHAR_T = sizeof(wchar_t);
|
||||
|
||||
void gowchar_set (wchar_t *arr, int pos, wchar_t val)
|
||||
{
|
||||
arr[pos] = val;
|
||||
}
|
||||
|
||||
wchar_t gowchar_get (wchar_t *arr, int pos)
|
||||
{
|
||||
return arr[pos];
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"unicode/utf16"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var SIZEOF_WCHAR_T C.size_t = C.size_t(C.SIZEOF_WCHAR_T)
|
||||
|
||||
func getFilename(s string) *C.wchar_t {
|
||||
wstr, _ := StringToWcharT(s)
|
||||
return wstr
|
||||
}
|
||||
|
||||
func StringToWcharT(s string) (*C.wchar_t, C.size_t) {
|
||||
switch SIZEOF_WCHAR_T {
|
||||
case 2:
|
||||
return stringToWchar2(s) // Windows
|
||||
case 4:
|
||||
return stringToWchar4(s) // Unix
|
||||
default:
|
||||
panic(fmt.Sprintf("Invalid sizeof(wchar_t) = %v", SIZEOF_WCHAR_T))
|
||||
}
|
||||
panic("?!!")
|
||||
}
|
||||
|
||||
// Windows
|
||||
func stringToWchar2(s string) (*C.wchar_t, C.size_t) {
|
||||
var slen int
|
||||
s1 := s
|
||||
for len(s1) > 0 {
|
||||
r, size := utf8.DecodeRuneInString(s1)
|
||||
if er, _ := utf16.EncodeRune(r); er == '\uFFFD' {
|
||||
slen += 1
|
||||
} else {
|
||||
slen += 2
|
||||
}
|
||||
s1 = s1[size:]
|
||||
}
|
||||
slen++ // \0
|
||||
res := C.malloc(C.size_t(slen) * SIZEOF_WCHAR_T)
|
||||
var i int
|
||||
for len(s) > 0 {
|
||||
r, size := utf8.DecodeRuneInString(s)
|
||||
if r1, r2 := utf16.EncodeRune(r); r1 != '\uFFFD' {
|
||||
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r1))
|
||||
i++
|
||||
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r2))
|
||||
i++
|
||||
} else {
|
||||
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r))
|
||||
i++
|
||||
}
|
||||
s = s[size:]
|
||||
}
|
||||
C.gowchar_set((*C.wchar_t)(res), C.int(slen-1), C.wchar_t(0)) // \0
|
||||
return (*C.wchar_t)(res), C.size_t(slen)
|
||||
}
|
||||
|
||||
// Unix
|
||||
func stringToWchar4(s string) (*C.wchar_t, C.size_t) {
|
||||
slen := utf8.RuneCountInString(s)
|
||||
slen++ // \0
|
||||
res := C.malloc(C.size_t(slen) * SIZEOF_WCHAR_T)
|
||||
var i int
|
||||
for len(s) > 0 {
|
||||
r, size := utf8.DecodeRuneInString(s)
|
||||
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r))
|
||||
s = s[size:]
|
||||
i++
|
||||
}
|
||||
C.gowchar_set((*C.wchar_t)(res), C.int(slen-1), C.wchar_t(0)) // \0
|
||||
return (*C.wchar_t)(res), C.size_t(slen)
|
||||
}
|
||||
@ -1,178 +0,0 @@
|
||||
package taglib
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"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"
|
||||
)
|
||||
|
||||
type extractor struct {
|
||||
baseDir string
|
||||
}
|
||||
|
||||
func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
|
||||
results := make(map[string]metadata.Info)
|
||||
for _, path := range files {
|
||||
props, err := e.extractMetadata(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
results[path] = *props
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (e extractor) Version() string {
|
||||
return Version()
|
||||
}
|
||||
|
||||
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
||||
fullPath := filepath.Join(e.baseDir, filePath)
|
||||
tags, err := Read(fullPath)
|
||||
if err != nil {
|
||||
log.Warn("extractor: Error reading metadata from file. Skipping", "filePath", fullPath, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse audio properties
|
||||
ap := metadata.AudioProperties{}
|
||||
ap.BitRate = parseProp(tags, "__bitrate")
|
||||
ap.Channels = parseProp(tags, "__channels")
|
||||
ap.SampleRate = parseProp(tags, "__samplerate")
|
||||
ap.BitDepth = parseProp(tags, "__bitspersample")
|
||||
length := parseProp(tags, "__lengthinmilliseconds")
|
||||
ap.Duration = (time.Millisecond * time.Duration(length)).Round(time.Millisecond * 10)
|
||||
|
||||
// Extract basic tags
|
||||
parseBasicTag(tags, "__title", "title")
|
||||
parseBasicTag(tags, "__artist", "artist")
|
||||
parseBasicTag(tags, "__album", "album")
|
||||
parseBasicTag(tags, "__comment", "comment")
|
||||
parseBasicTag(tags, "__genre", "genre")
|
||||
parseBasicTag(tags, "__year", "year")
|
||||
parseBasicTag(tags, "__track", "tracknumber")
|
||||
|
||||
// Parse track/disc totals
|
||||
parseTuple := func(prop string) {
|
||||
tagName := prop + "number"
|
||||
tagTotal := prop + "total"
|
||||
if value, ok := tags[tagName]; ok && len(value) > 0 {
|
||||
parts := strings.Split(value[0], "/")
|
||||
tags[tagName] = []string{parts[0]}
|
||||
if len(parts) == 2 {
|
||||
tags[tagTotal] = []string{parts[1]}
|
||||
}
|
||||
}
|
||||
}
|
||||
parseTuple("track")
|
||||
parseTuple("disc")
|
||||
|
||||
// Adjust some ID3 tags
|
||||
parseLyrics(tags)
|
||||
parseTIPL(tags)
|
||||
delete(tags, "tmcl") // TMCL is already parsed by TagLib
|
||||
|
||||
return &metadata.Info{
|
||||
Tags: tags,
|
||||
AudioProperties: ap,
|
||||
HasPicture: tags["has_picture"] != nil && len(tags["has_picture"]) > 0 && tags["has_picture"][0] == "true",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseLyrics make sure lyrics tags have language
|
||||
func parseLyrics(tags map[string][]string) {
|
||||
lyrics := tags["lyrics"]
|
||||
if len(lyrics) > 0 {
|
||||
tags["lyrics:xxx"] = lyrics
|
||||
delete(tags, "lyrics")
|
||||
}
|
||||
}
|
||||
|
||||
// These are the only roles we support, based on Picard's tag map:
|
||||
// https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html
|
||||
var tiplMapping = map[string]string{
|
||||
"arranger": "arranger",
|
||||
"engineer": "engineer",
|
||||
"producer": "producer",
|
||||
"mix": "mixer",
|
||||
"DJ-mix": "djmixer",
|
||||
}
|
||||
|
||||
// parseProp parses a property from the tags map and sets it to the target integer.
|
||||
// It also deletes the property from the tags map after parsing.
|
||||
func parseProp(tags map[string][]string, prop string) int {
|
||||
if value, ok := tags[prop]; ok && len(value) > 0 {
|
||||
v, _ := strconv.Atoi(value[0])
|
||||
delete(tags, prop)
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseBasicTag checks if a basic tag (like __title, __artist, etc.) exists in the tags map.
|
||||
// If it does, it moves the value to a more appropriate tag name (like title, artist, etc.),
|
||||
// and deletes the basic tag from the map. If the target tag already exists, it ignores the basic tag.
|
||||
func parseBasicTag(tags map[string][]string, basicName string, tagName string) {
|
||||
basicValue := tags[basicName]
|
||||
if len(basicValue) == 0 {
|
||||
return
|
||||
}
|
||||
delete(tags, basicName)
|
||||
if len(tags[tagName]) == 0 {
|
||||
tags[tagName] = basicValue
|
||||
}
|
||||
}
|
||||
|
||||
// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format:
|
||||
//
|
||||
// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson".
|
||||
//
|
||||
// and breaks it down into a map of roles and names, e.g.:
|
||||
//
|
||||
// {"arranger": ["Andrew Powell"], "engineer": ["Chris Blair", "Pat Stapley"], "producer": ["Eric Woolfson"]}.
|
||||
func parseTIPL(tags map[string][]string) {
|
||||
tipl := tags["tipl"]
|
||||
if len(tipl) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
addRole := func(currentRole string, currentValue []string) {
|
||||
if currentRole != "" && len(currentValue) > 0 {
|
||||
role := tiplMapping[currentRole]
|
||||
tags[role] = append(tags[role], strings.Join(currentValue, " "))
|
||||
}
|
||||
}
|
||||
|
||||
var currentRole string
|
||||
var currentValue []string
|
||||
for _, part := range strings.Split(tipl[0], " ") {
|
||||
if _, ok := tiplMapping[part]; ok {
|
||||
addRole(currentRole, currentValue)
|
||||
currentRole = part
|
||||
currentValue = nil
|
||||
continue
|
||||
}
|
||||
currentValue = append(currentValue, part)
|
||||
}
|
||||
addRole(currentRole, currentValue)
|
||||
delete(tags, "tipl")
|
||||
}
|
||||
|
||||
var _ local.Extractor = (*extractor)(nil)
|
||||
|
||||
func init() {
|
||||
local.RegisterExtractor("legacy-taglib", func(_ fs.FS, baseDir string) local.Extractor {
|
||||
// ignores fs, as taglib extractor only works with local files
|
||||
return &extractor{baseDir}
|
||||
})
|
||||
conf.AddHook(func() {
|
||||
log.Debug("TagLib version", "version", Version())
|
||||
})
|
||||
}
|
||||
@ -1,295 +0,0 @@
|
||||
package taglib
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Extractor", func() {
|
||||
var e *extractor
|
||||
|
||||
BeforeEach(func() {
|
||||
e = &extractor{}
|
||||
})
|
||||
|
||||
Describe("Parse", func() {
|
||||
It("correctly parses metadata from all files in folder", func() {
|
||||
mds, err := e.Parse(
|
||||
"tests/fixtures/test.mp3",
|
||||
"tests/fixtures/test.ogg",
|
||||
)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(mds).To(HaveLen(2))
|
||||
|
||||
// Test MP3
|
||||
m := mds["tests/fixtures/test.mp3"]
|
||||
Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Song"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
|
||||
|
||||
Expect(m.HasPicture).To(BeTrue())
|
||||
Expect(m.AudioProperties.Duration.String()).To(Equal("1.02s"))
|
||||
Expect(m.AudioProperties.BitRate).To(Equal(192))
|
||||
Expect(m.AudioProperties.Channels).To(Equal(2))
|
||||
Expect(m.AudioProperties.SampleRate).To(Equal(44100))
|
||||
|
||||
Expect(m.Tags).To(Or(
|
||||
HaveKeyWithValue("compilation", []string{"1"}),
|
||||
HaveKeyWithValue("tcmp", []string{"1"})),
|
||||
)
|
||||
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014-05-21"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("releasedate", []string{"2020-12-31"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("discnumber", []string{"1"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_gain", []string{"-1.48 dB"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_peak", []string{"0.4512"}))
|
||||
|
||||
Expect(m.Tags).To(HaveKeyWithValue("tracknumber", []string{"2"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"}))
|
||||
|
||||
Expect(m.Tags).ToNot(HaveKey("lyrics"))
|
||||
Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:eng", []string{
|
||||
"[00:00.00]This is\n[00:02.50]English SYLT\n",
|
||||
"[00:00.00]This is\n[00:02.50]English",
|
||||
}), HaveKeyWithValue("lyrics:eng", []string{
|
||||
"[00:00.00]This is\n[00:02.50]English",
|
||||
"[00:00.00]This is\n[00:02.50]English SYLT\n",
|
||||
})))
|
||||
Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:xxx", []string{
|
||||
"[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
|
||||
"[00:00.00]This is\n[00:02.50]unspecified",
|
||||
}), HaveKeyWithValue("lyrics:xxx", []string{
|
||||
"[00:00.00]This is\n[00:02.50]unspecified",
|
||||
"[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
|
||||
})))
|
||||
|
||||
// Test OGG
|
||||
m = mds["tests/fixtures/test.ogg"]
|
||||
Expect(err).To(BeNil())
|
||||
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
|
||||
|
||||
// TagLib 1.12 returns 18, previous versions return 39.
|
||||
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
|
||||
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
|
||||
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
|
||||
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
|
||||
Expect(m.HasPicture).To(BeTrue())
|
||||
})
|
||||
|
||||
DescribeTable("Format-Specific tests",
|
||||
func(file, duration string, channels, samplerate, bitdepth int, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool, image bool) {
|
||||
file = "tests/fixtures/" + file
|
||||
mds, err := e.Parse(file)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(mds).To(HaveLen(1))
|
||||
|
||||
m := mds[file]
|
||||
|
||||
Expect(m.HasPicture).To(Equal(image))
|
||||
Expect(m.AudioProperties.Duration.String()).To(Equal(duration))
|
||||
Expect(m.AudioProperties.Channels).To(Equal(channels))
|
||||
Expect(m.AudioProperties.SampleRate).To(Equal(samplerate))
|
||||
Expect(m.AudioProperties.BitDepth).To(Equal(bitdepth))
|
||||
|
||||
Expect(m.Tags).To(Or(
|
||||
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
|
||||
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}),
|
||||
))
|
||||
|
||||
Expect(m.Tags).To(Or(
|
||||
HaveKeyWithValue("replaygain_album_peak", []string{albumPeak}),
|
||||
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_peak", []string{albumPeak}),
|
||||
))
|
||||
Expect(m.Tags).To(Or(
|
||||
HaveKeyWithValue("replaygain_track_gain", []string{trackGain}),
|
||||
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{trackGain}),
|
||||
))
|
||||
Expect(m.Tags).To(Or(
|
||||
HaveKeyWithValue("replaygain_track_peak", []string{trackPeak}),
|
||||
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_peak", []string{trackPeak}),
|
||||
))
|
||||
|
||||
Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Title"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"}))
|
||||
|
||||
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
|
||||
Expect(m.Tags).To(Or(
|
||||
HaveKeyWithValue("tracknumber", []string{"3"}),
|
||||
HaveKeyWithValue("tracknumber", []string{"3/10"}),
|
||||
))
|
||||
if !strings.HasSuffix(file, "test.wma") {
|
||||
// TODO Not sure why this is not working for WMA
|
||||
Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"}))
|
||||
}
|
||||
Expect(m.Tags).To(Or(
|
||||
HaveKeyWithValue("discnumber", []string{"1"}),
|
||||
HaveKeyWithValue("discnumber", []string{"1/2"}),
|
||||
))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"}))
|
||||
|
||||
// WMA does not have a "compilation" tag, but "wm/iscompilation"
|
||||
Expect(m.Tags).To(Or(
|
||||
HaveKeyWithValue("compilation", []string{"1"}),
|
||||
HaveKeyWithValue("wm/iscompilation", []string{"1"})),
|
||||
)
|
||||
|
||||
if id3Lyrics {
|
||||
Expect(m.Tags).To(HaveKeyWithValue("lyrics:eng", []string{
|
||||
"[00:00.00]This is\n[00:02.50]English",
|
||||
}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{
|
||||
"[00:00.00]This is\n[00:02.50]unspecified",
|
||||
}))
|
||||
} else {
|
||||
Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{
|
||||
"[00:00.00]This is\n[00:02.50]unspecified",
|
||||
"[00:00.00]This is\n[00:02.50]English",
|
||||
}))
|
||||
}
|
||||
|
||||
Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
|
||||
},
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=1200:duration=1" test.flac
|
||||
Entry("correctly parses flac tags", "test.flac", "1s", 1, 44100, 16, "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false, true),
|
||||
|
||||
Entry("correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
|
||||
Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
|
||||
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true),
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma
|
||||
// Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order
|
||||
Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true),
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=800:duration=1" test.wv
|
||||
Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false, true),
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.wav
|
||||
Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", true, true),
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=1400:duration=1" test.aiff
|
||||
Entry("correctly parses aiff tags", "test.aiff", "1s", 1, 44100, 16, "2.00 dB", "0.124972", "2.00 dB", "0.124972", true, true),
|
||||
)
|
||||
|
||||
// Skip these tests when running as root
|
||||
Context("Access Forbidden", func() {
|
||||
var accessForbiddenFile string
|
||||
var RegularUserContext = XContext
|
||||
var isRegularUser = os.Getuid() != 0
|
||||
if isRegularUser {
|
||||
RegularUserContext = Context
|
||||
}
|
||||
|
||||
// Only run permission tests if we are not root
|
||||
RegularUserContext("when run without root privileges", func() {
|
||||
BeforeEach(func() {
|
||||
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
|
||||
|
||||
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
DeferCleanup(func() {
|
||||
Expect(f.Close()).To(Succeed())
|
||||
Expect(os.Remove(accessForbiddenFile)).To(Succeed())
|
||||
})
|
||||
})
|
||||
|
||||
It("correctly handle unreadable file due to insufficient read permission", func() {
|
||||
_, err := e.extractMetadata(accessForbiddenFile)
|
||||
Expect(err).To(MatchError(os.ErrPermission))
|
||||
})
|
||||
|
||||
It("skips the file if it cannot be read", func() {
|
||||
files := []string{
|
||||
"tests/fixtures/test.mp3",
|
||||
"tests/fixtures/test.ogg",
|
||||
accessForbiddenFile,
|
||||
}
|
||||
mds, err := e.Parse(files...)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(mds).To(HaveLen(2))
|
||||
Expect(mds).ToNot(HaveKey(accessForbiddenFile))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
Describe("Error Checking", func() {
|
||||
It("returns a generic ErrPath if file does not exist", func() {
|
||||
testFilePath := "tests/fixtures/NON_EXISTENT.ogg"
|
||||
_, err := e.extractMetadata(testFilePath)
|
||||
Expect(err).To(MatchError(fs.ErrNotExist))
|
||||
})
|
||||
It("does not throw a SIGSEGV error when reading a file with an invalid frame", func() {
|
||||
// File has an empty TDAT frame
|
||||
md, err := e.extractMetadata("tests/fixtures/invalid-files/test-invalid-frame.mp3")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(md.Tags).To(HaveKeyWithValue("albumartist", []string{"Elvis Presley"}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("parseTIPL", func() {
|
||||
var tags map[string][]string
|
||||
|
||||
BeforeEach(func() {
|
||||
tags = make(map[string][]string)
|
||||
})
|
||||
|
||||
Context("when the TIPL string is populated", func() {
|
||||
It("correctly parses roles and names", func() {
|
||||
tags["tipl"] = []string{"arranger Andrew Powell DJ-mix François Kevorkian DJ-mix Jane Doe engineer Chris Blair"}
|
||||
parseTIPL(tags)
|
||||
Expect(tags["arranger"]).To(ConsistOf("Andrew Powell"))
|
||||
Expect(tags["engineer"]).To(ConsistOf("Chris Blair"))
|
||||
Expect(tags["djmixer"]).To(ConsistOf("François Kevorkian", "Jane Doe"))
|
||||
})
|
||||
|
||||
It("handles multiple names for a single role", func() {
|
||||
tags["tipl"] = []string{"engineer Pat Stapley producer Eric Woolfson engineer Chris Blair"}
|
||||
parseTIPL(tags)
|
||||
Expect(tags["producer"]).To(ConsistOf("Eric Woolfson"))
|
||||
Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair"))
|
||||
})
|
||||
|
||||
It("discards roles without names", func() {
|
||||
tags["tipl"] = []string{"engineer Pat Stapley producer engineer Chris Blair"}
|
||||
parseTIPL(tags)
|
||||
Expect(tags).ToNot(HaveKey("producer"))
|
||||
Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when the TIPL string is empty", func() {
|
||||
It("does nothing", func() {
|
||||
tags["tipl"] = []string{""}
|
||||
parseTIPL(tags)
|
||||
Expect(tags).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when the TIPL is not present", func() {
|
||||
It("does nothing", func() {
|
||||
parseTIPL(tags)
|
||||
Expect(tags).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
@ -1,299 +0,0 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#define TAGLIB_STATIC
|
||||
#include <apeproperties.h>
|
||||
#include <apetag.h>
|
||||
#include <aifffile.h>
|
||||
#include <asffile.h>
|
||||
#include <dsffile.h>
|
||||
#include <fileref.h>
|
||||
#include <flacfile.h>
|
||||
#include <id3v2tag.h>
|
||||
#include <unsynchronizedlyricsframe.h>
|
||||
#include <synchronizedlyricsframe.h>
|
||||
#include <mp4file.h>
|
||||
#include <mpegfile.h>
|
||||
#include <opusfile.h>
|
||||
#include <tpropertymap.h>
|
||||
#include <vorbisfile.h>
|
||||
#include <wavfile.h>
|
||||
#include <wavfile.h>
|
||||
#include <wavpackfile.h>
|
||||
|
||||
#include "taglib_wrapper.h"
|
||||
|
||||
char has_cover(const TagLib::FileRef f);
|
||||
|
||||
static char TAGLIB_VERSION[16];
|
||||
|
||||
char* taglib_version() {
|
||||
snprintf((char *)TAGLIB_VERSION, 16, "%d.%d.%d", TAGLIB_MAJOR_VERSION, TAGLIB_MINOR_VERSION, TAGLIB_PATCH_VERSION);
|
||||
return (char *)TAGLIB_VERSION;
|
||||
}
|
||||
|
||||
int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
|
||||
TagLib::FileRef f(filename, true, TagLib::AudioProperties::Fast);
|
||||
|
||||
if (f.isNull()) {
|
||||
return TAGLIB_ERR_PARSE;
|
||||
}
|
||||
|
||||
if (!f.audioProperties()) {
|
||||
return TAGLIB_ERR_AUDIO_PROPS;
|
||||
}
|
||||
|
||||
// Add audio properties to the tags
|
||||
const TagLib::AudioProperties *props(f.audioProperties());
|
||||
goPutInt(id, (char *)"__lengthinmilliseconds", props->lengthInMilliseconds());
|
||||
goPutInt(id, (char *)"__bitrate", props->bitrate());
|
||||
goPutInt(id, (char *)"__channels", props->channels());
|
||||
goPutInt(id, (char *)"__samplerate", props->sampleRate());
|
||||
|
||||
// Extract bits per sample for supported formats
|
||||
int bitsPerSample = 0;
|
||||
if (const auto* apeProperties{ dynamic_cast<const TagLib::APE::Properties*>(props) })
|
||||
bitsPerSample = apeProperties->bitsPerSample();
|
||||
else if (const auto* asfProperties{ dynamic_cast<const TagLib::ASF::Properties*>(props) })
|
||||
bitsPerSample = asfProperties->bitsPerSample();
|
||||
else if (const auto* flacProperties{ dynamic_cast<const TagLib::FLAC::Properties*>(props) })
|
||||
bitsPerSample = flacProperties->bitsPerSample();
|
||||
else if (const auto* mp4Properties{ dynamic_cast<const TagLib::MP4::Properties*>(props) })
|
||||
bitsPerSample = mp4Properties->bitsPerSample();
|
||||
else if (const auto* wavePackProperties{ dynamic_cast<const TagLib::WavPack::Properties*>(props) })
|
||||
bitsPerSample = wavePackProperties->bitsPerSample();
|
||||
else if (const auto* aiffProperties{ dynamic_cast<const TagLib::RIFF::AIFF::Properties*>(props) })
|
||||
bitsPerSample = aiffProperties->bitsPerSample();
|
||||
else if (const auto* wavProperties{ dynamic_cast<const TagLib::RIFF::WAV::Properties*>(props) })
|
||||
bitsPerSample = wavProperties->bitsPerSample();
|
||||
else if (const auto* dsfProperties{ dynamic_cast<const TagLib::DSF::Properties*>(props) })
|
||||
bitsPerSample = dsfProperties->bitsPerSample();
|
||||
|
||||
if (bitsPerSample > 0) {
|
||||
goPutInt(id, (char *)"__bitspersample", bitsPerSample);
|
||||
}
|
||||
|
||||
// Send all properties to the Go map
|
||||
TagLib::PropertyMap tags = f.file()->properties();
|
||||
|
||||
// Make sure at least the basic properties are extracted
|
||||
TagLib::Tag *basic = f.file()->tag();
|
||||
if (!basic->isEmpty()) {
|
||||
if (!basic->title().isEmpty()) {
|
||||
tags.insert("__title", basic->title());
|
||||
}
|
||||
if (!basic->artist().isEmpty()) {
|
||||
tags.insert("__artist", basic->artist());
|
||||
}
|
||||
if (!basic->album().isEmpty()) {
|
||||
tags.insert("__album", basic->album());
|
||||
}
|
||||
if (!basic->comment().isEmpty()) {
|
||||
tags.insert("__comment", basic->comment());
|
||||
}
|
||||
if (!basic->genre().isEmpty()) {
|
||||
tags.insert("__genre", basic->genre());
|
||||
}
|
||||
if (basic->year() > 0) {
|
||||
tags.insert("__year", TagLib::String::number(basic->year()));
|
||||
}
|
||||
if (basic->track() > 0) {
|
||||
tags.insert("__track", TagLib::String::number(basic->track()));
|
||||
}
|
||||
}
|
||||
|
||||
TagLib::ID3v2::Tag *id3Tags = NULL;
|
||||
|
||||
// Get some extended/non-standard ID3-only tags (ex: iTunes extended frames)
|
||||
TagLib::MPEG::File *mp3File(dynamic_cast<TagLib::MPEG::File *>(f.file()));
|
||||
if (mp3File != NULL) {
|
||||
id3Tags = mp3File->ID3v2Tag();
|
||||
}
|
||||
|
||||
if (id3Tags == NULL) {
|
||||
TagLib::RIFF::WAV::File *wavFile(dynamic_cast<TagLib::RIFF::WAV::File *>(f.file()));
|
||||
if (wavFile != NULL && wavFile->hasID3v2Tag()) {
|
||||
id3Tags = wavFile->ID3v2Tag();
|
||||
}
|
||||
}
|
||||
|
||||
if (id3Tags == NULL) {
|
||||
TagLib::RIFF::AIFF::File *aiffFile(dynamic_cast<TagLib::RIFF::AIFF::File *>(f.file()));
|
||||
if (aiffFile && aiffFile->hasID3v2Tag()) {
|
||||
id3Tags = aiffFile->tag();
|
||||
}
|
||||
}
|
||||
|
||||
// Yes, it is possible to have ID3v2 tags in FLAC. However, that can cause problems
|
||||
// with many players, so they will not be parsed
|
||||
|
||||
if (id3Tags != NULL) {
|
||||
const auto &frames = id3Tags->frameListMap();
|
||||
|
||||
for (const auto &kv: frames) {
|
||||
if (kv.first == "USLT") {
|
||||
for (const auto &tag: kv.second) {
|
||||
TagLib::ID3v2::UnsynchronizedLyricsFrame *frame = dynamic_cast<TagLib::ID3v2::UnsynchronizedLyricsFrame *>(tag);
|
||||
if (frame == NULL) continue;
|
||||
|
||||
tags.erase("LYRICS");
|
||||
|
||||
const auto bv = frame->language();
|
||||
char language[4] = {'x', 'x', 'x', '\0'};
|
||||
if (bv.size() == 3) {
|
||||
strncpy(language, bv.data(), 3);
|
||||
}
|
||||
|
||||
char *val = const_cast<char*>(frame->text().toCString(true));
|
||||
|
||||
goPutLyrics(id, language, val);
|
||||
}
|
||||
} else if (kv.first == "SYLT") {
|
||||
for (const auto &tag: kv.second) {
|
||||
TagLib::ID3v2::SynchronizedLyricsFrame *frame = dynamic_cast<TagLib::ID3v2::SynchronizedLyricsFrame *>(tag);
|
||||
if (frame == NULL) continue;
|
||||
|
||||
const auto bv = frame->language();
|
||||
char language[4] = {'x', 'x', 'x', '\0'};
|
||||
if (bv.size() == 3) {
|
||||
strncpy(language, bv.data(), 3);
|
||||
}
|
||||
|
||||
const auto format = frame->timestampFormat();
|
||||
if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMilliseconds) {
|
||||
|
||||
for (const auto &line: frame->synchedText()) {
|
||||
char *text = const_cast<char*>(line.text.toCString(true));
|
||||
goPutLyricLine(id, language, text, line.time);
|
||||
}
|
||||
} else if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMpegFrames) {
|
||||
const int sampleRate = props->sampleRate();
|
||||
|
||||
if (sampleRate != 0) {
|
||||
for (const auto &line: frame->synchedText()) {
|
||||
const int timeInMs = (line.time * 1000) / sampleRate;
|
||||
char *text = const_cast<char*>(line.text.toCString(true));
|
||||
goPutLyricLine(id, language, text, timeInMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (kv.first == "TIPL"){
|
||||
if (!kv.second.isEmpty()) {
|
||||
tags.insert(kv.first, kv.second.front()->toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// M4A may have some iTunes specific tags not captured by the PropertyMap interface
|
||||
TagLib::MP4::File *m4afile(dynamic_cast<TagLib::MP4::File *>(f.file()));
|
||||
if (m4afile != NULL) {
|
||||
const auto itemListMap = m4afile->tag()->itemMap();
|
||||
for (const auto item: itemListMap) {
|
||||
char *key = const_cast<char*>(item.first.toCString(true));
|
||||
for (const auto value: item.second.toStringList()) {
|
||||
char *val = const_cast<char*>(value.toCString(true));
|
||||
goPutM4AStr(id, key, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WMA/ASF files may have additional tags not captured by the PropertyMap interface
|
||||
TagLib::ASF::File *asfFile(dynamic_cast<TagLib::ASF::File *>(f.file()));
|
||||
if (asfFile != NULL) {
|
||||
const TagLib::ASF::Tag *asfTags{asfFile->tag()};
|
||||
const auto itemListMap = asfTags->attributeListMap();
|
||||
for (const auto item : itemListMap) {
|
||||
char *key = const_cast<char*>(item.first.toCString(true));
|
||||
|
||||
for (auto j = item.second.begin();
|
||||
j != item.second.end(); ++j) {
|
||||
|
||||
char *val = const_cast<char*>(j->toString().toCString(true));
|
||||
goPutStr(id, key, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send all collected tags to the Go map
|
||||
for (TagLib::PropertyMap::ConstIterator i = tags.begin(); i != tags.end();
|
||||
++i) {
|
||||
char *key = const_cast<char*>(i->first.toCString(true));
|
||||
for (TagLib::StringList::ConstIterator j = i->second.begin();
|
||||
j != i->second.end(); ++j) {
|
||||
char *val = const_cast<char*>((*j).toCString(true));
|
||||
goPutStr(id, key, val);
|
||||
}
|
||||
}
|
||||
|
||||
// Cover art has to be handled separately
|
||||
if (has_cover(f)) {
|
||||
goPutStr(id, (char *)"has_picture", (char *)"true");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Detect if the file has cover art. Returns 1 if the file has cover art, 0 otherwise.
|
||||
char has_cover(const TagLib::FileRef f) {
|
||||
char hasCover = 0;
|
||||
// ----- MP3
|
||||
if (TagLib::MPEG::File * mp3File{dynamic_cast<TagLib::MPEG::File *>(f.file())}) {
|
||||
if (mp3File->ID3v2Tag()) {
|
||||
const auto &frameListMap{mp3File->ID3v2Tag()->frameListMap()};
|
||||
hasCover = !frameListMap["APIC"].isEmpty();
|
||||
}
|
||||
}
|
||||
// ----- FLAC
|
||||
else if (TagLib::FLAC::File * flacFile{dynamic_cast<TagLib::FLAC::File *>(f.file())}) {
|
||||
hasCover = !flacFile->pictureList().isEmpty();
|
||||
}
|
||||
// ----- MP4
|
||||
else if (TagLib::MP4::File * mp4File{dynamic_cast<TagLib::MP4::File *>(f.file())}) {
|
||||
auto &coverItem{mp4File->tag()->itemMap()["covr"]};
|
||||
TagLib::MP4::CoverArtList coverArtList{coverItem.toCoverArtList()};
|
||||
hasCover = !coverArtList.isEmpty();
|
||||
}
|
||||
// ----- Ogg
|
||||
else if (TagLib::Ogg::Vorbis::File * vorbisFile{dynamic_cast<TagLib::Ogg::Vorbis::File *>(f.file())}) {
|
||||
hasCover = !vorbisFile->tag()->pictureList().isEmpty();
|
||||
}
|
||||
// ----- Opus
|
||||
else if (TagLib::Ogg::Opus::File * opusFile{dynamic_cast<TagLib::Ogg::Opus::File *>(f.file())}) {
|
||||
hasCover = !opusFile->tag()->pictureList().isEmpty();
|
||||
}
|
||||
// ----- WAV
|
||||
else if (TagLib::RIFF::WAV::File * wavFile{ dynamic_cast<TagLib::RIFF::WAV::File*>(f.file()) }) {
|
||||
if (wavFile->hasID3v2Tag()) {
|
||||
const auto& frameListMap{ wavFile->ID3v2Tag()->frameListMap() };
|
||||
hasCover = !frameListMap["APIC"].isEmpty();
|
||||
}
|
||||
}
|
||||
// ----- AIFF
|
||||
else if (TagLib::RIFF::AIFF::File * aiffFile{ dynamic_cast<TagLib::RIFF::AIFF::File *>(f.file())}) {
|
||||
if (aiffFile->hasID3v2Tag()) {
|
||||
const auto& frameListMap{ aiffFile->tag()->frameListMap() };
|
||||
hasCover = !frameListMap["APIC"].isEmpty();
|
||||
}
|
||||
}
|
||||
// ----- WMA
|
||||
else if (TagLib::ASF::File * asfFile{dynamic_cast<TagLib::ASF::File *>(f.file())}) {
|
||||
const TagLib::ASF::Tag *tag{ asfFile->tag() };
|
||||
hasCover = tag && tag->attributeListMap().contains("WM/Picture");
|
||||
}
|
||||
// ----- DSF
|
||||
else if (TagLib::DSF::File * dsffile{ dynamic_cast<TagLib::DSF::File *>(f.file())}) {
|
||||
const TagLib::ID3v2::Tag *tag { dsffile->tag() };
|
||||
hasCover = tag && !tag->frameListMap()["APIC"].isEmpty();
|
||||
}
|
||||
// ----- WAVPAK (APE tag)
|
||||
else if (TagLib::WavPack::File * wvFile{dynamic_cast<TagLib::WavPack::File *>(f.file())}) {
|
||||
if (wvFile->hasAPETag()) {
|
||||
// This is the particular string that Picard uses
|
||||
hasCover = !wvFile->APETag()->itemListMap()["COVER ART (FRONT)"].isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
return hasCover;
|
||||
}
|
||||
@ -1,157 +0,0 @@
|
||||
package taglib
|
||||
|
||||
/*
|
||||
#cgo !windows pkg-config: --define-prefix taglib
|
||||
#cgo windows pkg-config: taglib
|
||||
#cgo illumos LDFLAGS: -lstdc++ -lsendfile
|
||||
#cgo linux darwin CXXFLAGS: -std=c++11
|
||||
#cgo darwin LDFLAGS: -L/opt/homebrew/opt/taglib/lib
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include "taglib_wrapper.h"
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"unsafe"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
const iTunesKeyPrefix = "----:com.apple.itunes:"
|
||||
|
||||
func Version() string {
|
||||
return C.GoString(C.taglib_version())
|
||||
}
|
||||
|
||||
func Read(filename string) (tags map[string][]string, err error) {
|
||||
// Do not crash on failures in the C code/library
|
||||
debug.SetPanicOnFault(true)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error("extractor: recovered from panic when reading tags", "file", filename, "error", r)
|
||||
err = fmt.Errorf("extractor: recovered from panic: %s", r)
|
||||
}
|
||||
}()
|
||||
|
||||
fp := getFilename(filename)
|
||||
defer C.free(unsafe.Pointer(fp))
|
||||
id, m, release := newMap()
|
||||
defer release()
|
||||
|
||||
log.Trace("extractor: reading tags", "filename", filename, "map_id", id)
|
||||
res := C.taglib_read(fp, C.ulong(id))
|
||||
switch res {
|
||||
case C.TAGLIB_ERR_PARSE:
|
||||
// Check additional case whether the file is unreadable due to permission
|
||||
file, fileErr := os.OpenFile(filename, os.O_RDONLY, 0600)
|
||||
defer file.Close()
|
||||
|
||||
if os.IsPermission(fileErr) {
|
||||
return nil, fmt.Errorf("navidrome does not have permission: %w", fileErr)
|
||||
} else if fileErr != nil {
|
||||
return nil, fmt.Errorf("cannot parse file media file: %w", fileErr)
|
||||
} else {
|
||||
return nil, fmt.Errorf("cannot parse file media file")
|
||||
}
|
||||
case C.TAGLIB_ERR_AUDIO_PROPS:
|
||||
return nil, fmt.Errorf("can't get audio properties from file")
|
||||
}
|
||||
if log.IsGreaterOrEqualTo(log.LevelDebug) {
|
||||
j, _ := json.Marshal(m)
|
||||
log.Trace("extractor: read tags", "tags", string(j), "filename", filename, "id", id)
|
||||
} else {
|
||||
log.Trace("extractor: read tags", "tags", m, "filename", filename, "id", id)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
type tagMap map[string][]string
|
||||
|
||||
var allMaps sync.Map
|
||||
var mapsNextID atomic.Uint32
|
||||
|
||||
func newMap() (uint32, tagMap, func()) {
|
||||
id := mapsNextID.Add(1)
|
||||
|
||||
m := tagMap{}
|
||||
allMaps.Store(id, m)
|
||||
|
||||
return id, m, func() {
|
||||
allMaps.Delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
func doPutTag(id C.ulong, key string, val *C.char) {
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
|
||||
r, _ := allMaps.Load(uint32(id))
|
||||
m := r.(tagMap)
|
||||
k := strings.ToLower(key)
|
||||
v := strings.TrimSpace(C.GoString(val))
|
||||
m[k] = append(m[k], v)
|
||||
}
|
||||
|
||||
//export goPutM4AStr
|
||||
func goPutM4AStr(id C.ulong, key *C.char, val *C.char) {
|
||||
k := C.GoString(key)
|
||||
|
||||
// Special for M4A, do not catch keys that have no actual name
|
||||
k = strings.TrimPrefix(k, iTunesKeyPrefix)
|
||||
doPutTag(id, k, val)
|
||||
}
|
||||
|
||||
//export goPutStr
|
||||
func goPutStr(id C.ulong, key *C.char, val *C.char) {
|
||||
doPutTag(id, C.GoString(key), val)
|
||||
}
|
||||
|
||||
//export goPutInt
|
||||
func goPutInt(id C.ulong, key *C.char, val C.int) {
|
||||
valStr := strconv.Itoa(int(val))
|
||||
vp := C.CString(valStr)
|
||||
defer C.free(unsafe.Pointer(vp))
|
||||
goPutStr(id, key, vp)
|
||||
}
|
||||
|
||||
//export goPutLyrics
|
||||
func goPutLyrics(id C.ulong, lang *C.char, val *C.char) {
|
||||
doPutTag(id, "lyrics:"+C.GoString(lang), val)
|
||||
}
|
||||
|
||||
//export goPutLyricLine
|
||||
func goPutLyricLine(id C.ulong, lang *C.char, text *C.char, time C.int) {
|
||||
language := C.GoString(lang)
|
||||
line := C.GoString(text)
|
||||
timeGo := int64(time)
|
||||
|
||||
ms := timeGo % 1000
|
||||
timeGo /= 1000
|
||||
sec := timeGo % 60
|
||||
timeGo /= 60
|
||||
minimum := timeGo % 60
|
||||
formattedLine := fmt.Sprintf("[%02d:%02d.%02d]%s\n", minimum, sec, ms/10, line)
|
||||
|
||||
key := "lyrics:" + language
|
||||
|
||||
r, _ := allMaps.Load(uint32(id))
|
||||
m := r.(tagMap)
|
||||
k := strings.ToLower(key)
|
||||
existing, ok := m[k]
|
||||
if ok {
|
||||
existing[0] += formattedLine
|
||||
} else {
|
||||
m[k] = []string{formattedLine}
|
||||
}
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
#define TAGLIB_ERR_PARSE -1
|
||||
#define TAGLIB_ERR_AUDIO_PROPS -2
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#ifdef WIN32
|
||||
#define FILENAME_CHAR_T wchar_t
|
||||
#else
|
||||
#define FILENAME_CHAR_T char
|
||||
#endif
|
||||
|
||||
extern void goPutM4AStr(unsigned long id, char *key, char *val);
|
||||
extern void goPutStr(unsigned long id, char *key, char *val);
|
||||
extern void goPutInt(unsigned long id, char *key, int val);
|
||||
extern void goPutLyrics(unsigned long id, char *lang, char *val);
|
||||
extern void goPutLyricLine(unsigned long id, char *lang, char *text, int time);
|
||||
int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id);
|
||||
char* taglib_version();
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@ -75,7 +75,7 @@ var (
|
||||
|
||||
func runBackup(ctx context.Context) {
|
||||
if backupDir != "" {
|
||||
conf.Server.Backup.Path = backupDir
|
||||
conf.Server.Backup.Path = conf.NewDir(backupDir)
|
||||
}
|
||||
|
||||
idx := strings.LastIndex(conf.Server.DbPath, "?")
|
||||
@ -104,7 +104,7 @@ func runBackup(ctx context.Context) {
|
||||
|
||||
func runPrune(ctx context.Context) {
|
||||
if backupDir != "" {
|
||||
conf.Server.Backup.Path = backupDir
|
||||
conf.Server.Backup.Path = conf.NewDir(backupDir)
|
||||
}
|
||||
|
||||
if backupCount != -1 {
|
||||
|
||||
@ -32,17 +32,17 @@ var inspectCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var marshalers = map[string]func(interface{}) ([]byte, error){
|
||||
var marshalers = map[string]func(any) ([]byte, error){
|
||||
"pretty": prettyMarshal,
|
||||
"toml": toml.Marshal,
|
||||
"yaml": yaml.Marshal,
|
||||
"json": json.Marshal,
|
||||
"jsonindent": func(v interface{}) ([]byte, error) {
|
||||
"jsonindent": func(v any) ([]byte, error) {
|
||||
return json.MarshalIndent(v, "", " ")
|
||||
},
|
||||
}
|
||||
|
||||
func prettyMarshal(v interface{}) ([]byte, error) {
|
||||
func prettyMarshal(v any) ([]byte, error) {
|
||||
out := v.([]core.InspectOutput)
|
||||
var res strings.Builder
|
||||
for i := range out {
|
||||
|
||||
224
cmd/pls.go
224
cmd/pls.go
@ -7,11 +7,19 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/ioutils"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@ -20,6 +28,7 @@ var (
|
||||
outputFile string
|
||||
userID string
|
||||
outputFormat string
|
||||
syncFlag bool
|
||||
)
|
||||
|
||||
type displayPlaylist struct {
|
||||
@ -41,6 +50,15 @@ func init() {
|
||||
listCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID")
|
||||
listCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]")
|
||||
plsCmd.AddCommand(listCommand)
|
||||
|
||||
exportCommand.Flags().StringVarP(&playlistID, "playlist", "p", "", "playlist name or ID")
|
||||
exportCommand.Flags().StringVarP(&outputFile, "output", "o", "", "output directory")
|
||||
exportCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID")
|
||||
plsCmd.AddCommand(exportCommand)
|
||||
|
||||
importCommand.Flags().StringVarP(&userID, "user", "u", "", "owner username or ID (default: first admin)")
|
||||
importCommand.Flags().BoolVar(&syncFlag, "sync", false, "mark imported playlists as synced")
|
||||
plsCmd.AddCommand(importCommand)
|
||||
}
|
||||
|
||||
var (
|
||||
@ -60,72 +78,165 @@ var (
|
||||
runList(cmd.Context())
|
||||
},
|
||||
}
|
||||
|
||||
exportCommand = &cobra.Command{
|
||||
Use: "export",
|
||||
Short: "Export playlists to M3U files",
|
||||
Long: "Export one or more Navidrome playlists to M3U files",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runExport(cmd.Context())
|
||||
},
|
||||
}
|
||||
|
||||
importCommand = &cobra.Command{
|
||||
Use: "import [files...]",
|
||||
Short: "Import M3U playlists",
|
||||
Long: "Import one or more M3U files as Navidrome playlists",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runImport(cmd.Context(), args)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func runExporter(ctx context.Context) {
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false)
|
||||
func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.Playlists {
|
||||
options := model.QueryOptions{Sort: sort}
|
||||
if userID != "" {
|
||||
user, err := getUser(ctx, userID, ds)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Error retrieving user", "username or id", userID)
|
||||
}
|
||||
options.Filters = squirrel.Eq{"owner_id": user.ID}
|
||||
}
|
||||
pls, err := ds.Playlist(ctx).GetAll(options)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to retrieve playlists", err)
|
||||
}
|
||||
return pls
|
||||
}
|
||||
|
||||
func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist {
|
||||
playlist, err := ds.Playlist(ctx).GetWithTracks(nameOrID, true, false)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
log.Fatal("Error retrieving playlist", "name", playlistID, err)
|
||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||
}
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": playlistID}})
|
||||
playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
|
||||
if err != nil {
|
||||
log.Fatal("Error retrieving playlist", "name", playlistID, err)
|
||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||
}
|
||||
if len(playlists) > 0 {
|
||||
playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false)
|
||||
if err != nil {
|
||||
log.Fatal("Error retrieving playlist", "name", playlistID, err)
|
||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if playlist == nil {
|
||||
log.Fatal("Playlist not found", "name", playlistID)
|
||||
log.Fatal("Playlist not found", "name", nameOrID)
|
||||
}
|
||||
return playlist
|
||||
}
|
||||
|
||||
func runExporter(ctx context.Context) {
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
playlist := findPlaylist(ctx, ds, playlistID)
|
||||
pls := playlist.ToM3U8()
|
||||
if outputFile == "-" || outputFile == "" {
|
||||
println(pls)
|
||||
return
|
||||
}
|
||||
|
||||
err = os.WriteFile(outputFile, []byte(pls), 0600)
|
||||
err := os.WriteFile(outputFile, []byte(pls), 0600)
|
||||
if err != nil {
|
||||
log.Fatal("Error writing to the output file", "file", outputFile, err)
|
||||
}
|
||||
}
|
||||
|
||||
func runExport(ctx context.Context) {
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
|
||||
if playlistID != "" && outputFile == "" {
|
||||
playlist := findPlaylist(ctx, ds, playlistID)
|
||||
println(playlist.ToM3U8())
|
||||
return
|
||||
}
|
||||
|
||||
if outputFile == "" {
|
||||
log.Fatal("Output directory (-o) is required for bulk export or when filtering by user")
|
||||
}
|
||||
|
||||
info, err := os.Stat(outputFile)
|
||||
if err != nil || !info.IsDir() {
|
||||
log.Fatal("Output path must be an existing directory", "path", outputFile)
|
||||
}
|
||||
|
||||
if playlistID != "" {
|
||||
pls := findPlaylist(ctx, ds, playlistID)
|
||||
filename := str.SanitizeFilename(pls.Name) + ".m3u"
|
||||
path := filepath.Join(outputFile, filename)
|
||||
err := os.WriteFile(path, []byte(pls.ToM3U8()), 0600)
|
||||
if err != nil {
|
||||
log.Fatal("Error writing playlist", "file", path, err)
|
||||
}
|
||||
fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path)
|
||||
return
|
||||
}
|
||||
|
||||
allPls := fetchPlaylists(ctx, ds, "name")
|
||||
|
||||
nameCounts := make(map[string]int)
|
||||
for _, pls := range allPls {
|
||||
nameCounts[str.SanitizeFilename(pls.Name)]++
|
||||
}
|
||||
|
||||
exported := 0
|
||||
for _, pls := range allPls {
|
||||
plsWithTracks, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false)
|
||||
if err != nil {
|
||||
log.Error("Error loading playlist tracks", "playlist", pls.Name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
sanitized := str.SanitizeFilename(pls.Name)
|
||||
filename := sanitized + ".m3u"
|
||||
if nameCounts[sanitized] > 1 {
|
||||
shortID := pls.ID
|
||||
if len(shortID) > 6 {
|
||||
shortID = shortID[:6]
|
||||
}
|
||||
filename = sanitized + "_" + shortID + ".m3u"
|
||||
}
|
||||
|
||||
path := filepath.Join(outputFile, filename)
|
||||
err = os.WriteFile(path, []byte(plsWithTracks.ToM3U8()), 0600)
|
||||
if err != nil {
|
||||
log.Error("Error writing playlist", "file", path, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path)
|
||||
exported++
|
||||
}
|
||||
fmt.Printf("\nExported %d playlists to %s\n", exported, outputFile)
|
||||
}
|
||||
|
||||
func runList(ctx context.Context) {
|
||||
if outputFormat != "csv" && outputFormat != "json" {
|
||||
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
|
||||
}
|
||||
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
options := model.QueryOptions{Sort: "owner_name"}
|
||||
|
||||
if userID != "" {
|
||||
user, err := getUser(ctx, userID, ds)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Error retrieving user", "username or id", userID)
|
||||
}
|
||||
options.Filters = squirrel.Eq{"owner_id": user.ID}
|
||||
}
|
||||
|
||||
playlists, err := ds.Playlist(ctx).GetAll(options)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to retrieve playlists", err)
|
||||
}
|
||||
allPls := fetchPlaylists(ctx, ds, "owner_name")
|
||||
|
||||
if outputFormat == "csv" {
|
||||
w := csv.NewWriter(os.Stdout)
|
||||
_ = w.Write([]string{"playlist id", "playlist name", "owner id", "owner name", "public"})
|
||||
for _, playlist := range playlists {
|
||||
for _, playlist := range allPls {
|
||||
_ = w.Write([]string{playlist.ID, playlist.Name, playlist.OwnerID, playlist.OwnerName, strconv.FormatBool(playlist.Public)})
|
||||
}
|
||||
w.Flush()
|
||||
} else {
|
||||
display := make(displayPlaylists, len(playlists))
|
||||
for idx, playlist := range playlists {
|
||||
display := make(displayPlaylists, len(allPls))
|
||||
for idx, playlist := range allPls {
|
||||
display[idx].Id = playlist.ID
|
||||
display[idx].Name = playlist.Name
|
||||
display[idx].OwnerId = playlist.OwnerID
|
||||
@ -137,3 +248,62 @@ func runList(ctx context.Context) {
|
||||
fmt.Printf("%s\n", j)
|
||||
}
|
||||
}
|
||||
|
||||
func runImport(ctx context.Context, files []string) {
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
|
||||
if userID != "" {
|
||||
user, err := getUser(ctx, userID, ds)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Error retrieving user", "username or id", userID)
|
||||
}
|
||||
ctx = request.WithUser(ctx, *user)
|
||||
}
|
||||
|
||||
pls := playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
for _, file := range files {
|
||||
absPath, err := filepath.Abs(file)
|
||||
if err != nil {
|
||||
log.Error("Error resolving path", "file", file, err)
|
||||
fmt.Fprintf(os.Stderr, "Error: could not resolve path %s: %v\n", file, err)
|
||||
continue
|
||||
}
|
||||
|
||||
totalLines := countM3UTrackLines(absPath)
|
||||
|
||||
imported, err := pls.ImportFile(ctx, absPath, syncFlag)
|
||||
if err != nil {
|
||||
log.Error("Error importing playlist", "file", absPath, err)
|
||||
fmt.Fprintf(os.Stderr, "Error importing %s: %v\n", file, err)
|
||||
continue
|
||||
}
|
||||
|
||||
matched := len(imported.Tracks)
|
||||
if totalLines > 0 {
|
||||
notFound := totalLines - matched
|
||||
fmt.Printf("Imported \"%s\" — %d/%d tracks matched (%d not found)\n", imported.Name, matched, totalLines, notFound)
|
||||
} else {
|
||||
fmt.Printf("Imported \"%s\" — %d tracks\n", imported.Name, matched)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func countM3UTrackLines(path string) int {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
count := 0
|
||||
reader := ioutils.UTF8Reader(file)
|
||||
for line := range slice.LinesFrom(reader) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
@ -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/taglib"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@ -76,13 +76,13 @@ var svcInstance = sync.OnceValue(func() service.Service {
|
||||
options["Restart"] = "on-failure"
|
||||
options["SuccessExitStatus"] = "1 2 8 SIGKILL"
|
||||
options["UserService"] = false
|
||||
options["LogDirectory"] = conf.Server.DataFolder
|
||||
options["LogDirectory"] = conf.Server.DataFolder.String()
|
||||
options["SystemdScript"] = systemdScript
|
||||
if conf.Server.LogFile != "" {
|
||||
options["LogOutput"] = false
|
||||
} else {
|
||||
options["LogOutput"] = true
|
||||
options["LogDirectory"] = conf.Server.DataFolder
|
||||
options["LogDirectory"] = conf.Server.DataFolder.String()
|
||||
}
|
||||
svcConfig := &service.Config{
|
||||
UserName: installUser,
|
||||
@ -131,11 +131,11 @@ func buildInstallCmd() *cobra.Command {
|
||||
println("Installing service with:")
|
||||
println(" working directory: " + executablePath())
|
||||
println(" music folder: " + conf.Server.MusicFolder)
|
||||
println(" data folder: " + conf.Server.DataFolder)
|
||||
println(" data folder: " + conf.Server.DataFolder.String())
|
||||
if conf.Server.LogFile != "" {
|
||||
println(" log file: " + conf.Server.LogFile)
|
||||
} else {
|
||||
println(" logs folder: " + conf.Server.DataFolder)
|
||||
println(" logs folder: " + conf.Server.DataFolder.String())
|
||||
}
|
||||
if cfgFile != "" {
|
||||
conf.Server.ConfigFile, err = filepath.Abs(cfgFile)
|
||||
|
||||
@ -17,10 +17,12 @@ import (
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"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/sonic"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@ -39,7 +41,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/taglib"
|
||||
)
|
||||
|
||||
// Injectors from wire_injectors.go:
|
||||
@ -72,7 +73,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
@ -93,7 +95,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
transcodingCache := stream.GetTranscodingCache()
|
||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
@ -108,7 +111,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
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)
|
||||
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
|
||||
return router
|
||||
}
|
||||
|
||||
@ -121,7 +125,8 @@ func CreatePublicRouter() *public.Router {
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
transcodingCache := stream.GetTranscodingCache()
|
||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
@ -168,7 +173,8 @@ func CreateScanner(ctx context.Context) model.Scanner {
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
imageUploadService := core.NewImageUploadService()
|
||||
@ -186,7 +192,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
imageUploadService := core.NewImageUploadService()
|
||||
@ -214,7 +221,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(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)))
|
||||
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, sonic.New, 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(sonic.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()
|
||||
|
||||
@ -15,6 +15,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/sonic"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
@ -43,9 +44,11 @@ var allProviders = wire.NewSet(
|
||||
metrics.GetPrometheusInstance,
|
||||
db.Db,
|
||||
plugins.GetManager,
|
||||
sonic.New,
|
||||
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(sonic.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)),
|
||||
|
||||
@ -2,9 +2,7 @@ package configtest
|
||||
|
||||
import "github.com/navidrome/navidrome/conf"
|
||||
|
||||
// TODO Remove this redirection and call SnapshotConfig directly from tests
|
||||
func SetupConfig() func() {
|
||||
oldValues := *conf.Server
|
||||
return func() {
|
||||
conf.Server = &oldValues
|
||||
}
|
||||
return conf.SnapshotConfig()
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package conf
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
@ -12,7 +13,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/go-viper/encoding/ini"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
"github.com/kr/pretty"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@ -26,9 +29,10 @@ type configOptions struct {
|
||||
Address string
|
||||
Port int
|
||||
UnixSocketPerm string
|
||||
EnforceNonRootUser bool
|
||||
MusicFolder string
|
||||
DataFolder string
|
||||
CacheFolder string
|
||||
DataFolder Dir
|
||||
CacheFolder Dir
|
||||
DbPath string
|
||||
LogLevel string
|
||||
LogFile string
|
||||
@ -43,7 +47,6 @@ type configOptions struct {
|
||||
UIWelcomeMessage string
|
||||
MaxSidebarPlaylists int
|
||||
EnableTranscodingConfig bool
|
||||
EnableTranscodingCancellation bool
|
||||
EnableDownloads bool
|
||||
EnableExternalServices bool
|
||||
EnableM3UExternalAlbumArt bool
|
||||
@ -59,8 +62,8 @@ type configOptions struct {
|
||||
SmartPlaylistRefreshDelay time.Duration
|
||||
AutoTranscodeDownload bool
|
||||
DefaultDownsamplingFormat string
|
||||
Search searchOptions `json:",omitzero"`
|
||||
SimilarSongsMatchThreshold int
|
||||
Search searchOptions `json:",omitzero"`
|
||||
Matcher matcherOptions `json:",omitzero"`
|
||||
RecentlyAddedByModTime bool
|
||||
PreferSortTags bool
|
||||
IgnoredArticles string
|
||||
@ -70,6 +73,7 @@ type configOptions struct {
|
||||
MPVCmdTemplate string
|
||||
CoverArtPriority string
|
||||
CoverArtQuality int
|
||||
EnableWebPEncoding bool
|
||||
ArtistArtPriority string
|
||||
ArtistImageFolder string
|
||||
DiscArtPriority string
|
||||
@ -78,7 +82,8 @@ type configOptions struct {
|
||||
EnableFavourites bool
|
||||
EnableStarRating bool
|
||||
EnableUserEditing bool
|
||||
EnableCoverArtUpload bool
|
||||
EnableArtworkUpload bool
|
||||
MaxImageUploadSize string
|
||||
EnableSharing bool
|
||||
ShareURL string
|
||||
DefaultShareExpiration time.Duration
|
||||
@ -87,9 +92,11 @@ type configOptions struct {
|
||||
DefaultLanguage string
|
||||
DefaultUIVolume int
|
||||
UISearchDebounceMs int
|
||||
UICoverArtSize int
|
||||
EnableReplayGain bool
|
||||
EnableCoverAnimation bool
|
||||
EnableNowPlaying bool
|
||||
UIPlaybackReportInterval time.Duration
|
||||
GATrackingID string
|
||||
EnableLogRedacting bool
|
||||
AuthRequestLimit int
|
||||
@ -105,6 +112,7 @@ type configOptions struct {
|
||||
PID pidOptions `json:",omitzero"`
|
||||
Inspect inspectOptions `json:",omitzero"`
|
||||
Subsonic subsonicOptions `json:",omitzero"`
|
||||
Transcoding transcodingOptions `json:",omitzero"`
|
||||
LastFM lastfmOptions `json:",omitzero"`
|
||||
Deezer deezerOptions `json:",omitzero"`
|
||||
ListenBrainz listenBrainzOptions `json:",omitzero"`
|
||||
@ -128,6 +136,7 @@ type configOptions struct {
|
||||
DevArtworkMaxRequests int
|
||||
DevArtworkThrottleBacklogLimit int
|
||||
DevArtworkThrottleBacklogTimeout time.Duration
|
||||
DevArtworkThrottleBuffered bool
|
||||
DevArtistInfoTimeToLive time.Duration
|
||||
DevAlbumInfoTimeToLive time.Duration
|
||||
DevExternalScanner bool
|
||||
@ -141,7 +150,6 @@ type configOptions struct {
|
||||
DevOptimizeDB bool
|
||||
DevPreserveUnicodeInExternalCalls bool
|
||||
DevEnableMediaFileProbe bool
|
||||
DevJpegCoverArt bool
|
||||
}
|
||||
|
||||
type scannerOptions struct {
|
||||
@ -157,6 +165,12 @@ type scannerOptions struct {
|
||||
PurgeMissing string // Values: "never", "always", "full"
|
||||
}
|
||||
|
||||
type transcodingOptions struct {
|
||||
MaxConcurrent int
|
||||
MaxConcurrentPerUser int
|
||||
EnableCancellation bool
|
||||
}
|
||||
|
||||
type subsonicOptions struct {
|
||||
AppendSubtitle bool
|
||||
AppendAlbumVersion bool
|
||||
@ -223,7 +237,7 @@ type jukeboxOptions struct {
|
||||
|
||||
type backupOptions struct {
|
||||
Count int
|
||||
Path string
|
||||
Path Dir
|
||||
Schedule string
|
||||
}
|
||||
|
||||
@ -241,7 +255,7 @@ type inspectOptions struct {
|
||||
|
||||
type pluginsOptions struct {
|
||||
Enabled bool
|
||||
Folder string
|
||||
Folder Dir
|
||||
CacheSize string
|
||||
AutoReload bool
|
||||
LogLevel string
|
||||
@ -258,87 +272,107 @@ type searchOptions struct {
|
||||
FullString bool
|
||||
}
|
||||
|
||||
type matcherOptions struct {
|
||||
PreferStarred bool
|
||||
FuzzyThreshold int
|
||||
}
|
||||
|
||||
// logFatal prints a fatal error message to stderr and exits.
|
||||
// Overridden in tests to allow testing fatal paths.
|
||||
var logFatal = func(args ...any) {
|
||||
_, _ = fmt.Fprintln(os.Stderr, append([]any{"FATAL:"}, args...)...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var getEUID = os.Geteuid
|
||||
|
||||
var currentGOOS = func() string {
|
||||
return runtime.GOOS
|
||||
}
|
||||
|
||||
var (
|
||||
Server = &configOptions{}
|
||||
hooks []func()
|
||||
)
|
||||
|
||||
// SnapshotConfig returns a function that restores Server to its current state.
|
||||
// Uses JSON round-tripping so Dir fields get fresh sync.Once values.
|
||||
func SnapshotConfig() func() {
|
||||
snapshot, err := json.Marshal(Server)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("SnapshotConfig: marshal failed: %v", err))
|
||||
}
|
||||
return func() {
|
||||
var restored configOptions
|
||||
if err := json.Unmarshal(snapshot, &restored); err != nil {
|
||||
panic(fmt.Sprintf("SnapshotConfig: unmarshal failed: %v", err))
|
||||
}
|
||||
Server = &restored
|
||||
}
|
||||
}
|
||||
|
||||
func LoadFromFile(confFile string) {
|
||||
viper.SetConfigFile(confFile)
|
||||
err := viper.ReadInConfig()
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error reading config file:", err)
|
||||
os.Exit(1)
|
||||
logFatal("Error reading config file:", err)
|
||||
}
|
||||
Load(true)
|
||||
}
|
||||
|
||||
func Load(noConfigDump bool) {
|
||||
parseIniFileConfiguration()
|
||||
remapEnvVarKeysFromConfig()
|
||||
|
||||
// Map deprecated options to their new names for backwards compatibility
|
||||
mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
||||
mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality")
|
||||
mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
|
||||
mapDeprecatedOption("EnableTranscodingCancellation", "Transcoding.EnableCancellation")
|
||||
|
||||
err := viper.Unmarshal(&Server)
|
||||
err := viper.Unmarshal(&Server, viper.DecodeHook(
|
||||
mapstructure.ComposeDecodeHookFunc(
|
||||
mapstructure.TextUnmarshallerHookFunc(),
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
mapstructure.StringToSliceHookFunc(","),
|
||||
),
|
||||
))
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
|
||||
os.Exit(1)
|
||||
logFatal("Error parsing config:", err)
|
||||
}
|
||||
|
||||
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", err)
|
||||
os.Exit(1)
|
||||
// Validate non-root user early, before any filesystem operations
|
||||
if err := validateEnforceNonRootUser(); err != nil {
|
||||
logFatal(err)
|
||||
}
|
||||
|
||||
if Server.CacheFolder == "" {
|
||||
Server.CacheFolder = filepath.Join(Server.DataFolder, "cache")
|
||||
}
|
||||
err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", err)
|
||||
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.CacheFolder.String() == "" {
|
||||
Server.CacheFolder = NewDir(filepath.Join(Server.DataFolder.String(), "cache"))
|
||||
}
|
||||
|
||||
if Server.Plugins.Enabled {
|
||||
if Server.Plugins.Folder == "" {
|
||||
Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins")
|
||||
}
|
||||
err = os.MkdirAll(Server.Plugins.Folder, 0700)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating plugins path:", err)
|
||||
os.Exit(1)
|
||||
if Server.Plugins.Folder.String() == "" {
|
||||
Server.Plugins.Folder = NewDirWithPerm(filepath.Join(Server.DataFolder.String(), "plugins"), 0700)
|
||||
} else {
|
||||
Server.Plugins.Folder = NewDirWithPerm(Server.Plugins.Folder.String(), 0700)
|
||||
}
|
||||
}
|
||||
|
||||
Server.ConfigFile = viper.GetViper().ConfigFileUsed()
|
||||
if Server.DbPath == "" {
|
||||
Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath)
|
||||
}
|
||||
|
||||
if Server.Backup.Path != "" {
|
||||
err = os.MkdirAll(Server.Backup.Path, os.ModePerm)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating backup path:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
Server.DbPath = filepath.Join(Server.DataFolder.String(), consts.DefaultDbPath)
|
||||
}
|
||||
|
||||
out := os.Stderr
|
||||
if Server.LogFile != "" {
|
||||
if mkErr := os.MkdirAll(filepath.Dir(Server.LogFile), os.ModePerm); mkErr != nil {
|
||||
logFatal(fmt.Sprintf("Error creating log file directory: %s", mkErr.Error()))
|
||||
}
|
||||
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "FATAL: Error opening log file %s: %s\n", Server.LogFile, err.Error())
|
||||
os.Exit(1)
|
||||
logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
|
||||
}
|
||||
log.SetOutput(out)
|
||||
} else if os.Getenv("ND_SYSTEMD_PRIORITY_LOGGING") != "" && os.Getenv("JOURNAL_STREAM") != "" {
|
||||
@ -359,10 +393,11 @@ func Load(noConfigDump bool) {
|
||||
validateBackupSchedule,
|
||||
validatePlaylistsPath,
|
||||
validatePurgeMissingOption,
|
||||
validateMaxImageUploadSize,
|
||||
validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
|
||||
)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
logFatal(err)
|
||||
}
|
||||
|
||||
Server.Search.Backend = normalizeSearchBackend(Server.Search.Backend)
|
||||
@ -370,8 +405,7 @@ func Load(noConfigDump bool) {
|
||||
if Server.BaseURL != "" {
|
||||
u, err := url.Parse(Server.BaseURL)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Invalid BaseURL:", err)
|
||||
os.Exit(1)
|
||||
logFatal("Invalid BaseURL:", err)
|
||||
}
|
||||
Server.BasePath = u.Path
|
||||
u.Path = ""
|
||||
@ -421,10 +455,19 @@ func Load(noConfigDump bool) {
|
||||
logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality")
|
||||
logDeprecatedOptions("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
|
||||
logDeprecatedOptions("EnableTranscodingCancellation", "Transcoding.EnableCancellation")
|
||||
|
||||
// Removed options
|
||||
logRemovedOptions("Spotify.ID", "Spotify.Secret")
|
||||
|
||||
// Validate other options
|
||||
if Server.UICoverArtSize < 200 || Server.UICoverArtSize > 1200 {
|
||||
newValue := max(200, min(1200, Server.UICoverArtSize))
|
||||
log.Warn("UICoverArtSize must be between 200 and 1200, clamping", "value", Server.UICoverArtSize, "newValue", newValue)
|
||||
Server.UICoverArtSize = newValue
|
||||
}
|
||||
|
||||
// Call init hooks
|
||||
for _, hook := range hooks {
|
||||
hook()
|
||||
@ -466,6 +509,35 @@ func logRemovedOptions(options ...string) {
|
||||
}
|
||||
}
|
||||
|
||||
// remapEnvVarKeysFromConfig detects ND_-prefixed keys in the config file (users mistakenly
|
||||
// using environment variable names) and remaps them to canonical Viper keys with a warning.
|
||||
func remapEnvVarKeysFromConfig() {
|
||||
for _, key := range viper.AllKeys() {
|
||||
if !strings.HasPrefix(key, "nd_") || !viper.InConfig(key) {
|
||||
continue
|
||||
}
|
||||
stripped := strings.TrimPrefix(key, "nd_")
|
||||
canonicalKey := strings.ReplaceAll(stripped, "_", ".")
|
||||
displayNDKey := "ND_" + strings.ToUpper(stripped)
|
||||
displayCanonical := toPascalCase(canonicalKey)
|
||||
|
||||
if viper.InConfig(canonicalKey) {
|
||||
logFatal(fmt.Sprintf(
|
||||
"Config file contains both '%s' and '%s'. Remove the ND_-prefixed version. "+
|
||||
"The 'ND_' prefix is only needed for environment variables, not config file keys.",
|
||||
displayNDKey, displayCanonical,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
viper.Set(canonicalKey, viper.Get(key))
|
||||
_, _ = fmt.Fprintf(os.Stderr, "WARNING: Config key '%s' uses environment variable naming. Use '%s' instead. "+
|
||||
"The 'ND_' prefix is only needed for environment variables.\n",
|
||||
displayNDKey, displayCanonical,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@ -483,18 +555,15 @@ func parseIniFileConfiguration() {
|
||||
var iniConfig map[string]any
|
||||
err := viper.Unmarshal(&iniConfig)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
|
||||
os.Exit(1)
|
||||
logFatal("Error parsing config:", err)
|
||||
}
|
||||
cfg, ok := iniConfig["default"].(map[string]any)
|
||||
if !ok {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config: missing [default] section:", iniConfig)
|
||||
os.Exit(1)
|
||||
logFatal("Error parsing config: missing [default] section:", iniConfig)
|
||||
}
|
||||
err = viper.MergeConfigMap(cfg)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
|
||||
os.Exit(1)
|
||||
logFatal("Error parsing config:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -516,8 +585,7 @@ func validatePlaylistsPath() error {
|
||||
for path := range strings.SplitSeq(Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
||||
_, err := doublestar.Match(path, "")
|
||||
if err != nil {
|
||||
log.Error("Invalid PlaylistsPath", "path", path, err)
|
||||
return err
|
||||
return fmt.Errorf("invalid PlaylistsPath %q: %w", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@ -544,13 +612,31 @@ func validatePurgeMissingOption() error {
|
||||
valid := slices.Contains(allowedValues, Server.Scanner.PurgeMissing)
|
||||
if !valid {
|
||||
err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
|
||||
log.Error(err.Error())
|
||||
Server.Scanner.PurgeMissing = consts.PurgeMissingNever
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMaxImageUploadSize() error {
|
||||
if _, err := humanize.ParseBytes(Server.MaxImageUploadSize); err != nil {
|
||||
return fmt.Errorf("invalid MaxImageUploadSize %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", Server.MaxImageUploadSize, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateEnforceNonRootUser() error {
|
||||
if !Server.EnforceNonRootUser || currentGOOS() == "windows" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if getEUID() == 0 {
|
||||
return fmt.Errorf("EnforceNonRootUser is enabled but Navidrome is running as root")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateScanSchedule() error {
|
||||
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
|
||||
Server.Scanner.Schedule = ""
|
||||
@ -562,7 +648,7 @@ func validateScanSchedule() error {
|
||||
}
|
||||
|
||||
func validateBackupSchedule() error {
|
||||
if Server.Backup.Path == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 {
|
||||
if Server.Backup.Path.String() == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 {
|
||||
Server.Backup.Schedule = ""
|
||||
return nil
|
||||
}
|
||||
@ -574,9 +660,9 @@ func validateBackupSchedule() error {
|
||||
func validateSchedule(schedule, field string) (string, error) {
|
||||
_, 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)
|
||||
return schedule, fmt.Errorf("invalid %s %q (see https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format): %w", field, schedule, err)
|
||||
}
|
||||
return schedule, err
|
||||
return schedule, nil
|
||||
}
|
||||
|
||||
// validateURL checks if the provided URL is valid and has either http or https scheme.
|
||||
@ -588,19 +674,13 @@ func validateURL(optionName, optionURL string) func() error {
|
||||
}
|
||||
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
|
||||
return fmt.Errorf("invalid %s %q: %w", optionName, optionURL, 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
|
||||
return fmt.Errorf("invalid scheme for %s: '%s'. Only 'http' and 'https' are allowed", optionName, u.Scheme)
|
||||
}
|
||||
// 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 fmt.Errorf("invalid %s: '%s'. A full http(s) URL with a non-empty host is required", optionName, optionURL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -617,6 +697,21 @@ func normalizeSearchBackend(value string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// toPascalCase converts a dotted lowercase config key to PascalCase for display.
|
||||
// Example: "scanner.schedule" → "Scanner.Schedule"
|
||||
func toPascalCase(key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(key, ".")
|
||||
for i, part := range parts {
|
||||
if len(part) > 0 {
|
||||
parts[i] = strings.ToUpper(part[:1]) + part[1:]
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
|
||||
// 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)
|
||||
@ -641,6 +736,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("address", "0.0.0.0")
|
||||
viper.SetDefault("port", 4533)
|
||||
viper.SetDefault("unixsocketperm", "0660")
|
||||
viper.SetDefault("enforcenonrootuser", false)
|
||||
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
|
||||
viper.SetDefault("baseurl", "")
|
||||
viper.SetDefault("tlscert", "")
|
||||
@ -649,7 +745,6 @@ func setViperDefaults() {
|
||||
viper.SetDefault("uiwelcomemessage", "")
|
||||
viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists)
|
||||
viper.SetDefault("enabletranscodingconfig", false)
|
||||
viper.SetDefault("enabletranscodingcancellation", false)
|
||||
viper.SetDefault("transcodingcachesize", "100MB")
|
||||
viper.SetDefault("imagecachesize", "100MB")
|
||||
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
|
||||
@ -666,16 +761,20 @@ func setViperDefaults() {
|
||||
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
|
||||
viper.SetDefault("search.fullstring", false)
|
||||
viper.SetDefault("search.backend", "fts")
|
||||
viper.SetDefault("similarsongsmatchthreshold", 85)
|
||||
viper.SetDefault("matcher.preferstarred", true)
|
||||
viper.SetDefault("matcher.fuzzythreshold", 85)
|
||||
viper.SetDefault("recentlyaddedbymodtime", false)
|
||||
viper.SetDefault("prefersorttags", false)
|
||||
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
|
||||
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
|
||||
viper.SetDefault("ffmpegpath", "")
|
||||
viper.SetDefault("mpvpath", "")
|
||||
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("coverartquality", 75)
|
||||
viper.SetDefault("enablewebpencoding", false)
|
||||
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
||||
viper.SetDefault("artistimagefolder", "")
|
||||
viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded")
|
||||
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
|
||||
viper.SetDefault("enablegravatar", false)
|
||||
@ -686,10 +785,13 @@ func setViperDefaults() {
|
||||
viper.SetDefault("defaultlanguage", "")
|
||||
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
|
||||
viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs)
|
||||
viper.SetDefault("uicoverartsize", consts.DefaultUICoverArtSize)
|
||||
viper.SetDefault("enablereplaygain", true)
|
||||
viper.SetDefault("enablecoveranimation", true)
|
||||
viper.SetDefault("enablenowplaying", true)
|
||||
viper.SetDefault("enablecoverartupload", true)
|
||||
viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval)
|
||||
viper.SetDefault("enableartworkupload", true)
|
||||
viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize)
|
||||
viper.SetDefault("enablesharing", false)
|
||||
viper.SetDefault("shareurl", "")
|
||||
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
|
||||
@ -727,6 +829,9 @@ func setViperDefaults() {
|
||||
viper.SetDefault("subsonic.enableaveragerating", true)
|
||||
viper.SetDefault("subsonic.legacyclients", "DSub")
|
||||
viper.SetDefault("subsonic.minimalclients", "SubMusic")
|
||||
viper.SetDefault("transcoding.maxconcurrent", 0)
|
||||
viper.SetDefault("transcoding.maxconcurrentperuser", 0)
|
||||
viper.SetDefault("transcoding.enablecancellation", false)
|
||||
viper.SetDefault("agents", "deezer,lastfm,listenbrainz")
|
||||
viper.SetDefault("lastfm.enabled", true)
|
||||
viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage)
|
||||
@ -754,6 +859,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("plugins.enabled", true)
|
||||
viper.SetDefault("plugins.cachesize", "200MB")
|
||||
viper.SetDefault("plugins.autoreload", false)
|
||||
viper.SetDefault("plugins.loglevel", "")
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
viper.SetDefault("devlogsourceline", false)
|
||||
@ -767,9 +873,10 @@ func setViperDefaults() {
|
||||
viper.SetDefault("devuishowconfig", true)
|
||||
viper.SetDefault("devneweventstream", true)
|
||||
viper.SetDefault("devoffsetoptimize", 50000)
|
||||
viper.SetDefault("devartworkmaxrequests", max(4, runtime.NumCPU()))
|
||||
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2))
|
||||
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
||||
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||
viper.SetDefault("devartworkthrottlebuffered", true)
|
||||
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
|
||||
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
|
||||
viper.SetDefault("devexternalscanner", true)
|
||||
@ -783,7 +890,6 @@ func setViperDefaults() {
|
||||
viper.SetDefault("devoptimizedb", true)
|
||||
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
|
||||
viper.SetDefault("devenablemediafileprobe", true)
|
||||
viper.SetDefault("devjpegcoverart", false)
|
||||
}
|
||||
|
||||
func init() {
|
||||
@ -820,8 +926,7 @@ func InitConfig(cfgFile string, loadEnvVars bool) {
|
||||
|
||||
err := viper.ReadInConfig()
|
||||
if viper.ConfigFileUsed() != "" && err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Navidrome could not open config file: ", err)
|
||||
os.Exit(1)
|
||||
logFatal("Navidrome could not open config file:", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ package conf_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@ -24,6 +25,11 @@ var _ = Describe("Configuration", func() {
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("loglevel", "error")
|
||||
conf.ResetConf()
|
||||
|
||||
// Panic instead of exiting on fatal errors to allow testing error conditions
|
||||
DeferCleanup(conf.SetLogFatal(func(args ...any) {
|
||||
panic(fmt.Sprint(args...))
|
||||
}))
|
||||
})
|
||||
|
||||
Describe("ParseLanguages", func() {
|
||||
@ -108,6 +114,170 @@ var _ = Describe("Configuration", func() {
|
||||
Entry("falls back to 'fts' for empty string", "", "fts"),
|
||||
)
|
||||
|
||||
DescribeTable("ToPascalCase",
|
||||
func(input, expected string) {
|
||||
Expect(conf.ToPascalCase(input)).To(Equal(expected))
|
||||
},
|
||||
Entry("simple key", "address", "Address"),
|
||||
Entry("dotted key", "scanner.schedule", "Scanner.Schedule"),
|
||||
Entry("already capitalized", "Address", "Address"),
|
||||
Entry("multi-segment", "lastfm.enabled", "Lastfm.Enabled"),
|
||||
Entry("empty string", "", ""),
|
||||
)
|
||||
|
||||
Describe("remapEnvVarKeysFromConfig", func() {
|
||||
BeforeEach(func() {
|
||||
viper.Reset()
|
||||
conf.SetViperDefaults()
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("loglevel", "error")
|
||||
conf.ResetConf()
|
||||
})
|
||||
|
||||
It("remaps ND_-prefixed keys to canonical keys", func() {
|
||||
filename := filepath.Join("testdata", "cfg_nd_keys.toml")
|
||||
conf.InitConfig(filename, false)
|
||||
conf.Load(true)
|
||||
|
||||
Expect(conf.Server.Address).To(Equal("127.0.0.1"))
|
||||
Expect(conf.Server.Port).To(Equal(4531))
|
||||
Expect(conf.Server.Scanner.Schedule).To(Equal("@every 1h"))
|
||||
})
|
||||
|
||||
It("exits with fatal error when both ND_ and canonical key exist", func() {
|
||||
filename := filepath.Join("testdata", "cfg_nd_conflict.toml")
|
||||
conf.InitConfig(filename, false)
|
||||
|
||||
Expect(func() { conf.Load(true) }).To(PanicWith(And(
|
||||
ContainSubstring("ND_ADDRESS"),
|
||||
ContainSubstring("Address"),
|
||||
ContainSubstring("only needed for environment variables"),
|
||||
)))
|
||||
})
|
||||
|
||||
It("does nothing when no ND_ keys are present", func() {
|
||||
filename := filepath.Join("testdata", "cfg.toml")
|
||||
conf.InitConfig(filename, false)
|
||||
conf.Load(true)
|
||||
|
||||
// Verify normal config loading still works
|
||||
Expect(conf.Server.MusicFolder).To(Equal("/toml/music"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("logFatal", func() {
|
||||
var invalidPath string
|
||||
BeforeEach(func() {
|
||||
viper.Reset()
|
||||
conf.SetViperDefaults()
|
||||
viper.SetDefault("loglevel", "error")
|
||||
conf.ResetConf()
|
||||
|
||||
// Create a file so that any path under it is invalid on all OSes
|
||||
f, err := os.CreateTemp(GinkgoT().TempDir(), "blocker")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
f.Close()
|
||||
invalidPath = filepath.Join(f.Name(), "subdir")
|
||||
})
|
||||
|
||||
It("is called when LoadFromFile gets an invalid config file", func() {
|
||||
Expect(func() {
|
||||
conf.LoadFromFile(filepath.Join(invalidPath, "file.toml"))
|
||||
}).To(PanicWith(ContainSubstring("Error reading config file")))
|
||||
})
|
||||
|
||||
It("is called when LogFile path is not writable", func() {
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("logfile", filepath.Join(invalidPath, "log.txt"))
|
||||
Expect(func() {
|
||||
conf.Load(true)
|
||||
}).To(PanicWith(ContainSubstring("Error creating log file directory")))
|
||||
})
|
||||
|
||||
It("is called when BaseURL is invalid", func() {
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("baseurl", "://invalid")
|
||||
Expect(func() {
|
||||
conf.Load(true)
|
||||
}).To(PanicWith(ContainSubstring("Invalid BaseURL")))
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
Describe("ValidateMaxImageUploadSize", func() {
|
||||
BeforeEach(func() {
|
||||
viper.Reset()
|
||||
conf.SetViperDefaults()
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("loglevel", "error")
|
||||
conf.ResetConf()
|
||||
})
|
||||
|
||||
DescribeTable("accepts valid size values",
|
||||
func(input string) {
|
||||
conf.Server.MaxImageUploadSize = input
|
||||
Expect(conf.ValidateMaxImageUploadSize()).To(Succeed())
|
||||
},
|
||||
Entry("megabytes", "10MB"),
|
||||
Entry("gigabytes", "1GB"),
|
||||
Entry("raw bytes", "10485760"),
|
||||
Entry("mebibytes", "10MiB"),
|
||||
Entry("lower case", "50mb"),
|
||||
)
|
||||
|
||||
DescribeTable("rejects invalid size values",
|
||||
func(input string) {
|
||||
conf.Server.MaxImageUploadSize = input
|
||||
Expect(conf.ValidateMaxImageUploadSize()).To(MatchError(ContainSubstring("invalid MaxImageUploadSize")))
|
||||
},
|
||||
Entry("garbage string", "not-a-size"),
|
||||
Entry("negative-looking", "-10MB"),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("EnforceNonRootUser", func() {
|
||||
It("defaults to false", func() {
|
||||
conf.Load(true)
|
||||
|
||||
Expect(conf.Server.EnforceNonRootUser).To(BeFalse())
|
||||
})
|
||||
|
||||
It("allows startup for non-root users when enabled", func() {
|
||||
DeferCleanup(conf.SetRuntimeInfoForTest("linux", 1000))
|
||||
viper.Set("enforcenonrootuser", true)
|
||||
|
||||
conf.Load(true)
|
||||
|
||||
Expect(conf.Server.EnforceNonRootUser).To(BeTrue())
|
||||
})
|
||||
|
||||
It("exits when enabled and running as root without having created a data folder", func() {
|
||||
// Create a path that doesn't exist yet
|
||||
tempBase := GinkgoT().TempDir()
|
||||
nonExistentDataFolder := filepath.Join(tempBase, "nonexistent", "data")
|
||||
DeferCleanup(conf.SetRuntimeInfoForTest("linux", 0))
|
||||
viper.Set("enforcenonrootuser", true)
|
||||
viper.Set("datafolder", nonExistentDataFolder)
|
||||
|
||||
// Attempt to load config as root user - should fail before creating directories
|
||||
Expect(func() {
|
||||
conf.Load(true)
|
||||
}).To(PanicWith(ContainSubstring("EnforceNonRootUser is enabled but Navidrome is running as root")))
|
||||
|
||||
// Verify that the data folder was NOT created
|
||||
Expect(nonExistentDataFolder).ToNot(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("is a no-op on non-unix platforms", func() {
|
||||
DeferCleanup(conf.SetRuntimeInfoForTest("windows", 0))
|
||||
viper.Set("enforcenonrootuser", true)
|
||||
|
||||
conf.Load(true)
|
||||
|
||||
Expect(conf.Server.EnforceNonRootUser).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTable("should load configuration from",
|
||||
func(format string) {
|
||||
filename := filepath.Join("testdata", "cfg."+format)
|
||||
|
||||
77
conf/dir.go
Normal file
77
conf/dir.go
Normal file
@ -0,0 +1,77 @@
|
||||
package conf
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Dir wraps a directory path and creates the directory on demand. Dir is a
|
||||
// plain value type — safe to copy, compare, and print via reflection-based
|
||||
// formatters (pretty.Sprintf("%# v", ...)) without any concurrency hazards.
|
||||
// Directory creation is delegated to os.MkdirAll on every Path() call;
|
||||
// MkdirAll is idempotent, so repeated calls cost one stat syscall when the
|
||||
// directory already exists.
|
||||
type Dir struct {
|
||||
path string
|
||||
perm os.FileMode
|
||||
}
|
||||
|
||||
// NewDir creates a new Dir with the given path and default permissions (os.ModePerm).
|
||||
func NewDir(path string) Dir {
|
||||
return Dir{path: path, perm: os.ModePerm}
|
||||
}
|
||||
|
||||
// NewDirWithPerm creates a new Dir with the given path and permissions.
|
||||
// A perm of 0 is treated as "default" and resolves to os.ModePerm at
|
||||
// directory-creation time; pass an explicit non-zero mode to constrain the
|
||||
// permissions.
|
||||
func NewDirWithPerm(path string, perm os.FileMode) Dir {
|
||||
return Dir{path: path, perm: perm}
|
||||
}
|
||||
|
||||
// String returns the raw path without creating the directory. Satisfies fmt.Stringer.
|
||||
func (d Dir) String() string {
|
||||
return d.path
|
||||
}
|
||||
|
||||
// Path ensures the directory exists and returns its path. Safe to call
|
||||
// repeatedly; an empty path is returned as-is with no error.
|
||||
func (d Dir) Path() (string, error) {
|
||||
if d.path == "" {
|
||||
return "", nil
|
||||
}
|
||||
if err := os.MkdirAll(d.path, cmp.Or(d.perm, os.ModePerm)); err != nil {
|
||||
return d.path, fmt.Errorf("creating directory %q: %w", d.path, err)
|
||||
}
|
||||
return d.path, nil
|
||||
}
|
||||
|
||||
// MustPath calls Path() and calls logFatal on error.
|
||||
func (d Dir) MustPath() string {
|
||||
path, err := d.Path()
|
||||
if err != nil {
|
||||
logFatal("creating directory:", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// GoString implements fmt.GoStringer so that %#v (used by pretty.Sprintf)
|
||||
// prints the path string instead of the internal struct fields.
|
||||
func (d Dir) GoString() string {
|
||||
return fmt.Sprintf("%q", d.path)
|
||||
}
|
||||
|
||||
// MarshalText returns the raw path bytes. No side effects.
|
||||
func (d Dir) MarshalText() ([]byte, error) {
|
||||
return []byte(d.path), nil
|
||||
}
|
||||
|
||||
// UnmarshalText sets the path from bytes. No side effects.
|
||||
func (d *Dir) UnmarshalText(text []byte) error {
|
||||
d.path = string(text)
|
||||
if d.perm == 0 {
|
||||
d.perm = os.ModePerm
|
||||
}
|
||||
return nil
|
||||
}
|
||||
164
conf/dir_test.go
Normal file
164
conf/dir_test.go
Normal file
@ -0,0 +1,164 @@
|
||||
package conf_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/kr/pretty"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Dir", func() {
|
||||
Describe("NewDir", func() {
|
||||
It("creates a Dir with the given path without side effects", func() {
|
||||
d := conf.NewDir("/some/path")
|
||||
Expect(d.String()).To(Equal("/some/path"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("String", func() {
|
||||
It("returns the raw path without creating the directory", func() {
|
||||
d := conf.NewDir("/nonexistent/path/that/should/not/be/created")
|
||||
Expect(d.String()).To(Equal("/nonexistent/path/that/should/not/be/created"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Path", func() {
|
||||
It("creates the directory and returns the path on first call", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
target := dir + "/subdir/nested"
|
||||
d := conf.NewDir(target)
|
||||
|
||||
path, err := d.Path()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(target))
|
||||
Expect(target).To(BeADirectory())
|
||||
})
|
||||
|
||||
It("is idempotent on subsequent calls", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
target := dir + "/idempotent"
|
||||
d := conf.NewDir(target)
|
||||
|
||||
path1, err1 := d.Path()
|
||||
path2, err2 := d.Path()
|
||||
Expect(err1).ToNot(HaveOccurred())
|
||||
Expect(err2).ToNot(HaveOccurred())
|
||||
Expect(path1).To(Equal(path2))
|
||||
Expect(target).To(BeADirectory())
|
||||
})
|
||||
|
||||
It("returns an error when directory cannot be created", func() {
|
||||
f := GinkgoT().TempDir()
|
||||
blocker := f + "/blocker"
|
||||
By("creating a file that blocks directory creation")
|
||||
Expect(os.WriteFile(blocker, []byte("x"), 0600)).To(Succeed())
|
||||
invalid := blocker + "/subdir"
|
||||
|
||||
d := conf.NewDir(invalid)
|
||||
_, pathErr := d.Path()
|
||||
Expect(pathErr).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns empty path and no error for empty path", func() {
|
||||
d := conf.NewDir("")
|
||||
path, err := d.Path()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("MustPath", func() {
|
||||
It("returns the path when directory is created successfully", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
target := dir + "/mustpath"
|
||||
d := conf.NewDir(target)
|
||||
|
||||
path := d.MustPath()
|
||||
Expect(path).To(Equal(target))
|
||||
Expect(target).To(BeADirectory())
|
||||
})
|
||||
|
||||
It("calls logFatal on error", func() {
|
||||
var fatalMsg []any
|
||||
restore := conf.SetLogFatal(func(args ...any) {
|
||||
fatalMsg = args
|
||||
panic("logFatal called")
|
||||
})
|
||||
DeferCleanup(restore)
|
||||
|
||||
f := GinkgoT().TempDir() + "/blocker"
|
||||
Expect(os.WriteFile(f, []byte("x"), 0600)).To(Succeed())
|
||||
invalid := f + "/subdir"
|
||||
|
||||
d := conf.NewDir(invalid)
|
||||
Expect(func() { d.MustPath() }).To(Panic())
|
||||
Expect(fatalMsg).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("MarshalText", func() {
|
||||
It("returns the raw path bytes without side effects", func() {
|
||||
d := conf.NewDir("/marshal/path")
|
||||
b, err := d.MarshalText()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(b)).To(Equal("/marshal/path"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("UnmarshalText", func() {
|
||||
It("sets the path from bytes without side effects", func() {
|
||||
d := conf.NewDir("")
|
||||
err := d.UnmarshalText([]byte("/unmarshal/path"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(d.String()).To(Equal("/unmarshal/path"))
|
||||
})
|
||||
|
||||
It("allows round-trip marshal/unmarshal", func() {
|
||||
d1 := conf.NewDir("/round/trip")
|
||||
b, err := d1.MarshalText()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var d2 conf.Dir
|
||||
err = d2.UnmarshalText(b)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(d2.String()).To(Equal(d1.String()))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GoString", func() {
|
||||
// Regression: pretty.Sprintf("%# v", ...) is used by the
|
||||
// configuration dump. It must render Dir as a quoted path via
|
||||
// GoString, not dump the internal struct fields.
|
||||
It("renders Dir as a quoted path under pretty.Sprintf", func() {
|
||||
type host struct {
|
||||
DataFolder conf.Dir
|
||||
}
|
||||
h := host{DataFolder: conf.NewDir("./data")}
|
||||
out := pretty.Sprintf("%# v", h)
|
||||
Expect(out).To(ContainSubstring(`DataFolder: "./data"`))
|
||||
Expect(out).ToNot(ContainSubstring("perm:"))
|
||||
Expect(out).ToNot(ContainSubstring("path:"))
|
||||
})
|
||||
|
||||
It("is safe to copy and use concurrently", func() {
|
||||
// Regression for the Windows "sync: unlock of unlocked mutex"
|
||||
// crash that was caused by copying a Dir embedding sync.Once.
|
||||
// Dir is a plain value type now, but keep the concurrent stress
|
||||
// test to lock in the property.
|
||||
dir := GinkgoT().TempDir()
|
||||
d := conf.NewDir(dir + "/race")
|
||||
var wg sync.WaitGroup
|
||||
for range 10 {
|
||||
wg.Go(func() {
|
||||
copy1 := d
|
||||
_ = pretty.Sprintf("%# v", copy1)
|
||||
_, _ = copy1.Path()
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -11,3 +11,24 @@ var ParseLanguages = parseLanguages
|
||||
var ValidateURL = validateURL
|
||||
|
||||
var NormalizeSearchBackend = normalizeSearchBackend
|
||||
|
||||
var ToPascalCase = toPascalCase
|
||||
|
||||
var ValidateMaxImageUploadSize = validateMaxImageUploadSize
|
||||
|
||||
func SetRuntimeInfoForTest(goos string, euid int) func() {
|
||||
oldGOOS := currentGOOS
|
||||
oldEUID := getEUID
|
||||
currentGOOS = func() string { return goos }
|
||||
getEUID = func() int { return euid }
|
||||
return func() {
|
||||
currentGOOS = oldGOOS
|
||||
getEUID = oldEUID
|
||||
}
|
||||
}
|
||||
|
||||
func SetLogFatal(f func(...any)) func() {
|
||||
old := logFatal
|
||||
logFatal = f
|
||||
return func() { logFatal = old }
|
||||
}
|
||||
|
||||
2
conf/testdata/cfg_nd_conflict.toml
vendored
Normal file
2
conf/testdata/cfg_nd_conflict.toml
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
ND_ADDRESS = "127.0.0.1"
|
||||
Address = "0.0.0.0"
|
||||
3
conf/testdata/cfg_nd_keys.toml
vendored
Normal file
3
conf/testdata/cfg_nd_keys.toml
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
ND_ADDRESS = "127.0.0.1"
|
||||
ND_PORT = 4531
|
||||
ND_SCANNER_SCHEDULE = "@every 1h"
|
||||
@ -67,11 +67,12 @@ const (
|
||||
ScanIgnoreFile = ".ndignore"
|
||||
ArtworkFolder = "artwork"
|
||||
|
||||
PlaceholderArtistArt = "artist-placeholder.webp"
|
||||
PlaceholderAlbumArt = "album-placeholder.webp"
|
||||
PlaceholderAvatar = "logo-192x192.png"
|
||||
DefaultUIVolume = 100
|
||||
DefaultUISearchDebounceMs = 200
|
||||
PlaceholderArtistArt = "artist-placeholder.webp"
|
||||
PlaceholderAlbumArt = "album-placeholder.webp"
|
||||
PlaceholderAvatar = "logo-192x192.png"
|
||||
DefaultUIVolume = 100
|
||||
DefaultUISearchDebounceMs = 200
|
||||
DefaultUIPlaybackReportInterval = time.Minute
|
||||
|
||||
DefaultHttpClientTimeOut = 10 * time.Second
|
||||
|
||||
@ -85,11 +86,10 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
UICoverArtSize = 600
|
||||
DefaultUICoverArtSize = 300
|
||||
DefaultMaxImageUploadSize = "10MB"
|
||||
)
|
||||
|
||||
var CacheWarmerImageSizes = []int{UICoverArtSize}
|
||||
|
||||
// Prometheus options
|
||||
const (
|
||||
PrometheusDefaultPath = "/metrics"
|
||||
@ -153,25 +153,25 @@ var (
|
||||
Name: "mp3 audio",
|
||||
TargetFormat: "mp3",
|
||||
DefaultBitRate: 192,
|
||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
|
||||
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
|
||||
},
|
||||
{
|
||||
Name: "opus audio",
|
||||
TargetFormat: "opus",
|
||||
DefaultBitRate: 128,
|
||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
|
||||
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
|
||||
},
|
||||
{
|
||||
Name: "aac audio",
|
||||
TargetFormat: "aac",
|
||||
DefaultBitRate: 256,
|
||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
|
||||
Command: "ffmpeg -ss %t -i %s -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 -",
|
||||
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
4
context7.json
Normal file
4
context7.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"url": "https://context7.com/navidrome/navidrome",
|
||||
"public_key": "pk_WqzhKScNKWQ84J4n0oG0J"
|
||||
}
|
||||
@ -3,6 +3,7 @@ package core
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@ -14,6 +15,7 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
type Archiver interface {
|
||||
@ -59,7 +61,15 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr
|
||||
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
|
||||
for _, mf := range album {
|
||||
file := a.albumFilename(mf, format, isMultiDisc)
|
||||
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
|
||||
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||
// Stop iterating: continuing would just rack up more
|
||||
// rejections from the limiter. Close finalises whatever
|
||||
// tracks were already written; the rejected one is not
|
||||
// present in the archive (addFileToZip aborts before
|
||||
// writing its entry header).
|
||||
_ = z.Close()
|
||||
return addErr
|
||||
}
|
||||
}
|
||||
}
|
||||
err = z.Close()
|
||||
@ -87,7 +97,7 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc
|
||||
if isMultiDisc {
|
||||
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
|
||||
}
|
||||
return fmt.Sprintf("%s/%s", sanitizeName(mf.Album), file)
|
||||
return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file)
|
||||
}
|
||||
|
||||
func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error {
|
||||
@ -119,14 +129,19 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st
|
||||
zippedMfs := make(model.MediaFiles, len(mfs))
|
||||
for idx, mf := range mfs {
|
||||
file := a.playlistFilename(mf, format, idx)
|
||||
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
|
||||
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||
// Abort the whole archive: continuing would silently emit
|
||||
// empty zip entries since the headers are already written.
|
||||
_ = z.Close()
|
||||
return addErr
|
||||
}
|
||||
mf.Path = file
|
||||
zippedMfs[idx] = mf
|
||||
}
|
||||
|
||||
// Add M3U file if requested
|
||||
if addM3U && len(zippedMfs) > 0 {
|
||||
plsName := sanitizeName(name)
|
||||
plsName := str.SanitizeFilename(name)
|
||||
w, err := z.CreateHeader(&zip.FileHeader{
|
||||
Name: plsName + ".m3u",
|
||||
Modified: mfs[0].UpdatedAt,
|
||||
@ -156,15 +171,32 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int)
|
||||
if format != "" && format != "raw" {
|
||||
ext = format
|
||||
}
|
||||
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, sanitizeName(mf.Artist), sanitizeName(mf.Title), ext)
|
||||
}
|
||||
|
||||
func sanitizeName(target string) string {
|
||||
return strings.ReplaceAll(target, "/", "_")
|
||||
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext)
|
||||
}
|
||||
|
||||
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {
|
||||
path := mf.AbsolutePath()
|
||||
|
||||
// Open the source before writing the zip entry header so a rejection
|
||||
// (limiter, missing file, etc.) does not leave an empty entry in the
|
||||
// archive.
|
||||
var r io.ReadCloser
|
||||
var err error
|
||||
if format != "raw" && format != "" {
|
||||
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
|
||||
} else {
|
||||
r, err = os.Open(path)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
|
||||
log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err)
|
||||
}
|
||||
}()
|
||||
|
||||
w, err := z.CreateHeader(&zip.FileHeader{
|
||||
Name: filename,
|
||||
Modified: mf.UpdatedAt,
|
||||
@ -175,23 +207,6 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
||||
return err
|
||||
}
|
||||
|
||||
var r io.ReadCloser
|
||||
if format != "raw" && format != "" {
|
||||
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
|
||||
} else {
|
||||
r, err = os.Open(path)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
|
||||
log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = io.Copy(w, r)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error zipping file", "file", path, err)
|
||||
|
||||
@ -89,6 +89,32 @@ var _ = Describe("Archiver", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Context("when the transcode limiter rejects a file", func() {
|
||||
It("aborts the archive instead of continuing with empty entries", func() {
|
||||
mfs := model.MediaFiles{
|
||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
|
||||
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
|
||||
}
|
||||
|
||||
mfRepo := &mockMediaFileRepository{}
|
||||
mfRepo.On("GetAll", []model.QueryOptions{{
|
||||
Filters: squirrel.Eq{"album_id": "1"},
|
||||
Sort: "album",
|
||||
}}).Return(mfs, nil)
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).
|
||||
Return(nil, stream.ErrTooManyTranscodes).Once()
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
|
||||
Expect(err).To(MatchError(stream.ErrTooManyTranscodes))
|
||||
// NewStream should only have been called once: the loop must bail
|
||||
// out on the rejection instead of trying every remaining track.
|
||||
ms.AssertNumberOfCalls(GinkgoT(), "NewStream", 1)
|
||||
})
|
||||
})
|
||||
|
||||
Context("ZipShare", func() {
|
||||
It("zips a share correctly", func() {
|
||||
mfs := model.MediaFiles{
|
||||
|
||||
@ -7,12 +7,11 @@ import (
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
_ "github.com/gen2brain/webp"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@ -38,10 +37,15 @@ var _ = Describe("Artwork", func() {
|
||||
conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*"
|
||||
|
||||
folderRepo = &fakeFolderRepo{}
|
||||
libRepo := &tests.MockLibraryRepo{}
|
||||
repoRoot, _ := os.Getwd()
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
|
||||
ds = &tests.MockDataStore{
|
||||
MockedTranscoding: &tests.MockTranscodingRepo{},
|
||||
MockedFolder: folderRepo,
|
||||
MockedLibrary: libRepo,
|
||||
}
|
||||
// Paths use forward slashes because the scanner stores fs.FS-relative paths in the DB.
|
||||
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"}, Discs: model.Discs{1: "", 2: ""}}
|
||||
@ -146,13 +150,61 @@ var _ = Describe("Artwork", func() {
|
||||
Entry(nil, " embedded , front.* , cover.*,folder.*", "tests/fixtures/artist/an-album/test.mp3"),
|
||||
)
|
||||
})
|
||||
Context("LastUpdated", func() {
|
||||
// Regression test for #5377: LastUpdated feeds the HTTP Last-Modified header.
|
||||
// It must return max(album.UpdatedAt, ImagesUpdatedAt) so browsers revalidate
|
||||
// cached cover art when only the image file changes.
|
||||
now := time.Now().Truncate(time.Second)
|
||||
DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt",
|
||||
func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) {
|
||||
album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt}
|
||||
folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album})
|
||||
|
||||
ar, err := newAlbumArtworkReader(ctx, aw, album.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ar.LastUpdated()).To(Equal(expected))
|
||||
},
|
||||
Entry("album newer than images", now, now.Add(-1*time.Hour), now),
|
||||
Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)),
|
||||
Entry("equal timestamps", now, now, now),
|
||||
)
|
||||
})
|
||||
})
|
||||
Describe("discArtworkReader", func() {
|
||||
Context("LastUpdated", func() {
|
||||
// Regression test for #5377: same bug as albumArtworkReader — disc covers
|
||||
// must also revalidate when the image file changes, not only when media files do.
|
||||
now := time.Now().Truncate(time.Second)
|
||||
DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt",
|
||||
func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) {
|
||||
album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt}
|
||||
folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album})
|
||||
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "al1", DiscNumber: 1, Path: "tests/fixtures/test.mp3"},
|
||||
})
|
||||
|
||||
artID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("al1", 1), nil)
|
||||
dr, err := newDiscArtworkReader(ctx, aw, artID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(dr.LastUpdated()).To(Equal(expected))
|
||||
},
|
||||
Entry("album newer than images", now, now.Add(-1*time.Hour), now),
|
||||
Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)),
|
||||
Entry("equal timestamps", now, now, now),
|
||||
)
|
||||
})
|
||||
})
|
||||
Describe("artistArtworkReader", func() {
|
||||
Context("Multiple covers", func() {
|
||||
BeforeEach(func() {
|
||||
repoRoot, err := os.Getwd()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"artist.png"},
|
||||
LibraryPath: testFileLibPath(repoRoot),
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"artist.png"},
|
||||
}}
|
||||
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{
|
||||
arMultipleCovers,
|
||||
@ -171,7 +223,7 @@ var _ = Describe("Artwork", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(expected))
|
||||
Expect(filepath.ToSlash(path)).To(HaveSuffix(expected))
|
||||
},
|
||||
Entry(nil, " folder.* , artist.*,album/artist.*", "tests/fixtures/artist/artist.jpg"),
|
||||
Entry(nil, "album/artist.*, folder.*,artist.*", "tests/fixtures/artist/an-album/artist.png"),
|
||||
@ -380,6 +432,69 @@ var _ = Describe("Artwork", func() {
|
||||
})
|
||||
})
|
||||
When("Square is false", func() {
|
||||
It("returns PNG 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(img.Bounds().Size().X).To(Equal(15))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||
})
|
||||
It("returns JPEG 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(err).ToNot(HaveOccurred())
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
})
|
||||
When("When square is true", func() {
|
||||
var alCover model.Album
|
||||
|
||||
DescribeTable("resize",
|
||||
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",
|
||||
FolderIDs: []string{"tmp"},
|
||||
}
|
||||
folderRepo.result = []model.Folder{{ImageFiles: []string{coverFileName}}}
|
||||
rootLibRepo := &tests.MockLibraryRepo{}
|
||||
rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}})
|
||||
ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alCover,
|
||||
})
|
||||
|
||||
conf.Server.CoverArtPriority = coverFileName
|
||||
r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), size, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
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", "png", false, 200),
|
||||
Entry("landscape png image", "png", "png", true, 200),
|
||||
Entry("portrait jpg image", "jpg", "png", false, 200),
|
||||
Entry("landscape jpg image", "jpg", "png", true, 200),
|
||||
)
|
||||
})
|
||||
When("EnableWebPEncoding is true and square is false", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.EnableWebPEncoding = true
|
||||
})
|
||||
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)
|
||||
@ -403,51 +518,18 @@ var _ = Describe("Artwork", func() {
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
})
|
||||
When("When square is true", func() {
|
||||
var alCover model.Album
|
||||
|
||||
DescribeTable("resize",
|
||||
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",
|
||||
FolderIDs: []string{"tmp"},
|
||||
}
|
||||
folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{coverFileName}}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alCover,
|
||||
})
|
||||
|
||||
conf.Server.CoverArtPriority = coverFileName
|
||||
r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), size, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
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", "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() {
|
||||
When("EnableWebPEncoding is false and square is false", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DevJpegCoverArt = true
|
||||
conf.Server.EnableWebPEncoding = false
|
||||
})
|
||||
It("returns JPEG even if original image is a PNG", func() {
|
||||
It("returns PNG 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(format).To(Equal("png"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(15))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||
})
|
||||
@ -463,11 +545,11 @@ var _ = Describe("Artwork", func() {
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
})
|
||||
When("DevJpegCoverArt is true and square is true", func() {
|
||||
When("EnableWebPEncoding is false and square is true", func() {
|
||||
var alCover model.Album
|
||||
|
||||
BeforeEach(func() {
|
||||
conf.Server.DevJpegCoverArt = true
|
||||
conf.Server.EnableWebPEncoding = false
|
||||
})
|
||||
It("returns PNG for square mode", func() {
|
||||
dirName := createImage("png", false, 200)
|
||||
@ -476,7 +558,10 @@ var _ = Describe("Artwork", func() {
|
||||
Name: "Only external",
|
||||
FolderIDs: []string{"tmp"},
|
||||
}
|
||||
folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{"cover.png"}}}
|
||||
folderRepo.result = []model.Folder{{ImageFiles: []string{"cover.png"}}}
|
||||
rootLibRepo := &tests.MockLibraryRepo{}
|
||||
rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}})
|
||||
ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{alCover})
|
||||
|
||||
conf.Server.CoverArtPriority = "cover.png"
|
||||
|
||||
@ -1,9 +1,17 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/core/storage"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@ -15,3 +23,49 @@ func TestArtwork(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Artwork Suite")
|
||||
}
|
||||
|
||||
// osDirFS wraps os.DirFS as a storage.MusicFS for integration tests.
|
||||
// ReadTags is not used by albumArtworkReader, so it is left as a stub.
|
||||
type osDirFS struct{ fs.FS }
|
||||
|
||||
func (o osDirFS) ReadTags(...string) (map[string]metadata.Info, error) { return nil, nil }
|
||||
|
||||
// testFileScheme is the URL scheme registered to expose a tempdir as a
|
||||
// storage.MusicFS for artwork integration tests.
|
||||
const testFileScheme = "testfile"
|
||||
|
||||
// testFileLibPath builds a `testfile://` library URL for the given absolute
|
||||
// filesystem path. On Windows, the native path (e.g. `C:\foo`) has no leading
|
||||
// slash after ToSlash, which makes url.Parse treat the drive letter as a
|
||||
// host. We prepend a `/` so parsing yields `u.Path == /C:/foo`, and the
|
||||
// registered constructor below strips that leading slash back off.
|
||||
func testFileLibPath(absPath string) string {
|
||||
p := filepath.ToSlash(absPath)
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p
|
||||
}
|
||||
return testFileScheme + "://" + p
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Register the testfile storage scheme (os.DirFS-backed MusicFS). Used by
|
||||
// integration tests that need real files but not the taglib extractor.
|
||||
storage.Register(testFileScheme, func(u url.URL) storage.Storage {
|
||||
root := u.Path
|
||||
// Undo the leading slash added by testFileLibPath on Windows so that
|
||||
// os.Stat / os.DirFS receive a native path like `C:\foo`.
|
||||
if runtime.GOOS == "windows" && len(root) >= 3 && root[0] == '/' && root[2] == ':' {
|
||||
root = root[1:]
|
||||
}
|
||||
return &osDirStorage{root: filepath.FromSlash(root)}
|
||||
})
|
||||
}
|
||||
|
||||
type osDirStorage struct{ root string }
|
||||
|
||||
func (s *osDirStorage) FS() (storage.MusicFS, error) {
|
||||
if _, err := os.Stat(s.root); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return osDirFS{os.DirFS(s.root)}, nil
|
||||
}
|
||||
|
||||
@ -52,7 +52,7 @@ func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID
|
||||
|
||||
// Configure cache
|
||||
conf.Server.ImageCacheSize = cacheSize
|
||||
conf.Server.CacheFolder = tmpDir
|
||||
conf.Server.CacheFolder = conf.NewDir(tmpDir)
|
||||
conf.Server.CoverArtQuality = 75
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
|
||||
@ -169,7 +169,7 @@ func BenchmarkArtworkGetE2EConcurrent(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(n)
|
||||
for g := 0; g < n; g++ {
|
||||
for range n {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
r, _, err := aw.Get(context.Background(), artID, 300, true)
|
||||
|
||||
@ -35,8 +35,8 @@ func generatePNG(t testing.TB, width, height int) []byte {
|
||||
// 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++ {
|
||||
for y := range height {
|
||||
for x := range width {
|
||||
r := uint8((x * 255) / width)
|
||||
g := uint8((y * 255) / height)
|
||||
b := uint8(((x + y) * 255) / (width + height))
|
||||
|
||||
@ -10,7 +10,6 @@ import (
|
||||
"time"
|
||||
|
||||
"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"
|
||||
@ -24,7 +23,7 @@ type CacheWarmer interface {
|
||||
|
||||
// NewCacheWarmer creates a new CacheWarmer instance. The CacheWarmer will pre-cache Artwork images in the background
|
||||
// to speed up the response time when the image is requested by the UI. The cache is pre-populated with the original
|
||||
// image size, as well as the size defined in the UICoverArtSize constant.
|
||||
// image size, as well as the size defined by the UICoverArtSize config option.
|
||||
func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
||||
// If image cache is disabled, return a NOOP implementation
|
||||
if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache {
|
||||
@ -38,10 +37,11 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
||||
}
|
||||
|
||||
a := &cacheWarmer{
|
||||
artwork: artwork,
|
||||
cache: cache,
|
||||
buffer: make(map[model.ArtworkID]struct{}),
|
||||
wakeSignal: make(chan struct{}, 1),
|
||||
artwork: artwork,
|
||||
cache: cache,
|
||||
buffer: make(map[model.ArtworkID]struct{}),
|
||||
wakeSignal: make(chan struct{}, 1),
|
||||
coverArtSize: conf.Server.UICoverArtSize,
|
||||
}
|
||||
|
||||
// Create a context with a fake admin user, to be able to pre-cache Playlist CoverArts
|
||||
@ -51,11 +51,12 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
||||
}
|
||||
|
||||
type cacheWarmer struct {
|
||||
artwork Artwork
|
||||
buffer map[model.ArtworkID]struct{}
|
||||
mutex sync.Mutex
|
||||
cache cache.FileCache
|
||||
wakeSignal chan struct{}
|
||||
artwork Artwork
|
||||
buffer map[model.ArtworkID]struct{}
|
||||
mutex sync.Mutex
|
||||
cache cache.FileCache
|
||||
wakeSignal chan struct{}
|
||||
coverArtSize int
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) PreCache(artID model.ArtworkID) {
|
||||
@ -142,16 +143,14 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
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
|
||||
size := a.coverArtSize
|
||||
r, _, err := a.artwork.Get(ctx, id, size, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err)
|
||||
}
|
||||
return nil
|
||||
_, err = io.Copy(io.Discard, r)
|
||||
r.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
func NoopCacheWarmer() CacheWarmer {
|
||||
|
||||
@ -12,7 +12,6 @@ import (
|
||||
|
||||
"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"
|
||||
@ -182,7 +181,7 @@ var _ = Describe("CacheWarmer", func() {
|
||||
|
||||
Eventually(func() []int {
|
||||
return aw.getCachedSizes()
|
||||
}).Should(ContainElements(consts.UICoverArtSize))
|
||||
}).Should(ContainElements(conf.Server.UICoverArtSize))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
377
core/artwork/e2e/album_test.go
Normal file
377
core/artwork/e2e/album_test.go
Normal file
@ -0,0 +1,377 @@
|
||||
package artworke2e_test
|
||||
|
||||
import (
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external"
|
||||
defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded"
|
||||
)
|
||||
|
||||
var _ = Describe("Album artwork resolution", func() {
|
||||
BeforeEach(func() {
|
||||
setupHarness()
|
||||
})
|
||||
|
||||
When("an album has a single folder with cover.jpg at the album root", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── cover.jpg ← matched by cover.*
|
||||
It("returns the album-root cover", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": imageFile("album-root"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
|
||||
})
|
||||
})
|
||||
|
||||
// https://github.com/navidrome/navidrome/issues/5376
|
||||
// cover.* basenames tie across album-root and per-disc folders;
|
||||
// compareImageFiles must prefer shallower paths.
|
||||
When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── CD1/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── cover.jpg ← should not win
|
||||
// ├── CD2/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── cover.jpg
|
||||
// └── cover.jpg ← should win (album-root fallback)
|
||||
It("prefers the album-root cover over per-disc covers", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
||||
"Artist/Album/cover.jpg": imageFile("album-root"),
|
||||
"Artist/Album/CD1/cover.jpg": imageFile("disc1"),
|
||||
"Artist/Album/CD2/cover.jpg": imageFile("disc2"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(al.FolderIDs).To(HaveLen(2),
|
||||
"sanity check: scanner should treat the two disc subfolders as one multi-disc album")
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
|
||||
})
|
||||
})
|
||||
|
||||
// https://github.com/navidrome/navidrome/issues/5376
|
||||
// folder.jpg basenames tie across album-root and per-disc folders;
|
||||
// compareImageFiles must prefer shallower paths.
|
||||
When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── CD1/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── folder.jpg ← should not win
|
||||
// ├── CD2/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── folder.jpg
|
||||
// └── folder.jpg ← should win (album-root fallback)
|
||||
It("prefers the album-root folder.jpg over per-disc folder.jpg", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
||||
"Artist/Album/folder.jpg": imageFile("album-root"),
|
||||
"Artist/Album/CD1/folder.jpg": imageFile("disc1"),
|
||||
"Artist/Album/CD2/folder.jpg": imageFile("disc2"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
|
||||
})
|
||||
})
|
||||
|
||||
// https://github.com/navidrome/navidrome/issues/5376
|
||||
// Single-subfolder albums must still consider the parent folder's images.
|
||||
When("an album lives entirely under a single disc subfolder with cover.jpg at the parent", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── disc1/
|
||||
// │ └── 01 - Track.mp3
|
||||
// └── cover.jpg ← should win (parent-folder fallback)
|
||||
It("uses the parent-folder cover for single-disc-subfolder albums", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": imageFile("album-root"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
|
||||
})
|
||||
})
|
||||
|
||||
// https://github.com/navidrome/navidrome/issues/5456
|
||||
When("a top-level multi-disc album has cover.jpg at the album root and per-disc folder.jpg", func() {
|
||||
// Album/ (top-level folder, Path=".")
|
||||
// ├── CD1/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── folder.jpg
|
||||
// ├── CD2/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── folder.jpg
|
||||
// └── cover.jpg ← should win (album-root)
|
||||
It("prefers the album-root cover.jpg", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
||||
"Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
||||
"Album/cover.jpg": imageFile("album-root"),
|
||||
"Album/CD1/folder.jpg": imageFile("disc1"),
|
||||
"Album/CD2/folder.jpg": imageFile("disc2"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
|
||||
})
|
||||
})
|
||||
|
||||
When("CoverArtPriority puts embedded first and the album has both embedded and external art", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3 ← has embedded picture (wins via "embedded")
|
||||
// └── cover.jpg
|
||||
It("returns the embedded image", func() {
|
||||
conf.Server.CoverArtPriority = "embedded, cover.*, folder.*, front.*, external"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
||||
"Artist/Album/cover.jpg": imageFile("external"),
|
||||
})
|
||||
scan()
|
||||
// Swap in real MP3 bytes so libFS.Open returns a taglib-readable stream.
|
||||
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes))
|
||||
})
|
||||
})
|
||||
|
||||
When("CoverArtPriority lists external first but no external file is present", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// └── 01 - Track.mp3 ← has embedded picture (falls through to "embedded")
|
||||
It("falls through to embedded artwork", func() {
|
||||
conf.Server.CoverArtPriority = "external, embedded"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
||||
})
|
||||
scan()
|
||||
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes))
|
||||
})
|
||||
})
|
||||
|
||||
When("the only cover file uses uppercase extension and a different case in its name", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── Cover.JPG ← matched case-insensitively by cover.*
|
||||
It("matches case-insensitively against cover.*", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*, folder.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/Cover.JPG": imageFile("case-insensitive"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("case-insensitive")))
|
||||
})
|
||||
})
|
||||
|
||||
When("two cover files have basenames that tie under the natural-sort tiebreaker", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// ├── cover.jpg ← wins (no numeric suffix)
|
||||
// └── cover.1.jpg
|
||||
It("prefers the file without a numeric suffix", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": imageFile("primary"),
|
||||
"Artist/Album/cover.1.jpg": imageFile("secondary"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary")))
|
||||
})
|
||||
})
|
||||
|
||||
When("the album has no cover and CoverArtPriority lists only file patterns", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// └── 01 - Track.mp3 (no image files — returns ErrUnavailable)
|
||||
It("returns ErrUnavailable", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*, folder.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
_, err := readArtworkOrErr(model.NewArtworkID(model.KindAlbumArtwork, al.ID, &al.UpdatedAt))
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
// Doc scenarios from:
|
||||
// https://www.navidrome.org/docs/usage/library/artwork/#albums
|
||||
// Default CoverArtPriority is "cover.*, folder.*, front.*, embedded, external".
|
||||
When("only folder.jpg is present (cover.* and front.* missing)", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── folder.jpg ← matched by folder.*
|
||||
It("falls through to folder.jpg", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/folder.jpg": imageFile("folder"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder")))
|
||||
})
|
||||
})
|
||||
|
||||
When("only front.jpg is present (cover.* and folder.* missing)", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── front.jpg ← matched by front.*
|
||||
It("falls through to front.jpg", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/front.jpg": imageFile("front"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("front")))
|
||||
})
|
||||
})
|
||||
|
||||
When("cover.*, folder.*, and front.* all exist in the same folder", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// ├── cover.jpg ← wins (cover.* is first in priority)
|
||||
// ├── folder.jpg
|
||||
// └── front.jpg
|
||||
It("prefers cover.* (first in CoverArtPriority)", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": imageFile("cover"),
|
||||
"Artist/Album/folder.jpg": imageFile("folder"),
|
||||
"Artist/Album/front.jpg": imageFile("front"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
|
||||
})
|
||||
})
|
||||
|
||||
When("only folder.* and front.* exist (priority order check)", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// ├── folder.jpg ← wins (folder.* comes before front.*)
|
||||
// └── front.jpg
|
||||
It("prefers folder.* over front.*", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/folder.jpg": imageFile("folder"),
|
||||
"Artist/Album/front.jpg": imageFile("front"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder")))
|
||||
})
|
||||
})
|
||||
|
||||
When("three cover files tie by basename and differ only by numeric suffix", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// ├── cover.jpg ← wins (no numeric suffix)
|
||||
// ├── cover.1.jpg
|
||||
// └── cover.2.jpg
|
||||
It("selects the unsuffixed file first regardless of numeric-suffix order", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.2.jpg": imageFile("second"),
|
||||
"Artist/Album/cover.jpg": imageFile("primary"),
|
||||
"Artist/Album/cover.1.jpg": imageFile("first"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary")))
|
||||
})
|
||||
})
|
||||
|
||||
When("CoverArtPriority contains an unknown pattern before a matching one", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── cover.jpg ← wins (unknown "bogus.*" is skipped)
|
||||
It("skips the unknown pattern and falls through to the matching one", func() {
|
||||
conf.Server.CoverArtPriority = "bogus.*, cover.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": imageFile("cover"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
|
||||
})
|
||||
})
|
||||
|
||||
When("embedded is first in CoverArtPriority but the track has no embedded art", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3 (no embedded picture)
|
||||
// └── cover.jpg ← wins (embedded skipped, falls through)
|
||||
It("falls through to the next priority entry", func() {
|
||||
conf.Server.CoverArtPriority = "embedded, cover.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": imageFile("cover"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
|
||||
})
|
||||
})
|
||||
})
|
||||
167
core/artwork/e2e/artist_test.go
Normal file
167
core/artwork/e2e/artist_test.go
Normal file
@ -0,0 +1,167 @@
|
||||
package artworke2e_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Doc reference:
|
||||
// https://www.navidrome.org/docs/usage/library/artwork/#artists
|
||||
// Default ArtistArtPriority is "artist.*, album/artist.*, external".
|
||||
var _ = Describe("Artist artwork resolution", func() {
|
||||
BeforeEach(func() {
|
||||
setupHarness()
|
||||
})
|
||||
|
||||
When("the artist folder contains an artist.jpg", func() {
|
||||
// Artist/
|
||||
// ├── artist.jpg ← matched by artist.*
|
||||
// └── Album/
|
||||
// └── 01 - Track.mp3
|
||||
It("returns the artist.* image from the artist folder", func() {
|
||||
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||
"Artist/artist.jpg": imageFile("artist-folder"),
|
||||
})
|
||||
scan()
|
||||
|
||||
ar := soleArtist()
|
||||
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder")))
|
||||
})
|
||||
})
|
||||
|
||||
When("artist.* only exists inside an album folder", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── artist.jpg ← matched by album/artist.*
|
||||
It("falls through to album/artist.* and returns that image", func() {
|
||||
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||
"Artist/Album/artist.jpg": imageFile("album-artist"),
|
||||
})
|
||||
scan()
|
||||
|
||||
ar := soleArtist()
|
||||
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist")))
|
||||
})
|
||||
})
|
||||
|
||||
When("both the artist folder and an album folder have an artist.* image", func() {
|
||||
// Artist/
|
||||
// ├── artist.jpg ← wins (artist.* before album/artist.*)
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── artist.jpg
|
||||
It("prefers the artist-folder image (artist.* comes before album/artist.*)", func() {
|
||||
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||
"Artist/artist.jpg": imageFile("artist-folder"),
|
||||
"Artist/Album/artist.jpg": imageFile("album-artist"),
|
||||
})
|
||||
scan()
|
||||
|
||||
ar := soleArtist()
|
||||
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder")))
|
||||
})
|
||||
})
|
||||
|
||||
When("an artist has an uploaded image and a matching artist.* file", func() {
|
||||
// <DataFolder>/
|
||||
// └── artwork/
|
||||
// └── artist/
|
||||
// └── <id>_upload.jpg ← wins (uploaded image beats the priority chain)
|
||||
// Library:
|
||||
// Artist/
|
||||
// ├── artist.jpg (ignored — uploaded image comes first)
|
||||
// └── Album/
|
||||
// └── 01 - Track.mp3
|
||||
It("prefers the uploaded image over any priority-chain match", func() {
|
||||
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||
"Artist/artist.jpg": imageFile("artist-folder"),
|
||||
})
|
||||
scan()
|
||||
ar := soleArtist()
|
||||
|
||||
uploaded := ar.ID + "_upload.jpg"
|
||||
writeUploadedImage(consts.EntityArtist, uploaded, imageBytes("artist-uploaded"))
|
||||
ar.UploadedImage = uploaded
|
||||
Expect(ds.Artist(ctx).Put(&ar)).To(Succeed())
|
||||
|
||||
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-uploaded")))
|
||||
})
|
||||
})
|
||||
|
||||
When("ArtistArtPriority uses album/<arbitrary pattern> (not just album/artist.*)", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── artist.jpg ← matched by album/artist.*
|
||||
It("resolves the pattern against the artist's album image files", func() {
|
||||
conf.Server.ArtistArtPriority = "album/artist.*, external"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||
"Artist/Album/artist.jpg": imageFile("album-artist"),
|
||||
})
|
||||
scan()
|
||||
|
||||
ar := soleArtist()
|
||||
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist")))
|
||||
})
|
||||
})
|
||||
|
||||
When("ArtistArtPriority starts with image-folder and ArtistImageFolder has a name-matching image", func() {
|
||||
// <ArtistImageFolder>/
|
||||
// └── Artist.jpg ← matched by artist name (image-folder source)
|
||||
// Library:
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// └── 01 - Track.mp3 (no artist.* present in library)
|
||||
It("returns the image from the configured artist image folder", func() {
|
||||
imgFolder := GinkgoT().TempDir()
|
||||
Expect(os.WriteFile(filepath.Join(imgFolder, "Artist.jpg"), imageBytes("image-folder"), 0600)).To(Succeed())
|
||||
conf.Server.ArtistImageFolder = imgFolder
|
||||
conf.Server.ArtistArtPriority = "image-folder, artist.*, album/artist.*"
|
||||
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||
})
|
||||
scan()
|
||||
|
||||
ar := soleArtist()
|
||||
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("image-folder")))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func soleArtist() model.Artist {
|
||||
GinkgoHelper()
|
||||
artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"artist.name": "Artist"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
if len(artists) == 0 {
|
||||
Fail("sole artist not found")
|
||||
return model.Artist{}
|
||||
}
|
||||
return artists[0]
|
||||
}
|
||||
371
core/artwork/e2e/disc_test.go
Normal file
371
core/artwork/e2e/disc_test.go
Normal file
@ -0,0 +1,371 @@
|
||||
package artworke2e_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Disc artwork resolution", func() {
|
||||
BeforeEach(func() {
|
||||
setupHarness()
|
||||
})
|
||||
|
||||
When("the album is single-disc with a disc1.jpg in the only folder", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── disc1.jpg ← matched by disc*.*
|
||||
It("returns the disc1.jpg image (matched as disc*.*)", func() {
|
||||
conf.Server.DiscArtPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, embedded"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/disc1.jpg": imageFile("disc1-image"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-image")))
|
||||
})
|
||||
})
|
||||
|
||||
When("the album has no per-disc image and no album cover", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// └── 01 - Track.mp3 (no disc or album art — returns ErrUnavailable)
|
||||
It("returns ErrUnavailable for the disc lookup", func() {
|
||||
conf.Server.DiscArtPriority = "disc*.*, cd*.*"
|
||||
conf.Server.CoverArtPriority = "cover.*, folder.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||
_, err := readArtworkOrErr(discID)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
When("the album has no per-disc image but has an album cover", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── cover.jpg ← album-level fallback (no disc art present)
|
||||
It("falls back to the album cover", func() {
|
||||
conf.Server.DiscArtPriority = "disc*.*, cd*.*"
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": imageFile("album-cover"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover")))
|
||||
})
|
||||
})
|
||||
|
||||
When("multiple disc images exist in the same folder (disc1 vs disc10)", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// ├── disc1.jpg ← matches request for disc 1
|
||||
// └── disc10.jpg
|
||||
It("matches the requested disc number, not a higher-numbered one", func() {
|
||||
conf.Server.DiscArtPriority = "disc*.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/disc1.jpg": imageFile("disc-one"),
|
||||
"Artist/Album/disc10.jpg": imageFile("disc-ten"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("disc-one")))
|
||||
})
|
||||
})
|
||||
|
||||
When("a multi-disc album has per-disc covers", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── CD1/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── disc1.jpg ← matches request for disc 1
|
||||
// └── CD2/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── disc2.jpg ← matches request for disc 2
|
||||
It("returns the requested disc's image", func() {
|
||||
conf.Server.DiscArtPriority = "disc*.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
|
||||
"Artist/Album/CD2/disc2.jpg": imageFile("disc-2"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("disc-2")))
|
||||
})
|
||||
})
|
||||
|
||||
// Doc scenarios from:
|
||||
// https://www.navidrome.org/docs/usage/library/artwork/#disc-cover-art
|
||||
// Default DiscArtPriority is "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded".
|
||||
When("a disc subfolder has a cd2.png image", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── CD1/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── disc1.jpg
|
||||
// └── CD2/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── cd2.png ← matched by cd*.* for disc 2
|
||||
It("matches via the cd*.* pattern", func() {
|
||||
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
|
||||
"Artist/Album/CD2/cd2.png": imageFile("cd-2"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("cd-2")))
|
||||
})
|
||||
})
|
||||
|
||||
When("a disc subfolder has cover.jpg but no disc*.*/cd*.* image", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── CD1/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── cover.jpg ← matched by cover.* inside disc folder
|
||||
// └── CD2/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── cover.jpg
|
||||
It("falls through to cover.* inside the disc folder", func() {
|
||||
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/CD1/cover.jpg": imageFile("disc1-cover"),
|
||||
"Artist/Album/CD2/cover.jpg": imageFile("disc2-cover"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-cover")))
|
||||
})
|
||||
})
|
||||
|
||||
When("DiscArtPriority is the empty string", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── CD1/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── disc1.jpg (ignored — DiscArtPriority is empty)
|
||||
// ├── CD2/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── cd2.png (ignored — DiscArtPriority is empty)
|
||||
// └── cover.jpg ← used for every disc (album-level fallback)
|
||||
It("skips every disc-level source and returns the album cover", func() {
|
||||
conf.Server.DiscArtPriority = ""
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
|
||||
"Artist/Album/CD2/cd2.png": imageFile("cd-2"),
|
||||
"Artist/Album/cover.jpg": imageFile("album-cover"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
for _, n := range []int{1, 2} {
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, n), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover")),
|
||||
"disc %d should use the album cover when DiscArtPriority is empty", n)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
When("the documented multi-disc layout is used (disc1.jpg + cd2.png + album-root cover.jpg)", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── disc1/
|
||||
// │ ├── disc1.jpg ← matched by disc*.* for disc 1
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── 02 - Track.mp3
|
||||
// ├── disc2/
|
||||
// │ ├── cd2.png ← matched by cd*.* for disc 2
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── 02 - Track.mp3
|
||||
// └── cover.jpg (album-level fallback, unused here)
|
||||
It("matches the per-disc image for each disc", func() {
|
||||
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/disc1/02 - Track.mp3": trackFile(2, "T2", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/disc2/01 - Track.mp3": trackFile(1, "T3", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/disc2/02 - Track.mp3": trackFile(2, "T4", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/disc1/disc1.jpg": imageFile("disc-1"),
|
||||
"Artist/Album/disc2/cd2.png": imageFile("cd-2"),
|
||||
"Artist/Album/cover.jpg": imageFile("album-root"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
disc1ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||
disc2ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt)
|
||||
Expect(readArtwork(disc1ID)).To(Equal(imageBytes("disc-1")))
|
||||
Expect(readArtwork(disc2ID)).To(Equal(imageBytes("cd-2")))
|
||||
})
|
||||
})
|
||||
|
||||
When("discsubtitle keyword matches an image whose stem equals the disc's subtitle", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks")
|
||||
// └── Bonus Tracks.jpg ← matched by "discsubtitle" keyword
|
||||
It("selects the subtitle-named image", func() {
|
||||
conf.Server.DiscArtPriority = "discsubtitle"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}),
|
||||
"Artist/Album/Bonus Tracks.jpg": imageFile("bonus-tracks"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("bonus-tracks")))
|
||||
})
|
||||
})
|
||||
|
||||
// Reproduces https://github.com/navidrome/navidrome/issues/5456
|
||||
// Deeply nested layout matching the reporter's actual structure.
|
||||
When("a deeply nested multi-disc album has cover.jpg and per-disc folder.jpg", func() {
|
||||
// Genre/Artist/Album/ ← album root with cover.jpg
|
||||
// ├── cover.jpg ← album-level cover
|
||||
// ├── Disc 01 (Subtitle)/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── folder.jpg ← disc 1 art
|
||||
// ├── Disc 02 (Subtitle)/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── folder.jpg
|
||||
// └── ... (12 discs)
|
||||
It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
|
||||
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
discNames := []string{
|
||||
"Disc 01 (Birth of the Dead - The Studio Sides)",
|
||||
"Disc 02 (Birth of the Dead - The Live Sides)",
|
||||
"Disc 03 (The Grateful Dead)",
|
||||
"Disc 04 (Anthem of the Sun)",
|
||||
"Disc 05 (Aoxomoxoa)",
|
||||
"Disc 06 (Live; Dead)",
|
||||
"Disc 07 (Workingman's Dead)",
|
||||
"Disc 08 (American Beauty)",
|
||||
"Disc 09 (Grateful Dead)",
|
||||
"Disc 10 (Europe '72)",
|
||||
"Disc 11 (Europe '72)",
|
||||
"Disc 12 (History of the Grateful Dead, Volume One (Bear's Choice))",
|
||||
}
|
||||
layout := fstest.MapFS{
|
||||
"Pop; Rock/Grateful Dead/(2001) The Golden Road/cover.jpg": imageFile("album-root-cover"),
|
||||
}
|
||||
for i, name := range discNames {
|
||||
discNum := i + 1
|
||||
prefix := fmt.Sprintf("Pop; Rock/Grateful Dead/(2001) The Golden Road/%s/", name)
|
||||
layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", discNum), map[string]any{"disc": fmt.Sprintf("%d", discNum)})
|
||||
layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", discNum))
|
||||
}
|
||||
setLayout(layout)
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover")))
|
||||
|
||||
for i := range discNames {
|
||||
discNum := i + 1
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, discNum), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", discNum))),
|
||||
"disc %d should use its own folder.jpg", discNum)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// https://github.com/navidrome/navidrome/issues/5456
|
||||
// Top-level album variant — album folder at library root (Path=".").
|
||||
When("a top-level multi-disc album has cover.jpg and per-disc folder.jpg", func() {
|
||||
// Album/ (top-level, Path=".")
|
||||
// ├── cover.jpg ← album-level cover
|
||||
// ├── Disc 01/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── folder.jpg ← disc 1 art
|
||||
// ├── Disc 02/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── folder.jpg
|
||||
// └── Disc 03/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── folder.jpg
|
||||
It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
|
||||
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
layout := fstest.MapFS{
|
||||
"Album/cover.jpg": imageFile("album-root-cover"),
|
||||
}
|
||||
for i := 1; i <= 3; i++ {
|
||||
prefix := fmt.Sprintf("Album/Disc %02d/", i)
|
||||
layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", i), map[string]any{"disc": fmt.Sprintf("%d", i)})
|
||||
layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", i))
|
||||
}
|
||||
setLayout(layout)
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover")))
|
||||
|
||||
for i := 1; i <= 3; i++ {
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, i), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", i))),
|
||||
"disc %d should use its own folder.jpg", i)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
When("discsubtitle is set but no image filename matches the subtitle", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks")
|
||||
// └── cover.jpg ← wins (discsubtitle has no match, falls through)
|
||||
It("falls through to the next priority entry", func() {
|
||||
conf.Server.DiscArtPriority = "discsubtitle, cover.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}),
|
||||
"Artist/Album/cover.jpg": imageFile("cover"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("cover")))
|
||||
})
|
||||
})
|
||||
})
|
||||
184
core/artwork/e2e/helpers_test.go
Normal file
184
core/artwork/e2e/helpers_test.go
Normal file
@ -0,0 +1,184 @@
|
||||
package artworke2e_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"hash/fnv"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"io"
|
||||
"maps"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"go.senan.xyz/taglib"
|
||||
)
|
||||
|
||||
// realMP3WithEmbeddedArt is the bytes of the canonical test fixture that
|
||||
// contains a valid MP3 stream with an embedded picture. Used in the
|
||||
// embedded-art e2e scenarios where FakeFS's JSON-encoded tag data isn't
|
||||
// readable by taglib. Swap this into fakeFS.MapFS *after* scanning so the
|
||||
// scanner still populates EmbedArtPath via the JSON-tagged track, and the
|
||||
// artwork reader gets real bytes when it calls libFS.Open.
|
||||
//
|
||||
//go:embed testdata/embedded_art.mp3
|
||||
var realMP3WithEmbeddedArt []byte
|
||||
|
||||
// embeddedArtBytes is the exact image payload that the artwork reader will
|
||||
// extract from realMP3WithEmbeddedArt. Computed once via taglib so tests can
|
||||
// assert byte-for-byte equality — if this ever differs it means the reader
|
||||
// pulled from a different source.
|
||||
var embeddedArtBytes = extractEmbeddedArt(realMP3WithEmbeddedArt)
|
||||
|
||||
func extractEmbeddedArt(mp3 []byte) []byte {
|
||||
tf, err := taglib.OpenStream(bytes.NewReader(mp3))
|
||||
if err != nil {
|
||||
panic("embedded-art fixture: taglib.OpenStream failed: " + err.Error())
|
||||
}
|
||||
defer tf.Close()
|
||||
images := tf.Properties().Images
|
||||
if len(images) == 0 {
|
||||
panic("embedded-art fixture has no embedded images")
|
||||
}
|
||||
data, err := tf.Image(0)
|
||||
if err != nil || len(data) == 0 {
|
||||
panic("embedded-art fixture: could not read image 0")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// replaceWithRealMP3 swaps the FakeFS entry at the given library-relative
|
||||
// path so libFS.Open returns an MP3 stream taglib can parse.
|
||||
func replaceWithRealMP3(relPath string) {
|
||||
GinkgoHelper()
|
||||
fakeFS.MapFS[relPath] = &fstest.MapFile{Data: realMP3WithEmbeddedArt}
|
||||
}
|
||||
|
||||
// placeholderBytes returns the bundled album-placeholder image bytes — the
|
||||
// same stream the artwork reader emits when every source falls through.
|
||||
func placeholderBytes() []byte {
|
||||
GinkgoHelper()
|
||||
r, err := resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer r.Close()
|
||||
data, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return data
|
||||
}
|
||||
|
||||
// writeUploadedImage drops `filename` into <DataFolder>/artwork/<entity>/ with
|
||||
// the given bytes, matching the on-disk layout expected by
|
||||
// model.UploadedImagePath.
|
||||
func writeUploadedImage(entity, filename string, data []byte) {
|
||||
GinkgoHelper()
|
||||
dir := filepath.Dir(model.UploadedImagePath(entity, filename))
|
||||
Expect(os.MkdirAll(dir, 0755)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(dir, filename), data, 0600)).To(Succeed())
|
||||
}
|
||||
|
||||
func newNoopFFmpeg() *tests.MockFFmpeg {
|
||||
ff := tests.NewMockFFmpeg("")
|
||||
ff.Error = errors.New("noop")
|
||||
return ff
|
||||
}
|
||||
|
||||
// trackFile builds a FakeFS MP3 entry with optional tag overrides.
|
||||
func trackFile(num int, title string, extra ...map[string]any) *fstest.MapFile {
|
||||
tags := storagetest.Track(num, title)
|
||||
for _, e := range extra {
|
||||
maps.Copy(tags, e)
|
||||
}
|
||||
return storagetest.MP3(tags)
|
||||
}
|
||||
|
||||
// imageFile builds a label-keyed image entry. The bytes are deterministic
|
||||
// per-label so tests can assert which file won.
|
||||
func imageFile(label string) *fstest.MapFile {
|
||||
return &fstest.MapFile{Data: []byte("image:" + label)}
|
||||
}
|
||||
|
||||
// realPNG builds a minimal 2x2 PNG with a color derived from label. Needed by
|
||||
// tests that feed the bytes into image.Decode (e.g. playlist tiled covers).
|
||||
func realPNG(label string) *fstest.MapFile {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
||||
// Derive a deterministic color per label.
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(label))
|
||||
sum := h.Sum32()
|
||||
c := color.RGBA{R: byte(sum), G: byte(sum >> 8), B: byte(sum >> 16), A: 255}
|
||||
for y := range 2 {
|
||||
for x := range 2 {
|
||||
img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
Expect(png.Encode(&buf, img)).To(Succeed())
|
||||
return &fstest.MapFile{Data: buf.Bytes()}
|
||||
}
|
||||
|
||||
// imageBytes returns the bytes that imageFile(label) writes.
|
||||
func imageBytes(label string) []byte { return imageFile(label).Data }
|
||||
|
||||
// setLayout populates fakeFS with the given map. Call after setupHarness.
|
||||
// All paths must be forward-slash and relative (no leading "/").
|
||||
func setLayout(files fstest.MapFS) {
|
||||
GinkgoHelper()
|
||||
fakeFS.SetFiles(files)
|
||||
}
|
||||
|
||||
func readArtwork(artID model.ArtworkID) []byte {
|
||||
GinkgoHelper()
|
||||
r, _, err := aw.Get(ctx, artID, 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer r.Close()
|
||||
b, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return b
|
||||
}
|
||||
|
||||
func readArtworkOrErr(artID model.ArtworkID) ([]byte, error) {
|
||||
r, _, err := aw.Get(ctx, artID, 0, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer r.Close()
|
||||
return io.ReadAll(r)
|
||||
}
|
||||
|
||||
// noopProvider implements external.Provider with not-found returns so the
|
||||
// "external" priority entry never produces a result.
|
||||
type noopProvider struct{}
|
||||
|
||||
func (n *noopProvider) UpdateAlbumInfo(context.Context, string) (*model.Album, error) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
func (n *noopProvider) UpdateArtistInfo(context.Context, string, int, bool) (*model.Artist, error) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
func (n *noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (n *noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (n *noopProvider) ArtistImage(context.Context, string) (*url.URL, error) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
func (n *noopProvider) AlbumImage(context.Context, string) (*url.URL, error) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
var _ external.Provider = (*noopProvider)(nil)
|
||||
110
core/artwork/e2e/mediafile_test.go
Normal file
110
core/artwork/e2e/mediafile_test.go
Normal file
@ -0,0 +1,110 @@
|
||||
package artworke2e_test
|
||||
|
||||
import (
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Doc reference:
|
||||
// https://www.navidrome.org/docs/usage/library/artwork/#mediafiles
|
||||
// Navidrome resolves mediafile artwork in this order:
|
||||
// 1. Embedded image from the mediafile itself
|
||||
// 2. For multi-disc albums, disc-level artwork
|
||||
// 3. Album cover art
|
||||
//
|
||||
// FakeFS cannot synthesize taglib-readable embedded JPEGs, so scenario (1)
|
||||
// is covered by the existing embedded-art album tests (which currently
|
||||
// Skip under FakeFS). The tests below cover (2) and (3): the fallback
|
||||
// chain for tracks without embedded art.
|
||||
var _ = Describe("MediaFile artwork fallback", func() {
|
||||
BeforeEach(func() {
|
||||
setupHarness()
|
||||
})
|
||||
|
||||
When("a multi-disc album track has no embedded art", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── CD1/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── disc1.jpg
|
||||
// ├── CD2/
|
||||
// │ ├── 01 - Track.mp3 ← track requested
|
||||
// │ └── disc2.jpg ← wins (disc-level before album-level)
|
||||
// └── cover.jpg
|
||||
It("falls back to the disc-level artwork (not the album cover)", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
|
||||
"Artist/Album/CD2/disc2.jpg": imageFile("disc-2"),
|
||||
"Artist/Album/cover.jpg": imageFile("album-root"),
|
||||
})
|
||||
scan()
|
||||
|
||||
mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3")
|
||||
Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("disc-2")))
|
||||
})
|
||||
})
|
||||
|
||||
When("a single-disc album track has no embedded art", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3 ← track requested
|
||||
// └── cover.jpg ← wins (album-level fallback, no disc subfolder)
|
||||
It("falls back to the album cover", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": imageFile("album-cover"),
|
||||
})
|
||||
scan()
|
||||
|
||||
mf := mediafileOn("Artist/Album/01 - Track.mp3")
|
||||
Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-cover")))
|
||||
})
|
||||
})
|
||||
|
||||
When("a multi-disc album track has no embedded art and the disc has no disc-level image", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── CD1/
|
||||
// │ └── 01 - Track.mp3
|
||||
// ├── CD2/
|
||||
// │ └── 01 - Track.mp3 ← track requested
|
||||
// └── cover.jpg ← wins (no disc image → album-level fallback)
|
||||
It("falls through from disc to album cover", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/cover.jpg": imageFile("album-root"),
|
||||
})
|
||||
scan()
|
||||
|
||||
mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3")
|
||||
Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-root")))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func mediafileOn(relPath string) model.MediaFile {
|
||||
GinkgoHelper()
|
||||
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Like{"media_file.path": relPath},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
if len(mfs) == 0 {
|
||||
Fail("mediafile not found: " + relPath)
|
||||
return model.MediaFile{}
|
||||
}
|
||||
return mfs[0]
|
||||
}
|
||||
158
core/artwork/e2e/playlist_test.go
Normal file
158
core/artwork/e2e/playlist_test.go
Normal file
@ -0,0 +1,158 @@
|
||||
package artworke2e_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Playlist artwork resolves in this priority order:
|
||||
// 1. Uploaded image (<DataFolder>/artwork/playlist/<file>)
|
||||
// 2. Sidecar image next to the .m3u file (same basename, any image ext)
|
||||
// 3. ExternalImageURL (http/https requires EnableM3UExternalAlbumArt; local path always allowed)
|
||||
// 4. Generated 2x2 tiled cover from the playlist's albums
|
||||
// 5. Album placeholder image
|
||||
//
|
||||
// The library FS is FakeFS, but uploaded/sidecar/local-external images are
|
||||
// real files on disk — the reader reads them via os.Open, so the tests
|
||||
// place them in a real tempdir under DataFolder.
|
||||
var _ = Describe("Playlist artwork resolution", func() {
|
||||
BeforeEach(func() {
|
||||
setupHarness()
|
||||
})
|
||||
|
||||
When("a playlist has an uploaded image", func() {
|
||||
// <DataFolder>/
|
||||
// └── artwork/
|
||||
// └── playlist/
|
||||
// └── pl-1_upload.jpg ← matched by UploadedImagePath() (highest priority)
|
||||
It("returns the uploaded image bytes", func() {
|
||||
writeUploadedImage(consts.EntityPlaylist, "pl-1_upload.jpg", imageBytes("playlist-upload"))
|
||||
|
||||
pl := putPlaylist(model.Playlist{ID: "pl-1", Name: "Test", UploadedImage: "pl-1_upload.jpg"})
|
||||
|
||||
Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("playlist-upload")))
|
||||
})
|
||||
})
|
||||
|
||||
When("a playlist has no uploaded image but a sidecar image beside its .m3u file", func() {
|
||||
// <tempdir>/
|
||||
// ├── MyList.m3u
|
||||
// └── MyList.jpg ← matched by sidecar (same basename, case-insensitive)
|
||||
It("returns the sidecar image", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
m3uPath := filepath.Join(dir, "MyList.m3u")
|
||||
Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(dir, "MyList.jpg"), imageBytes("sidecar"), 0600)).To(Succeed())
|
||||
|
||||
pl := putPlaylist(model.Playlist{ID: "pl-2", Name: "MyList", Path: m3uPath})
|
||||
|
||||
Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar")))
|
||||
})
|
||||
})
|
||||
|
||||
When("a playlist's sidecar uses a different extension case", func() {
|
||||
// <tempdir>/
|
||||
// ├── MyList.m3u
|
||||
// └── MyList.PNG ← matched case-insensitively
|
||||
It("matches case-insensitively", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
m3uPath := filepath.Join(dir, "MyList.m3u")
|
||||
Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(dir, "MyList.PNG"), imageBytes("sidecar-png"), 0600)).To(Succeed())
|
||||
|
||||
pl := putPlaylist(model.Playlist{ID: "pl-3", Name: "MyList", Path: m3uPath})
|
||||
|
||||
Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar-png")))
|
||||
})
|
||||
})
|
||||
|
||||
When("a playlist has an ExternalImageURL pointing to a local file", func() {
|
||||
// <tempdir>/
|
||||
// └── cover.jpg ← absolute path stored in ExternalImageURL
|
||||
It("returns the local file regardless of EnableM3UExternalAlbumArt", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = false // local paths bypass the toggle
|
||||
dir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(dir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, imageBytes("external-local"), 0600)).To(Succeed())
|
||||
|
||||
pl := putPlaylist(model.Playlist{ID: "pl-4", Name: "WithExt", ExternalImageURL: imgPath})
|
||||
|
||||
Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("external-local")))
|
||||
})
|
||||
})
|
||||
|
||||
When("a playlist has an http(s) ExternalImageURL and EnableM3UExternalAlbumArt is false", func() {
|
||||
// (no local files — http source is gated off, reader falls through to placeholder)
|
||||
It("skips the URL and falls through to the bundled placeholder", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
|
||||
pl := putPlaylist(model.Playlist{ID: "pl-5", Name: "HttpGated", ExternalImageURL: "https://example.com/cover.jpg"})
|
||||
|
||||
Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes()))
|
||||
})
|
||||
})
|
||||
|
||||
When("a playlist has no images and no tracks", func() {
|
||||
// (reader falls all the way through to the bundled album placeholder)
|
||||
It("returns the album placeholder", func() {
|
||||
pl := putPlaylist(model.Playlist{ID: "pl-6", Name: "Empty"})
|
||||
|
||||
Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes()))
|
||||
})
|
||||
})
|
||||
|
||||
When("a playlist has no uploaded/sidecar/external image but has tracks with album covers", func() {
|
||||
// Library:
|
||||
// Artist/
|
||||
// ├── AlbumA/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── cover.png (real PNG — wins as tile 1 source)
|
||||
// └── AlbumB/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── cover.png (real PNG — wins as tile 2 source)
|
||||
// Playlist "pl-7" references tracks from both albums, so the reader
|
||||
// generates a 2x2 tiled cover from 2 distinct album art tiles (the
|
||||
// tiled generator mirrors when it has fewer than 4 unique tiles).
|
||||
It("generates a tiled cover from album art", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/AlbumA/01 - Track.mp3": trackFile(1, "TA", map[string]any{"album": "AlbumA"}),
|
||||
"Artist/AlbumA/cover.png": realPNG("albumA"),
|
||||
"Artist/AlbumB/01 - Track.mp3": trackFile(1, "TB", map[string]any{"album": "AlbumB"}),
|
||||
"Artist/AlbumB/cover.png": realPNG("albumB"),
|
||||
})
|
||||
scan()
|
||||
|
||||
// Pull the scanned mediafile IDs so we can attach them to the playlist.
|
||||
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfs).To(HaveLen(2))
|
||||
|
||||
pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"}
|
||||
pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID})
|
||||
Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed())
|
||||
|
||||
data := readArtwork(pl.CoverArtID())
|
||||
// The tiled cover is a PNG-encoded 600x600 image (tileSize const).
|
||||
// Exact bytes vary (random album order), so assert format + non-trivial size.
|
||||
Expect(data[:8]).To(Equal([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}))
|
||||
Expect(len(data)).To(BeNumerically(">", 1000))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func putPlaylist(pl model.Playlist) model.Playlist {
|
||||
GinkgoHelper()
|
||||
if pl.OwnerID == "" {
|
||||
pl.OwnerID = "admin-1"
|
||||
}
|
||||
Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed())
|
||||
return pl
|
||||
}
|
||||
42
core/artwork/e2e/radio_test.go
Normal file
42
core/artwork/e2e/radio_test.go
Normal file
@ -0,0 +1,42 @@
|
||||
package artworke2e_test
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Radio artwork resolution", func() {
|
||||
BeforeEach(func() {
|
||||
setupHarness()
|
||||
})
|
||||
|
||||
When("a radio has an uploaded image", func() {
|
||||
// <DataFolder>/
|
||||
// └── artwork/
|
||||
// └── radio/
|
||||
// └── rd-1_logo.jpg ← matched by UploadedImagePath()
|
||||
It("returns the uploaded image bytes", func() {
|
||||
writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", imageBytes("radio-logo"))
|
||||
|
||||
rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"}
|
||||
Expect(ds.Radio(ctx).Put(&rd)).To(Succeed())
|
||||
|
||||
artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("radio-logo")))
|
||||
})
|
||||
})
|
||||
|
||||
When("a radio has no uploaded image", func() {
|
||||
// (no files on disk — reader has no sources to fall back to)
|
||||
It("returns ErrUnavailable", func() {
|
||||
rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"}
|
||||
Expect(ds.Radio(ctx).Put(&rd)).To(Succeed())
|
||||
|
||||
artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil)
|
||||
_, err := readArtworkOrErr(artID)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
106
core/artwork/e2e/suite_test.go
Normal file
106
core/artwork/e2e/suite_test.go
Normal file
@ -0,0 +1,106 @@
|
||||
package artworke2e_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "github.com/navidrome/navidrome/adapters/gotaglib"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestArtworkE2E(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Artwork E2E Suite")
|
||||
}
|
||||
|
||||
const fakeLibScheme = "artworkfake"
|
||||
const fakeLibPath = fakeLibScheme + ":///music"
|
||||
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
aw artwork.Artwork
|
||||
fakeFS *storagetest.FakeFS
|
||||
)
|
||||
|
||||
// The DB file lives in a suite-level tempdir: the go-sqlite3 singleton keeps
|
||||
// the file open for the whole suite, and Ginkgo's per-spec TempDir cleanup
|
||||
// can't unlink a file with a live handle on Windows. A suite-level tempdir
|
||||
// combined with an AfterSuite close avoids the lock conflict.
|
||||
var suiteDBTempDir string
|
||||
|
||||
var _ = BeforeSuite(func() {
|
||||
suiteDBTempDir = GinkgoT().TempDir()
|
||||
})
|
||||
|
||||
var _ = AfterSuite(func() {
|
||||
db.Close(GinkgoT().Context())
|
||||
})
|
||||
|
||||
func setupHarness() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
tempDir := GinkgoT().TempDir()
|
||||
// Reuse the suite-level DB path so the singleton connection keeps working
|
||||
// across specs (see suiteDBTempDir comment).
|
||||
conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-e2e.db") + "?_journal_mode=WAL"
|
||||
conf.Server.DataFolder = conf.NewDir(tempDir)
|
||||
conf.Server.MusicFolder = fakeLibPath
|
||||
conf.Server.DevExternalScanner = false
|
||||
conf.Server.ImageCacheSize = "0" // disabled cache → reader runs on every call
|
||||
conf.Server.EnableExternalServices = false
|
||||
|
||||
db.Db().SetMaxOpenConns(1)
|
||||
ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin-1", UserName: "admin", IsAdmin: true})
|
||||
db.Init(ctx)
|
||||
DeferCleanup(func() { Expect(tests.ClearDB()).To(Succeed()) })
|
||||
|
||||
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||
|
||||
adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"}
|
||||
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
|
||||
|
||||
lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath}
|
||||
Expect(ds.Library(ctx).Put(&lib)).To(Succeed())
|
||||
Expect(ds.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())
|
||||
|
||||
fakeFS = &storagetest.FakeFS{}
|
||||
storagetest.Register(fakeLibScheme, fakeFS)
|
||||
|
||||
aw = artwork.NewArtwork(ds, artwork.GetImageCache(), newNoopFFmpeg(), &noopProvider{})
|
||||
}
|
||||
|
||||
func scan() {
|
||||
GinkgoHelper()
|
||||
s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||
playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance())
|
||||
_, err := s.ScanAll(ctx, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
func firstAlbum() model.Album {
|
||||
GinkgoHelper()
|
||||
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums))
|
||||
return albums[0]
|
||||
}
|
||||
BIN
core/artwork/e2e/testdata/embedded_art.mp3
vendored
Normal file
BIN
core/artwork/e2e/testdata/embedded_art.mp3
vendored
Normal file
Binary file not shown.
44
core/artwork/library_fs.go
Normal file
44
core/artwork/library_fs.go
Normal file
@ -0,0 +1,44 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/core/storage"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// libraryView bundles the MusicFS for a library with its absolute root path,
|
||||
// so readers can open library-relative paths through FS and compose absolute
|
||||
// paths (for ffmpeg, which is path-based) via Abs.
|
||||
type libraryView struct {
|
||||
FS storage.MusicFS
|
||||
absRoot string
|
||||
}
|
||||
|
||||
// Abs returns the absolute path for a library-relative path. Returns "" for an
|
||||
// empty rel so callers (fromFFmpegTag) can treat it as "no path available".
|
||||
func (v libraryView) Abs(rel string) string {
|
||||
if rel == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(v.absRoot, rel)
|
||||
}
|
||||
|
||||
// loadLibraryView resolves the MusicFS and absolute root path in a single
|
||||
// library lookup.
|
||||
func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (libraryView, error) {
|
||||
lib, err := ds.Library(ctx).Get(libID)
|
||||
if err != nil {
|
||||
return libraryView{}, err
|
||||
}
|
||||
s, err := storage.For(lib.Path)
|
||||
if err != nil {
|
||||
return libraryView{}, err
|
||||
}
|
||||
fs, err := s.FS()
|
||||
if err != nil {
|
||||
return libraryView{}, err
|
||||
}
|
||||
return libraryView{FS: fs, absRoot: lib.Path}, nil
|
||||
}
|
||||
45
core/artwork/library_fs_test.go
Normal file
45
core/artwork/library_fs_test.go
Normal file
@ -0,0 +1,45 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("loadLibraryView", Ordered, func() {
|
||||
var ctx context.Context
|
||||
var ds *tests.MockDataStore
|
||||
|
||||
BeforeAll(func() {
|
||||
storagetest.Register("fake", &storagetest.FakeFS{})
|
||||
})
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
ds = &tests.MockDataStore{MockedLibrary: &tests.MockLibraryRepo{}}
|
||||
})
|
||||
|
||||
It("returns a view for a library backed by registered storage", func() {
|
||||
Expect(ds.Library(ctx).Put(&model.Library{ID: 1, Path: "fake:///music"})).To(Succeed())
|
||||
|
||||
lib, err := loadLibraryView(ctx, ds, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lib.FS).ToNot(BeNil())
|
||||
Expect(lib.absRoot).To(Equal("fake:///music"))
|
||||
})
|
||||
|
||||
It("returns an error when the library does not exist", func() {
|
||||
_, err := loadLibraryView(ctx, ds, 999)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns an error when the library path uses an unregistered scheme", func() {
|
||||
Expect(ds.Library(ctx).Put(&model.Library{ID: 2, Path: "unsupported:///music"})).To(Succeed())
|
||||
_, err := loadLibraryView(ctx, ds, 2)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
@ -7,29 +7,29 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"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"
|
||||
"github.com/navidrome/navidrome/utils/natural"
|
||||
)
|
||||
|
||||
type albumArtworkReader struct {
|
||||
cacheKey
|
||||
a *artwork
|
||||
provider external.Provider
|
||||
album model.Album
|
||||
updatedAt *time.Time
|
||||
imgFiles []string
|
||||
rootFolder string
|
||||
a *artwork
|
||||
provider external.Provider
|
||||
album model.Album
|
||||
updatedAt *time.Time
|
||||
imgFiles []string // library-relative, forward-slash, no leading slash
|
||||
lib libraryView
|
||||
}
|
||||
|
||||
func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*albumArtworkReader, error) {
|
||||
@ -41,19 +41,22 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lib, err := loadLibraryView(ctx, artwork.ds, al.LibraryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &albumArtworkReader{
|
||||
a: artwork,
|
||||
provider: provider,
|
||||
album: *al,
|
||||
updatedAt: imagesUpdateAt,
|
||||
imgFiles: imgFiles,
|
||||
rootFolder: core.AbsolutePath(ctx, artwork.ds, al.LibraryID, ""),
|
||||
a: artwork,
|
||||
provider: provider,
|
||||
album: *al,
|
||||
updatedAt: imagesUpdateAt,
|
||||
imgFiles: imgFiles,
|
||||
lib: lib,
|
||||
}
|
||||
a.cacheKey.artID = artID
|
||||
if a.updatedAt != nil && a.updatedAt.After(al.UpdatedAt) {
|
||||
a.cacheKey.lastUpdate = *a.updatedAt
|
||||
} else {
|
||||
a.cacheKey.lastUpdate = al.UpdatedAt
|
||||
a.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt)
|
||||
if imagesUpdateAt != nil {
|
||||
a.cacheKey.lastUpdate = utils.TimeNewest(a.cacheKey.lastUpdate, *imagesUpdateAt)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
@ -61,7 +64,7 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar
|
||||
func (a *albumArtworkReader) Key() string {
|
||||
hashInput := conf.Server.CoverArtPriority
|
||||
if conf.Server.EnableExternalServices {
|
||||
hashInput += conf.Server.Agents
|
||||
hashInput = conf.Server.Agents + hashInput
|
||||
}
|
||||
hash := md5.Sum([]byte(hashInput))
|
||||
return fmt.Sprintf(
|
||||
@ -72,7 +75,7 @@ func (a *albumArtworkReader) Key() string {
|
||||
)
|
||||
}
|
||||
func (a *albumArtworkReader) LastUpdated() time.Time {
|
||||
return a.album.UpdatedAt
|
||||
return a.lastUpdate
|
||||
}
|
||||
|
||||
func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
@ -86,12 +89,15 @@ func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ff
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "embedded":
|
||||
embedArtPath := filepath.Join(a.rootFolder, a.album.EmbedArtPath)
|
||||
ff = append(ff, fromTag(ctx, embedArtPath), fromFFmpegTag(ctx, ffmpeg, embedArtPath))
|
||||
embedRel := a.album.EmbedArtPath
|
||||
ff = append(ff,
|
||||
fromTag(ctx, a.lib.FS, embedRel),
|
||||
fromFFmpegTag(ctx, ffmpeg, a.lib.Abs(embedRel)),
|
||||
)
|
||||
case pattern == "external":
|
||||
ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.provider))
|
||||
case len(a.imgFiles) > 0:
|
||||
ff = append(ff, fromExternalFile(ctx, a.imgFiles, pattern))
|
||||
ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, pattern))
|
||||
}
|
||||
}
|
||||
return ff
|
||||
@ -112,19 +118,22 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
|
||||
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.
|
||||
// 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 disc subfolders like "Artist/Album/CD1/" and "Artist/Album/CD2/").
|
||||
// For single-folder albums, the parent is only included when the folder has no
|
||||
// images of its own (indicating a disc subfolder needing parent artwork).
|
||||
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)
|
||||
if len(folders) >= 2 || !anyFolderHasImages(folders) {
|
||||
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 && parentFolder.ParentID != "" {
|
||||
folders = append(folders, *parentFolder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -132,13 +141,13 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
|
||||
var imgFiles []string
|
||||
var updatedAt time.Time
|
||||
for _, f := range folders {
|
||||
path := f.AbsolutePath()
|
||||
paths = append(paths, path)
|
||||
paths = append(paths, f.AbsolutePath())
|
||||
if f.ImagesUpdatedAt.After(updatedAt) {
|
||||
updatedAt = f.ImagesUpdatedAt
|
||||
}
|
||||
rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/")
|
||||
for _, img := range f.ImageFiles {
|
||||
imgFiles = append(imgFiles, filepath.Join(path, img))
|
||||
imgFiles = append(imgFiles, path.Join(rel, img))
|
||||
}
|
||||
}
|
||||
|
||||
@ -150,10 +159,19 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
|
||||
return paths, imgFiles, &updatedAt, nil
|
||||
}
|
||||
|
||||
func anyFolderHasImages(folders []model.Folder) bool {
|
||||
for _, f := range folders {
|
||||
if len(f.ImageFiles) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if len(folders) == 0 {
|
||||
return ""
|
||||
}
|
||||
parentID := folders[0].ParentID
|
||||
@ -168,23 +186,21 @@ func commonParentFolder(folders []model.Folder, folderIDSet map[string]bool) str
|
||||
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".
|
||||
// Note: This function is called O(n log n) times during sorting, but in practice albums
|
||||
// typically have only 1-20 image files, making the repeated string operations negligible.
|
||||
// compareImageFiles sorts image paths by: base filename (natural order),
|
||||
// then path depth (shallower first), then full path (stable tiebreaker).
|
||||
func compareImageFiles(a, b string) int {
|
||||
// Case-insensitive comparison
|
||||
a = strings.ToLower(a)
|
||||
b = strings.ToLower(b)
|
||||
|
||||
// Extract base filenames without extensions
|
||||
baseA := strings.TrimSuffix(filepath.Base(a), filepath.Ext(a))
|
||||
baseB := strings.TrimSuffix(filepath.Base(b), filepath.Ext(b))
|
||||
baseA := strings.TrimSuffix(path.Base(a), path.Ext(a))
|
||||
baseB := strings.TrimSuffix(path.Base(b), path.Ext(b))
|
||||
|
||||
// Compare base names first, then full paths if equal
|
||||
// Compare base names first, then prefer shallower paths, then full path as tiebreaker
|
||||
return cmp.Or(
|
||||
natural.Compare(baseA, baseB),
|
||||
cmp.Compare(strings.Count(a, "/"), strings.Count(b, "/")),
|
||||
natural.Compare(a, b),
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@ package artwork
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@ -69,11 +68,11 @@ var _ = Describe("Album Artwork Reader", func() {
|
||||
// Files should be sorted by base filename without extension, then by full path
|
||||
// "back" < "cover", so back.jpg comes first
|
||||
// Then all cover.jpg files, sorted by path
|
||||
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/back.jpg")))
|
||||
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.jpg")))
|
||||
Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Disc2/cover.jpg")))
|
||||
Expect(imgFiles[3]).To(Equal(filepath.FromSlash("Artist/Album/Disc10/cover.jpg")))
|
||||
Expect(imgFiles[4]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.1.jpg")))
|
||||
Expect(imgFiles[0]).To(Equal("Artist/Album/Disc1/back.jpg"))
|
||||
Expect(imgFiles[1]).To(Equal("Artist/Album/Disc1/cover.jpg"))
|
||||
Expect(imgFiles[2]).To(Equal("Artist/Album/Disc2/cover.jpg"))
|
||||
Expect(imgFiles[3]).To(Equal("Artist/Album/Disc10/cover.jpg"))
|
||||
Expect(imgFiles[4]).To(Equal("Artist/Album/Disc1/cover.1.jpg"))
|
||||
})
|
||||
|
||||
It("prioritizes files without numeric suffixes", func() {
|
||||
@ -92,9 +91,9 @@ var _ = Describe("Album Artwork Reader", func() {
|
||||
Expect(imgFiles).To(HaveLen(3))
|
||||
|
||||
// cover.jpg should come first because "cover" < "cover.1" < "cover.2"
|
||||
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
|
||||
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.1.jpg")))
|
||||
Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/cover.2.jpg")))
|
||||
Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg"))
|
||||
Expect(imgFiles[1]).To(Equal("Artist/Album/cover.1.jpg"))
|
||||
Expect(imgFiles[2]).To(Equal("Artist/Album/cover.2.jpg"))
|
||||
})
|
||||
|
||||
It("handles case-insensitive sorting", func() {
|
||||
@ -113,9 +112,9 @@ var _ = Describe("Album Artwork Reader", func() {
|
||||
Expect(imgFiles).To(HaveLen(3))
|
||||
|
||||
// Files should be sorted case-insensitively: BACK, cover, Folder
|
||||
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/BACK.jpg")))
|
||||
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
|
||||
Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Folder.jpg")))
|
||||
Expect(imgFiles[0]).To(Equal("Artist/Album/BACK.jpg"))
|
||||
Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg"))
|
||||
Expect(imgFiles[2]).To(Equal("Artist/Album/Folder.jpg"))
|
||||
})
|
||||
|
||||
It("includes images from parent folder for multi-disc albums", func() {
|
||||
@ -142,6 +141,7 @@ var _ = Describe("Album Artwork Reader", func() {
|
||||
ID: "parentFolder",
|
||||
Path: "Artist",
|
||||
Name: "Album",
|
||||
ParentID: "artistFolder",
|
||||
ImagesUpdatedAt: expectedAt,
|
||||
ImageFiles: []string{"cover.jpg", "back.jpg"},
|
||||
}
|
||||
@ -151,8 +151,8 @@ var _ = Describe("Album Artwork Reader", func() {
|
||||
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")))
|
||||
Expect(imgFiles[0]).To(Equal("Artist/Album/back.jpg"))
|
||||
Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg"))
|
||||
})
|
||||
|
||||
It("does not query parent when parent ID is already in album folders", func() {
|
||||
@ -179,7 +179,7 @@ var _ = Describe("Album Artwork Reader", func() {
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(HaveLen(1))
|
||||
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
|
||||
Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg"))
|
||||
// Get should not have been called (parent already in folder set)
|
||||
Expect(repo.getCallCount).To(Equal(0))
|
||||
})
|
||||
@ -209,14 +209,88 @@ var _ = Describe("Album Artwork Reader", func() {
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(HaveLen(1))
|
||||
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist1/Album/part1/cover.jpg")))
|
||||
Expect(imgFiles[0]).To(Equal("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
|
||||
It("does not include library root parent for multi-folder albums", func() {
|
||||
// Two album parts directly under the library root — parent is the root itself
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: ".",
|
||||
Name: "AlbumPart1",
|
||||
ParentID: "rootFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
},
|
||||
{
|
||||
ID: "folder2",
|
||||
Path: ".",
|
||||
Name: "AlbumPart2",
|
||||
ParentID: "rootFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
}
|
||||
repo.parentResult = &model.Folder{
|
||||
ID: "rootFolder",
|
||||
Path: "",
|
||||
Name: ".",
|
||||
ParentID: "",
|
||||
ImageFiles: []string{"unrelated.jpg"},
|
||||
}
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(HaveLen(1))
|
||||
Expect(imgFiles[0]).To(Equal("AlbumPart1/cover.jpg"))
|
||||
Expect(repo.getCallCount).To(Equal(1))
|
||||
})
|
||||
|
||||
It("includes top-level album folder for multi-disc albums", func() {
|
||||
// Album folder directly under library root, with disc subfolders
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: "Album",
|
||||
Name: "Disc1",
|
||||
ParentID: "albumFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"folder.jpg"},
|
||||
},
|
||||
{
|
||||
ID: "folder2",
|
||||
Path: "Album",
|
||||
Name: "Disc2",
|
||||
ParentID: "albumFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"folder.jpg"},
|
||||
},
|
||||
}
|
||||
repo.parentResult = &model.Folder{
|
||||
ID: "albumFolder",
|
||||
Path: ".",
|
||||
Name: "Album",
|
||||
ParentID: "rootFolder",
|
||||
ImagesUpdatedAt: expectedAt,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}
|
||||
|
||||
_, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(*imagesUpdatedAt).To(Equal(expectedAt))
|
||||
Expect(imgFiles).To(HaveLen(3))
|
||||
Expect(imgFiles[0]).To(Equal("Album/cover.jpg"))
|
||||
Expect(imgFiles[1]).To(Equal("Album/Disc1/folder.jpg"))
|
||||
Expect(imgFiles[2]).To(Equal("Album/Disc2/folder.jpg"))
|
||||
Expect(repo.getCallCount).To(Equal(1))
|
||||
})
|
||||
|
||||
It("does not query parent for single-folder albums that already have images", func() {
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
@ -232,11 +306,39 @@ var _ = Describe("Album Artwork Reader", func() {
|
||||
|
||||
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(imgFiles[0]).To(Equal("Artist/Album/cover.jpg"))
|
||||
Expect(repo.getCallCount).To(Equal(0))
|
||||
})
|
||||
|
||||
It("includes parent images for single-disc-subfolder albums", func() {
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: "Artist/Album",
|
||||
Name: "disc1",
|
||||
ParentID: "albumFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
}
|
||||
repo.parentResult = &model.Folder{
|
||||
ID: "albumFolder",
|
||||
Path: "Artist",
|
||||
Name: "Album",
|
||||
ParentID: "artistFolder",
|
||||
ImagesUpdatedAt: expectedAt,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}
|
||||
|
||||
_, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(*imagesUpdatedAt).To(Equal(expectedAt))
|
||||
Expect(imgFiles).To(HaveLen(1))
|
||||
Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg"))
|
||||
Expect(repo.getCallCount).To(Equal(1))
|
||||
})
|
||||
|
||||
It("propagates non-ErrNotFound errors from parent folder lookup", func() {
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
@ -290,7 +392,7 @@ var _ = Describe("Album Artwork Reader", func() {
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(HaveLen(1))
|
||||
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/CD1/cover.jpg")))
|
||||
Expect(imgFiles[0]).To(Equal("Artist/Album/CD1/cover.jpg"))
|
||||
Expect(repo.getCallCount).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
@ -35,6 +36,7 @@ type artistReader struct {
|
||||
artistFolder string
|
||||
imgFiles []string
|
||||
imgFolderImgPath string // cached path from ArtistImageFolder lookup
|
||||
lib libraryView
|
||||
}
|
||||
|
||||
func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
|
||||
@ -60,12 +62,20 @@ func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.A
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var lib libraryView
|
||||
if len(als) > 0 {
|
||||
lib, err = loadLibraryView(ctx, artwork.ds, als[0].LibraryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
a := &artistReader{
|
||||
a: artwork,
|
||||
provider: provider,
|
||||
artist: *ar,
|
||||
artistFolder: artistFolder,
|
||||
imgFiles: imgFiles,
|
||||
lib: lib,
|
||||
}
|
||||
// TODO Find a way to factor in the ExternalUpdateInfoAt in the cache key. Problem is that it can
|
||||
// change _after_ retrieving from external sources, making the key invalid
|
||||
@ -124,38 +134,62 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin
|
||||
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/")))
|
||||
if a.lib.FS != nil {
|
||||
ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, strings.TrimPrefix(pattern, "album/")))
|
||||
}
|
||||
default:
|
||||
ff = append(ff, fromArtistFolder(ctx, a.artistFolder, pattern))
|
||||
ff = append(ff, fromArtistFolder(ctx, a.lib.FS, a.lib.absRoot, a.artistFolder, pattern))
|
||||
}
|
||||
}
|
||||
return ff
|
||||
}
|
||||
|
||||
func fromArtistFolder(ctx context.Context, artistFolder string, pattern string) sourceFunc {
|
||||
// fromArtistFolder walks up from artistFolder toward libPath looking for a
|
||||
// file matching pattern. Traversal is bounded by both maxArtistFolderTraversalDepth
|
||||
// and the library root: once we reach libPath (or if artistFolder is outside
|
||||
// libPath), the walk stops. All reads go through libFS, which keeps artwork
|
||||
// resolution scoped to the configured library.
|
||||
func fromArtistFolder(ctx context.Context, libFS fs.FS, libPath, artistFolder, pattern string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
if libFS == nil {
|
||||
return nil, "", fmt.Errorf("artist folder lookup unavailable")
|
||||
}
|
||||
rel, err := filepath.Rel(libPath, artistFolder)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return nil, "", fmt.Errorf(`artist folder '%s' is outside library '%s'`, artistFolder, libPath)
|
||||
}
|
||||
// fs.Glob / path.Join below expect forward-slash paths; filepath.Rel may
|
||||
// return backslash separators on Windows.
|
||||
rel = filepath.ToSlash(rel)
|
||||
current := artistFolder
|
||||
for range maxArtistFolderTraversalDepth {
|
||||
if reader, path, err := findImageInFolder(ctx, current, pattern); err == nil {
|
||||
return reader, path, nil
|
||||
reader, hit, err := findImageInFolder(ctx, libFS, rel, current, pattern)
|
||||
if err == nil {
|
||||
return reader, hit, nil
|
||||
}
|
||||
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
break
|
||||
if rel == "." {
|
||||
break // reached library root; don't traverse above it
|
||||
}
|
||||
current = parent
|
||||
rel = path.Dir(rel)
|
||||
current = filepath.Dir(current)
|
||||
}
|
||||
return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories`, pattern, artistFolder)
|
||||
return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories (within library)`, pattern, artistFolder)
|
||||
}
|
||||
}
|
||||
|
||||
func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadCloser, string, error) {
|
||||
log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", folder)
|
||||
fsys := os.DirFS(folder)
|
||||
matches, err := fs.Glob(fsys, pattern)
|
||||
// findImageInFolder globs libFS at relFolder for pattern and returns the first
|
||||
// matching image. absFolder is used only for the returned display path and log
|
||||
// messages so callers see absolute-looking paths consistent with the rest of
|
||||
// the artwork pipeline.
|
||||
func findImageInFolder(ctx context.Context, libFS fs.FS, relFolder, absFolder, pattern string) (io.ReadCloser, string, error) {
|
||||
log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", absFolder)
|
||||
globPattern := pattern
|
||||
if relFolder != "." {
|
||||
globPattern = path.Join(escapeGlobLiteral(relFolder), pattern)
|
||||
}
|
||||
matches, err := fs.Glob(libFS, globPattern)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", folder, err)
|
||||
log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", absFolder, err)
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
@ -172,18 +206,30 @@ func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadClos
|
||||
// suffixes (e.g., artist.jpg before artist.1.jpg)
|
||||
slices.SortFunc(imagePaths, compareImageFiles)
|
||||
|
||||
// Try to open files in sorted order
|
||||
for _, p := range imagePaths {
|
||||
filePath := filepath.Join(folder, p)
|
||||
f, err := os.Open(filePath)
|
||||
f, err := libFS.Open(p)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not open cover art file", "file", filePath, err)
|
||||
log.Warn(ctx, "Could not open cover art file", "file", p, err)
|
||||
continue
|
||||
}
|
||||
return f, filePath, nil
|
||||
_, name := path.Split(p)
|
||||
return f, filepath.Join(absFolder, name), nil
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, folder)
|
||||
return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, absFolder)
|
||||
}
|
||||
|
||||
func escapeGlobLiteral(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '\\', '*', '?', '[', ']':
|
||||
b.WriteByte('\\')
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) {
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@ -66,7 +67,7 @@ var _ = Describe("artistArtworkReader", func() {
|
||||
}
|
||||
folder, upd, err := loadArtistFolder(ctx, fds, albums, paths)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(folder).To(Equal("/music/artist"))
|
||||
Expect(folder).To(Equal(filepath.FromSlash("/music/artist")))
|
||||
Expect(upd).To(Equal(expectedUpdTime))
|
||||
})
|
||||
})
|
||||
@ -92,7 +93,7 @@ var _ = Describe("artistArtworkReader", func() {
|
||||
}
|
||||
folder, upd, err := loadArtistFolder(ctx, fds, albums, paths)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(folder).To(Equal("/music/artist"))
|
||||
Expect(folder).To(Equal(filepath.FromSlash("/music/artist")))
|
||||
Expect(upd).To(Equal(expectedUpdTime))
|
||||
})
|
||||
})
|
||||
@ -117,12 +118,14 @@ var _ = Describe("artistArtworkReader", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
tempDir string
|
||||
libFS fs.FS
|
||||
testFunc sourceFunc
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
tempDir = GinkgoT().TempDir()
|
||||
libFS = os.DirFS(tempDir)
|
||||
})
|
||||
|
||||
When("artist folder contains matching image", func() {
|
||||
@ -134,7 +137,7 @@ var _ = Describe("artistArtworkReader", func() {
|
||||
artistImagePath := filepath.Join(artistDir, "artist.jpg")
|
||||
Expect(os.WriteFile(artistImagePath, []byte("fake image data"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("finds and returns the image", func() {
|
||||
@ -151,6 +154,30 @@ var _ = Describe("artistArtworkReader", func() {
|
||||
})
|
||||
})
|
||||
|
||||
When("artist folder name contains glob metacharacters", func() {
|
||||
BeforeEach(func() {
|
||||
artistDir := filepath.Join(tempDir, "Artist [Live]")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
artistImagePath := filepath.Join(artistDir, "artist.jpg")
|
||||
Expect(os.WriteFile(artistImagePath, []byte("bracketed artist image"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("treats the folder path literally when globbing through the library fs", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
Expect(path).To(ContainSubstring("Artist [Live]" + string(filepath.Separator) + "artist.jpg"))
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("bracketed artist image"))
|
||||
reader.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("artist folder is empty but parent contains image", func() {
|
||||
BeforeEach(func() {
|
||||
// Create test structure: /temp/parent/artist.jpg and /temp/parent/artist/album/
|
||||
@ -163,7 +190,7 @@ var _ = Describe("artistArtworkReader", func() {
|
||||
artistImagePath := filepath.Join(parentDir, "artist.jpg")
|
||||
Expect(os.WriteFile(artistImagePath, []byte("parent image"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("finds image in parent directory", func() {
|
||||
@ -191,7 +218,7 @@ var _ = Describe("artistArtworkReader", func() {
|
||||
artistImagePath := filepath.Join(grandparentDir, "artist.jpg")
|
||||
Expect(os.WriteFile(artistImagePath, []byte("grandparent image"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("finds image in grandparent directory", func() {
|
||||
@ -220,7 +247,7 @@ var _ = Describe("artistArtworkReader", func() {
|
||||
Expect(os.WriteFile(filepath.Join(parentDir, "artist.jpg"), []byte("parent level"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(grandparentDir, "artist.jpg"), []byte("grandparent level"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("prioritizes the closest (artist folder) image", func() {
|
||||
@ -246,7 +273,7 @@ var _ = Describe("artistArtworkReader", func() {
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.png"), []byte("png image"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("returns the first valid image file in sorted order", func() {
|
||||
@ -273,7 +300,7 @@ var _ = Describe("artistArtworkReader", func() {
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist main"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.2.jpg"), []byte("artist 2"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("returns artist.jpg before artist.1.jpg and artist.2.jpg", func() {
|
||||
@ -301,7 +328,7 @@ var _ = Describe("artistArtworkReader", func() {
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "BACK.jpg"), []byte("back"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "*.*")
|
||||
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "*.*")
|
||||
})
|
||||
|
||||
It("sorts case-insensitively", func() {
|
||||
@ -327,7 +354,7 @@ var _ = Describe("artistArtworkReader", func() {
|
||||
// Create non-matching files
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "cover.jpg"), []byte("cover image"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("returns an error", func() {
|
||||
@ -346,7 +373,7 @@ var _ = Describe("artistArtworkReader", func() {
|
||||
artistDir := filepath.Join(tempDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("handles root boundary gracefully", func() {
|
||||
@ -367,7 +394,7 @@ var _ = Describe("artistArtworkReader", func() {
|
||||
restrictedFile := filepath.Join(artistDir, "artist.jpg")
|
||||
Expect(os.WriteFile(restrictedFile, []byte("restricted"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("logs warning and continues searching", func() {
|
||||
@ -397,7 +424,7 @@ var _ = Describe("artistArtworkReader", func() {
|
||||
Expect(os.WriteFile(artistImagePath, []byte("single album artist image"), 0600)).To(Succeed())
|
||||
|
||||
// The fromArtistFolder is called with the artist folder path
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("finds artist.jpg in artist folder for single album artist", func() {
|
||||
@ -425,7 +452,7 @@ var _ = Describe("artistArtworkReader", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tempDir = GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = tempDir
|
||||
conf.Server.DataFolder = conf.NewDir(tempDir)
|
||||
|
||||
// Create the artwork/artist directory
|
||||
Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "artist"), 0755)).To(Succeed())
|
||||
|
||||
@ -5,7 +5,7 @@ import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -13,10 +13,10 @@ import (
|
||||
|
||||
"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"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
type discArtworkReader struct {
|
||||
@ -24,10 +24,11 @@ type discArtworkReader struct {
|
||||
a *artwork
|
||||
album model.Album
|
||||
discNumber int
|
||||
imgFiles []string
|
||||
discFolders map[string]bool
|
||||
imgFiles []string // library-relative, forward-slash, no leading slash
|
||||
discFoldersRel map[string]bool // library-relative folder paths
|
||||
isMultiFolder bool
|
||||
firstTrackPath string
|
||||
firstTrackRel string // library-relative; for fromTag / ffmpeg via lib.Abs
|
||||
lib libraryView
|
||||
updatedAt *time.Time
|
||||
}
|
||||
|
||||
@ -57,18 +58,23 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build disc folder set and find first track
|
||||
discFolders := make(map[string]bool)
|
||||
var firstTrackPath string
|
||||
lib, err := loadLibraryView(ctx, a.ds, al.LibraryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build disc folder set and find first track. mf.Path is already library-relative.
|
||||
var firstTrackRel string
|
||||
allFolderIDs := make(map[string]bool)
|
||||
for _, mf := range mfs {
|
||||
allFolderIDs[mf.FolderID] = true
|
||||
if firstTrackPath == "" {
|
||||
firstTrackPath = mf.Path
|
||||
if firstTrackRel == "" {
|
||||
firstTrackRel = filepath.ToSlash(mf.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve folder IDs to absolute paths
|
||||
// Resolve folder IDs to library-relative paths
|
||||
discFoldersRel := make(map[string]bool)
|
||||
if len(allFolderIDs) > 0 {
|
||||
folderIDs := make([]string, 0, len(allFolderIDs))
|
||||
for id := range allFolderIDs {
|
||||
@ -81,7 +87,8 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID
|
||||
return nil, err
|
||||
}
|
||||
for _, f := range folders {
|
||||
discFolders[f.AbsolutePath()] = true
|
||||
rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/")
|
||||
discFoldersRel[rel] = true
|
||||
}
|
||||
}
|
||||
|
||||
@ -92,16 +99,16 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID
|
||||
album: *al,
|
||||
discNumber: discNumber,
|
||||
imgFiles: imgFiles,
|
||||
discFolders: discFolders,
|
||||
discFoldersRel: discFoldersRel,
|
||||
isMultiFolder: isMultiFolder,
|
||||
firstTrackPath: core.AbsolutePath(ctx, a.ds, al.LibraryID, firstTrackPath),
|
||||
firstTrackRel: firstTrackRel,
|
||||
lib: lib,
|
||||
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
|
||||
r.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt)
|
||||
if imagesUpdatedAt != nil {
|
||||
r.cacheKey.lastUpdate = utils.TimeNewest(r.cacheKey.lastUpdate, *imagesUpdatedAt)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
@ -116,7 +123,7 @@ func (d *discArtworkReader) Key() string {
|
||||
}
|
||||
|
||||
func (d *discArtworkReader) LastUpdated() time.Time {
|
||||
return d.album.UpdatedAt
|
||||
return d.lastUpdate
|
||||
}
|
||||
|
||||
func (d *discArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
@ -133,7 +140,10 @@ func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmp
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "embedded":
|
||||
ff = append(ff, fromTag(ctx, d.firstTrackPath), fromFFmpegTag(ctx, ffmpeg, d.firstTrackPath))
|
||||
ff = append(ff,
|
||||
fromTag(ctx, d.lib.FS, d.firstTrackRel),
|
||||
fromFFmpegTag(ctx, ffmpeg, d.lib.Abs(d.firstTrackRel)),
|
||||
)
|
||||
case pattern == "external":
|
||||
// Not supported for disc art, silently ignore
|
||||
case pattern == "discsubtitle":
|
||||
@ -152,12 +162,12 @@ func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmp
|
||||
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))
|
||||
name := path.Base(file)
|
||||
stem := strings.TrimSuffix(name, path.Ext(name))
|
||||
if !strings.EqualFold(stem, subtitle) {
|
||||
continue
|
||||
}
|
||||
f, err := os.Open(file)
|
||||
f, err := d.lib.FS.Open(file)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not open disc art file", "file", file, err)
|
||||
continue
|
||||
@ -168,47 +178,38 @@ func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle strin
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// globMetaChars holds the substitution metacharacters understood by
|
||||
// filepath.Match. The '\' escape character is intentionally excluded:
|
||||
// disc art patterns come from user config and never include escaped
|
||||
// metachars in practice, and treating '\' as a metachar would misalign
|
||||
// the literal-prefix extraction in extractDiscNumber.
|
||||
const globMetaChars = "*?["
|
||||
|
||||
// extractDiscNumber parses the disc number from a filename matched by a
|
||||
// filepath.Match-style glob pattern.
|
||||
//
|
||||
// Both pattern and filename must already be lowercased by the caller, which
|
||||
// is also expected to have verified that filepath.Match(pattern, filename)
|
||||
// is true before calling this function.
|
||||
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 {
|
||||
metaIdx := strings.IndexAny(pattern, globMetaChars)
|
||||
if metaIdx < 0 {
|
||||
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
|
||||
prefix := pattern[:metaIdx]
|
||||
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
|
||||
}
|
||||
start := len(prefix)
|
||||
end := start
|
||||
for end < len(filename) && filename[end] >= '0' && filename[end] <= '9' {
|
||||
end++
|
||||
}
|
||||
|
||||
if len(digits) == 0 {
|
||||
if end == start {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
num, err := strconv.Atoi(string(digits))
|
||||
num, err := strconv.Atoi(filename[start:end])
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
@ -216,20 +217,15 @@ func extractDiscNumber(pattern, filename string) (int, bool) {
|
||||
}
|
||||
|
||||
// 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).
|
||||
// pattern. A numbered filename whose number equals the target disc wins over
|
||||
// any unnumbered candidate; callers must pass a lowercase pattern.
|
||||
func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string) sourceFunc {
|
||||
isLiteral := !strings.ContainsAny(pattern, globMetaChars)
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
var fallbacks []string
|
||||
for _, file := range d.imgFiles {
|
||||
_, name := filepath.Split(file)
|
||||
match, err := filepath.Match(pattern, strings.ToLower(name))
|
||||
name := strings.ToLower(path.Base(file))
|
||||
match, err := filepath.Match(pattern, name)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error matching disc art file to pattern", "pattern", pattern, "file", file)
|
||||
continue
|
||||
@ -238,25 +234,28 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string
|
||||
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
|
||||
if !isLiteral {
|
||||
if num, hasNum := extractDiscNumber(pattern, name); hasNum {
|
||||
if num != d.discNumber {
|
||||
continue
|
||||
}
|
||||
f, err := d.lib.FS.Open(file)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not open disc art file", "file", file, err)
|
||||
continue
|
||||
}
|
||||
return f, file, nil
|
||||
}
|
||||
} 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 d.isMultiFolder && !d.discFoldersRel[path.Dir(file)] {
|
||||
continue
|
||||
}
|
||||
fallbacks = append(fallbacks, file)
|
||||
}
|
||||
|
||||
for _, file := range fallbacks {
|
||||
f, err := d.lib.FS.Open(file)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not open disc art file", "file", file, err)
|
||||
continue
|
||||
|
||||
@ -42,11 +42,24 @@ var _ = Describe("Disc Artwork Reader", func() {
|
||||
// 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),
|
||||
// HasPrefix guard: filename doesn't share the pattern's literal prefix
|
||||
Entry("cover.jpg with disc*.* (no prefix match)", "disc*.*", "cover.jpg", 0, false),
|
||||
|
||||
// Pattern with no wildcard before dot
|
||||
Entry("front1.jpg with front*.*", "front*.*", "front1.jpg", 1, true),
|
||||
|
||||
// '?' single-char wildcard
|
||||
Entry("disc?.jpg with disc1.jpg", "disc?.jpg", "disc1.jpg", 1, true),
|
||||
Entry("disc?.jpg with disc2.jpg", "disc?.jpg", "disc2.jpg", 2, true),
|
||||
Entry("cd??.jpg with cd07.jpg", "cd??.jpg", "cd07.jpg", 7, true),
|
||||
|
||||
// '[...]' character class wildcard
|
||||
Entry("cd[12].jpg with cd1.jpg", "cd[12].jpg", "cd1.jpg", 1, true),
|
||||
Entry("cd[12].jpg with cd2.jpg", "cd[12].jpg", "cd2.jpg", 2, true),
|
||||
Entry("disc[0-9].jpg with disc5.jpg", "disc[0-9].jpg", "disc5.jpg", 5, true),
|
||||
|
||||
// Literal pattern (no wildcard) returns false
|
||||
Entry("shellac.png literal", "shellac.png", "shellac.png", 0, false),
|
||||
)
|
||||
})
|
||||
|
||||
@ -61,20 +74,27 @@ var _ = Describe("Disc Artwork Reader", func() {
|
||||
tmpDir = GinkgoT().TempDir()
|
||||
})
|
||||
|
||||
createFile := func(path string) string {
|
||||
fullPath := filepath.Join(tmpDir, filepath.FromSlash(path))
|
||||
// createFile creates the file on disk and returns its library-relative forward-slash path.
|
||||
createFile := func(relPath string) string {
|
||||
fullPath := filepath.Join(tmpDir, filepath.FromSlash(relPath))
|
||||
Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed())
|
||||
Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed())
|
||||
return fullPath
|
||||
return relPath
|
||||
}
|
||||
|
||||
// removeFile removes a library-relative file from disk.
|
||||
removeFile := func(relPath string) {
|
||||
Expect(os.Remove(filepath.Join(tmpDir, filepath.FromSlash(relPath)))).To(Succeed())
|
||||
}
|
||||
|
||||
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},
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1, f2},
|
||||
discFoldersRel: map[string]bool{"album": true},
|
||||
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, "disc*.*")
|
||||
@ -85,27 +105,203 @@ var _ = Describe("Disc Artwork Reader", func() {
|
||||
Expect(path).To(Equal(f1))
|
||||
})
|
||||
|
||||
It("skips file without number in single-folder album", func() {
|
||||
f1 := createFile("album/disc.jpg")
|
||||
It("matches file without number in single-folder album (shared disc art)", func() {
|
||||
f1 := createFile("album/cover.png")
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1},
|
||||
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1},
|
||||
discFoldersRel: map[string]bool{"album": true},
|
||||
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, "cover.*")
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(f1))
|
||||
})
|
||||
|
||||
It("returns shared disc art for every disc number in single-folder album", func() {
|
||||
f1 := createFile("album/shellac.png")
|
||||
makeReader := func(discNum int) *discArtworkReader {
|
||||
return &discArtworkReader{
|
||||
discNumber: discNum,
|
||||
imgFiles: []string{f1},
|
||||
discFoldersRel: map[string]bool{"album": true},
|
||||
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
|
||||
}
|
||||
}
|
||||
|
||||
for _, disc := range []int{1, 2, 5} {
|
||||
sf := makeReader(disc).fromExternalFile(ctx, "shellac.png")
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred(), "disc %d", disc)
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(f1), "disc %d", disc)
|
||||
}
|
||||
})
|
||||
|
||||
It("numbered and unnumbered patterns both resolve against the same reader", func() {
|
||||
f1 := createFile("album/cover.png")
|
||||
f2 := createFile("album/disc1.jpg")
|
||||
f3 := createFile("album/disc2.jpg")
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 2,
|
||||
imgFiles: []string{f1, f2, f3},
|
||||
discFoldersRel: map[string]bool{"album": true},
|
||||
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, "disc*.*")
|
||||
r, _, _ := sf()
|
||||
Expect(r).To(BeNil())
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(f3))
|
||||
|
||||
sf = reader.fromExternalFile(ctx, "cover.*")
|
||||
r, path, err = sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(f1))
|
||||
})
|
||||
|
||||
It("respects DiscArtPriority order when both numbered and unnumbered patterns match", func() {
|
||||
f1 := createFile("album/cover.png")
|
||||
f2 := createFile("album/disc1.jpg")
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1, f2},
|
||||
discFoldersRel: map[string]bool{"album": true},
|
||||
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
|
||||
}
|
||||
|
||||
ff := reader.fromDiscArtPriority(ctx, nil, "disc*.*, cover.*")
|
||||
Expect(ff).To(HaveLen(2))
|
||||
r, path, err := ff[0]()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(f2))
|
||||
r.Close()
|
||||
|
||||
ff = reader.fromDiscArtPriority(ctx, nil, "cover.*, disc*.*")
|
||||
Expect(ff).To(HaveLen(2))
|
||||
r, path, err = ff[0]()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(f1))
|
||||
r.Close()
|
||||
})
|
||||
|
||||
DescribeTable("numbered match wins over shared fallback within a pattern",
|
||||
func(discNumber, expectedIdx int) {
|
||||
files := []string{
|
||||
createFile("album/disc.jpg"),
|
||||
createFile("album/disc1.jpg"),
|
||||
createFile("album/disc2.jpg"),
|
||||
}
|
||||
reader := &discArtworkReader{
|
||||
discNumber: discNumber,
|
||||
imgFiles: files,
|
||||
discFoldersRel: map[string]bool{"album": true},
|
||||
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, "disc*.*")
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(files[expectedIdx]))
|
||||
},
|
||||
Entry("disc 2 picks disc2.jpg over the shared disc.jpg", 2, 2),
|
||||
Entry("disc 3 falls back to disc.jpg when no numbered match exists", 3, 0),
|
||||
)
|
||||
|
||||
It("tries the next fallback candidate when the first one cannot be opened", func() {
|
||||
f1 := createFile("album/cover.jpg")
|
||||
f2 := createFile("album/cover.png")
|
||||
// Remove f1 so Open will fail on it; f2 should still win.
|
||||
removeFile(f1)
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1, f2},
|
||||
discFoldersRel: map[string]bool{"album": true},
|
||||
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, "cover.*")
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(f2))
|
||||
})
|
||||
|
||||
It("keeps scanning literal-pattern matches so fallback retry still works", func() {
|
||||
// Guards against an 'early break on first literal match' optimization.
|
||||
// Multiple imgFiles entries can share a basename (symlinks, case-variant
|
||||
// duplicates on case-sensitive filesystems). If the loop breaks after
|
||||
// recording just the first, the fallback retry cannot recover when
|
||||
// that first file is unreadable.
|
||||
f1 := createFile("album/stale/cover.png")
|
||||
f2 := createFile("album/cover.png")
|
||||
removeFile(f1)
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1, f2},
|
||||
discFoldersRel: map[string]bool{
|
||||
"album": true,
|
||||
"album/stale": true,
|
||||
},
|
||||
isMultiFolder: true,
|
||||
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, "cover.png")
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(f2))
|
||||
})
|
||||
|
||||
DescribeTable("filters by disc number for non-'*' wildcard patterns",
|
||||
func(pattern string, discNumber, expectedIdx int) {
|
||||
files := []string{
|
||||
createFile("album/disc1.jpg"),
|
||||
createFile("album/disc2.jpg"),
|
||||
}
|
||||
reader := &discArtworkReader{
|
||||
discNumber: discNumber,
|
||||
imgFiles: files,
|
||||
discFoldersRel: map[string]bool{"album": true},
|
||||
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, pattern)
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(files[expectedIdx]))
|
||||
},
|
||||
Entry("disc?.jpg, target disc 1 → disc1.jpg", "disc?.jpg", 1, 0),
|
||||
Entry("disc?.jpg, target disc 2 → disc2.jpg", "disc?.jpg", 2, 1),
|
||||
Entry("disc[0-9].jpg, target disc 1 → disc1.jpg", "disc[0-9].jpg", 1, 0),
|
||||
Entry("disc[0-9].jpg, target disc 2 → disc2.jpg", "disc[0-9].jpg", 2, 1),
|
||||
)
|
||||
|
||||
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,
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1, f2},
|
||||
discFoldersRel: map[string]bool{"album/cd1": true},
|
||||
isMultiFolder: true,
|
||||
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, "disc*.*")
|
||||
@ -120,10 +316,11 @@ var _ = Describe("Disc Artwork Reader", 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,
|
||||
discNumber: 2,
|
||||
imgFiles: []string{f1},
|
||||
discFoldersRel: map[string]bool{"album/cd1": true},
|
||||
isMultiFolder: true,
|
||||
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, "disc*.*")
|
||||
@ -137,9 +334,10 @@ var _ = Describe("Disc Artwork Reader", func() {
|
||||
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},
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1},
|
||||
discFoldersRel: map[string]bool{"album": true},
|
||||
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, "disc*.*")
|
||||
@ -159,11 +357,11 @@ var _ = Describe("Disc Artwork Reader", func() {
|
||||
tmpDir = GinkgoT().TempDir()
|
||||
})
|
||||
|
||||
createFile := func(path string) string {
|
||||
fullPath := filepath.Join(tmpDir, filepath.FromSlash(path))
|
||||
createFile := func(relPath string) string {
|
||||
fullPath := filepath.Join(tmpDir, filepath.FromSlash(relPath))
|
||||
Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed())
|
||||
Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed())
|
||||
return fullPath
|
||||
return relPath
|
||||
}
|
||||
|
||||
It("matches image file whose stem equals the disc subtitle (case-insensitive)", func() {
|
||||
@ -171,6 +369,7 @@ var _ = Describe("Disc Artwork Reader", func() {
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1},
|
||||
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
|
||||
}
|
||||
|
||||
sf := reader.fromDiscSubtitle(ctx, "The Blue Disc")
|
||||
@ -186,6 +385,7 @@ var _ = Describe("Disc Artwork Reader", func() {
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 2,
|
||||
imgFiles: []string{f1},
|
||||
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
|
||||
}
|
||||
|
||||
sf := reader.fromDiscSubtitle(ctx, "Bonus Tracks")
|
||||
@ -201,6 +401,7 @@ var _ = Describe("Disc Artwork Reader", func() {
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1},
|
||||
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
|
||||
}
|
||||
|
||||
sf := reader.fromDiscSubtitle(ctx, "The Blue Disc")
|
||||
@ -214,6 +415,7 @@ var _ = Describe("Disc Artwork Reader", func() {
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1, f2},
|
||||
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
|
||||
}
|
||||
|
||||
sf := reader.fromDiscSubtitle(ctx, "The Blue Disc")
|
||||
@ -227,19 +429,24 @@ var _ = Describe("Disc Artwork Reader", func() {
|
||||
|
||||
Describe("discArtworkReader", func() {
|
||||
Describe("fromDiscArtPriority", func() {
|
||||
var reader *discArtworkReader
|
||||
var (
|
||||
reader *discArtworkReader
|
||||
tmpDir string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
tmpDir = GinkgoT().TempDir()
|
||||
reader = &discArtworkReader{
|
||||
discNumber: 2,
|
||||
isMultiFolder: true,
|
||||
discFolders: map[string]bool{"/music/album/cd2": true},
|
||||
discNumber: 2,
|
||||
isMultiFolder: true,
|
||||
discFoldersRel: map[string]bool{"music/album/cd2": true},
|
||||
imgFiles: []string{
|
||||
"/music/album/cd1/disc.jpg",
|
||||
"/music/album/cd2/disc.jpg",
|
||||
"/music/album/cd2/disc2.jpg",
|
||||
"music/album/cd1/disc.jpg",
|
||||
"music/album/cd2/disc.jpg",
|
||||
"music/album/cd2/disc2.jpg",
|
||||
},
|
||||
firstTrackPath: "/music/album/cd2/track1.flac",
|
||||
firstTrackRel: "music/album/cd2/track1.flac",
|
||||
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ type mediafileArtworkReader struct {
|
||||
a *artwork
|
||||
mediafile model.MediaFile
|
||||
album model.Album
|
||||
lib libraryView
|
||||
}
|
||||
|
||||
func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*mediafileArtworkReader, error) {
|
||||
@ -30,10 +31,15 @@ func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID mode
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lib, err := loadLibraryView(ctx, artwork.ds, mf.LibraryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &mediafileArtworkReader{
|
||||
a: artwork,
|
||||
mediafile: *mf,
|
||||
album: *al,
|
||||
lib: lib,
|
||||
}
|
||||
a.cacheKey.artID = artID
|
||||
a.cacheKey.lastUpdate = mf.UpdatedAt
|
||||
@ -60,10 +66,9 @@ func (a *mediafileArtworkReader) LastUpdated() time.Time {
|
||||
func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
var ff []sourceFunc
|
||||
if a.mediafile.CoverArtID().Kind == model.KindMediaFileArtwork {
|
||||
path := a.mediafile.AbsolutePath()
|
||||
ff = []sourceFunc{
|
||||
fromTag(ctx, path),
|
||||
fromFFmpegTag(ctx, a.a.ffmpeg, path),
|
||||
fromTag(ctx, a.lib.FS, a.mediafile.Path),
|
||||
fromFFmpegTag(ctx, a.a.ffmpeg, a.lib.Abs(a.mediafile.Path)),
|
||||
}
|
||||
}
|
||||
// For multi-disc albums, fall back to disc artwork first; for single-disc albums,
|
||||
|
||||
@ -21,7 +21,7 @@ var _ = Describe("radioArtworkReader", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tempDir = GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = tempDir
|
||||
conf.Server.DataFolder = conf.NewDir(tempDir)
|
||||
|
||||
Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "radio"), 0755)).To(Succeed())
|
||||
|
||||
|
||||
@ -19,6 +19,16 @@ import (
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
if err := webp.Dynamic(); err != nil {
|
||||
log.Debug("Using WASM WebP encoder/decoder", "reason", err)
|
||||
} else {
|
||||
log.Debug("Using native libwebp for WebP encoding/decoding")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var bufPool = sync.Pool{
|
||||
New: func() any {
|
||||
return new(bytes.Buffer)
|
||||
@ -98,28 +108,26 @@ func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader
|
||||
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)
|
||||
// Preserve animation for animated images
|
||||
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
|
||||
}
|
||||
} 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
|
||||
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))
|
||||
original, format, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@ -159,14 +167,12 @@ func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, erro
|
||||
|
||||
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 {
|
||||
if conf.Server.EnableWebPEncoding {
|
||||
err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality})
|
||||
} else if format == "png" || square {
|
||||
err = png.Encode(buf, dst)
|
||||
} else {
|
||||
err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality})
|
||||
}
|
||||
if err != nil {
|
||||
bufPool.Put(buf)
|
||||
|
||||
@ -54,17 +54,17 @@ var _ = Describe("resizeImage", func() {
|
||||
Expect(len(output)).To(BeNumerically(">", 0))
|
||||
})
|
||||
|
||||
It("skips animation for square thumbnails even with animated GIF", func() {
|
||||
It("preserves animation for square thumbnails 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
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
// Should have been processed by ffmpeg (mock returns input data)
|
||||
output, err := io.ReadAll(result)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output).To(Equal(data))
|
||||
})
|
||||
})
|
||||
|
||||
@ -81,13 +81,17 @@ var _ = Describe("resizeImage", func() {
|
||||
Expect(output).To(Equal(data))
|
||||
})
|
||||
|
||||
It("does not passthrough animated WebP for square thumbnails", func() {
|
||||
It("preserves 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())
|
||||
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))
|
||||
})
|
||||
})
|
||||
|
||||
@ -104,15 +108,17 @@ var _ = Describe("resizeImage", func() {
|
||||
Expect(output).To(Equal(data))
|
||||
})
|
||||
|
||||
It("does not passthrough animated PNG for square thumbnails", func() {
|
||||
It("preserves 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())
|
||||
}
|
||||
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))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -5,9 +5,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
@ -53,7 +53,7 @@ func (f sourceFunc) String() string {
|
||||
return name
|
||||
}
|
||||
|
||||
func fromExternalFile(ctx context.Context, files []string, pattern string) sourceFunc {
|
||||
func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
for _, file := range files {
|
||||
_, name := filepath.Split(file)
|
||||
@ -65,12 +65,12 @@ func fromExternalFile(ctx context.Context, files []string, pattern string) sourc
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
f, err := os.Open(file)
|
||||
f, err := libFS.Open(file)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not open cover art file", "file", file, err)
|
||||
continue
|
||||
}
|
||||
return f, file, err
|
||||
return f, file, nil
|
||||
}
|
||||
return nil, "", fmt.Errorf("pattern '%s' not matched by files %v", pattern, files)
|
||||
}
|
||||
@ -83,28 +83,43 @@ var picTypeRegexes = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i).*cover.*`),
|
||||
}
|
||||
|
||||
func fromTag(ctx context.Context, path string) sourceFunc {
|
||||
func fromTag(ctx context.Context, libFS fs.FS, relPath string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
if path == "" {
|
||||
if relPath == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
f, err := taglib.OpenReadOnly(path, taglib.WithReadStyle(taglib.ReadStyleFast))
|
||||
f, err := libFS.Open(relPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
rs, ok := f.(io.ReadSeeker)
|
||||
if !ok {
|
||||
f.Close()
|
||||
return nil, "", fmt.Errorf("FS file %s is not seekable; cannot read tags", relPath)
|
||||
}
|
||||
tf, err := taglib.OpenStream(rs,
|
||||
taglib.WithReadStyle(taglib.ReadStyleFast),
|
||||
taglib.WithFilename(relPath),
|
||||
)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, "", err
|
||||
}
|
||||
// Close in LIFO order: tf first (it holds rs internally), then f.
|
||||
defer f.Close()
|
||||
defer tf.Close()
|
||||
|
||||
images := f.Properties().Images
|
||||
images := tf.Properties().Images
|
||||
if len(images) == 0 {
|
||||
return nil, "", fmt.Errorf("no embedded image found in %s", path)
|
||||
return nil, "", fmt.Errorf("no embedded image found in %s", relPath)
|
||||
}
|
||||
|
||||
imageIndex := findBestImageIndex(ctx, images, path)
|
||||
data, err := f.Image(imageIndex)
|
||||
imageIndex := findBestImageIndex(ctx, images, relPath)
|
||||
data, err := tf.Image(imageIndex)
|
||||
if err != nil || len(data) == 0 {
|
||||
return nil, "", fmt.Errorf("could not load embedded image from %s", path)
|
||||
return nil, "", fmt.Errorf("could not load embedded image from %s", relPath)
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(data)), path, nil
|
||||
return io.NopCloser(bytes.NewReader(data)), relPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -121,6 +136,13 @@ func findBestImageIndex(ctx context.Context, images []taglib.ImageDesc, path str
|
||||
return 0
|
||||
}
|
||||
|
||||
// fromFFmpegTag is intentionally absolute-path-based. ffmpeg is a subprocess
|
||||
// and cannot read from arbitrary fs.FS implementations; piping via stdin is a
|
||||
// non-trivial refactor with stream/seek implications.
|
||||
//
|
||||
// TODO(artwork-musicfs): when the storage backing the library is not local
|
||||
// (e.g. a future S3 backend, or FakeFS in tests), short-circuit this source
|
||||
// func to return (nil, "", nil) so callers fall through cleanly.
|
||||
func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
if path == "" {
|
||||
@ -130,10 +152,25 @@ func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourc
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return r, path, nil
|
||||
// Validate that the stream actually contains image data by reading the first byte.
|
||||
// ffmpeg.ExtractImage returns a pipe reader that may fail asynchronously if the
|
||||
// file has no video/image stream (e.g., an MP3 without embedded art).
|
||||
buf := make([]byte, 1)
|
||||
n, err := r.Read(buf)
|
||||
if n == 0 || err != nil {
|
||||
r.Close()
|
||||
return nil, "", fmt.Errorf("ffmpeg produced no image data for %s: %w", path, err)
|
||||
}
|
||||
return readCloser{Reader: io.MultiReader(bytes.NewReader(buf[:n]), r), Closer: r}, path, nil
|
||||
}
|
||||
}
|
||||
|
||||
// readCloser combines a Reader and a Closer into an io.ReadCloser.
|
||||
type readCloser struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}
|
||||
|
||||
func fromAlbum(ctx context.Context, a *artwork, id model.ArtworkID) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
r, _, err := a.Get(ctx, id, 0, false)
|
||||
|
||||
92
core/artwork/sources_internal_test.go
Normal file
92
core/artwork/sources_internal_test.go
Normal file
@ -0,0 +1,92 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"testing/fstest"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("fromExternalFile", func() {
|
||||
It("opens a matching file via the library FS", func() {
|
||||
fsys := fstest.MapFS{
|
||||
"Artist/Album/cover.jpg": &fstest.MapFile{Data: []byte("cover-bytes")},
|
||||
}
|
||||
f := fromExternalFile(GinkgoT().Context(), fsys, []string{"Artist/Album/cover.jpg"}, "cover.*")
|
||||
r, path, err := f()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer r.Close()
|
||||
b, _ := io.ReadAll(r)
|
||||
Expect(b).To(Equal([]byte("cover-bytes")))
|
||||
Expect(path).To(Equal("Artist/Album/cover.jpg"))
|
||||
})
|
||||
|
||||
It("returns an error when no file matches", func() {
|
||||
fsys := fstest.MapFS{
|
||||
"Artist/Album/something.txt": &fstest.MapFile{Data: []byte("x")},
|
||||
}
|
||||
f := fromExternalFile(GinkgoT().Context(), fsys, []string{"Artist/Album/something.txt"}, "cover.*")
|
||||
_, _, err := f()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("skips files that fail to open and tries the next match", func() {
|
||||
fsys := fstest.MapFS{
|
||||
"a/cover.jpg": &fstest.MapFile{Data: []byte("a")},
|
||||
}
|
||||
// "missing/cover.jpg" is in candidates but not in the FS — should be skipped.
|
||||
f := fromExternalFile(GinkgoT().Context(), fsys, []string{"missing/cover.jpg", "a/cover.jpg"}, "cover.*")
|
||||
r, path, err := f()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer r.Close()
|
||||
b, _ := io.ReadAll(r)
|
||||
Expect(b).To(Equal([]byte("a")))
|
||||
Expect(path).To(Equal("a/cover.jpg"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("fromTag", func() {
|
||||
It("opens an embedded image via fs.FS", func() {
|
||||
fsys := os.DirFS("tests/fixtures/artist/an-album")
|
||||
f := fromTag(GinkgoT().Context(), fsys, "test.mp3")
|
||||
r, path, err := f()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer r.Close()
|
||||
Expect(path).To(Equal("test.mp3"))
|
||||
b, _ := io.ReadAll(r)
|
||||
Expect(b).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns nil reader when the relative path is empty", func() {
|
||||
f := fromTag(GinkgoT().Context(), os.DirFS("."), "")
|
||||
r, _, err := f()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
})
|
||||
|
||||
It("errors when the FS file is not seekable", func() {
|
||||
fsys := nonSeekableFS{data: []byte("garbage")}
|
||||
f := fromTag(GinkgoT().Context(), fsys, "x.mp3")
|
||||
_, _, err := f()
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("not seekable"))
|
||||
})
|
||||
})
|
||||
|
||||
// nonSeekableFS is a single-file fs.FS whose Open returns a non-seekable file.
|
||||
type nonSeekableFS struct{ data []byte }
|
||||
|
||||
func (n nonSeekableFS) Open(name string) (fs.File, error) {
|
||||
return &nonSeekableFile{r: bytes.NewReader(n.data)}, nil
|
||||
}
|
||||
|
||||
type nonSeekableFile struct{ r *bytes.Reader }
|
||||
|
||||
func (n *nonSeekableFile) Read(p []byte) (int, error) { return n.r.Read(p) }
|
||||
func (n *nonSeekableFile) Close() error { return nil }
|
||||
func (n *nonSeekableFile) Stat() (fs.FileInfo, error) { return nil, errors.New("not implemented") }
|
||||
@ -100,7 +100,7 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
|
||||
} else {
|
||||
log.Error(ctx, "No admin user found!", err)
|
||||
}
|
||||
u = &model.User{}
|
||||
u = &model.User{IsAdmin: true, UserName: "admin"}
|
||||
}
|
||||
|
||||
ctx = request.WithUsername(ctx, u.UserName)
|
||||
|
||||
@ -21,8 +21,7 @@ func TestAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
const (
|
||||
testJWTSecret = "not so secret"
|
||||
oneDay = 24 * time.Hour
|
||||
oneDay = 24 * time.Hour
|
||||
)
|
||||
|
||||
var _ = BeforeSuite(func() {
|
||||
|
||||
@ -21,6 +21,7 @@ type Claims struct {
|
||||
ID string // "id" - artwork/mediafile ID
|
||||
Format string // "f" - audio format
|
||||
BitRate int // "b" - audio bitrate
|
||||
ShareID string // "sid" - share ID for share stream tokens
|
||||
}
|
||||
|
||||
// ToMap converts Claims to a map[string]any for use with TokenAuth.Encode().
|
||||
@ -54,6 +55,9 @@ func (c Claims) ToMap() map[string]any {
|
||||
if c.BitRate != 0 {
|
||||
m["b"] = c.BitRate
|
||||
}
|
||||
if c.ShareID != "" {
|
||||
m["sid"] = c.ShareID
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@ -92,5 +96,9 @@ func ClaimsFromToken(token jwt.Token) Claims {
|
||||
c.BitRate = int(bf)
|
||||
}
|
||||
}
|
||||
var sid string
|
||||
if err := token.Get("sid", &sid); err == nil {
|
||||
c.ShareID = sid
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@ var _ = Describe("Claims", func() {
|
||||
Expect(m).NotTo(HaveKey("id"))
|
||||
Expect(m).NotTo(HaveKey("f"))
|
||||
Expect(m).NotTo(HaveKey("b"))
|
||||
Expect(m).NotTo(HaveKey("sid"))
|
||||
})
|
||||
|
||||
It("includes expiration and issued-at when set", func() {
|
||||
@ -52,6 +53,12 @@ var _ = Describe("Claims", func() {
|
||||
Expect(m).To(HaveKeyWithValue("f", "mp3"))
|
||||
Expect(m).To(HaveKeyWithValue("b", 192))
|
||||
})
|
||||
|
||||
It("includes share ID claim when set", func() {
|
||||
c := auth.Claims{ShareID: "abc1234567"}
|
||||
m := c.ToMap()
|
||||
Expect(m).To(HaveKeyWithValue("sid", "abc1234567"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ClaimsFromToken", func() {
|
||||
@ -84,6 +91,7 @@ var _ = Describe("Claims", func() {
|
||||
ID: "al-456",
|
||||
Format: "opus",
|
||||
BitRate: 128,
|
||||
ShareID: "abc1234567",
|
||||
}
|
||||
token, _, err := tokenAuth.Encode(original.ToMap())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
@ -91,6 +99,7 @@ var _ = Describe("Claims", func() {
|
||||
c := auth.ClaimsFromToken(token)
|
||||
Expect(c.Issuer).To(Equal("ND"))
|
||||
Expect(c.ID).To(Equal("al-456"))
|
||||
Expect(c.ShareID).To(Equal("abc1234567"))
|
||||
Expect(c.Format).To(Equal("opus"))
|
||||
Expect(c.BitRate).To(Equal(128))
|
||||
})
|
||||
|
||||
@ -41,6 +41,7 @@ var _ = Describe("common.go", func() {
|
||||
})
|
||||
|
||||
It("returns the absolute path when library exists", func() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-core)")
|
||||
ctx := context.Background()
|
||||
abs := AbsolutePath(ctx, ds, libId, path)
|
||||
Expect(abs).To(Equal("/library/root/music/file.mp3"))
|
||||
|
||||
24
core/external/provider.go
vendored
24
core/external/provider.go
vendored
@ -12,6 +12,7 @@ import (
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
@ -41,6 +42,7 @@ type Provider interface {
|
||||
type provider struct {
|
||||
ds model.DataStore
|
||||
ag Agents
|
||||
matcher *matcher.Matcher
|
||||
artistQueue refreshQueue[auxArtist]
|
||||
albumQueue refreshQueue[auxAlbum]
|
||||
}
|
||||
@ -85,8 +87,8 @@ type Agents interface {
|
||||
agents.SimilarSongsByArtistRetriever
|
||||
}
|
||||
|
||||
func NewProvider(ds model.DataStore, agents Agents) Provider {
|
||||
e := &provider{ds: ds, ag: agents}
|
||||
func NewProvider(ds model.DataStore, agents Agents, m *matcher.Matcher) Provider {
|
||||
e := &provider{ds: ds, ag: agents, matcher: m}
|
||||
e.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo)
|
||||
e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo)
|
||||
return e
|
||||
@ -151,7 +153,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
|
||||
return album, err
|
||||
}
|
||||
|
||||
album.ExternalInfoUpdatedAt = P(time.Now())
|
||||
album.ExternalInfoUpdatedAt = new(time.Now())
|
||||
album.ExternalUrl = info.URL
|
||||
|
||||
if info.Description != "" {
|
||||
@ -267,7 +269,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
|
||||
return artist, ctx.Err()
|
||||
}
|
||||
|
||||
artist.ExternalInfoUpdatedAt = P(time.Now())
|
||||
artist.ExternalInfoUpdatedAt = new(time.Now())
|
||||
err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName,
|
||||
@ -300,7 +302,7 @@ func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (mode
|
||||
}
|
||||
|
||||
if err == nil && len(songs) > 0 {
|
||||
return e.matchSongsToLibrary(ctx, songs, count)
|
||||
return e.matcher.MatchSongs(ctx, songs, count)
|
||||
}
|
||||
|
||||
// Fallback to existing similar artists + top songs algorithm
|
||||
@ -374,8 +376,6 @@ func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error)
|
||||
return nil, 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
|
||||
@ -385,6 +385,14 @@ func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error)
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
imageUrl = artist.ArtistImageUrl()
|
||||
} else {
|
||||
// If cached info is expired, enqueue a background refresh so that config changes
|
||||
// (e.g. disabling an agent) take effect without waiting for a full artist info refresh.
|
||||
updatedAt := V(artist.ExternalInfoUpdatedAt)
|
||||
if !updatedAt.IsZero() && time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
|
||||
log.Debug(ctx, "Artist image info expired, enqueuing background refresh", "artist", artist.Name(), "updatedAt", updatedAt)
|
||||
e.artistQueue.enqueue(&artist)
|
||||
}
|
||||
}
|
||||
|
||||
if imageUrl == "" {
|
||||
@ -473,7 +481,7 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
|
||||
}
|
||||
}
|
||||
|
||||
mfs, err := e.matchSongsToLibrary(ctx, songs, count)
|
||||
mfs, err := e.matcher.MatchSongs(ctx, songs, count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
3
core/external/provider_albumimage_test.go
vendored
3
core/external/provider_albumimage_test.go
vendored
@ -9,6 +9,7 @@ import (
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@ -43,7 +44,7 @@ var _ = Describe("Provider - AlbumImage", func() {
|
||||
mockAlbumAgent = newMockAlbumInfoAgent()
|
||||
|
||||
agentsCombined := &mockAgents{albumInfoAgent: mockAlbumAgent}
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
provider = NewProvider(ds, agentsCombined, matcher.New(ds))
|
||||
|
||||
// Default mocks
|
||||
// Mocks for GetEntityByID sequence (initial failed lookups)
|
||||
|
||||
66
core/external/provider_artistimage_test.go
vendored
66
core/external/provider_artistimage_test.go
vendored
@ -1,14 +1,18 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@ -48,7 +52,7 @@ var _ = Describe("Provider - ArtistImage", func() {
|
||||
imageAgent: mockImageAgent,
|
||||
}
|
||||
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
provider = NewProvider(ds, agentsCombined, matcher.New(ds))
|
||||
|
||||
// Default mocks for successful Get calls
|
||||
mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Maybe()
|
||||
@ -266,6 +270,66 @@ var _ = Describe("Provider - ArtistImage", func() {
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("returns cached URL and does not call agent when info is not expired", func() {
|
||||
// Arrange: artist has a cached image URL with recent ExternalInfoUpdatedAt
|
||||
cachedArtist := &model.Artist{
|
||||
ID: "artist-cached",
|
||||
Name: "Cached Artist",
|
||||
LargeImageUrl: "http://example.com/cached-large.jpg",
|
||||
ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Minute)),
|
||||
}
|
||||
mockArtistRepo.On("Get", "artist-cached").Return(cachedArtist, nil).Maybe()
|
||||
expectedURL, _ := url.Parse("http://example.com/cached-large.jpg")
|
||||
|
||||
// Capture log output
|
||||
var logBuf bytes.Buffer
|
||||
log.SetOutput(&logBuf)
|
||||
defer log.SetOutput(GinkgoWriter)
|
||||
log.SetLevel(log.LevelDebug)
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-cached")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-cached", mock.Anything, mock.Anything)
|
||||
|
||||
// Assert: background refresh was NOT enqueued
|
||||
Expect(logBuf.String()).ToNot(ContainSubstring("Artist image info expired, enqueuing background refresh"))
|
||||
|
||||
})
|
||||
|
||||
It("returns stale URL and enqueues refresh when info is expired", func() {
|
||||
// Arrange
|
||||
conf.Server.DevArtistInfoTimeToLive = 1 * time.Nanosecond
|
||||
staleArtist := &model.Artist{
|
||||
ID: "artist-expired",
|
||||
Name: "Expired Artist",
|
||||
LargeImageUrl: "http://example.com/expired-large.jpg",
|
||||
ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Hour)),
|
||||
}
|
||||
mockArtistRepo.On("Get", "artist-expired").Return(staleArtist, nil).Maybe()
|
||||
expectedURL, _ := url.Parse("http://example.com/expired-large.jpg")
|
||||
|
||||
// Capture log output
|
||||
var logBuf bytes.Buffer
|
||||
log.SetOutput(&logBuf)
|
||||
defer log.SetOutput(GinkgoWriter)
|
||||
log.SetLevel(log.LevelDebug)
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-expired")
|
||||
|
||||
// Assert: returns stale URL immediately, no agent call
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-expired", mock.Anything, mock.Anything)
|
||||
|
||||
// Assert: background refresh was enqueued
|
||||
Expect(logBuf.String()).To(ContainSubstring("Artist image info expired, enqueuing background refresh"))
|
||||
})
|
||||
|
||||
Context("Unicode handling in artist names", func() {
|
||||
var artistWithEnDash *model.Artist
|
||||
var expectedURL *url.URL
|
||||
|
||||
762
core/external/provider_matching_test.go
vendored
762
core/external/provider_matching_test.go
vendored
@ -1,762 +0,0 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("Provider - Song Matching", func() {
|
||||
var ds model.DataStore
|
||||
var provider Provider
|
||||
var agentsCombined *mockAgents
|
||||
var artistRepo *mockArtistRepo
|
||||
var mediaFileRepo *mockMediaFileRepo
|
||||
var albumRepo *mockAlbumRepo
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
artistRepo = newMockArtistRepo()
|
||||
mediaFileRepo = newMockMediaFileRepo()
|
||||
albumRepo = newMockAlbumRepo()
|
||||
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtist: artistRepo,
|
||||
MockedMediaFile: mediaFileRepo,
|
||||
MockedAlbum: albumRepo,
|
||||
}
|
||||
|
||||
agentsCombined = &mockAgents{}
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
})
|
||||
|
||||
// Shared helper for tests that only need artist track queries (no ID/MBID matching)
|
||||
setupSimilarSongsExpectations := func(returnedSongs []agents.Song, artistTracks model.MediaFiles) {
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||
Return(returnedSongs, nil).Once()
|
||||
|
||||
// loadTracksByTitleAndArtist - queries by artist name
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
and, ok := opt.Filters.(squirrel.And)
|
||||
if !ok || len(and) < 2 {
|
||||
return false
|
||||
}
|
||||
eq, hasEq := and[0].(squirrel.Eq)
|
||||
if !hasEq {
|
||||
return false
|
||||
}
|
||||
_, hasArtist := eq["order_artist_name"]
|
||||
return hasArtist
|
||||
})).Return(artistTracks, nil).Maybe()
|
||||
}
|
||||
|
||||
Describe("matchSongsToLibrary priority matching", func() {
|
||||
var track model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
// Disable fuzzy matching for these tests to avoid unexpected GetAll calls
|
||||
conf.Server.SimilarSongsMatchThreshold = 100
|
||||
|
||||
track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist", MbzRecordingID: ""}
|
||||
|
||||
// Setup for GetEntityByID to return the track
|
||||
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
||||
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
|
||||
})
|
||||
|
||||
setupExpectations := func(returnedSongs []agents.Song, idMatches, mbidMatches, artistTracks model.MediaFiles) {
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||
Return(returnedSongs, nil).Once()
|
||||
|
||||
// loadTracksByID
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
_, ok := opt.Filters.(squirrel.Eq)
|
||||
return ok
|
||||
})).Return(idMatches, nil).Once()
|
||||
|
||||
// loadTracksByMBID
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
and, ok := opt.Filters.(squirrel.And)
|
||||
if !ok || len(and) < 1 {
|
||||
return false
|
||||
}
|
||||
eq, hasEq := and[0].(squirrel.Eq)
|
||||
if !hasEq {
|
||||
return false
|
||||
}
|
||||
_, hasMBID := eq["mbz_recording_id"]
|
||||
return hasMBID
|
||||
})).Return(mbidMatches, nil).Once()
|
||||
|
||||
// loadTracksByTitleAndArtist - now queries by artist name
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
and, ok := opt.Filters.(squirrel.And)
|
||||
if !ok || len(and) < 2 {
|
||||
return false
|
||||
}
|
||||
eq, hasEq := and[0].(squirrel.Eq)
|
||||
if !hasEq {
|
||||
return false
|
||||
}
|
||||
_, hasArtist := eq["order_artist_name"]
|
||||
return hasArtist
|
||||
})).Return(artistTracks, nil).Maybe()
|
||||
}
|
||||
|
||||
Context("when agent returns artist and album metadata", func() {
|
||||
It("matches by title + artist MBID + album MBID (highest priority)", func() {
|
||||
// Song in library with all MBIDs
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Violator",
|
||||
MbzArtistID: "artist-mbid-123", MbzAlbumID: "album-mbid-456",
|
||||
}
|
||||
// Another song with same title but different MBIDs (should NOT match)
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Some Other Album",
|
||||
MbzArtistID: "artist-mbid-123", MbzAlbumID: "different-album-mbid",
|
||||
}
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"},
|
||||
}
|
||||
|
||||
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("correct-match"))
|
||||
})
|
||||
|
||||
It("matches by title + artist name + album name when MBIDs unavailable", func() {
|
||||
// Song in library without MBIDs but with matching artist/album names
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "violator",
|
||||
}
|
||||
// Another song with same title but different artist (should NOT match)
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album",
|
||||
}
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"}, // No MBIDs
|
||||
}
|
||||
|
||||
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("correct-match"))
|
||||
})
|
||||
|
||||
It("matches by title + artist only when album info unavailable", func() {
|
||||
// Song in library with matching artist
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "Some Album",
|
||||
}
|
||||
// Another song with same title but different artist
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album",
|
||||
}
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Depeche Mode"}, // No album info
|
||||
}
|
||||
|
||||
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("correct-match"))
|
||||
})
|
||||
|
||||
It("does not match songs without artist info", func() {
|
||||
// Songs without artist info cannot be matched since we query by artist
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song"}, // No artist/album info at all
|
||||
}
|
||||
|
||||
// No artist to query, so no GetAll calls for title matching
|
||||
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when matching multiple songs with the same title but different artists", func() {
|
||||
It("returns distinct matches for each artist's version (covers scenario)", func() {
|
||||
// Multiple covers of the same song by different artists
|
||||
cover1 := model.MediaFile{
|
||||
ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!",
|
||||
}
|
||||
cover2 := model.MediaFile{
|
||||
ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits",
|
||||
}
|
||||
cover3 := model.MediaFile{
|
||||
ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way",
|
||||
}
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"},
|
||||
{Name: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"},
|
||||
{Name: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"},
|
||||
}
|
||||
|
||||
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{cover1, cover2, cover3})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// All three covers should be returned, not just the first one
|
||||
Expect(songs).To(HaveLen(3))
|
||||
// Verify all three different versions are included
|
||||
ids := []string{songs[0].ID, songs[1].ID, songs[2].ID}
|
||||
Expect(ids).To(ContainElements("cover-1", "cover-2", "cover-3"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when matching multiple songs with different precision levels", func() {
|
||||
It("prefers more precise matches for each song", func() {
|
||||
// Library has multiple versions of same song
|
||||
preciseMatch := model.MediaFile{
|
||||
ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One",
|
||||
MbzArtistID: "mbid-1", MbzAlbumID: "album-mbid-1",
|
||||
}
|
||||
lessAccurateMatch := model.MediaFile{
|
||||
ID: "less-accurate", Title: "Song A", Artist: "Artist One", Album: "Compilation",
|
||||
MbzArtistID: "mbid-1",
|
||||
}
|
||||
artistTwoMatch := model.MediaFile{
|
||||
ID: "artist-two", Title: "Song B", Artist: "Artist Two",
|
||||
}
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Song A", Artist: "Artist One", ArtistMBID: "mbid-1", Album: "Album One", AlbumMBID: "album-mbid-1"},
|
||||
{Name: "Song B", Artist: "Artist Two"}, // Different artist
|
||||
}
|
||||
|
||||
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(2))
|
||||
// First song should be the precise match (has all MBIDs)
|
||||
Expect(songs[0].ID).To(Equal("precise"))
|
||||
// Second song matches by title + artist
|
||||
Expect(songs[1].ID).To(Equal("artist-two"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Fuzzy matching fallback", func() {
|
||||
var track model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"}
|
||||
|
||||
// Setup for GetEntityByID to return the track
|
||||
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
||||
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
|
||||
})
|
||||
|
||||
Context("with default threshold (85%)", func() {
|
||||
It("matches songs with remastered suffix", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
|
||||
// Agent returns "Paranoid Android" but library has "Paranoid Android - Remastered"
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Paranoid Android", Artist: "Radiohead"},
|
||||
}
|
||||
// Artist catalog has the remastered version (fuzzy match will find it)
|
||||
artistTracks := model.MediaFiles{
|
||||
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("remastered"))
|
||||
})
|
||||
|
||||
It("matches songs with live suffix", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Bohemian Rhapsody", Artist: "Queen"},
|
||||
}
|
||||
artistTracks := model.MediaFiles{
|
||||
{ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"},
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("live"))
|
||||
})
|
||||
|
||||
It("does not match completely different songs", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Yesterday", Artist: "The Beatles"},
|
||||
}
|
||||
// Artist catalog has completely different songs
|
||||
artistTracks := model.MediaFiles{
|
||||
{ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"},
|
||||
{ID: "different2", Title: "Here Comes The Sun", Artist: "The Beatles"},
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with threshold set to 100 (exact match only)", func() {
|
||||
It("only matches exact titles", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 100
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Paranoid Android", Artist: "Radiohead"},
|
||||
}
|
||||
// Artist catalog has only remastered version - no exact match
|
||||
artistTracks := model.MediaFiles{
|
||||
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with lower threshold (75%)", func() {
|
||||
It("matches more aggressively", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 75
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Song", Artist: "Artist"},
|
||||
}
|
||||
artistTracks := model.MediaFiles{
|
||||
{ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"},
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("extended"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with fuzzy album matching", func() {
|
||||
It("matches album with (Remaster) suffix", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
|
||||
// Agent returns "A Night at the Opera" but library has remastered version
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
|
||||
}
|
||||
// Library has same album with remaster suffix
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera (2011 Remaster)",
|
||||
}
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits",
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
// Should prefer the fuzzy album match (Level 3) over title+artist only (Level 1)
|
||||
Expect(songs[0].ID).To(Equal("correct"))
|
||||
})
|
||||
|
||||
It("matches album with (Deluxe Edition) suffix", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
|
||||
}
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)",
|
||||
}
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101",
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("correct"))
|
||||
})
|
||||
|
||||
It("prefers exact album match over fuzzy album match", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
|
||||
}
|
||||
exactMatch := model.MediaFile{
|
||||
ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator",
|
||||
}
|
||||
fuzzyMatch := model.MediaFile{
|
||||
ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)",
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{fuzzyMatch, exactMatch})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
// Both have same title similarity (1.0), so should prefer exact album match (higher specificity via higher album similarity)
|
||||
Expect(songs[0].ID).To(Equal("exact"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Duration matching", func() {
|
||||
var track model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.SimilarSongsMatchThreshold = 100 // Exact title match for predictable tests
|
||||
|
||||
track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"}
|
||||
|
||||
// Setup for GetEntityByID to return the track
|
||||
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
||||
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
|
||||
})
|
||||
|
||||
Context("when agent provides duration", func() {
|
||||
It("prefers tracks with matching duration", func() {
|
||||
// Agent returns song with duration 180000ms (180 seconds)
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
}
|
||||
// Library has two versions: one matching duration, one not
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0,
|
||||
}
|
||||
wrongDuration := model.MediaFile{
|
||||
ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0,
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongDuration, correctMatch})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("correct"))
|
||||
})
|
||||
|
||||
It("matches tracks with close duration", func() {
|
||||
// Agent returns song with duration 180000ms (180 seconds)
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
}
|
||||
// Library has track with 182.5 seconds (close to target)
|
||||
closeDuration := model.MediaFile{
|
||||
ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5,
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{closeDuration})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("close-duration"))
|
||||
})
|
||||
|
||||
It("prefers closer duration over farther duration", func() {
|
||||
// Agent returns song with duration 180000ms (180 seconds)
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
}
|
||||
// Library has one close, one far
|
||||
closeDuration := model.MediaFile{
|
||||
ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0,
|
||||
}
|
||||
farDuration := model.MediaFile{
|
||||
ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0,
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{farDuration, closeDuration})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("close"))
|
||||
})
|
||||
|
||||
It("still matches when no tracks have matching duration", func() {
|
||||
// Agent returns song with duration 180000ms
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
}
|
||||
// Library only has tracks with very different duration
|
||||
differentDuration := model.MediaFile{
|
||||
ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0,
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{differentDuration})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Duration mismatch doesn't exclude the track; it's just scored lower
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("different"))
|
||||
})
|
||||
|
||||
It("prefers title match over duration match when titles differ", func() {
|
||||
// Agent returns "Similar Song" with duration 180000ms
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
}
|
||||
// Library has:
|
||||
// - differentTitle: matches duration but has different title (won't pass title threshold)
|
||||
// - correctTitle: doesn't match duration but has correct title (wins on title similarity)
|
||||
differentTitle := model.MediaFile{
|
||||
ID: "wrong-title", Title: "Different Song", Artist: "Test Artist", Duration: 180.0,
|
||||
}
|
||||
correctTitle := model.MediaFile{
|
||||
ID: "correct-title", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0,
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{differentTitle, correctTitle})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Title similarity is the top priority, so the correct title wins despite duration mismatch
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("correct-title"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when agent does not provide duration", func() {
|
||||
It("matches without duration filtering (duration=0)", func() {
|
||||
// Agent returns song without duration
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 0},
|
||||
}
|
||||
// Library tracks with various durations should all be candidates
|
||||
anyTrack := model.MediaFile{
|
||||
ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0,
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{anyTrack})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("any"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("edge cases", func() {
|
||||
It("handles very short songs with close duration", func() {
|
||||
// 30-second song with 1-second difference
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Short Song", Artist: "Test Artist", Duration: 30000},
|
||||
}
|
||||
shortTrack := model.MediaFile{
|
||||
ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0,
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{shortTrack})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("short"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Deduplication of mismatched songs", func() {
|
||||
var track model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.SimilarSongsMatchThreshold = 85 // Allow fuzzy matching
|
||||
|
||||
track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"}
|
||||
|
||||
// Setup for GetEntityByID to return the track
|
||||
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
||||
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
|
||||
})
|
||||
|
||||
It("removes duplicates when different input songs match the same library track", func() {
|
||||
// Agent returns two different versions that will both fuzzy-match to the same library track
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Bohemian Rhapsody (Live)", Artist: "Queen"},
|
||||
{Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"},
|
||||
}
|
||||
// Library only has one version
|
||||
libraryTrack := model.MediaFile{
|
||||
ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen",
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should only return one track, not two duplicates
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("br-live"))
|
||||
})
|
||||
|
||||
It("preserves duplicates when identical input songs match the same library track", func() {
|
||||
// Agent returns the exact same song twice (intentional repetition)
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
|
||||
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
|
||||
}
|
||||
// Library has matching track
|
||||
libraryTrack := model.MediaFile{
|
||||
ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera",
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should return two tracks since input songs were identical
|
||||
Expect(songs).To(HaveLen(2))
|
||||
Expect(songs[0].ID).To(Equal("br"))
|
||||
Expect(songs[1].ID).To(Equal("br"))
|
||||
})
|
||||
|
||||
It("handles mixed scenario with both identical and different input songs", func() {
|
||||
// Agent returns: Song A, Song B (different from A), Song A again (same as first)
|
||||
// All three match to the same library track
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"},
|
||||
{Name: "Yesterday (Remastered)", Artist: "The Beatles", Album: "1"}, // Different version
|
||||
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, // Same as first
|
||||
{Name: "Yesterday (Anthology)", Artist: "The Beatles", Album: "Anthology"}, // Another different version
|
||||
}
|
||||
// Library only has one version
|
||||
libraryTrack := model.MediaFile{
|
||||
ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!",
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should return 2 tracks:
|
||||
// 1. First "Yesterday" (original)
|
||||
// 2. Third "Yesterday" (same as first, so kept)
|
||||
// Skip: Second "Yesterday (Remastered)" (different input, same library track)
|
||||
// Skip: Fourth "Yesterday (Anthology)" (different input, same library track)
|
||||
Expect(songs).To(HaveLen(2))
|
||||
Expect(songs[0].ID).To(Equal("yesterday"))
|
||||
Expect(songs[1].ID).To(Equal("yesterday"))
|
||||
})
|
||||
|
||||
It("does not deduplicate songs that match different library tracks", func() {
|
||||
// Agent returns different songs that match different library tracks
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Song A", Artist: "Artist"},
|
||||
{Name: "Song B", Artist: "Artist"},
|
||||
{Name: "Song C", Artist: "Artist"},
|
||||
}
|
||||
// Library has all three songs
|
||||
trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"}
|
||||
trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"}
|
||||
trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist"}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{trackA, trackB, trackC})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// All three should be returned since they match different library tracks
|
||||
Expect(songs).To(HaveLen(3))
|
||||
Expect(songs[0].ID).To(Equal("track-a"))
|
||||
Expect(songs[1].ID).To(Equal("track-b"))
|
||||
Expect(songs[2].ID).To(Equal("track-c"))
|
||||
})
|
||||
|
||||
It("respects count limit after deduplication", func() {
|
||||
// Agent returns 4 songs: 2 unique + 2 that would create duplicates
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Song A", Artist: "Artist"},
|
||||
{Name: "Song A (Live)", Artist: "Artist"}, // Different, matches same track
|
||||
{Name: "Song B", Artist: "Artist"},
|
||||
{Name: "Song B (Remix)", Artist: "Artist"}, // Different, matches same track
|
||||
}
|
||||
trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"}
|
||||
trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{trackA, trackB})
|
||||
|
||||
// Request only 2 songs
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 2)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should return exactly 2: Song A and Song B (skipping duplicates)
|
||||
Expect(songs).To(HaveLen(2))
|
||||
Expect(songs[0].ID).To(Equal("track-a"))
|
||||
Expect(songs[1].ID).To(Equal("track-b"))
|
||||
})
|
||||
})
|
||||
})
|
||||
3
core/external/provider_similarsongs_test.go
vendored
3
core/external/provider_similarsongs_test.go
vendored
@ -7,6 +7,7 @@ import (
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@ -48,7 +49,7 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
||||
similarAgent: mockSimilarAgent,
|
||||
}
|
||||
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
provider = NewProvider(ds, agentsCombined, matcher.New(ds))
|
||||
})
|
||||
|
||||
Describe("dispatch by entity type", func() {
|
||||
|
||||
5
core/external/provider_topsongs_test.go
vendored
5
core/external/provider_topsongs_test.go
vendored
@ -10,6 +10,7 @@ import (
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@ -29,7 +30,7 @@ var _ = Describe("Provider - TopSongs", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
// Disable fuzzy matching for these tests to avoid unexpected GetAll calls
|
||||
conf.Server.SimilarSongsMatchThreshold = 100
|
||||
conf.Server.Matcher.FuzzyThreshold = 100
|
||||
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
@ -44,7 +45,7 @@ var _ = Describe("Provider - TopSongs", func() {
|
||||
|
||||
ag = new(mockAgents)
|
||||
|
||||
p = NewProvider(ds, ag)
|
||||
p = NewProvider(ds, ag, matcher.New(ds))
|
||||
})
|
||||
|
||||
It("returns top songs for a known artist", func() {
|
||||
|
||||
@ -8,10 +8,10 @@ import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@ -34,7 +34,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
|
||||
ctx = GinkgoT().Context()
|
||||
ds = new(tests.MockDataStore)
|
||||
ag = new(mockAgents)
|
||||
p = external.NewProvider(ds, ag)
|
||||
p = external.NewProvider(ds, ag, matcher.New(ds))
|
||||
mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
||||
conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour
|
||||
})
|
||||
@ -89,7 +89,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
|
||||
ExternalUrl: "http://cached.com/album",
|
||||
Description: "Cached Desc",
|
||||
LargeImageUrl: "http://cached.com/large.jpg",
|
||||
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)),
|
||||
ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)),
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
@ -112,7 +112,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
|
||||
ExternalUrl: "http://expired.com/album",
|
||||
Description: "Expired Desc",
|
||||
LargeImageUrl: "http://expired.com/large.jpg",
|
||||
ExternalInfoUpdatedAt: gg.P(expiredTime),
|
||||
ExternalInfoUpdatedAt: new(expiredTime),
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
|
||||
33
core/external/provider_updateartistinfo_test.go
vendored
33
core/external/provider_updateartistinfo_test.go
vendored
@ -9,10 +9,10 @@ import (
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@ -37,7 +37,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
|
||||
ctx = GinkgoT().Context()
|
||||
ds = new(tests.MockDataStore)
|
||||
ag = new(mockAgents)
|
||||
p = external.NewProvider(ds, ag)
|
||||
p = external.NewProvider(ds, ag, matcher.New(ds))
|
||||
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
|
||||
})
|
||||
|
||||
@ -104,6 +104,29 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("preserves decoded plain text in biography storage", func() {
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-encoded-bio",
|
||||
Name: "Encoded Bio Artist",
|
||||
}
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist})
|
||||
|
||||
expectedMBID := "mbid-encoded-bio"
|
||||
expectedBio := "R&B"
|
||||
|
||||
ag.On("GetArtistMBID", ctx, "ar-encoded-bio", "Encoded Bio Artist").Return(expectedMBID, nil).Once()
|
||||
ag.On("GetArtistImages", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return(nil, nil).Maybe()
|
||||
ag.On("GetArtistBiography", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return(expectedBio, nil).Once()
|
||||
ag.On("GetArtistURL", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return("", nil).Maybe()
|
||||
ag.On("GetSimilarArtists", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID, 100).Return(nil, nil).Maybe()
|
||||
|
||||
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-encoded-bio", 10, false)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedArtist).NotTo(BeNil())
|
||||
Expect(updatedArtist.Biography).To(Equal("R&B"))
|
||||
})
|
||||
|
||||
It("returns cached info when artist exists and info is not expired", func() {
|
||||
now := time.Now()
|
||||
originalArtist := &model.Artist{
|
||||
@ -113,7 +136,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
|
||||
ExternalUrl: "http://cached.url",
|
||||
Biography: "Cached Bio",
|
||||
LargeImageUrl: "http://cached_large.jpg",
|
||||
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
|
||||
ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
|
||||
SimilarArtists: model.Artists{
|
||||
{ID: "ar-similar-present", Name: "Similar Present"},
|
||||
{ID: "ar-similar-absent", Name: "Similar Absent"},
|
||||
@ -150,7 +173,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-expired",
|
||||
Name: "Expired Artist",
|
||||
ExternalInfoUpdatedAt: gg.P(expiredTime),
|
||||
ExternalInfoUpdatedAt: new(expiredTime),
|
||||
SimilarArtists: model.Artists{
|
||||
{ID: "ar-exp-similar", Name: "Expired Similar"},
|
||||
},
|
||||
@ -181,7 +204,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-similar-test",
|
||||
Name: "Similar Test Artist",
|
||||
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
|
||||
ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
|
||||
SimilarArtists: model.Artists{
|
||||
{ID: "ar-sim-present", Name: "Similar Present"},
|
||||
{ID: "", Name: "Similar Absent Raw"},
|
||||
|
||||
@ -10,9 +10,11 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
@ -49,6 +51,7 @@ type FFmpeg interface {
|
||||
ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error)
|
||||
CmdPath() (string, error)
|
||||
IsAvailable() bool
|
||||
IsProbeAvailable() bool
|
||||
Version() string
|
||||
}
|
||||
|
||||
@ -56,6 +59,11 @@ func New() FFmpeg {
|
||||
return &ffmpeg{}
|
||||
}
|
||||
|
||||
// ErrAnimatedWebPUnsupported is returned by ConvertAnimatedImage when the
|
||||
// ffmpeg binary lacks the libwebp_anim encoder. Callers can use errors.Is to
|
||||
// detect this specific case and fall back to static resize.
|
||||
var ErrAnimatedWebPUnsupported = errors.New("ffmpeg lacks libwebp_anim encoder — install an ffmpeg build with libwebp")
|
||||
|
||||
const (
|
||||
extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
|
||||
probeCmd = "ffmpeg %s -f ffmetadata"
|
||||
@ -85,6 +93,9 @@ func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, max
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !animWebP.has(cmdPath, "libwebp_anim") {
|
||||
return nil, ErrAnimatedWebPUnsupported
|
||||
}
|
||||
|
||||
args := []string{cmdPath, "-i", "pipe:0"}
|
||||
if maxSize > 0 {
|
||||
@ -97,6 +108,19 @@ func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, max
|
||||
return e.start(ctx, args, reader)
|
||||
}
|
||||
|
||||
// parseEncodersOutput scans the stdout of `ffmpeg -encoders` for a whole-word
|
||||
// match of encoder name. The output has rows like " V....D libwebp_anim ..."
|
||||
// where the name is the 2nd whitespace-separated field.
|
||||
func parseEncodersOutput(out []byte, name string) bool {
|
||||
for line := range strings.SplitSeq(string(out), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 && fields[1] == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) {
|
||||
if _, err := ffmpegCmd(); err != nil {
|
||||
return nil, err
|
||||
@ -224,6 +248,19 @@ func (e *ffmpeg) IsAvailable() bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (e *ffmpeg) IsProbeAvailable() bool {
|
||||
if _, err := ffmpegCmd(); err != nil {
|
||||
return false
|
||||
}
|
||||
probeOnce.Do(func() {
|
||||
probePath := ffprobePath(ffmpegPath)
|
||||
if _, err := exec.LookPath(probePath); err == nil {
|
||||
probeAvail = true
|
||||
}
|
||||
})
|
||||
return probeAvail
|
||||
}
|
||||
|
||||
// Version executes ffmpeg -version and extracts the version from the output.
|
||||
// Sample output: ffmpeg version 6.0 Copyright (c) 2000-2023 the FFmpeg developers
|
||||
func (e *ffmpeg) Version() string {
|
||||
@ -289,8 +326,7 @@ func (j *ffCmd) start(ctx context.Context) error {
|
||||
|
||||
func (j *ffCmd) wait() {
|
||||
if err := j.cmd.Wait(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||
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
|
||||
@ -358,12 +394,13 @@ func isDefaultCommand(format, command string) bool {
|
||||
// including all transcoding parameters (bitrate, sample rate, channels).
|
||||
func buildDynamicArgs(opts TranscodeOptions) []string {
|
||||
cmdPath, _ := ffmpegCmd()
|
||||
args := []string{cmdPath, "-i", opts.FilePath}
|
||||
args := []string{cmdPath}
|
||||
|
||||
if opts.Offset > 0 {
|
||||
args = append(args, "-ss", strconv.Itoa(opts.Offset))
|
||||
}
|
||||
|
||||
args = append(args, "-i", opts.FilePath)
|
||||
args = append(args, "-map", "0:a:0")
|
||||
|
||||
if codec, ok := formatCodecMap[opts.Format]; ok {
|
||||
@ -373,18 +410,7 @@ func buildDynamicArgs(opts TranscodeOptions) []string {
|
||||
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 = injectDynamicAudioFlags(args, opts)
|
||||
|
||||
args = append(args, "-v", "0")
|
||||
|
||||
@ -398,12 +424,19 @@ func buildDynamicArgs(opts TranscodeOptions) []string {
|
||||
|
||||
// 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.
|
||||
// Values in opts have already been clamped to codec limits upstream (see
|
||||
// core/stream/codec.go codecMax* helpers), so injecting them unconditionally is safe —
|
||||
// ffmpeg honors the last occurrence of a duplicate flag.
|
||||
func buildTemplateArgs(opts TranscodeOptions) []string {
|
||||
args := createFFmpegCommand(opts.Command, opts.FilePath, opts.BitRate, opts.Offset)
|
||||
return injectDynamicAudioFlags(args, opts)
|
||||
}
|
||||
|
||||
// Dynamically inject -ar, -ac, and -sample_fmt before the output target
|
||||
// injectDynamicAudioFlags appends -ar, -ac, and -sample_fmt flags based on opts.
|
||||
// Only passes -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.
|
||||
func injectDynamicAudioFlags(args []string, opts TranscodeOptions) []string {
|
||||
if opts.SampleRate > 0 {
|
||||
args = injectBeforeOutput(args, "-ar", strconv.Itoa(opts.SampleRate))
|
||||
}
|
||||
@ -459,11 +492,20 @@ func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
|
||||
var args []string
|
||||
for _, s := range fixCmd(cmd) {
|
||||
if strings.Contains(s, "%s") {
|
||||
if offset > 0 && !strings.Contains(cmd, "%t") {
|
||||
// Pre-input seeking: ffmpeg seeks at the demuxer level (fast)
|
||||
// instead of decoding all frames up to the offset (slow).
|
||||
insertAt := len(args)
|
||||
for i, arg := range slices.Backward(args) {
|
||||
if arg == "-i" {
|
||||
insertAt = i
|
||||
break
|
||||
}
|
||||
}
|
||||
args = slices.Insert(args, insertAt, "-ss", strconv.Itoa(offset))
|
||||
}
|
||||
s = strings.ReplaceAll(s, "%s", path)
|
||||
args = append(args, s)
|
||||
if offset > 0 && !strings.Contains(cmd, "%t") {
|
||||
args = append(args, "-ss", strconv.Itoa(offset))
|
||||
}
|
||||
} else {
|
||||
s = strings.ReplaceAll(s, "%t", strconv.Itoa(offset))
|
||||
s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate))
|
||||
@ -528,9 +570,55 @@ func ffmpegCmd() (string, error) {
|
||||
return ffmpegPath, ffmpegErr
|
||||
}
|
||||
|
||||
type encoderProbeState uint8
|
||||
|
||||
const (
|
||||
encoderProbeUnknown encoderProbeState = iota
|
||||
encoderProbeAvailable
|
||||
encoderProbeUnavailable
|
||||
)
|
||||
|
||||
type encoderProbe struct {
|
||||
mu sync.Mutex
|
||||
state encoderProbeState
|
||||
}
|
||||
|
||||
func (p *encoderProbe) has(cmdPath, encoder string) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
switch p.state {
|
||||
case encoderProbeAvailable:
|
||||
return true
|
||||
case encoderProbeUnavailable:
|
||||
return false
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, cmdPath, "-hide_banner", "-encoders").Output() // #nosec
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not probe ffmpeg encoders; will retry on next animated cover", err)
|
||||
return false
|
||||
}
|
||||
|
||||
if parseEncodersOutput(out, encoder) {
|
||||
p.state = encoderProbeAvailable
|
||||
return true
|
||||
}
|
||||
|
||||
p.state = encoderProbeUnavailable
|
||||
log.Warn(ctx, "ffmpeg has no libwebp_anim encoder; animated covers will be served as static images",
|
||||
"path", cmdPath, "hint", "install ffmpeg built with libwebp (e.g. `brew install ffmpeg@7`)")
|
||||
return false
|
||||
}
|
||||
|
||||
// These variables are accessible here for tests. Do not use them directly in production code. Use ffmpegCmd() instead.
|
||||
var (
|
||||
ffOnce sync.Once
|
||||
ffmpegPath string
|
||||
ffmpegErr error
|
||||
probeOnce sync.Once
|
||||
probeAvail bool
|
||||
animWebP encoderProbe
|
||||
)
|
||||
|
||||
@ -3,9 +3,11 @@ package ffmpeg
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
sync "sync"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -45,15 +47,15 @@ var _ = Describe("ffmpeg", func() {
|
||||
})
|
||||
Context("when command has time offset param", func() {
|
||||
It("creates a valid command line with offset", func() {
|
||||
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk -ss %t mp3 -", "/music library/file.mp3", 123, 456)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-ss", "456", "mp3", "-"}))
|
||||
args := createFFmpegCommand("ffmpeg -ss %t -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-ss", "456", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
})
|
||||
|
||||
})
|
||||
Context("when command does not have time offset param", func() {
|
||||
It("adds time offset after the input file name", func() {
|
||||
It("adds time offset before the input file name", func() {
|
||||
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-ss", "456", "-b:a", "123k", "mp3", "-"}))
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-ss", "456", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -80,16 +82,16 @@ 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())
|
||||
Expect(isDefaultCommand("mp3", "ffmpeg -ss %t -i %s -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())
|
||||
Expect(isDefaultCommand("opus", "ffmpeg -ss %t -i %s -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())
|
||||
Expect(isDefaultCommand("aac", "ffmpeg -ss %t -i %s -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())
|
||||
Expect(isDefaultCommand("flac", "ffmpeg -ss %t -i %s -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())
|
||||
@ -163,8 +165,9 @@ var _ = Describe("ffmpeg", func() {
|
||||
Offset: 30,
|
||||
})
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.mp3",
|
||||
"ffmpeg",
|
||||
"-ss", "30",
|
||||
"-i", "/music/file.mp3",
|
||||
"-map", "0:a:0",
|
||||
"-c:a", "libmp3lame",
|
||||
"-b:a", "192k",
|
||||
@ -584,9 +587,12 @@ var _ = Describe("ffmpeg", func() {
|
||||
// Cancel the context
|
||||
cancel()
|
||||
|
||||
// Next read should fail due to cancelled context
|
||||
_, err = stream.Read(buf)
|
||||
Expect(err).To(HaveOccurred())
|
||||
// Subsequent reads should eventually fail due to cancelled context.
|
||||
// There may be buffered data in the pipe, so we drain until an error occurs.
|
||||
Eventually(func() error {
|
||||
_, err = stream.Read(buf)
|
||||
return err
|
||||
}).WithTimeout(5 * time.Second).WithPolling(10 * time.Millisecond).Should(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should handle immediate context cancellation", func() {
|
||||
@ -690,4 +696,57 @@ var _ = Describe("ffmpeg", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("parseEncodersOutput", func() {
|
||||
const sample = `Encoders:
|
||||
V..... = Video
|
||||
------
|
||||
V....D apng APNG (Animated Portable Network Graphics) image
|
||||
V....D libwebp_anim libwebp WebP image (codec webp)
|
||||
V....D libwebp libwebp WebP image (codec webp)
|
||||
A....D aac AAC (Advanced Audio Coding)
|
||||
`
|
||||
It("returns true when the encoder is present", func() {
|
||||
Expect(parseEncodersOutput([]byte(sample), "libwebp_anim")).To(BeTrue())
|
||||
Expect(parseEncodersOutput([]byte(sample), "libwebp")).To(BeTrue())
|
||||
Expect(parseEncodersOutput([]byte(sample), "aac")).To(BeTrue())
|
||||
})
|
||||
It("returns false when the encoder is absent", func() {
|
||||
Expect(parseEncodersOutput([]byte(sample), "libwebp_missing")).To(BeFalse())
|
||||
Expect(parseEncodersOutput([]byte(sample), "")).To(BeFalse())
|
||||
})
|
||||
It("does not match partial names", func() {
|
||||
// libwebp is a prefix of libwebp_anim; the parser must treat names as whole-word.
|
||||
stripped := `Encoders:
|
||||
V....D libwebp libwebp WebP image (codec webp)
|
||||
`
|
||||
Expect(parseEncodersOutput([]byte(stripped), "libwebp_anim")).To(BeFalse())
|
||||
})
|
||||
It("handles empty output", func() {
|
||||
Expect(parseEncodersOutput(nil, "libwebp_anim")).To(BeFalse())
|
||||
Expect(parseEncodersOutput([]byte(""), "libwebp_anim")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ConvertAnimatedImage", func() {
|
||||
// Point ffmpegCmd at a stand-in binary that produces empty `-encoders`
|
||||
// output so hasAnimatedWebPEncoder returns false. /usr/bin/true is
|
||||
// portable across POSIX systems.
|
||||
It("returns ErrAnimatedWebPUnsupported when the binary lacks libwebp_anim", func() {
|
||||
truePath, err := exec.LookPath("true")
|
||||
if err != nil {
|
||||
Skip("true(1) not available")
|
||||
}
|
||||
origPath, origErr := ffmpegPath, ffmpegErr
|
||||
ffmpegPath = truePath
|
||||
ffmpegErr = nil
|
||||
defer func() {
|
||||
ffmpegPath, ffmpegErr = origPath, origErr
|
||||
}()
|
||||
|
||||
ff := &ffmpeg{}
|
||||
_, err = ff.ConvertAnimatedImage(GinkgoT().Context(), strings.NewReader("x"), 100, 75)
|
||||
Expect(err).To(MatchError(ErrAnimatedWebPUnsupported))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -21,7 +21,7 @@ var _ = Describe("ImageUploadService", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tmpDir = GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = tmpDir
|
||||
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
||||
svc = core.NewImageUploadService()
|
||||
})
|
||||
|
||||
|
||||
@ -7,7 +7,6 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
)
|
||||
|
||||
type InspectOutput struct {
|
||||
@ -44,7 +43,7 @@ func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, e
|
||||
result := &InspectOutput{
|
||||
File: filePath,
|
||||
RawTags: tags[file].Tags,
|
||||
MappedTags: P(md.ToMediaFile(libraryId, folderId)),
|
||||
MappedTags: new(md.ToMediaFile(libraryId, folderId)),
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
||||
@ -10,8 +10,8 @@ import (
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -31,15 +31,15 @@ var _ = Describe("sources", func() {
|
||||
Lang: "eng",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: gg.P(int64(18800)),
|
||||
Start: new(int64(18800)),
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: gg.P(int64(22801)),
|
||||
Start: new(int64(22801)),
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Offset: gg.P(int64(-100)),
|
||||
Offset: new(int64(-100)),
|
||||
Synced: true,
|
||||
},
|
||||
}
|
||||
@ -93,6 +93,7 @@ var _ = Describe("sources", func() {
|
||||
var accessForbiddenFile string
|
||||
|
||||
BeforeEach(func() {
|
||||
tests.SkipOnWindows("uses Unix file permission bits")
|
||||
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
|
||||
|
||||
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
|
||||
|
||||
@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -74,15 +73,15 @@ var _ = Describe("sources", func() {
|
||||
Lang: "eng",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: gg.P(int64(18800)),
|
||||
Start: new(int64(18800)),
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: gg.P(int64(22801)),
|
||||
Start: new(int64(22801)),
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Offset: gg.P(int64(-100)),
|
||||
Offset: new(int64(-100)),
|
||||
Synced: true,
|
||||
},
|
||||
}))
|
||||
@ -122,7 +121,7 @@ var _ = Describe("sources", func() {
|
||||
// The critical assertion: even with BOM, synced should be true
|
||||
Expect(lyrics[0].Synced).To(BeTrue(), "Lyrics with BOM marker should be recognized as synced")
|
||||
Expect(lyrics[0].Line).To(HaveLen(1))
|
||||
Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(0))))
|
||||
Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0))))
|
||||
Expect(lyrics[0].Line[0].Value).To(ContainSubstring("作曲"))
|
||||
})
|
||||
|
||||
@ -137,9 +136,9 @@ var _ = Describe("sources", func() {
|
||||
// UTF-16 should be properly converted to UTF-8
|
||||
Expect(lyrics[0].Synced).To(BeTrue(), "UTF-16 encoded lyrics should be recognized as synced")
|
||||
Expect(lyrics[0].Line).To(HaveLen(2))
|
||||
Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(18800))))
|
||||
Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800))))
|
||||
Expect(lyrics[0].Line[0].Value).To(Equal("We're no strangers to love"))
|
||||
Expect(lyrics[0].Line[1].Start).To(Equal(gg.P(int64(22801))))
|
||||
Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801))))
|
||||
Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I"))
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package external
|
||||
package matcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
@ -13,7 +13,17 @@ import (
|
||||
"github.com/xrash/smetrics"
|
||||
)
|
||||
|
||||
// matchSongsToLibrary matches agent song results to local library tracks using a multi-phase
|
||||
// Matcher matches agent song results to local library tracks.
|
||||
type Matcher struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
// New creates a new Matcher with the given DataStore.
|
||||
func New(ds model.DataStore) *Matcher {
|
||||
return &Matcher{ds: ds}
|
||||
}
|
||||
|
||||
// MatchSongs matches agent song results to local library tracks using a multi-phase
|
||||
// matching algorithm that prioritizes accuracy over recall.
|
||||
//
|
||||
// # Algorithm Overview
|
||||
@ -36,18 +46,20 @@ import (
|
||||
// # Fuzzy Matching Details
|
||||
//
|
||||
// For title+artist matching, the algorithm uses Jaro-Winkler similarity (threshold configurable
|
||||
// via SimilarSongsMatchThreshold, default 85%). Matches are ranked by:
|
||||
// via Matcher.FuzzyThreshold, default 85%). Matches are ranked by:
|
||||
//
|
||||
// 1. Title similarity (Jaro-Winkler score, 0.0-1.0)
|
||||
// 2. Duration proximity (closer duration = higher score, 1.0 if unknown)
|
||||
// 3. Specificity level (0-5, based on metadata precision):
|
||||
// 3. Preferred track flag (enabled by Matcher.PreferStarred; prioritized when the track is
|
||||
// starred or has rating >= 4)
|
||||
// 4. Specificity level (0-5, based on metadata precision):
|
||||
// - Level 5: Title + Artist MBID + Album MBID (most specific)
|
||||
// - Level 4: Title + Artist MBID + Album name (fuzzy)
|
||||
// - Level 3: Title + Artist name + Album name (fuzzy)
|
||||
// - Level 2: Title + Artist MBID
|
||||
// - Level 1: Title + Artist name
|
||||
// - Level 0: Title only
|
||||
// 4. Album similarity (Jaro-Winkler, as final tiebreaker)
|
||||
// 5. Album similarity (Jaro-Winkler, as final tiebreaker)
|
||||
//
|
||||
// # Examples
|
||||
//
|
||||
@ -95,36 +107,67 @@ import (
|
||||
//
|
||||
// Returns up to 'count' MediaFiles from the library that best match the input songs,
|
||||
// preserving the original order from the agent. Songs that cannot be matched are skipped.
|
||||
func (e *provider) matchSongsToLibrary(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) {
|
||||
idMatches, err := e.loadTracksByID(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by ID: %w", err)
|
||||
}
|
||||
mbidMatches, err := e.loadTracksByMBID(ctx, songs, idMatches)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
|
||||
}
|
||||
isrcMatches, err := e.loadTracksByISRC(ctx, songs, idMatches, mbidMatches)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by ISRC: %w", err)
|
||||
}
|
||||
titleMatches, err := e.loadTracksByTitleAndArtist(ctx, songs, idMatches, mbidMatches, isrcMatches)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
|
||||
func (m *Matcher) MatchSongs(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) {
|
||||
if len(songs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return e.selectBestMatchingSongs(songs, idMatches, mbidMatches, isrcMatches, titleMatches, count), nil
|
||||
byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.selectBestMatchingSongs(songs, byID, byMBID, byISRC, byTitle, count), nil
|
||||
}
|
||||
|
||||
// MatchSongsIndexed matches agent song results to local library tracks and returns a map
|
||||
// from input song index to matched MediaFile. Songs that cannot be matched are omitted from the map.
|
||||
// This preserves original indices, allowing callers to correlate results back to the input slice.
|
||||
func (m *Matcher) MatchSongsIndexed(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) {
|
||||
if len(songs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[int]model.MediaFile, len(songs))
|
||||
for i, t := range songs {
|
||||
if mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitle); found {
|
||||
result[i] = mf
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Matcher) loadAllMatches(ctx context.Context, songs []agents.Song) (byID, byMBID, byISRC, byTitle map[string]model.MediaFile, err error) {
|
||||
byID, err = m.loadTracksByID(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ID: %w", err)
|
||||
}
|
||||
byMBID, err = m.loadTracksByMBID(ctx, songs, byID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
|
||||
}
|
||||
byISRC, err = m.loadTracksByISRC(ctx, songs, byID, byMBID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ISRC: %w", err)
|
||||
}
|
||||
byTitle, err = m.loadTracksByTitleAndArtist(ctx, songs, byID, byMBID, byISRC)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by title: %w", err)
|
||||
}
|
||||
return byID, byMBID, byISRC, byTitle, nil
|
||||
}
|
||||
|
||||
// songMatchedIn checks if a song has already been matched in any of the provided match maps.
|
||||
// It checks the song's ID, MBID, and ISRC fields against the corresponding map keys.
|
||||
func songMatchedIn(s agents.Song, priorMatches ...map[string]model.MediaFile) bool {
|
||||
_, found := lookupByIdentifiers(s, priorMatches...)
|
||||
return found
|
||||
}
|
||||
|
||||
// lookupByIdentifiers searches for a song's identifiers (ID, MBID, ISRC) in the provided maps.
|
||||
// Returns the first matching MediaFile found and true, or an empty MediaFile and false if no match.
|
||||
func lookupByIdentifiers(s agents.Song, maps ...map[string]model.MediaFile) (model.MediaFile, bool) {
|
||||
keys := []string{s.ID, s.MBID, s.ISRC}
|
||||
for _, m := range maps {
|
||||
@ -140,10 +183,7 @@ func lookupByIdentifiers(s agents.Song, maps ...map[string]model.MediaFile) (mod
|
||||
}
|
||||
|
||||
// loadTracksByID fetches MediaFiles from the library using direct ID matching.
|
||||
// It extracts all non-empty ID fields from the input songs and performs a single
|
||||
// batch query to the database. Returns a map keyed by MediaFile ID for O(1) lookup.
|
||||
// Only non-missing files are returned.
|
||||
func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
|
||||
func (m *Matcher) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
|
||||
var ids []string
|
||||
for _, s := range songs {
|
||||
if s.ID != "" {
|
||||
@ -154,7 +194,7 @@ func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map
|
||||
if len(ids) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"media_file.id": ids},
|
||||
squirrel.Eq{"missing": false},
|
||||
@ -172,10 +212,7 @@ func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map
|
||||
}
|
||||
|
||||
// loadTracksByMBID fetches MediaFiles from the library using MusicBrainz Recording IDs.
|
||||
// It extracts all non-empty MBID fields from the input songs and performs a single
|
||||
// batch query against the mbz_recording_id column. Returns a map keyed by MBID for
|
||||
// O(1) lookup. Only non-missing files are returned.
|
||||
func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
func (m *Matcher) loadTracksByMBID(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
var mbids []string
|
||||
for _, s := range songs {
|
||||
if s.MBID != "" && !songMatchedIn(s, priorMatches...) {
|
||||
@ -186,7 +223,7 @@ func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song, pr
|
||||
if len(mbids) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"mbz_recording_id": mbids},
|
||||
squirrel.Eq{"missing": false},
|
||||
@ -205,11 +242,8 @@ func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song, pr
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// loadTracksByISRC fetches MediaFiles from the library using ISRC (International Standard
|
||||
// Recording Code) matching. It extracts all non-empty ISRC fields from the input songs and
|
||||
// queries the tags JSON column for matching ISRC values. Returns a map keyed by ISRC for
|
||||
// O(1) lookup. Only non-missing files are returned.
|
||||
func (e *provider) loadTracksByISRC(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
// loadTracksByISRC fetches MediaFiles from the library using ISRC matching.
|
||||
func (m *Matcher) loadTracksByISRC(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
var isrcs []string
|
||||
for _, s := range songs {
|
||||
if s.ISRC != "" && !songMatchedIn(s, priorMatches...) {
|
||||
@ -220,8 +254,9 @@ func (e *provider) loadTracksByISRC(ctx context.Context, songs []agents.Song, pr
|
||||
if len(isrcs) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
res, err := e.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{
|
||||
res, err := m.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{
|
||||
Filters: squirrel.Eq{"missing": false},
|
||||
Sort: "starred desc, rating desc, year asc, compilation asc",
|
||||
})
|
||||
if err != nil {
|
||||
return matches, err
|
||||
@ -237,27 +272,25 @@ func (e *provider) loadTracksByISRC(ctx context.Context, songs []agents.Song, pr
|
||||
}
|
||||
|
||||
// songQuery represents a normalized query for matching a song to library tracks.
|
||||
// All string fields are sanitized (lowercased, diacritics removed) for comparison.
|
||||
// This struct is used internally by loadTracksByTitleAndArtist to group queries by artist.
|
||||
type songQuery struct {
|
||||
title string // Sanitized song title
|
||||
artist string // Sanitized artist name (without articles like "The")
|
||||
artistMBID string // MusicBrainz Artist ID (optional, for higher specificity matching)
|
||||
album string // Sanitized album name (optional, for specificity scoring)
|
||||
albumMBID string // MusicBrainz Album ID (optional, for highest specificity matching)
|
||||
durationMs uint32 // Duration in milliseconds (0 means unknown, skip duration filtering)
|
||||
title string
|
||||
artist string
|
||||
artistMBID string
|
||||
album string
|
||||
albumMBID string
|
||||
durationMs uint32
|
||||
}
|
||||
|
||||
// matchScore combines title/album similarity with metadata specificity for ranking matches
|
||||
// matchScore combines title/album similarity with metadata specificity for ranking matches.
|
||||
type matchScore struct {
|
||||
titleSimilarity float64 // 0.0-1.0 (Jaro-Winkler)
|
||||
durationProximity float64 // 0.0-1.0 (closer duration = higher, 1.0 if unknown)
|
||||
albumSimilarity float64 // 0.0-1.0 (Jaro-Winkler), used as tiebreaker
|
||||
specificityLevel int // 0-5 (higher = more specific metadata match)
|
||||
titleSimilarity float64
|
||||
durationProximity float64
|
||||
preferredMatch bool
|
||||
albumSimilarity float64
|
||||
specificityLevel int
|
||||
}
|
||||
|
||||
// betterThan returns true if this score beats another.
|
||||
// Comparison order: title similarity > duration proximity > specificity level > album similarity
|
||||
func (s matchScore) betterThan(other matchScore) bool {
|
||||
if s.titleSimilarity != other.titleSimilarity {
|
||||
return s.titleSimilarity > other.titleSimilarity
|
||||
@ -265,64 +298,71 @@ func (s matchScore) betterThan(other matchScore) bool {
|
||||
if s.durationProximity != other.durationProximity {
|
||||
return s.durationProximity > other.durationProximity
|
||||
}
|
||||
if s.preferredMatch != other.preferredMatch {
|
||||
return s.preferredMatch
|
||||
}
|
||||
if s.specificityLevel != other.specificityLevel {
|
||||
return s.specificityLevel > other.specificityLevel
|
||||
}
|
||||
return s.albumSimilarity > other.albumSimilarity
|
||||
}
|
||||
|
||||
// computeSpecificityLevel determines how well query metadata matches a track (0-5).
|
||||
// Higher values indicate more specific matches (MBIDs > names > title only).
|
||||
// Uses fuzzy matching for album names with the same threshold as title matching.
|
||||
func computeSpecificityLevel(q songQuery, mf model.MediaFile, albumThreshold float64) int {
|
||||
title := str.SanitizeFieldForSorting(mf.Title)
|
||||
artist := str.SanitizeFieldForSortingNoArticle(mf.Artist)
|
||||
album := str.SanitizeFieldForSorting(mf.Album)
|
||||
// sanitizedTrack holds pre-sanitized fields for a media file, avoiding redundant sanitization
|
||||
// when the same track is scored against multiple queries in the inner loop. The `mf` field
|
||||
// is a pointer to avoid copying the large MediaFile struct into each entry of the per-artist
|
||||
// sanitized slice.
|
||||
type sanitizedTrack struct {
|
||||
mf *model.MediaFile
|
||||
title string
|
||||
artist string
|
||||
album string
|
||||
}
|
||||
|
||||
// Level 5: Title + Artist MBID + Album MBID (most specific)
|
||||
func newSanitizedTrack(mf *model.MediaFile) sanitizedTrack {
|
||||
return sanitizedTrack{
|
||||
mf: mf,
|
||||
title: str.SanitizeFieldForSorting(mf.Title),
|
||||
artist: str.SanitizeFieldForSortingNoArticle(mf.Artist),
|
||||
album: str.SanitizeFieldForSorting(mf.Album),
|
||||
}
|
||||
}
|
||||
|
||||
// computeSpecificityLevel determines how well query metadata matches a track (0-5).
|
||||
// The track's title, artist, and album fields must be pre-sanitized.
|
||||
func computeSpecificityLevel(q songQuery, t sanitizedTrack, albumThreshold float64) int {
|
||||
if q.artistMBID != "" && q.albumMBID != "" &&
|
||||
mf.MbzArtistID == q.artistMBID && mf.MbzAlbumID == q.albumMBID {
|
||||
t.mf.MbzArtistID == q.artistMBID && t.mf.MbzAlbumID == q.albumMBID {
|
||||
return 5
|
||||
}
|
||||
// Level 4: Title + Artist MBID + Album name (fuzzy)
|
||||
if q.artistMBID != "" && q.album != "" &&
|
||||
mf.MbzArtistID == q.artistMBID && similarityRatio(album, q.album) >= albumThreshold {
|
||||
t.mf.MbzArtistID == q.artistMBID && similarityRatio(t.album, q.album) >= albumThreshold {
|
||||
return 4
|
||||
}
|
||||
// Level 3: Title + Artist name + Album name (fuzzy)
|
||||
if q.artist != "" && q.album != "" &&
|
||||
artist == q.artist && similarityRatio(album, q.album) >= albumThreshold {
|
||||
t.artist == q.artist && similarityRatio(t.album, q.album) >= albumThreshold {
|
||||
return 3
|
||||
}
|
||||
// Level 2: Title + Artist MBID
|
||||
if q.artistMBID != "" && mf.MbzArtistID == q.artistMBID {
|
||||
if q.artistMBID != "" && t.mf.MbzArtistID == q.artistMBID {
|
||||
return 2
|
||||
}
|
||||
// Level 1: Title + Artist name
|
||||
if q.artist != "" && artist == q.artist {
|
||||
if q.artist != "" && t.artist == q.artist {
|
||||
return 1
|
||||
}
|
||||
// Level 0: Title only match (but for fuzzy, title matched via similarity)
|
||||
// Check if at least the title matches exactly
|
||||
if title == q.title {
|
||||
if t.title == q.title {
|
||||
return 0
|
||||
}
|
||||
return -1 // No exact title match, but could still be a fuzzy match
|
||||
return -1
|
||||
}
|
||||
|
||||
// loadTracksByTitleAndArtist loads tracks matching by title with optional artist/album filtering.
|
||||
// Uses a unified scoring approach that combines title similarity (Jaro-Winkler) with
|
||||
// metadata specificity (MBIDs, album names) for both exact and fuzzy matches.
|
||||
// Returns a map keyed by "title|artist" for compatibility with selectBestMatchingSongs.
|
||||
func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
queries := e.buildTitleQueries(songs, priorMatches...)
|
||||
func (m *Matcher) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
queries := m.buildTitleQueries(songs, priorMatches...)
|
||||
if len(queries) == 0 {
|
||||
return map[string]model.MediaFile{}, nil
|
||||
}
|
||||
|
||||
threshold := float64(conf.Server.SimilarSongsMatchThreshold) / 100.0
|
||||
threshold := float64(conf.Server.Matcher.FuzzyThreshold) / 100.0
|
||||
|
||||
// Group queries by artist for efficient DB access
|
||||
byArtist := map[string][]songQuery{}
|
||||
for _, q := range queries {
|
||||
if q.artist != "" {
|
||||
@ -332,8 +372,7 @@ func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agent
|
||||
|
||||
matches := map[string]model.MediaFile{}
|
||||
for artist, artistQueries := range byArtist {
|
||||
// Single DB query per artist - get all their tracks
|
||||
tracks, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
tracks, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"order_artist_name": artist},
|
||||
squirrel.Eq{"missing": false},
|
||||
@ -344,9 +383,13 @@ func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agent
|
||||
continue
|
||||
}
|
||||
|
||||
// Find best match for each query using unified scoring
|
||||
sanitized := make([]sanitizedTrack, len(tracks))
|
||||
for i := range tracks {
|
||||
sanitized[i] = newSanitizedTrack(&tracks[i])
|
||||
}
|
||||
|
||||
for _, q := range artistQueries {
|
||||
if mf, found := e.findBestMatch(q, tracks, threshold); found {
|
||||
if mf, found := m.findBestMatch(q, sanitized, threshold); found {
|
||||
key := q.title + "|" + q.artist
|
||||
if _, exists := matches[key]; !exists {
|
||||
matches[key] = mf
|
||||
@ -357,13 +400,11 @@ func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agent
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// durationProximity returns a score from 0.0 to 1.0 indicating how close
|
||||
// the track's duration is to the target. A perfect match returns 1.0, and the
|
||||
// score decreases as the difference grows (using 1 / (1 + diff)). Returns 1.0
|
||||
// if durationMs is 0 (unknown), so duration does not influence scoring.
|
||||
// durationProximity returns a score from 0.0 to 1.0 indicating how close the track's duration
|
||||
// is to the target. Returns 1.0 if durationMs is 0 (unknown).
|
||||
func durationProximity(durationMs uint32, mediaFileDurationSec float32) float64 {
|
||||
if durationMs <= 0 {
|
||||
return 1.0 // Unknown duration — don't penalise
|
||||
if durationMs == 0 {
|
||||
return 1.0
|
||||
}
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
diff := math.Abs(durationSec - float64(mediaFileDurationSec))
|
||||
@ -371,51 +412,46 @@ func durationProximity(durationMs uint32, mediaFileDurationSec float32) float64
|
||||
}
|
||||
|
||||
// findBestMatch finds the best matching track using combined title/album similarity and specificity scoring.
|
||||
// A track must meet the threshold for title similarity, then the best match is chosen by:
|
||||
// 1. Highest title similarity
|
||||
// 2. Duration proximity (closer duration = higher score, 1.0 if unknown)
|
||||
// 3. Highest specificity level
|
||||
// 4. Highest album similarity (as final tiebreaker)
|
||||
func (e *provider) findBestMatch(q songQuery, tracks model.MediaFiles, threshold float64) (model.MediaFile, bool) {
|
||||
func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, threshold float64) (model.MediaFile, bool) {
|
||||
var bestMatch model.MediaFile
|
||||
bestScore := matchScore{titleSimilarity: -1}
|
||||
found := false
|
||||
|
||||
for _, mf := range tracks {
|
||||
trackTitle := str.SanitizeFieldForSorting(mf.Title)
|
||||
titleSim := similarityRatio(q.title, trackTitle)
|
||||
for _, t := range sanitizedTracks {
|
||||
titleSim := similarityRatio(q.title, t.title)
|
||||
|
||||
if titleSim < threshold {
|
||||
continue
|
||||
}
|
||||
|
||||
// Compute album similarity for tiebreaking (0.0 if no album in query)
|
||||
var albumSim float64
|
||||
if q.album != "" {
|
||||
trackAlbum := str.SanitizeFieldForSorting(mf.Album)
|
||||
albumSim = similarityRatio(q.album, trackAlbum)
|
||||
albumSim = similarityRatio(q.album, t.album)
|
||||
}
|
||||
|
||||
score := matchScore{
|
||||
titleSimilarity: titleSim,
|
||||
durationProximity: durationProximity(q.durationMs, mf.Duration),
|
||||
durationProximity: durationProximity(q.durationMs, t.mf.Duration),
|
||||
preferredMatch: conf.Server.Matcher.PreferStarred && isPreferredTrack(t.mf),
|
||||
albumSimilarity: albumSim,
|
||||
specificityLevel: computeSpecificityLevel(q, mf, threshold),
|
||||
specificityLevel: computeSpecificityLevel(q, t, threshold),
|
||||
}
|
||||
|
||||
if score.betterThan(bestScore) {
|
||||
bestScore = score
|
||||
bestMatch = mf
|
||||
bestMatch = *t.mf
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return bestMatch, found
|
||||
}
|
||||
|
||||
func isPreferredTrack(mf *model.MediaFile) bool {
|
||||
return mf.Starred || mf.Rating >= 4
|
||||
}
|
||||
|
||||
// buildTitleQueries converts agent songs into normalized songQuery structs for title+artist matching.
|
||||
// It skips songs that have already been matched in prior phases (by ID, MBID, or ISRC) and sanitizes
|
||||
// all string fields for consistent comparison (lowercase, diacritics removed, articles stripped from artist names).
|
||||
func (e *provider) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery {
|
||||
func (m *Matcher) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery {
|
||||
var queries []songQuery
|
||||
for _, s := range songs {
|
||||
if songMatchedIn(s, priorMatches...) {
|
||||
@ -434,18 +470,9 @@ func (e *provider) buildTitleQueries(songs []agents.Song, priorMatches ...map[st
|
||||
}
|
||||
|
||||
// selectBestMatchingSongs assembles the final result by mapping input songs to their best matching
|
||||
// library tracks. It iterates through the input songs in order and selects the first available match
|
||||
// using priority order: ID > MBID > ISRC > title+artist.
|
||||
//
|
||||
// The function also handles deduplication: when multiple different input songs would match the same
|
||||
// library track (e.g., "Song (Live)" and "Song (Remastered)" both matching "Song (Live)" in the library),
|
||||
// only the first match is kept. However, if the same input song appears multiple times (intentional
|
||||
// repetition), duplicates are preserved in the output.
|
||||
//
|
||||
// Returns up to 'count' MediaFiles, preserving the input order. Songs that cannot be matched are skipped.
|
||||
func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles {
|
||||
// library tracks using priority order: ID > MBID > ISRC > title+artist.
|
||||
func (m *Matcher) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles {
|
||||
mfs := make(model.MediaFiles, 0, len(songs))
|
||||
// Track MediaFile.ID -> input song that added it, for deduplication
|
||||
addedBy := make(map[string]agents.Song, len(songs))
|
||||
|
||||
for _, t := range songs {
|
||||
@ -458,11 +485,9 @@ func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, by
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for duplicate library track
|
||||
if prevSong, alreadyAdded := addedBy[mf.ID]; alreadyAdded {
|
||||
// Only add duplicate if input songs are identical
|
||||
if t != prevSong {
|
||||
continue // Different input songs → skip mismatch-induced duplicate
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
addedBy[mf.ID] = t
|
||||
@ -473,14 +498,11 @@ func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, by
|
||||
return mfs
|
||||
}
|
||||
|
||||
// findMatchingTrack looks up a song in the match maps using priority order: ID > MBID > ISRC > title+artist.
|
||||
// Returns the matched MediaFile and true if found, or an empty MediaFile and false if no match exists.
|
||||
// findMatchingTrack looks up a song in the match maps using priority order.
|
||||
func findMatchingTrack(t agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile) (model.MediaFile, bool) {
|
||||
// Try identifier-based matches first (ID, MBID, ISRC)
|
||||
if mf, found := lookupByIdentifiers(t, byID, byMBID, byISRC); found {
|
||||
return mf, true
|
||||
}
|
||||
// Fall back to title+artist fuzzy match
|
||||
key := str.SanitizeFieldForSorting(t.Name) + "|" + str.SanitizeFieldForSortingNoArticle(t.Artist)
|
||||
if mf, ok := byTitleArtist[key]; ok {
|
||||
return mf, true
|
||||
@ -489,9 +511,6 @@ func findMatchingTrack(t agents.Song, byID, byMBID, byISRC, byTitleArtist map[st
|
||||
}
|
||||
|
||||
// similarityRatio calculates the similarity between two strings using Jaro-Winkler algorithm.
|
||||
// Returns a value between 0.0 (completely different) and 1.0 (identical).
|
||||
// Jaro-Winkler is well-suited for matching song titles because it gives higher scores
|
||||
// when strings share a common prefix (e.g., "Song Title" vs "Song Title - Remastered").
|
||||
func similarityRatio(a, b string) float64 {
|
||||
if a == b {
|
||||
return 1.0
|
||||
@ -499,6 +518,5 @@ func similarityRatio(a, b string) float64 {
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
// JaroWinkler params: boostThreshold=0.7, prefixSize=4
|
||||
return smetrics.JaroWinkler(a, b, 0.7, 4)
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package external
|
||||
package matcher
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@ -16,25 +16,21 @@ var _ = Describe("similarityRatio", func() {
|
||||
})
|
||||
|
||||
It("returns high similarity for remastered suffix", func() {
|
||||
// Jaro-Winkler gives ~0.92 for this case
|
||||
ratio := similarityRatio("paranoid android", "paranoid android remastered")
|
||||
Expect(ratio).To(BeNumerically(">=", 0.85))
|
||||
})
|
||||
|
||||
It("returns high similarity for suffix additions like (Live)", func() {
|
||||
// Jaro-Winkler gives ~0.96 for this case
|
||||
ratio := similarityRatio("bohemian rhapsody", "bohemian rhapsody live")
|
||||
Expect(ratio).To(BeNumerically(">=", 0.90))
|
||||
})
|
||||
|
||||
It("returns high similarity for 'yesterday' variants (common prefix)", func() {
|
||||
// Jaro-Winkler gives ~0.90 because of common prefix
|
||||
ratio := similarityRatio("yesterday", "yesterday once more")
|
||||
Expect(ratio).To(BeNumerically(">=", 0.85))
|
||||
})
|
||||
|
||||
It("returns low similarity for same suffix", func() {
|
||||
// Jaro-Winkler gives ~0.70 for this case
|
||||
ratio := similarityRatio("postman (live)", "taxman (live)")
|
||||
Expect(ratio).To(BeNumerically("<", 0.85))
|
||||
})
|
||||
17
core/matcher/matcher_suite_test.go
Normal file
17
core/matcher/matcher_suite_test.go
Normal file
@ -0,0 +1,17 @@
|
||||
package matcher_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestMatcher(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Matcher Suite")
|
||||
}
|
||||
897
core/matcher/matcher_test.go
Normal file
897
core/matcher/matcher_test.go
Normal file
@ -0,0 +1,897 @@
|
||||
package matcher_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("Matcher", func() {
|
||||
var ds model.DataStore
|
||||
var mediaFileRepo *mockMediaFileRepo
|
||||
var ctx context.Context
|
||||
var m *matcher.Matcher
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
mediaFileRepo = newMockMediaFileRepo()
|
||||
DeferCleanup(func() {
|
||||
mediaFileRepo.AssertExpectations(GinkgoT())
|
||||
})
|
||||
ds = &tests.MockDataStore{
|
||||
MockedMediaFile: mediaFileRepo,
|
||||
}
|
||||
m = matcher.New(ds)
|
||||
})
|
||||
|
||||
// Per-phase expectation helpers. Each `expect*Phase` registers a .Once() expectation
|
||||
// that will fail the suite via AssertExpectations if the phase is NOT called. Tests
|
||||
// use these to deterministically verify which matching phases fire. Phases that may
|
||||
// or may not fire should use the `allow*Phase` variants instead, which register
|
||||
// .Maybe() fallbacks.
|
||||
expectIDPhase := func(matches model.MediaFiles) {
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("media_file.id"))).
|
||||
Return(matches, nil).Once()
|
||||
}
|
||||
expectMBIDPhase := func(matches model.MediaFiles) {
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("mbz_recording_id"))).
|
||||
Return(matches, nil).Once()
|
||||
}
|
||||
expectISRCPhase := func(matches model.MediaFiles) {
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))).
|
||||
Return(matches, nil).Once()
|
||||
}
|
||||
|
||||
// allowOtherPhases installs .Maybe() catch-alls so phases that short-circuit (return
|
||||
// early without hitting the DB) don't cause test failures for unexpected calls. Call
|
||||
// this after expect*Phase for the phases the test actually wants to verify.
|
||||
allowOtherPhases := func() {
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("media_file.id"))).
|
||||
Return(model.MediaFiles{}, nil).Maybe()
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("mbz_recording_id"))).
|
||||
Return(model.MediaFiles{}, nil).Maybe()
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))).
|
||||
Return(model.MediaFiles{}, nil).Maybe()
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))).
|
||||
Return(model.MediaFiles{}, nil).Maybe()
|
||||
}
|
||||
|
||||
// setupTitleOnlyExpectations is a convenience for fuzzy-match tests that only exercise
|
||||
// the title+artist phase. The title phase uses .Maybe() because it may short-circuit
|
||||
// when no songs have an artist.
|
||||
setupTitleOnlyExpectations := func(artistTracks model.MediaFiles) {
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))).
|
||||
Return(artistTracks, nil).Maybe()
|
||||
}
|
||||
|
||||
Describe("MatchSongs", func() {
|
||||
Context("matching by direct ID", func() {
|
||||
It("matches songs with an ID field to MediaFiles by ID", func() {
|
||||
conf.Server.Matcher.FuzzyThreshold = 100
|
||||
songs := []agents.Song{
|
||||
{ID: "track-1", Name: "Some Song", Artist: "Some Artist"},
|
||||
}
|
||||
idMatch := model.MediaFile{
|
||||
ID: "track-1", Title: "Some Song", Artist: "Some Artist",
|
||||
}
|
||||
expectIDPhase(model.MediaFiles{idMatch})
|
||||
allowOtherPhases()
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("track-1"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("matching by MBID", func() {
|
||||
It("matches songs with MBID to tracks with matching mbz_recording_id", func() {
|
||||
conf.Server.Matcher.FuzzyThreshold = 100
|
||||
songs := []agents.Song{
|
||||
{Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"},
|
||||
}
|
||||
mbidMatch := model.MediaFile{
|
||||
ID: "track-mbid", Title: "Paranoid Android", Artist: "Radiohead",
|
||||
MbzRecordingID: "abc-123",
|
||||
}
|
||||
expectMBIDPhase(model.MediaFiles{mbidMatch})
|
||||
allowOtherPhases()
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("track-mbid"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("matching by ISRC", func() {
|
||||
It("matches songs with ISRC to tracks with matching ISRC tag", func() {
|
||||
conf.Server.Matcher.FuzzyThreshold = 100
|
||||
songs := []agents.Song{
|
||||
{Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"},
|
||||
}
|
||||
isrcMatch := model.MediaFile{
|
||||
ID: "track-isrc", Title: "Paranoid Android", Artist: "Radiohead",
|
||||
Tags: model.Tags{model.TagISRC: []string{"GBAYE0000351"}},
|
||||
}
|
||||
expectISRCPhase(model.MediaFiles{isrcMatch})
|
||||
allowOtherPhases()
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("track-isrc"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("fuzzy title+artist matching", func() {
|
||||
It("matches songs by title and artist name", func() {
|
||||
conf.Server.Matcher.FuzzyThreshold = 100
|
||||
songs := []agents.Song{
|
||||
{Name: "Enjoy the Silence", Artist: "Depeche Mode"},
|
||||
}
|
||||
titleMatch := model.MediaFile{
|
||||
ID: "track-title", Title: "Enjoy the Silence", Artist: "Depeche Mode",
|
||||
}
|
||||
setupTitleOnlyExpectations(model.MediaFiles{titleMatch})
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("track-title"))
|
||||
})
|
||||
|
||||
It("matches songs with fuzzy title similarity", func() {
|
||||
conf.Server.Matcher.FuzzyThreshold = 85
|
||||
songs := []agents.Song{
|
||||
{Name: "Bohemian Rhapsody", Artist: "Queen"},
|
||||
}
|
||||
fuzzyMatch := model.MediaFile{
|
||||
ID: "track-fuzzy", Title: "Bohemian Rhapsody (Live)", Artist: "Queen",
|
||||
}
|
||||
setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch})
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("track-fuzzy"))
|
||||
})
|
||||
|
||||
It("does not match completely different titles", func() {
|
||||
conf.Server.Matcher.FuzzyThreshold = 85
|
||||
songs := []agents.Song{
|
||||
{Name: "Yesterday", Artist: "The Beatles"},
|
||||
}
|
||||
differentTracks := model.MediaFiles{
|
||||
{ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"},
|
||||
}
|
||||
setupTitleOnlyExpectations(differentTracks)
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("deduplication", func() {
|
||||
It("removes duplicates when different input songs match the same library track", func() {
|
||||
conf.Server.Matcher.FuzzyThreshold = 85
|
||||
songs := []agents.Song{
|
||||
{Name: "Bohemian Rhapsody (Live)", Artist: "Queen"},
|
||||
{Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"},
|
||||
}
|
||||
libraryTrack := model.MediaFile{
|
||||
ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen",
|
||||
}
|
||||
setupTitleOnlyExpectations(model.MediaFiles{libraryTrack})
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("br-live"))
|
||||
})
|
||||
|
||||
It("preserves duplicates when identical input songs match the same library track", func() {
|
||||
conf.Server.Matcher.FuzzyThreshold = 85
|
||||
songs := []agents.Song{
|
||||
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
|
||||
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
|
||||
}
|
||||
libraryTrack := model.MediaFile{
|
||||
ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera",
|
||||
}
|
||||
setupTitleOnlyExpectations(model.MediaFiles{libraryTrack})
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
Expect(result[0].ID).To(Equal("br"))
|
||||
Expect(result[1].ID).To(Equal("br"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("priority ordering", func() {
|
||||
It("prefers ID match over MBID match", func() {
|
||||
conf.Server.Matcher.FuzzyThreshold = 100
|
||||
// Song has both ID and MBID set. The matcher should resolve via ID
|
||||
// and short-circuit the MBID phase entirely, so no MBID fetch should
|
||||
// occur even though an mbz_recording_id exists in the input.
|
||||
songs := []agents.Song{
|
||||
{ID: "track-id", Name: "Song", MBID: "mbid-1", Artist: "Artist"},
|
||||
}
|
||||
idMatch := model.MediaFile{
|
||||
ID: "track-id", Title: "Song", Artist: "Artist",
|
||||
}
|
||||
expectIDPhase(model.MediaFiles{idMatch})
|
||||
allowOtherPhases()
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("track-id"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("count limit", func() {
|
||||
It("returns at most 'count' results", func() {
|
||||
conf.Server.Matcher.FuzzyThreshold = 100
|
||||
songs := []agents.Song{
|
||||
{Name: "Song A", Artist: "Artist"},
|
||||
{Name: "Song B", Artist: "Artist"},
|
||||
{Name: "Song C", Artist: "Artist"},
|
||||
}
|
||||
tracks := model.MediaFiles{
|
||||
{ID: "a", Title: "Song A", Artist: "Artist"},
|
||||
{ID: "b", Title: "Song B", Artist: "Artist"},
|
||||
{ID: "c", Title: "Song C", Artist: "Artist"},
|
||||
}
|
||||
setupTitleOnlyExpectations(tracks)
|
||||
result, err := m.MatchSongs(ctx, songs, 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
})
|
||||
})
|
||||
|
||||
Context("empty input", func() {
|
||||
It("returns empty results for no songs", func() {
|
||||
result, err := m.MatchSongs(ctx, []agents.Song{}, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("MatchSongsIndexed", func() {
|
||||
It("returns index-keyed map of matched songs", func() {
|
||||
songs := []agents.Song{
|
||||
{ID: "track-1", Name: "Song One", Artist: "Artist A"},
|
||||
{ID: "track-2", Name: "Song Two", Artist: "Artist B"},
|
||||
{ID: "track-3", Name: "Song Three", Artist: "Artist C"},
|
||||
}
|
||||
mf1 := model.MediaFile{ID: "track-1", Title: "Song One", Artist: "Artist A"}
|
||||
mf2 := model.MediaFile{ID: "track-2", Title: "Song Two", Artist: "Artist B"}
|
||||
|
||||
expectIDPhase(model.MediaFiles{mf1, mf2})
|
||||
allowOtherPhases()
|
||||
|
||||
result, err := m.MatchSongsIndexed(ctx, songs)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
Expect(result[0].ID).To(Equal("track-1"))
|
||||
Expect(result[1].ID).To(Equal("track-2"))
|
||||
_, exists := result[2]
|
||||
Expect(exists).To(BeFalse())
|
||||
})
|
||||
|
||||
It("preserves original indices when some songs don't match", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Unknown Song", Artist: "Unknown Artist"},
|
||||
{ID: "track-1", Name: "Known Song", Artist: "Known Artist"},
|
||||
}
|
||||
mf1 := model.MediaFile{ID: "track-1", Title: "Known Song", Artist: "Known Artist"}
|
||||
|
||||
expectIDPhase(model.MediaFiles{mf1})
|
||||
allowOtherPhases()
|
||||
|
||||
result, err := m.MatchSongsIndexed(ctx, songs)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
_, exists := result[0]
|
||||
Expect(exists).To(BeFalse())
|
||||
Expect(result[1].ID).To(Equal("track-1"))
|
||||
})
|
||||
|
||||
It("returns empty map for empty input", func() {
|
||||
result, err := m.MatchSongsIndexed(ctx, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("specificity level matching", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Matcher.FuzzyThreshold = 100
|
||||
})
|
||||
|
||||
It("matches by title + artist MBID + album MBID (highest priority)", func() {
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Violator",
|
||||
MbzArtistID: "artist-mbid-123", MbzAlbumID: "album-mbid-456",
|
||||
}
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Some Other Album",
|
||||
MbzArtistID: "artist-mbid-123", MbzAlbumID: "different-album-mbid",
|
||||
}
|
||||
songs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"},
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("correct-match"))
|
||||
})
|
||||
|
||||
It("matches by title + artist name + album name when MBIDs unavailable", func() {
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "violator",
|
||||
}
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album",
|
||||
}
|
||||
songs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"},
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("correct-match"))
|
||||
})
|
||||
|
||||
It("matches by title + artist only when album info unavailable", func() {
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "Some Album",
|
||||
}
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album",
|
||||
}
|
||||
songs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Depeche Mode"},
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("correct-match"))
|
||||
})
|
||||
|
||||
It("does not match songs without artist info", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Similar Song"},
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{})
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns distinct matches for each artist's version (covers scenario)", func() {
|
||||
cover1 := model.MediaFile{ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!"}
|
||||
cover2 := model.MediaFile{ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"}
|
||||
cover3 := model.MediaFile{ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"}
|
||||
|
||||
songs := []agents.Song{
|
||||
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"},
|
||||
{Name: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"},
|
||||
{Name: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"},
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{cover1, cover2, cover3})
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(3))
|
||||
ids := []string{result[0].ID, result[1].ID, result[2].ID}
|
||||
Expect(ids).To(ContainElements("cover-1", "cover-2", "cover-3"))
|
||||
})
|
||||
|
||||
It("prefers more precise matches for each song", func() {
|
||||
preciseMatch := model.MediaFile{
|
||||
ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One",
|
||||
MbzArtistID: "mbid-1", MbzAlbumID: "album-mbid-1",
|
||||
}
|
||||
lessAccurateMatch := model.MediaFile{
|
||||
ID: "less-accurate", Title: "Song A", Artist: "Artist One", Album: "Compilation",
|
||||
MbzArtistID: "mbid-1",
|
||||
}
|
||||
artistTwoMatch := model.MediaFile{
|
||||
ID: "artist-two", Title: "Song B", Artist: "Artist Two",
|
||||
}
|
||||
|
||||
songs := []agents.Song{
|
||||
{Name: "Song A", Artist: "Artist One", ArtistMBID: "mbid-1", Album: "Album One", AlbumMBID: "album-mbid-1"},
|
||||
{Name: "Song B", Artist: "Artist Two"},
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch})
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
Expect(result[0].ID).To(Equal("precise"))
|
||||
Expect(result[1].ID).To(Equal("artist-two"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fuzzy matching thresholds", func() {
|
||||
Context("with default threshold (85%)", func() {
|
||||
It("matches songs with remastered suffix", func() {
|
||||
conf.Server.Matcher.FuzzyThreshold = 85
|
||||
|
||||
songs := []agents.Song{
|
||||
{Name: "Paranoid Android", Artist: "Radiohead"},
|
||||
}
|
||||
artistTracks := model.MediaFiles{
|
||||
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(artistTracks)
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("remastered"))
|
||||
})
|
||||
|
||||
It("matches songs with live suffix", func() {
|
||||
conf.Server.Matcher.FuzzyThreshold = 85
|
||||
|
||||
songs := []agents.Song{
|
||||
{Name: "Bohemian Rhapsody", Artist: "Queen"},
|
||||
}
|
||||
artistTracks := model.MediaFiles{
|
||||
{ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"},
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(artistTracks)
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("live"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with threshold set to 100 (exact match only)", func() {
|
||||
It("only matches exact titles", func() {
|
||||
conf.Server.Matcher.FuzzyThreshold = 100
|
||||
|
||||
songs := []agents.Song{
|
||||
{Name: "Paranoid Android", Artist: "Radiohead"},
|
||||
}
|
||||
artistTracks := model.MediaFiles{
|
||||
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(artistTracks)
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with lower threshold (75%)", func() {
|
||||
It("matches more aggressively", func() {
|
||||
conf.Server.Matcher.FuzzyThreshold = 75
|
||||
|
||||
songs := []agents.Song{
|
||||
{Name: "Song", Artist: "Artist"},
|
||||
}
|
||||
artistTracks := model.MediaFiles{
|
||||
{ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"},
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(artistTracks)
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("extended"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fuzzy album matching", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Matcher.FuzzyThreshold = 85
|
||||
conf.Server.Matcher.PreferStarred = false
|
||||
})
|
||||
|
||||
It("matches album with (Remaster) suffix", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
|
||||
}
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera (2011 Remaster)",
|
||||
}
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits",
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("correct"))
|
||||
})
|
||||
|
||||
It("matches album with (Deluxe Edition) suffix", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
|
||||
}
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)",
|
||||
}
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101",
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("correct"))
|
||||
})
|
||||
|
||||
It("prefers exact album match over fuzzy album match", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
|
||||
}
|
||||
exactMatch := model.MediaFile{
|
||||
ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator",
|
||||
}
|
||||
fuzzyMatch := model.MediaFile{
|
||||
ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)",
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch, exactMatch})
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("exact"))
|
||||
})
|
||||
|
||||
It("prefers starred songs over better album match when enabled", func() {
|
||||
conf.Server.Matcher.PreferStarred = true
|
||||
songs := []agents.Song{
|
||||
{Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
|
||||
}
|
||||
albumMatch := model.MediaFile{
|
||||
ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator",
|
||||
}
|
||||
starredTrack := model.MediaFile{
|
||||
ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Starred: true},
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{albumMatch, starredTrack})
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("starred"))
|
||||
})
|
||||
|
||||
It("prefers 4-star songs over better album match when enabled", func() {
|
||||
conf.Server.Matcher.PreferStarred = true
|
||||
songs := []agents.Song{
|
||||
{Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
|
||||
}
|
||||
albumMatch := model.MediaFile{
|
||||
ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator",
|
||||
}
|
||||
ratedTrack := model.MediaFile{
|
||||
ID: "rated", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Rating: 4},
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{albumMatch, ratedTrack})
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("rated"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("duration matching", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Matcher.FuzzyThreshold = 100
|
||||
})
|
||||
|
||||
It("prefers tracks with matching duration", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
}
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0,
|
||||
}
|
||||
wrongDuration := model.MediaFile{
|
||||
ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0,
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{wrongDuration, correctMatch})
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("correct"))
|
||||
})
|
||||
|
||||
It("matches tracks with close duration", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
}
|
||||
closeDuration := model.MediaFile{
|
||||
ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5,
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{closeDuration})
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("close-duration"))
|
||||
})
|
||||
|
||||
It("prefers closer duration over farther duration", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
}
|
||||
closeDuration := model.MediaFile{
|
||||
ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0,
|
||||
}
|
||||
farDuration := model.MediaFile{
|
||||
ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0,
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{farDuration, closeDuration})
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("close"))
|
||||
})
|
||||
|
||||
It("still matches when no tracks have matching duration", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
}
|
||||
differentDuration := model.MediaFile{
|
||||
ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0,
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{differentDuration})
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("different"))
|
||||
})
|
||||
|
||||
It("prefers title match over duration match when titles differ", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
}
|
||||
differentTitle := model.MediaFile{
|
||||
ID: "wrong-title", Title: "Different Song", Artist: "Test Artist", Duration: 180.0,
|
||||
}
|
||||
correctTitle := model.MediaFile{
|
||||
ID: "correct-title", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0,
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{differentTitle, correctTitle})
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("correct-title"))
|
||||
})
|
||||
|
||||
It("matches without duration filtering when agent duration is 0", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 0},
|
||||
}
|
||||
anyTrack := model.MediaFile{
|
||||
ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0,
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{anyTrack})
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("any"))
|
||||
})
|
||||
|
||||
It("handles very short songs with close duration", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Short Song", Artist: "Test Artist", Duration: 30000},
|
||||
}
|
||||
shortTrack := model.MediaFile{
|
||||
ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0,
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{shortTrack})
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal("short"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("deduplication edge cases", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Matcher.FuzzyThreshold = 85
|
||||
})
|
||||
|
||||
It("handles mixed scenario with both identical and different input songs", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"},
|
||||
{Name: "Yesterday (Remastered)", Artist: "The Beatles", Album: "1"},
|
||||
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"},
|
||||
{Name: "Yesterday (Anthology)", Artist: "The Beatles", Album: "Anthology"},
|
||||
}
|
||||
libraryTrack := model.MediaFile{
|
||||
ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!",
|
||||
}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{libraryTrack})
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
Expect(result[0].ID).To(Equal("yesterday"))
|
||||
Expect(result[1].ID).To(Equal("yesterday"))
|
||||
})
|
||||
|
||||
It("does not deduplicate songs that match different library tracks", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Song A", Artist: "Artist"},
|
||||
{Name: "Song B", Artist: "Artist"},
|
||||
{Name: "Song C", Artist: "Artist"},
|
||||
}
|
||||
trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"}
|
||||
trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"}
|
||||
trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist"}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB, trackC})
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(3))
|
||||
Expect(result[0].ID).To(Equal("track-a"))
|
||||
Expect(result[1].ID).To(Equal("track-b"))
|
||||
Expect(result[2].ID).To(Equal("track-c"))
|
||||
})
|
||||
|
||||
It("respects count limit after deduplication", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Song A", Artist: "Artist"},
|
||||
{Name: "Song A (Live)", Artist: "Artist"},
|
||||
{Name: "Song B", Artist: "Artist"},
|
||||
{Name: "Song B (Remix)", Artist: "Artist"},
|
||||
}
|
||||
trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"}
|
||||
trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"}
|
||||
|
||||
setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB})
|
||||
|
||||
result, err := m.MatchSongs(ctx, songs, 2)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
Expect(result[0].ID).To(Equal("track-a"))
|
||||
Expect(result[1].ID).To(Equal("track-b"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockMediaFileRepo struct {
|
||||
mock.Mock
|
||||
model.MediaFileRepository
|
||||
}
|
||||
|
||||
func newMockMediaFileRepo() *mockMediaFileRepo {
|
||||
return &mockMediaFileRepo{}
|
||||
}
|
||||
|
||||
func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
argsSlice := make([]any, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
}
|
||||
args := m.Called(argsSlice...)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(model.MediaFiles), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
return m.GetAll(options...)
|
||||
}
|
||||
|
||||
func (m *mockMediaFileRepo) SetError(hasError bool) {
|
||||
if hasError {
|
||||
m.On("GetAll", mock.Anything).Return(nil, errors.New("mock repo error"))
|
||||
}
|
||||
}
|
||||
|
||||
// matchFieldInAnd returns a matcher that checks whether QueryOptions.Filters is a
|
||||
// squirrel.And whose first element is a squirrel.Eq containing the given field name.
|
||||
func matchFieldInAnd(fieldName string) func(opt model.QueryOptions) bool {
|
||||
return func(opt model.QueryOptions) bool {
|
||||
and, ok := opt.Filters.(squirrel.And)
|
||||
if !ok || len(and) < 2 {
|
||||
return false
|
||||
}
|
||||
eq, hasEq := and[0].(squirrel.Eq)
|
||||
if !hasEq {
|
||||
return false
|
||||
}
|
||||
_, hasField := eq[fieldName]
|
||||
return hasField
|
||||
}
|
||||
}
|
||||
|
||||
// matchFieldInEq returns a matcher that checks whether QueryOptions.Filters is a
|
||||
// squirrel.Eq containing the given field name.
|
||||
func matchFieldInEq(fieldName string) func(opt model.QueryOptions) bool {
|
||||
return func(opt model.QueryOptions) bool {
|
||||
eq, ok := opt.Filters.(squirrel.Eq)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, hasField := eq[fieldName]
|
||||
return hasField
|
||||
}
|
||||
}
|
||||
@ -165,7 +165,7 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
data.OS.Containerized = consts.InContainer
|
||||
|
||||
// Install info
|
||||
packageFilename := filepath.Join(conf.Server.DataFolder, ".package")
|
||||
packageFilename := filepath.Join(conf.Server.DataFolder.String(), ".package")
|
||||
packageFileData, err := os.ReadFile(packageFilename)
|
||||
if err == nil {
|
||||
data.OS.Package = string(packageFileData)
|
||||
@ -179,12 +179,12 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
|
||||
// FS info
|
||||
data.FS.Music = getFSInfo(conf.Server.MusicFolder)
|
||||
data.FS.Data = getFSInfo(conf.Server.DataFolder)
|
||||
if conf.Server.CacheFolder != "" {
|
||||
data.FS.Cache = getFSInfo(conf.Server.CacheFolder)
|
||||
data.FS.Data = getFSInfo(conf.Server.DataFolder.String())
|
||||
if conf.Server.CacheFolder.String() != "" {
|
||||
data.FS.Cache = getFSInfo(conf.Server.CacheFolder.String())
|
||||
}
|
||||
if conf.Server.Backup.Path != "" {
|
||||
data.FS.Backup = getFSInfo(conf.Server.Backup.Path)
|
||||
if conf.Server.Backup.Path.String() != "" {
|
||||
data.FS.Backup = getFSInfo(conf.Server.Backup.Path.String())
|
||||
}
|
||||
|
||||
// Config info
|
||||
@ -193,6 +193,10 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
data.Config.TLSConfigured = conf.Server.TLSCert != "" && conf.Server.TLSKey != ""
|
||||
data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL
|
||||
data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache
|
||||
data.Config.EnableArtworkUpload = conf.Server.EnableArtworkUpload
|
||||
data.Config.CoverArtQuality = conf.Server.CoverArtQuality
|
||||
data.Config.EnableWebPEncoding = conf.Server.EnableWebPEncoding
|
||||
data.Config.UICoverArtSize = conf.Server.UICoverArtSize
|
||||
data.Config.EnableCoverAnimation = conf.Server.EnableCoverAnimation
|
||||
data.Config.EnableNowPlaying = conf.Server.EnableNowPlaying
|
||||
data.Config.EnableDownloads = conf.Server.EnableDownloads
|
||||
|
||||
@ -63,6 +63,10 @@ type Data struct {
|
||||
EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"`
|
||||
EnableJukebox bool `json:"enableJukebox,omitempty"`
|
||||
EnablePrometheus bool `json:"enablePrometheus,omitempty"`
|
||||
EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"`
|
||||
CoverArtQuality int `json:"coverArtQuality,omitempty"`
|
||||
EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"`
|
||||
UICoverArtSize int `json:"uiCoverArtSize,omitempty"`
|
||||
EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"`
|
||||
EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
|
||||
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user