mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Compare commits
No commits in common. "master" and "v0.63.2" have entirely different histories.
@ -4,7 +4,7 @@
|
||||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
|
||||
"VARIANT": "1.27",
|
||||
"VARIANT": "1.26",
|
||||
// Options
|
||||
"INSTALL_NODE": "true",
|
||||
"NODE_VERSION": "v24"
|
||||
|
||||
6
.github/FUNDING.yml
vendored
6
.github/FUNDING.yml
vendored
@ -1,10 +1,10 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
ko_fi: deluan
|
||||
github: deluan
|
||||
open_collective: navidrome
|
||||
liberapay: deluan
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: deluan
|
||||
liberapay: deluan
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
|
||||
5
.github/actions/prepare-docker/action.yml
vendored
5
.github/actions/prepare-docker/action.yml
vendored
@ -68,11 +68,6 @@ runs:
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
# Runner IPs are shared, so anonymous base image pulls get rate-limited.
|
||||
buildkitd-config-inline: |
|
||||
[registry."docker.io"]
|
||||
mirrors = ["mirror.gcr.io"]
|
||||
|
||||
- name: Extract metadata for Docker image
|
||||
id: meta
|
||||
|
||||
2
.github/workflows/download-link-on-pr.yml
vendored
2
.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@v9
|
||||
- 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
|
||||
|
||||
40
.github/workflows/pipeline.yml
vendored
40
.github/workflows/pipeline.yml
vendored
@ -68,16 +68,10 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
# Keep CI on the same version `make lint` installs, so a clean local run
|
||||
# cannot turn red in CI just because a new golangci-lint was released.
|
||||
- name: Resolve golangci-lint version
|
||||
id: golangci-version
|
||||
run: echo "version=$(grep '^GOLANGCI_LINT_VERSION' Makefile | cut -d ' ' -f 3)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: ${{ steps.golangci-version.outputs.version }}
|
||||
version: latest
|
||||
problem-matchers: true
|
||||
args: --timeout 2m
|
||||
|
||||
@ -138,7 +132,7 @@ jobs:
|
||||
run: go mod download
|
||||
|
||||
- name: Test
|
||||
run: go test -shuffle=on -tags netgo,sqlite_fts5 -race -v $(go list ./... | grep -v '/plugins$')
|
||||
run: go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v
|
||||
|
||||
- name: Test ndpgen
|
||||
run: |
|
||||
@ -147,30 +141,6 @@ jobs:
|
||||
go build -o ndpgen .
|
||||
./ndpgen --help
|
||||
|
||||
go-plugins:
|
||||
name: Test Go plugins
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
id: setup-go
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
# Without this, the suite recompiles every test plugin WASM module,
|
||||
# which dominates its runtime under -race.
|
||||
- name: Cache the WASM compilation cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: plugins/testdata/.wazero-cache
|
||||
key: wazero-${{ runner.os }}-go${{ steps.setup-go.outputs.go-version }}-${{ hashFiles('plugins/testdata/*/*.go', 'plugins/testdata/*/go.*', 'plugins/pdk/go/**/*.go', 'plugins/pdk/go/go.*') }}
|
||||
restore-keys: wazero-${{ runner.os }}-
|
||||
|
||||
- name: Test plugins
|
||||
run: go tool ginkgo -p -race -tags netgo,sqlite_fts5 ./plugins/
|
||||
|
||||
go-windows:
|
||||
name: Test Go code (Windows)
|
||||
runs-on: windows-2022
|
||||
@ -196,7 +166,7 @@ jobs:
|
||||
|
||||
- name: Cache ffmpeg
|
||||
id: ffmpeg-cache
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: C:\ffmpeg
|
||||
key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64
|
||||
@ -308,7 +278,7 @@ jobs:
|
||||
|
||||
build:
|
||||
name: Build
|
||||
needs: [js, go, go-plugins, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations]
|
||||
needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations]
|
||||
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 ]
|
||||
@ -353,7 +323,7 @@ jobs:
|
||||
|
||||
- name: Set up QEMU for smoke test
|
||||
if: env.IS_LINUX == 'true'
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
# The binary is static, so binfmt+qemu runs it directly on the runner.
|
||||
# Catches startup crashes in cross-compiled binaries before they ship,
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@ -40,11 +40,3 @@ openspec/
|
||||
.agents
|
||||
go.work*
|
||||
.worktrees/
|
||||
.playwright-mcp/
|
||||
|
||||
# Temp benchmark files
|
||||
zz_*_test.go
|
||||
|
||||
# wazero compilation cache for the plugins test suite
|
||||
/plugins/testdata/.wazero-cache/
|
||||
/plugins/testdata/*.stage/
|
||||
|
||||
@ -27,9 +27,6 @@ linters:
|
||||
disable:
|
||||
- staticcheck
|
||||
settings:
|
||||
errcheck:
|
||||
exclude-functions:
|
||||
- (*github.com/zeebo/xxh3.Hasher).Write
|
||||
gocritic:
|
||||
disable-all: true
|
||||
enabled-checks:
|
||||
@ -72,7 +69,6 @@ linters:
|
||||
- examples$
|
||||
- node_modules
|
||||
- _gen\.go$
|
||||
- .worktrees
|
||||
formatters:
|
||||
exclusions:
|
||||
generated: lax
|
||||
|
||||
45
Dockerfile
45
Dockerfile
@ -2,7 +2,7 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros
|
||||
|
||||
########################################################################################################################
|
||||
### Build xx (original image: tonistiigi/xx)
|
||||
FROM --platform=$BUILDPLATFORM alpine:3.22 AS xx-build
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS xx-build
|
||||
|
||||
# v1.9.0
|
||||
ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
|
||||
@ -26,7 +26,7 @@ COPY --from=xx-build /out/ /usr/bin/
|
||||
|
||||
########################################################################################################################
|
||||
### Build Navidrome UI
|
||||
FROM --platform=$BUILDPLATFORM node:lts-alpine AS ui
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/node:lts-alpine AS ui
|
||||
WORKDIR /app
|
||||
|
||||
# Install node dependencies
|
||||
@ -43,7 +43,7 @@ COPY --from=ui /build /build
|
||||
|
||||
########################################################################################################################
|
||||
### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen)
|
||||
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build-alpine
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-alpine AS build-alpine
|
||||
COPY --from=xx / /
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
@ -85,7 +85,7 @@ EOT
|
||||
|
||||
########################################################################################################################
|
||||
### Build Navidrome binary for standalone distribution (static glibc, cross-compiled)
|
||||
FROM --platform=$BUILDPLATFORM golang:1.27-trixie AS base
|
||||
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
|
||||
@ -152,52 +152,19 @@ RUN xx-verify --static /out/navidrome*
|
||||
FROM scratch AS binary
|
||||
COPY --from=build /out /
|
||||
|
||||
########################################################################################################################
|
||||
### Build no-op stubs for mpv's video-output libraries
|
||||
# mpv links libEGL/libgbm for video output only; Navidrome drives it headless, for audio.
|
||||
# Real mesa pulls in LLVM + gallium (+218MB uncompressed), so ship stubs it never calls.
|
||||
FROM --platform=$BUILDPLATFORM alpine:3.22 AS mpv-stubs
|
||||
COPY --from=xx / /
|
||||
RUN apk add --no-cache clang lld binutils mesa-egl mesa-gbm
|
||||
ARG TARGETPLATFORM
|
||||
RUN xx-apk add --no-cache musl-dev
|
||||
RUN <<EOT
|
||||
set -e
|
||||
mkdir -p /out
|
||||
for so in libEGL.so.1 libgbm.so.1; do
|
||||
readelf -sW /usr/lib/$so \
|
||||
| awk '$5 == "GLOBAL" && $7 != "UND" { print $8 }' \
|
||||
| sed 's/@.*//' \
|
||||
| grep -vE '^(_init|_fini|_edata|_end|__bss_start|_GLOBAL_OFFSET_TABLE_)$' \
|
||||
| sort -u \
|
||||
| awk '{ print "void " $1 "(void) {}" }' > /tmp/stub.c
|
||||
test -s /tmp/stub.c
|
||||
xx-clang -shared -nostdlib -fPIC -Wl,-soname,$so -o /out/$so /tmp/stub.c
|
||||
xx-verify /out/$so
|
||||
done
|
||||
EOT
|
||||
|
||||
########################################################################################################################
|
||||
### Build Final Image
|
||||
FROM alpine:3.22 AS final
|
||||
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 runtime dependencies
|
||||
# - libwebp + symlinks: enables native WebP encoding via purego/dlopen
|
||||
# The mesa/LLVM stack mpv pulls in for video output is dropped in this same layer,
|
||||
# otherwise the deleted bytes still ship in the image.
|
||||
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 && \
|
||||
rm -rf /usr/lib/gallium-pipe /usr/lib/dri \
|
||||
/usr/lib/libEGL.so* /usr/lib/libgbm.so* /usr/lib/libgallium*.so /usr/lib/libLLVM.so* \
|
||||
/usr/lib/libGL.so* /usr/lib/libGLESv2.so* /usr/lib/libglapi.so*
|
||||
|
||||
COPY --from=mpv-stubs /out/ /usr/lib/
|
||||
RUN mpv --no-video --ao=null --version > /dev/null
|
||||
done
|
||||
|
||||
# Copy navidrome binary (musl build for Docker, enables native libwebp)
|
||||
COPY --from=build-alpine /out/navidrome /app/
|
||||
|
||||
2
Makefile
2
Makefile
@ -20,7 +20,7 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin
|
||||
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
||||
DOCKER_TAG ?= deluan/navidrome:develop
|
||||
|
||||
GOLANGCI_LINT_VERSION ?= v2.13.2
|
||||
GOLANGCI_LINT_VERSION ?= v2.12.0
|
||||
|
||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
package deezer
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@ -14,7 +13,6 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils/httpclient"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
@ -36,7 +34,9 @@ func deezerConstructor(dataStore model.DataStore) agents.Interface {
|
||||
dataStore: dataStore,
|
||||
languages: conf.Server.Deezer.Languages,
|
||||
}
|
||||
httpClient := httpclient.New(consts.DefaultHttpClientTimeOut)
|
||||
httpClient := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
|
||||
agent.client = newClient(cachedHttpClient)
|
||||
return agent
|
||||
@ -68,27 +68,16 @@ func (s *deezerAgent) GetArtistImages(ctx context.Context, _, name, _ string) ([
|
||||
{artist.PictureSmall, deezerApiPictureSmallSize},
|
||||
}
|
||||
for _, imgData := range possibleImages {
|
||||
if imgData.URL != "" && !isPlaceholderPicture(imgData.URL) {
|
||||
if imgData.URL != "" {
|
||||
res = append(res, agents.ExternalImage{
|
||||
URL: imgData.URL,
|
||||
Size: imgData.Size,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(res) == 0 {
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// deezerEmptyPicturePath is Deezer's empty-image-id path shape for artists with no picture
|
||||
// (…/images/artist//1000x1000-…), which serves a generic silhouette on any CDN host.
|
||||
const deezerEmptyPicturePath = "/images/artist//"
|
||||
|
||||
func isPlaceholderPicture(url string) bool {
|
||||
return strings.Contains(url, deezerEmptyPicturePath)
|
||||
}
|
||||
|
||||
func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, error) {
|
||||
artists, err := s.client.searchArtists(ctx, name, deezerArtistSearchLimit)
|
||||
if errors.Is(err, ErrNotFound) || len(artists) == 0 {
|
||||
@ -106,32 +95,13 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e
|
||||
}
|
||||
}
|
||||
|
||||
// Deezer's RANKING order isn't reliable for homonyms: rank name matches
|
||||
// ahead of non-matches, prefer an exact-case match, then the most fans.
|
||||
rank := func(a Artist) int {
|
||||
switch {
|
||||
case a.Name == name:
|
||||
return 2
|
||||
case strings.EqualFold(a.Name, name):
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
slices.SortFunc(artists, func(a, b Artist) int {
|
||||
return cmp.Or(
|
||||
cmp.Compare(rank(b), rank(a)),
|
||||
cmp.Compare(b.NbFan, a.NbFan),
|
||||
cmp.Compare(a.ID, b.ID),
|
||||
)
|
||||
})
|
||||
best := artists[0]
|
||||
if !strings.EqualFold(best.Name, name) {
|
||||
log.Trace(ctx, "No artist matched the searched name", "searched_name", name, "found_name", artists[0].Name)
|
||||
// If the first one has the same name, that's the one
|
||||
if !strings.EqualFold(artists[0].Name, name) {
|
||||
log.Trace(ctx, "Top artist do not match", "searched_name", name, "found_name", artists[0].Name)
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
log.Trace(ctx, "Found artist", "name", best.Name, "id", best.ID, "link", best.Link, "nb_fan", best.NbFan)
|
||||
return new(best), nil
|
||||
log.Trace(ctx, "Found artist", "name", artists[0].Name, "id", artists[0].ID, "link", artists[0].Link)
|
||||
return &artists[0], err
|
||||
}
|
||||
|
||||
func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) {
|
||||
|
||||
@ -34,114 +34,6 @@ var _ = Describe("deezerAgent", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("searchArtist", func() {
|
||||
var agent *deezerAgent
|
||||
var httpClient *fakeHttpClient
|
||||
|
||||
BeforeEach(func() {
|
||||
httpClient = &fakeHttpClient{}
|
||||
agent = &deezerAgent{
|
||||
dataStore: &tests.MockDataStore{},
|
||||
client: newClient(httpClient),
|
||||
}
|
||||
})
|
||||
|
||||
It("picks the exact-name match with the most fans when several share the name", func() {
|
||||
// Deezer RANKING order returns a low-popularity homonym first (see issue #5802)
|
||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
||||
{"id":61045802,"name":"Queen","nb_fan":75},
|
||||
{"id":141954732,"name":"Queen","nb_fan":397},
|
||||
{"id":135041032,"name":"Queen(Ares)","nb_fan":133},
|
||||
{"id":183179807,"name":"Queen","nb_fan":53},
|
||||
{"id":412,"name":"Queen","nb_fan":12744378}
|
||||
],"total":5}`)),
|
||||
})
|
||||
|
||||
artist, err := agent.searchArtist(ctx, "Queen")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artist.ID).To(Equal(412))
|
||||
})
|
||||
|
||||
It("matches the name case-insensitively", func() {
|
||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
||||
{"id":1,"name":"QUEEN","nb_fan":10},
|
||||
{"id":2,"name":"queen","nb_fan":20}
|
||||
],"total":2}`)),
|
||||
})
|
||||
|
||||
artist, err := agent.searchArtist(ctx, "Queen")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artist.ID).To(Equal(2))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when no result matches the name exactly", func() {
|
||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
||||
{"id":1,"name":"Queens of the Stone Age","nb_fan":100}
|
||||
],"total":1}`)),
|
||||
})
|
||||
|
||||
_, err := agent.searchArtist(ctx, "Queen")
|
||||
|
||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetArtistImages", func() {
|
||||
var agent *deezerAgent
|
||||
var httpClient *fakeHttpClient
|
||||
|
||||
BeforeEach(func() {
|
||||
httpClient = &fakeHttpClient{}
|
||||
agent = &deezerAgent{
|
||||
dataStore: &tests.MockDataStore{},
|
||||
client: newClient(httpClient),
|
||||
}
|
||||
})
|
||||
|
||||
It("returns the real images when the artist has a picture", func() {
|
||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
||||
{"id":412,"name":"Queen","nb_fan":12744378,
|
||||
"picture_xl":"https://cdn-images.dzcdn.net/images/artist/abc/1000x1000-000000-80-0-0.jpg",
|
||||
"picture_big":"https://cdn-images.dzcdn.net/images/artist/abc/500x500-000000-80-0-0.jpg"}
|
||||
],"total":1}`)),
|
||||
})
|
||||
|
||||
images, err := agent.GetArtistImages(ctx, "", "Queen", "")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(images).To(HaveLen(2))
|
||||
Expect(images[0].URL).To(ContainSubstring("1000x1000"))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when the artist only has empty-id placeholder pictures", func() {
|
||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
||||
{"id":412,"name":"Queen","nb_fan":12744378,
|
||||
"picture_xl":"https://cdn-images.dzcdn.net/images/artist//1000x1000-000000-80-0-0.jpg",
|
||||
"picture_big":"https://cdn-images.dzcdn.net/images/artist//500x500-000000-80-0-0.jpg",
|
||||
"picture_medium":"https://cdn-images.dzcdn.net/images/artist//250x250-000000-80-0-0.jpg",
|
||||
"picture_small":"https://cdn-images.dzcdn.net/images/artist//56x56-000000-80-0-0.jpg"}
|
||||
],"total":1}`)),
|
||||
})
|
||||
|
||||
images, err := agent.GetArtistImages(ctx, "", "Queen", "")
|
||||
|
||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
||||
Expect(images).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetArtistBiography - Language Fallback", func() {
|
||||
var agent *deezerAgent
|
||||
var httpClient *langAwareHttpClient
|
||||
|
||||
@ -18,7 +18,6 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils/httpclient"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
@ -60,7 +59,9 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
|
||||
secret: conf.Server.LastFM.Secret,
|
||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
}
|
||||
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
l.httpClient = chc
|
||||
l.client = newClient(l.apiKey, l.secret, chc)
|
||||
@ -92,7 +93,7 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin
|
||||
var resp agents.AlbumInfo
|
||||
for _, lang := range l.languages {
|
||||
var err error
|
||||
a, err = l.callAlbumGetInfo(ctx, name, artist, lang)
|
||||
a, err = l.callAlbumGetInfo(ctx, name, artist, mbid, lang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -113,7 +114,7 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
|
||||
a, err := l.callAlbumGetInfo(ctx, name, artist, l.languages[0])
|
||||
a, err := l.callAlbumGetInfo(ctx, name, artist, mbid, l.languages[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -285,18 +286,22 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// callAlbumGetInfo matches on name+artist only. Last.fm's album.getInfo by MBID is unreliable —
|
||||
// a correct MBID can return a different album (or none) — so the MBID is deliberately not passed.
|
||||
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, lang string) (*Album, error) {
|
||||
a, err := l.client.albumGetInfo(ctx, name, artist, "", lang)
|
||||
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string, lang string) (*Album, error) {
|
||||
a, err := l.client.albumGetInfo(ctx, name, artist, mbid, lang)
|
||||
var lfErr *lastFMError
|
||||
isLastFMError := errors.As(err, &lfErr)
|
||||
|
||||
if mbid != "" && (isLastFMError && lfErr.Code == 6) {
|
||||
log.Debug(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid)
|
||||
return l.callAlbumGetInfo(ctx, name, artist, "", lang)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if lfErr, ok := errors.AsType[*lastFMError](err); ok && lfErr.Code == 6 {
|
||||
// A not-found is a definitive absence, not a fault: return the shared sentinel so the
|
||||
// artwork worker's breaker/transient checks don't retry it, and log it at Debug.
|
||||
log.Debug(ctx, "Album not found in Last.fm", "album", name, "artist", artist)
|
||||
return nil, agents.ErrNotFound
|
||||
if isLastFMError && lfErr.Code == 6 {
|
||||
log.Debug(ctx, "Album not found", "album", name, "mbid", mbid, err)
|
||||
} else {
|
||||
log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "mbid", mbid, err)
|
||||
}
|
||||
log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "artist", artist, err)
|
||||
return nil, err
|
||||
}
|
||||
return a, nil
|
||||
@ -308,12 +313,6 @@ func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, lang s
|
||||
|
||||
a, err := l.client.artistGetInfo(ctx, name, lang)
|
||||
if err != nil {
|
||||
if lfErr, ok := errors.AsType[*lastFMError](err); ok && lfErr.Code == 6 {
|
||||
// A not-found is a definitive absence, not a fault: return the shared sentinel so it
|
||||
// doesn't trip the artwork worker's breaker, and log at Debug instead of Error.
|
||||
log.Debug(ctx, "Artist not found in Last.fm", "artist", name)
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err)
|
||||
return nil, err
|
||||
}
|
||||
@ -405,8 +404,7 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
|
||||
log.Warn(ctx, "Last.fm client.scrobble returned error", "track", s.Title, err)
|
||||
return errors.Join(err, scrobbler.ErrRetryLater)
|
||||
}
|
||||
// 11: service offline; 16: temporarily unavailable. Rate limiting is mapped by the client.
|
||||
if lfErr.Code == 11 || lfErr.Code == 16 || errors.Is(err, scrobbler.ErrRetryLater) {
|
||||
if lfErr.Code == 11 || lfErr.Code == 16 {
|
||||
return errors.Join(err, scrobbler.ErrRetryLater)
|
||||
}
|
||||
return errors.Join(err, scrobbler.ErrUnrecoverable)
|
||||
|
||||
@ -100,15 +100,6 @@ var _ = Describe("lastfmAgent", func() {
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
_, err := agent.GetArtistBiography(ctx, "123", "U2", "")
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Language Fallback", func() {
|
||||
@ -506,16 +497,6 @@ var _ = Describe("lastfmAgent", func() {
|
||||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
||||
Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater on http errors", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`internal server error`)),
|
||||
@ -558,10 +539,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||
URL: "https://www.last.fm/music/Cher/Believe",
|
||||
}))
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
// MBID is deliberately not sent — album.getInfo matches on name+artist only.
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("Believe"))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Cher"))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("03c91c40-49a6-44a7-90e7-a700edf97a62"))
|
||||
})
|
||||
|
||||
It("returns empty images if no images are available", func() {
|
||||
@ -580,7 +558,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
|
||||
})
|
||||
|
||||
It("returns an error if Last.fm call returns an error", func() {
|
||||
@ -588,17 +566,23 @@ var _ = Describe("lastfmAgent", func() {
|
||||
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
|
||||
})
|
||||
|
||||
It("returns an error when Last.fm returns an error 6 (album not found)", func() {
|
||||
It("returns an error if Last.fm call returns an error 6 and mbid is empty", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
|
||||
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
|
||||
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "")
|
||||
Expect(err).To(HaveOccurred())
|
||||
// A definitive not-found must satisfy the sentinel, or the artwork worker retries it.
|
||||
Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
||||
})
|
||||
|
||||
Context("MBID non existent in Last.fm", func() {
|
||||
It("calls again when last.fm returns an error 6", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
|
||||
_, _ = agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
|
||||
Expect(httpClient.RequestCount).To(Equal(2))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -629,13 +613,6 @@ var _ = Describe("lastfmAgent", func() {
|
||||
Expect(images[0].URL).To(Equal("https://lastfm.freetls.fastly.net/i/u/ar0/818148bf682d429dc21b59a73ef6f68e.png"))
|
||||
})
|
||||
|
||||
It("maps a Last.fm error 6 (artist not found) to the shared not-found sentinel", func() {
|
||||
apiClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
|
||||
_, err := agent.GetArtistImages(ctx, "123", "Nonexistent Artist", "")
|
||||
// Not a fault: runs of missing artists must not trip the worker's circuit breaker.
|
||||
Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns empty list if image is the ignored default image", func() {
|
||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
||||
|
||||
@ -18,7 +18,6 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/utils/httpclient"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
@ -42,7 +41,9 @@ func NewRouter(ds model.DataStore) *Router {
|
||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
}
|
||||
r.Handler = r.routes()
|
||||
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
r.client = newClient(r.apiKey, r.secret, hc)
|
||||
return r
|
||||
}
|
||||
|
||||
@ -214,14 +214,5 @@ var _ = Describe("auth_router", func() {
|
||||
_, err = verifyLinkToken(nonExpiringToken)
|
||||
Expect(err).To(MatchError("link token missing expiration"))
|
||||
})
|
||||
|
||||
It("rejects a Jellyfin access token", func() {
|
||||
usr := &model.User{ID: "u1", UserName: "johndoe"}
|
||||
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = verifyLinkToken(tokenStr)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -5,7 +5,6 @@ import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@ -15,15 +14,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
const (
|
||||
apiBaseUrl = "https://ws.audioscrobbler.com/2.0/"
|
||||
// errCodeRateLimit is Last.fm's "rate limit exceeded"; it arrives in the body, with HTTP 200
|
||||
// and no rate-limit headers, so the body code is the only signal.
|
||||
errCodeRateLimit = 29
|
||||
)
|
||||
|
||||
type lastFMError struct {
|
||||
@ -230,11 +225,7 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu
|
||||
return nil, jsonErr
|
||||
}
|
||||
if response.Error != 0 {
|
||||
var err error = &lastFMError{Code: response.Error, Message: response.Message}
|
||||
if response.Error == errCodeRateLimit {
|
||||
err = errors.Join(err, &agents.RetryLaterError{})
|
||||
}
|
||||
return &response, err
|
||||
return &response, &lastFMError{Code: response.Error, Message: response.Message}
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
|
||||
@ -3,6 +3,7 @@ package listenbrainz
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
@ -11,7 +12,6 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils/httpclient"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
@ -33,7 +33,9 @@ func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent {
|
||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
baseURL: conf.Server.ListenBrainz.BaseURL,
|
||||
}
|
||||
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
l.client = newClient(l.baseURL, chc)
|
||||
return l
|
||||
|
||||
@ -164,19 +164,6 @@ var _ = Describe("listenBrainzAgent", func() {
|
||||
err := agent.Scrobble(ctx, "user-1", sc)
|
||||
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
|
||||
})
|
||||
|
||||
It("keeps a 429 scrobble for retry and carries the delay", func() {
|
||||
httpClient.Res = http.Response{
|
||||
StatusCode: 429,
|
||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}},
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"code":429,"error":"rate limited"}`)),
|
||||
}
|
||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
||||
Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue())
|
||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(retry.RetryIn).To(Equal(7 * time.Second))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetArtistUrl", func() {
|
||||
|
||||
@ -16,7 +16,6 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/utils/httpclient"
|
||||
)
|
||||
|
||||
type sessionKeysRepo interface {
|
||||
@ -38,7 +37,9 @@ func NewRouter(ds model.DataStore) *Router {
|
||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
}
|
||||
r.Handler = r.routes()
|
||||
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
r.client = newClient(conf.Server.ListenBrainz.BaseURL, hc)
|
||||
return r
|
||||
}
|
||||
|
||||
@ -13,7 +13,6 @@ import (
|
||||
"slices"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
@ -22,12 +21,6 @@ const (
|
||||
labsBase = "https://labs.api.listenbrainz.org/"
|
||||
)
|
||||
|
||||
// retryLaterErr reads the wait ListenBrainz asked for. It sends X-RateLimit-Reset-In
|
||||
// (delta-seconds) on every response, including the 429, and never Retry-After.
|
||||
func retryLaterErr(h http.Header) *agents.RetryLaterError {
|
||||
return &agents.RetryLaterError{RetryIn: agents.ParseRetryIn(h.Get("X-RateLimit-Reset-In"))}
|
||||
}
|
||||
|
||||
var (
|
||||
ErrorNotFound = errors.New("listenbrainz: not found")
|
||||
)
|
||||
@ -181,9 +174,6 @@ func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, en
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
return nil, retryLaterErr(resp.Header)
|
||||
}
|
||||
decoder := json.NewDecoder(resp.Body)
|
||||
|
||||
var response listenBrainzResponse
|
||||
@ -195,10 +185,6 @@ func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, en
|
||||
return nil, jsonErr
|
||||
}
|
||||
if response.Code != 0 && response.Code != 200 {
|
||||
// LB also reports rate limiting as a body code, not only as an HTTP status.
|
||||
if response.Code == http.StatusTooManyRequests {
|
||||
return &response, retryLaterErr(resp.Header)
|
||||
}
|
||||
return &response, &listenBrainzError{Code: response.Code, Message: response.Error}
|
||||
}
|
||||
|
||||
@ -225,9 +211,6 @@ func (c *client) makeGenericRequest(ctx context.Context, method string, endpoint
|
||||
// On a 200 code, there is no code. Decode using using error message if it exists
|
||||
if resp.StatusCode != 200 {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
return nil, retryLaterErr(resp.Header)
|
||||
}
|
||||
decoder := json.NewDecoder(resp.Body)
|
||||
|
||||
var lbzError lbzHttpError
|
||||
|
||||
@ -4,17 +4,13 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@ -465,73 +461,4 @@ var _ = Describe("client", func() {
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("rate limiting", func() {
|
||||
It("returns RetryLaterError with the header delay on 429", func() {
|
||||
httpClient.Res = http.Response{
|
||||
StatusCode: 429,
|
||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{"3"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)),
|
||||
}
|
||||
_, err := client.validateToken(context.Background(), "token")
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(retry.RetryIn).To(Equal(3 * time.Second))
|
||||
})
|
||||
|
||||
It("returns RetryLaterError with zero delay when no header is present", func() {
|
||||
httpClient.Res = http.Response{
|
||||
StatusCode: 429,
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)),
|
||||
}
|
||||
_, err := client.validateToken(context.Background(), "token")
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
||||
retry, _ := errors.AsType[*agents.RetryLaterError](err)
|
||||
Expect(retry.RetryIn).To(BeZero())
|
||||
})
|
||||
|
||||
DescribeTable("caps absurd header values at one hour",
|
||||
func(header string) {
|
||||
httpClient.Res = http.Response{
|
||||
StatusCode: 429,
|
||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{header}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)),
|
||||
}
|
||||
_, err := client.validateToken(context.Background(), "token")
|
||||
retry, _ := errors.AsType[*agents.RetryLaterError](err)
|
||||
Expect(retry.RetryIn).To(Equal(time.Hour))
|
||||
},
|
||||
Entry("a large value", "999999"),
|
||||
Entry("a huge value", "99999999999"),
|
||||
// Scaling this to nanoseconds before capping wraps past 2^64, landing on ~0.29s.
|
||||
Entry("a value that overflows int64 nanoseconds", "18446744074"),
|
||||
)
|
||||
|
||||
It("maps a body-level 429 sent with a non-429 status", func() {
|
||||
httpClient.Res = http.Response{
|
||||
StatusCode: 200,
|
||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)),
|
||||
}
|
||||
_, err := client.validateToken(context.Background(), "token")
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(retry.RetryIn).To(Equal(7 * time.Second))
|
||||
})
|
||||
|
||||
It("returns RetryLaterError on a 429 from makeGenericRequest", func() {
|
||||
httpClient.Res = http.Response{
|
||||
StatusCode: 429,
|
||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{"5"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)),
|
||||
}
|
||||
_, err := client.getArtistUrl(context.Background(), "1")
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(retry.RetryIn).To(Equal(5 * time.Second))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
1007
cmd/artwork.go
1007
cmd/artwork.go
File diff suppressed because it is too large
Load Diff
1186
cmd/artwork_test.go
1186
cmd/artwork_test.go
File diff suppressed because it is too large
Load Diff
21
cmd/pls.go
21
cmd/pls.go
@ -6,14 +6,13 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@ -142,16 +141,14 @@ func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *mod
|
||||
func runExporter(ctx context.Context) {
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
playlist := findPlaylist(ctx, ds, playlistID)
|
||||
writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile)
|
||||
}
|
||||
|
||||
func writePlaylist(m3u string, out io.Writer, file string) {
|
||||
if file == "" || file == "-" {
|
||||
fmt.Fprint(out, m3u)
|
||||
pls := playlist.ToM3U8()
|
||||
if outputFile == "-" || outputFile == "" {
|
||||
println(pls)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(file, []byte(m3u), 0600); err != nil {
|
||||
log.Fatal("Error writing to the output file", "file", file, err)
|
||||
err := os.WriteFile(outputFile, []byte(pls), 0600)
|
||||
if err != nil {
|
||||
log.Fatal("Error writing to the output file", "file", outputFile, err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -160,7 +157,7 @@ func runExport(ctx context.Context) {
|
||||
|
||||
if playlistID != "" && outputFile == "" {
|
||||
playlist := findPlaylist(ctx, ds, playlistID)
|
||||
writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile)
|
||||
println(playlist.ToM3U8())
|
||||
return
|
||||
}
|
||||
|
||||
@ -263,7 +260,7 @@ func runImport(ctx context.Context, files []string) {
|
||||
ctx = request.WithUser(ctx, *user)
|
||||
}
|
||||
|
||||
pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
pls := playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
for _, file := range files {
|
||||
absPath, err := filepath.Abs(file)
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("writePlaylist", func() {
|
||||
const m3u = "#EXTM3U\n#PLAYLIST:DJ Wave\n#EXTINF:364,Bel Canto - Dreaming Girl\n"
|
||||
plsFile := filepath.Join(os.TempDir(), fmt.Sprintf("navidrome-pls-%d.m3u8", os.Getpid()))
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(func() { _ = os.Remove(plsFile) })
|
||||
})
|
||||
|
||||
DescribeTable("writes the playlist to exactly one destination",
|
||||
func(file, wantStream, wantFile string) {
|
||||
var out strings.Builder
|
||||
|
||||
writePlaylist(m3u, &out, file)
|
||||
|
||||
written, _ := os.ReadFile(plsFile)
|
||||
Expect(out.String()).To(Equal(wantStream))
|
||||
Expect(string(written)).To(Equal(wantFile))
|
||||
},
|
||||
Entry("no file name writes to the stream", "", m3u, ""),
|
||||
Entry("a dash writes to the stream", "-", m3u, ""),
|
||||
Entry("a path writes to the file", plsFile, "", m3u),
|
||||
)
|
||||
})
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@ -313,7 +314,7 @@ func formatPluginList(list model.Plugins, format string) (string, error) {
|
||||
return sb.String(), w.Error()
|
||||
case "table":
|
||||
var sb strings.Builder
|
||||
w := newTabWriter(&sb)
|
||||
w := tabwriter.NewWriter(&sb, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ID\tNAME\tVERSION\tENABLED\tLAST ERROR")
|
||||
for _, p := range list {
|
||||
name, version := manifestSummary(p)
|
||||
|
||||
104
cmd/root.go
104
cmd/root.go
@ -2,7 +2,6 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
@ -12,7 +11,6 @@ import (
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@ -88,11 +86,8 @@ func runNavidrome(ctx context.Context) {
|
||||
g.Go(startPlaybackServer(ctx))
|
||||
g.Go(schedulePeriodicBackup(ctx))
|
||||
g.Go(startInsightsCollector(ctx))
|
||||
g.Go(scheduleDBAnalyzer(ctx))
|
||||
g.Go(scheduleDBOptimizer(ctx))
|
||||
g.Go(startPluginManager(ctx))
|
||||
artworkWorker := CreateArtworkWorker()
|
||||
g.Go(startArtworkWorker(ctx, artworkWorker))
|
||||
g.Go(scheduleArtworkHousekeeping(ctx, artworkWorker))
|
||||
g.Go(runInitialScan(ctx))
|
||||
if conf.Server.Scanner.Enabled {
|
||||
g.Go(startScanWatcher(ctx))
|
||||
@ -129,9 +124,6 @@ func startServer(ctx context.Context) func() error {
|
||||
if conf.Server.ListenBrainz.Enabled {
|
||||
a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter())
|
||||
}
|
||||
if conf.Server.Jellyfin.Enabled {
|
||||
a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx))
|
||||
}
|
||||
if conf.Server.Prometheus.Enabled {
|
||||
p := CreatePrometheus()
|
||||
// blocking call because takes <100ms but useful if fails
|
||||
@ -139,7 +131,7 @@ func startServer(ctx context.Context) func() error {
|
||||
a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, p.GetHandler())
|
||||
}
|
||||
if conf.Server.DevEnableProfiler {
|
||||
a.MountRouter("Profiling", "/debug", profilerHandler())
|
||||
a.MountRouter("Profiling", "/debug", middleware.Profiler())
|
||||
}
|
||||
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
|
||||
a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler())
|
||||
@ -148,14 +140,6 @@ func startServer(ctx context.Context) func() error {
|
||||
}
|
||||
}
|
||||
|
||||
// profilerHandler returns the pprof handler. net/http/pprof resolves the profile
|
||||
// name from the raw request path, so the BasePath has to come off first.
|
||||
func profilerHandler() http.Handler {
|
||||
// A trailing or root slash would make StripPrefix drop the leading slash chi needs.
|
||||
basePath := strings.TrimRight(conf.Server.BasePath, "/")
|
||||
return http.StripPrefix(basePath, middleware.Profiler())
|
||||
}
|
||||
|
||||
// schedulePeriodicScan schedules a periodic scan of the music library, if configured.
|
||||
func schedulePeriodicScan(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
@ -291,24 +275,16 @@ func schedulePeriodicBackup(ctx context.Context) func() error {
|
||||
}
|
||||
}
|
||||
|
||||
func scheduleDBAnalyzer(ctx context.Context) func() error {
|
||||
func scheduleDBOptimizer(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
if !conf.Server.EnableScheduledDBAnalyze {
|
||||
log.Info(ctx, "Scheduled DB analysis is DISABLED")
|
||||
return nil
|
||||
}
|
||||
log.Info(ctx, "Scheduling DB analysis check", "schedule", consts.DBAnalyzeCheckSchedule)
|
||||
log.Info(ctx, "Scheduling DB optimizer", "schedule", consts.OptimizeDBSchedule)
|
||||
schedulerInstance := scheduler.GetInstance()
|
||||
_, err := schedulerInstance.Add(consts.DBAnalyzeCheckSchedule, func() {
|
||||
release, ok := scanner.LockForMaintenance()
|
||||
if !ok {
|
||||
log.Debug(ctx, "Skipping DB analysis check because a scan is in progress")
|
||||
_, err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() {
|
||||
if scanner.IsScanning() {
|
||||
log.Debug(ctx, "Skipping DB optimization because a scan is in progress")
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
if _, err := db.OptimizeIfNeeded(ctx); err != nil {
|
||||
log.Error(ctx, "Error analyzing DB", err)
|
||||
}
|
||||
db.Optimize(ctx)
|
||||
})
|
||||
return err
|
||||
}
|
||||
@ -357,68 +333,6 @@ func startPlaybackServer(ctx context.Context) func() error {
|
||||
}
|
||||
}
|
||||
|
||||
// startArtworkWorker starts the background artwork acquisition worker. It always
|
||||
// runs; the queue is simply empty until something enqueues work into it.
|
||||
func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() error {
|
||||
return func() error {
|
||||
log.Info(ctx, "Starting artwork worker")
|
||||
return worker.Run(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// scheduleArtworkHousekeeping runs the startup fingerprint backfill and registers the
|
||||
// recurring stale-absent recheck and prune jobs.
|
||||
func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error {
|
||||
return func() error {
|
||||
schedulerInstance := scheduler.GetInstance()
|
||||
|
||||
if _, err := schedulerInstance.Add(consts.ArtworkStaleAbsentRecheckSchedule, func() {
|
||||
if err := worker.EnqueueStaleAbsentAll(ctx); err != nil {
|
||||
log.Error(ctx, "Error enqueueing stale artwork rechecks", err)
|
||||
}
|
||||
if err := worker.EnqueueMissingAll(ctx); err != nil {
|
||||
log.Error(ctx, "Error enqueueing missing artwork rechecks", err)
|
||||
}
|
||||
}); err != nil {
|
||||
log.Error(ctx, "Error scheduling artwork stale-absent recheck", err)
|
||||
}
|
||||
|
||||
if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() {
|
||||
if err := worker.RunPrune(ctx); err != nil {
|
||||
log.Error(ctx, "Error running artwork prune", err)
|
||||
}
|
||||
}); err != nil {
|
||||
log.Error(ctx, "Error scheduling artwork prune", err)
|
||||
}
|
||||
|
||||
// Also run the missing-row recheck once at startup so a never-scanned entity is picked up
|
||||
// immediately, not only on the next hourly tick (e.g. after enabling the feature).
|
||||
if err := worker.EnqueueMissingAll(ctx); err != nil {
|
||||
log.Error(ctx, "Error enqueueing missing artwork rechecks", err)
|
||||
}
|
||||
|
||||
backfilled, err := worker.Backfill(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error running artwork backfill", err)
|
||||
return nil
|
||||
}
|
||||
if !backfilled {
|
||||
return nil
|
||||
}
|
||||
log.Info(ctx, "Artwork backfill enqueued, scheduling a follow-up prune")
|
||||
timer := time.NewTimer(consts.ArtworkPostBackfillPruneDelay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
if err := worker.RunPrune(ctx); err != nil {
|
||||
log.Error(ctx, "Error running post-backfill artwork prune", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// startPluginManager starts the plugin manager, if configured.
|
||||
func startPluginManager(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
@ -469,7 +383,7 @@ func init() {
|
||||
rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized")
|
||||
rootCmd.Flags().Bool("autoimportplaylists", viper.GetBool("autoimportplaylists"), "enable/disable .m3u playlist auto-import`")
|
||||
|
||||
rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint")
|
||||
rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint`")
|
||||
rootCmd.Flags().String("prometheus.metricspath", viper.GetString("prometheus.metricspath"), "http endpoint for prometheus metrics")
|
||||
|
||||
_ = viper.BindPFlag("address", rootCmd.Flags().Lookup("address"))
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path"
|
||||
"runtime/pprof"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = pprof.NewProfile("nd-profiler-test")
|
||||
|
||||
var _ = Describe("profilerHandler", func() {
|
||||
// Mirrors how server.MountRouter mounts the handler.
|
||||
mount := func() http.Handler {
|
||||
router := chi.NewRouter()
|
||||
router.Mount(path.Join(conf.Server.BasePath, "/debug"), profilerHandler())
|
||||
return router
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
|
||||
DescribeTable("serves a named profile",
|
||||
func(basePath string) {
|
||||
conf.Server.BasePath = basePath
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
target := path.Join(basePath, "/debug/pprof/nd-profiler-test") + "?debug=1"
|
||||
mount().ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(HavePrefix("nd-profiler-test profile: total 0"))
|
||||
},
|
||||
Entry("without a BasePath", ""),
|
||||
Entry("with a BasePath", "/music"),
|
||||
Entry("with a root BasePath", "/"),
|
||||
Entry("with a trailing-slash BasePath", "/music/"),
|
||||
)
|
||||
})
|
||||
41
cmd/scan.go
41
cmd/scan.go
@ -4,12 +4,11 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/gob"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@ -44,20 +43,15 @@ var scanCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) (bool, error) {
|
||||
var changesDetected bool
|
||||
var scanErrors []error
|
||||
func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) {
|
||||
for status := range pl.ReadOrDone(ctx, progress) {
|
||||
if status.Warning != "" {
|
||||
log.Warn(ctx, "Scan warning", "error", status.Warning)
|
||||
}
|
||||
if status.Error != "" {
|
||||
log.Error(ctx, "Scan error", "error", status.Error)
|
||||
scanErrors = append(scanErrors, errors.New(status.Error))
|
||||
}
|
||||
if status.ChangesDetected {
|
||||
changesDetected = true
|
||||
}
|
||||
// Discard the progress status, we only care about errors
|
||||
}
|
||||
|
||||
if fullScan {
|
||||
@ -65,7 +59,6 @@ func trackScanInteractively(ctx context.Context, progress <-chan *scanner.Progre
|
||||
} else {
|
||||
log.Info("Finished rescan")
|
||||
}
|
||||
return changesDetected, errors.Join(scanErrors...)
|
||||
}
|
||||
|
||||
func trackScanAsSubprocess(ctx context.Context, progress <-chan *scanner.ProgressInfo) {
|
||||
@ -82,7 +75,7 @@ func runScanner(ctx context.Context) {
|
||||
sqlDB := db.Db()
|
||||
defer db.Db().Close()
|
||||
ds := persistence.New(sqlDB)
|
||||
pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
pls := playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
// Parse targets from command line or file
|
||||
var scanTargets []model.ScanTarget
|
||||
@ -102,16 +95,6 @@ func runScanner(ctx context.Context) {
|
||||
log.Info(ctx, "Scanning specific folders", "numTargets", len(scanTargets))
|
||||
}
|
||||
|
||||
effectiveFullScan := fullScan
|
||||
if !subprocess {
|
||||
effectiveFullScan = scanner.EffectiveFullScan(ctx, ds, fullScan, scanTargets)
|
||||
if effectiveFullScan {
|
||||
if err := db.MarkOptimizePending(ctx); err != nil {
|
||||
log.Error(ctx, "Error marking DB analysis pending", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
progress, err := scanner.CallScan(ctx, ds, pls, fullScan, scanTargets)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to scan", err)
|
||||
@ -121,21 +104,7 @@ func runScanner(ctx context.Context) {
|
||||
if subprocess {
|
||||
trackScanAsSubprocess(ctx, progress)
|
||||
} else {
|
||||
changesDetected, scanErr := trackScanInteractively(ctx, progress)
|
||||
runPostScanAnalysis(ctx, changesDetected, effectiveFullScan, scanErr)
|
||||
}
|
||||
}
|
||||
|
||||
func runPostScanAnalysis(ctx context.Context, changesDetected, effectiveFullScan bool, scanErr error) {
|
||||
if changesDetected {
|
||||
if err := db.MarkOptimizePending(ctx); err != nil {
|
||||
log.Error(ctx, "Error marking DB analysis pending", err)
|
||||
}
|
||||
}
|
||||
if effectiveFullScan && scanErr == nil {
|
||||
if err := db.Optimize(ctx); err != nil {
|
||||
log.Error(ctx, "Error analyzing DB", err)
|
||||
}
|
||||
trackScanInteractively(ctx, progress)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,29 +1,14 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("trackScanInteractively", func() {
|
||||
It("reports changes and scan errors", func() {
|
||||
progress := make(chan *scanner.ProgressInfo, 2)
|
||||
progress <- &scanner.ProgressInfo{ChangesDetected: true}
|
||||
progress <- &scanner.ProgressInfo{Error: "scan failed"}
|
||||
close(progress)
|
||||
|
||||
changesDetected, err := trackScanInteractively(context.Background(), progress)
|
||||
Expect(changesDetected).To(BeTrue())
|
||||
Expect(err).To(MatchError("scan failed"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("readTargetsFromFile", func() {
|
||||
var tempDir string
|
||||
|
||||
|
||||
@ -4,8 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
@ -15,11 +13,6 @@ import (
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
)
|
||||
|
||||
// newTabWriter keeps every CLI table on the same column settings.
|
||||
func newTabWriter(out io.Writer) *tabwriter.Writer {
|
||||
return tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||
}
|
||||
|
||||
func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
|
||||
sqlDB := db.Db()
|
||||
ds := persistence.New(sqlDB)
|
||||
|
||||
134
cmd/wire_gen.go
134
cmd/wire_gen.go
@ -31,7 +31,6 @@ import (
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/jellyfin"
|
||||
"github.com/navidrome/navidrome/server/nativeapi"
|
||||
"github.com/navidrome/navidrome/server/public"
|
||||
"github.com/navidrome/navidrome/server/subsonic"
|
||||
@ -65,21 +64,25 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
share := core.NewShare(dataStore)
|
||||
uploader := artwork.NewUploader(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||
imageUploadService := core.NewImageUploadService()
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||
insights := metrics.GetInstance(dataStore)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
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)
|
||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
|
||||
user := core.NewUser(dataStore, manager)
|
||||
maintenance := core.NewMaintenance(dataStore)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
||||
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider)
|
||||
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, imageUploadService)
|
||||
return router
|
||||
}
|
||||
|
||||
@ -87,23 +90,23 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
imageStore := artwork.GetImageStore()
|
||||
fFmpeg := ffmpeg.New()
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
||||
transcodingCache := stream.GetTranscodingCache()
|
||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
share := core.NewShare(dataStore)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
players := core.NewPlayers(dataStore)
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
||||
uploader := artwork.NewUploader(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
transcodingCache := stream.GetTranscodingCache()
|
||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
share := core.NewShare(dataStore)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
players := core.NewPlayers(dataStore)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
imageUploadService := core.NewImageUploadService()
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
||||
@ -113,39 +116,18 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
return router
|
||||
}
|
||||
|
||||
func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
imageStore := artwork.GetImageStore()
|
||||
fFmpeg := ffmpeg.New()
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
||||
transcodingCache := stream.GetTranscodingCache()
|
||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
||||
players := core.NewPlayers(dataStore)
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
uploader := artwork.NewUploader(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
||||
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
||||
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker)
|
||||
return router
|
||||
}
|
||||
|
||||
func CreatePublicRouter() *public.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
imageStore := artwork.GetImageStore()
|
||||
fFmpeg := ffmpeg.New()
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
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)
|
||||
share := core.NewShare(dataStore)
|
||||
@ -185,22 +167,38 @@ func CreatePrometheus() metrics.Metrics {
|
||||
func CreateScanner(ctx context.Context) model.Scanner {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
broker := events.GetBroker()
|
||||
uploader := artwork.NewUploader(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
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()
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
return modelScanner
|
||||
}
|
||||
|
||||
func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
broker := events.GetBroker()
|
||||
uploader := artwork.NewUploader(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
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()
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||
return watcher
|
||||
}
|
||||
@ -212,32 +210,6 @@ func GetPlaybackServer() playback.PlaybackServer {
|
||||
return playbackServer
|
||||
}
|
||||
|
||||
func CreateArtworkWorker() *artwork.Worker {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
imageStore := artwork.GetImageStore()
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
fFmpeg := ffmpeg.New()
|
||||
fileCache := artwork.GetImageCache()
|
||||
worker := artwork.NewWorker(dataStore, imageStore, agentsAgents, fFmpeg, broker, fileCache)
|
||||
return worker
|
||||
}
|
||||
|
||||
func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
fFmpeg := ffmpeg.New()
|
||||
tracingResolver := artwork.NewTracingResolver(dataStore, agentsAgents, fFmpeg, trace, live)
|
||||
return tracingResolver
|
||||
}
|
||||
|
||||
func getPluginManager() *plugins.Manager {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
@ -249,7 +221,7 @@ func getPluginManager() *plugins.Manager {
|
||||
|
||||
// wire_injectors.go:
|
||||
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.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(sonic.Engine), new(*sonic.Sonic)), 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)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
|
||||
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()
|
||||
|
||||
@ -14,7 +14,6 @@ import (
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/sonic"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
@ -24,7 +23,6 @@ import (
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/jellyfin"
|
||||
"github.com/navidrome/navidrome/server/nativeapi"
|
||||
"github.com/navidrome/navidrome/server/public"
|
||||
"github.com/navidrome/navidrome/server/subsonic"
|
||||
@ -35,7 +33,6 @@ var allProviders = wire.NewSet(
|
||||
artwork.Set,
|
||||
server.New,
|
||||
subsonic.New,
|
||||
jellyfin.New,
|
||||
nativeapi.New,
|
||||
public.New,
|
||||
persistence.New,
|
||||
@ -52,12 +49,10 @@ var allProviders = wire.NewSet(
|
||||
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(sonic.Engine), new(*sonic.Sonic)),
|
||||
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)),
|
||||
wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)),
|
||||
)
|
||||
|
||||
func CreateDataStore() model.DataStore {
|
||||
@ -84,12 +79,6 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
))
|
||||
}
|
||||
|
||||
func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
||||
func CreatePublicRouter() *public.Router {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
@ -138,19 +127,6 @@ func GetPlaybackServer() playback.PlaybackServer {
|
||||
))
|
||||
}
|
||||
|
||||
func CreateArtworkWorker() *artwork.Worker {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
||||
func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
artwork.NewTracingResolver,
|
||||
))
|
||||
}
|
||||
|
||||
func getPluginManager() *plugins.Manager {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
|
||||
@ -2,19 +2,14 @@ package conf
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
@ -26,12 +21,11 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/scheduler"
|
||||
"github.com/navidrome/navidrome/utils/run"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type configOptions struct {
|
||||
ConfigFile string `conf:"-"`
|
||||
ConfigFile string
|
||||
Address string
|
||||
Port int
|
||||
UnixSocketPerm string
|
||||
@ -57,7 +51,6 @@ type configOptions struct {
|
||||
EnableExternalServices bool
|
||||
EnableM3UExternalAlbumArt bool
|
||||
EnableInsightsCollector bool
|
||||
EnableScheduledDBAnalyze bool
|
||||
EnableMediaFileCoverArt bool
|
||||
TranscodingCacheSize string
|
||||
ImageCacheSize string
|
||||
@ -73,7 +66,6 @@ type configOptions struct {
|
||||
Matcher matcherOptions `json:",omitzero"`
|
||||
RecentlyAddedByModTime bool
|
||||
PreferSortTags bool
|
||||
EnableNaturalSorting bool
|
||||
IgnoredArticles string
|
||||
IndexGroups string
|
||||
FFmpegPath string
|
||||
@ -92,7 +84,6 @@ type configOptions struct {
|
||||
EnableUserEditing bool
|
||||
EnableArtworkUpload bool
|
||||
MaxImageUploadSize string
|
||||
MaxImageSize string
|
||||
EnableSharing bool
|
||||
ShareURL string
|
||||
DefaultShareExpiration time.Duration
|
||||
@ -125,7 +116,6 @@ type configOptions struct {
|
||||
LastFM lastfmOptions `json:",omitzero"`
|
||||
Deezer deezerOptions `json:",omitzero"`
|
||||
ListenBrainz listenBrainzOptions `json:",omitzero"`
|
||||
Jellyfin jellyfinOptions `json:",omitzero"`
|
||||
EnableScrobbleHistory bool
|
||||
Tags map[string]TagConf `json:",omitempty"`
|
||||
Agents string
|
||||
@ -147,8 +137,6 @@ type configOptions struct {
|
||||
DevArtworkThrottleBacklogLimit int
|
||||
DevArtworkThrottleBacklogTimeout time.Duration
|
||||
DevArtworkThrottleBuffered bool
|
||||
DevArtworkWorkerConcurrency int
|
||||
DevArtworkExternalMaxRPS int
|
||||
DevArtistInfoTimeToLive time.Duration
|
||||
DevAlbumInfoTimeToLive time.Duration
|
||||
DevExternalScanner bool
|
||||
@ -159,6 +147,7 @@ type configOptions struct {
|
||||
DevEnablePluginsInsights bool
|
||||
DevPluginCompilationTimeout time.Duration
|
||||
DevExternalArtistFetchMultiplier float64
|
||||
DevOptimizeDB bool
|
||||
DevPreserveUnicodeInExternalCalls bool
|
||||
DevEnableMediaFileProbe bool
|
||||
}
|
||||
@ -211,7 +200,7 @@ type lastfmOptions struct {
|
||||
ScrobbleFirstArtistOnly bool
|
||||
|
||||
// Computed values
|
||||
Languages []string `conf:"-"` // Computed from Language, split by comma
|
||||
Languages []string // Computed from Language, split by comma
|
||||
}
|
||||
|
||||
type deezerOptions struct {
|
||||
@ -219,7 +208,7 @@ type deezerOptions struct {
|
||||
Language string
|
||||
|
||||
// Computed values
|
||||
Languages []string `conf:"-"` // Computed from Language, split by comma
|
||||
Languages []string // Computed from Language, split by comma
|
||||
}
|
||||
|
||||
type listenBrainzOptions struct {
|
||||
@ -229,18 +218,6 @@ type listenBrainzOptions struct {
|
||||
TrackAlgorithm string
|
||||
}
|
||||
|
||||
type jellyfinOptions struct {
|
||||
Enabled bool
|
||||
ServerName string
|
||||
// ExposedPublicUsers is a comma-separated list of usernames to advertise on the unauthenticated
|
||||
// GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users.
|
||||
ExposedPublicUsers string
|
||||
// MaxConcurrentStreams bounds how many collection responses can stream at once. Each holds a DB
|
||||
// cursor — and its pooled connection — for the whole client-paced response, so without a bound
|
||||
// enough slow clients would take the entire pool and stall the scanner, scrobbles and the UI.
|
||||
MaxConcurrentStreams int
|
||||
}
|
||||
|
||||
type httpHeaderOptions struct {
|
||||
FrameOptions string
|
||||
}
|
||||
@ -350,11 +327,12 @@ func Load(noConfigDump bool) {
|
||||
remapEnvVarKeysFromConfig()
|
||||
|
||||
// Map deprecated options to their new names for backwards compatibility
|
||||
for _, o := range deprecatedOptions {
|
||||
if o.replacement != "" {
|
||||
mapDeprecatedOption(o.name, o.replacement)
|
||||
}
|
||||
}
|
||||
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, viper.DecodeHook(
|
||||
mapstructure.ComposeDecodeHookFunc(
|
||||
@ -412,20 +390,12 @@ func Load(noConfigDump bool) {
|
||||
log.SetLogSourceLine(Server.DevLogSourceLine)
|
||||
log.SetRedacting(Server.EnableLogRedacting)
|
||||
|
||||
// Log deprecated, removed and unknown options
|
||||
for _, o := range deprecatedOptions {
|
||||
logDeprecatedOptions(o.name, o.replacement)
|
||||
}
|
||||
logRemovedOptions(removedOptions...)
|
||||
logUnknownOptions()
|
||||
|
||||
err = run.Sequentially(
|
||||
validateScanSchedule,
|
||||
validateBackupSchedule,
|
||||
validatePlaylistsPath,
|
||||
validatePurgeMissingOption,
|
||||
validateByteSize("MaxImageUploadSize", Server.MaxImageUploadSize),
|
||||
validateByteSize("MaxImageSize", Server.MaxImageSize),
|
||||
validateMaxImageUploadSize,
|
||||
validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
|
||||
)
|
||||
if err != nil {
|
||||
@ -478,6 +448,21 @@ func Load(noConfigDump bool) {
|
||||
// Parse Deezer.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
|
||||
Server.Deezer.Languages = parseLanguages(Server.Deezer.Language)
|
||||
|
||||
// Deprecated options
|
||||
logDeprecatedOptions("Scanner.GenreSeparators", "")
|
||||
logDeprecatedOptions("Scanner.GroupAlbumReleases", "")
|
||||
logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
|
||||
logDeprecatedOptions("SearchFullString", "Search.FullString")
|
||||
logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
||||
logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality")
|
||||
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))
|
||||
@ -485,40 +470,15 @@ func Load(noConfigDump bool) {
|
||||
Server.UICoverArtSize = newValue
|
||||
}
|
||||
|
||||
// Floor MaxImageSize at MaxImageUploadSize so accepted uploads can always be read back.
|
||||
imgSize, _ := humanize.ParseBytes(Server.MaxImageSize)
|
||||
uploadSize, _ := humanize.ParseBytes(Server.MaxImageUploadSize)
|
||||
if imgSize < uploadSize {
|
||||
log.Warn("MaxImageSize must be at least MaxImageUploadSize, raising", "value", Server.MaxImageSize, "newValue", Server.MaxImageUploadSize)
|
||||
Server.MaxImageSize = Server.MaxImageUploadSize
|
||||
}
|
||||
|
||||
// Call init hooks
|
||||
for _, hook := range hooks {
|
||||
hook()
|
||||
}
|
||||
}
|
||||
|
||||
// deprecatedOptions still work, but will be removed in a future release. An empty
|
||||
// replacement means the option is now ignored.
|
||||
var deprecatedOptions = []struct{ name, replacement string }{
|
||||
{"Scanner.GenreSeparators", ""},
|
||||
{"Scanner.GroupAlbumReleases", ""},
|
||||
{"DevEnableBufferedScrobble", ""},
|
||||
{"SearchFullString", "Search.FullString"},
|
||||
{"ReverseProxyWhitelist", "ExtAuth.TrustedSources"},
|
||||
{"ReverseProxyUserHeader", "ExtAuth.UserHeader"},
|
||||
{"HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions"},
|
||||
{"CoverJpegQuality", "CoverArtQuality"},
|
||||
{"SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold"},
|
||||
{"EnableTranscodingCancellation", "Transcoding.EnableCancellation"},
|
||||
}
|
||||
|
||||
var removedOptions = []string{"Spotify.ID", "Spotify.Secret"}
|
||||
|
||||
func logDeprecatedOptions(oldName, newName string) {
|
||||
envVar := envVarName(oldName)
|
||||
newEnvVar := envVarName(newName)
|
||||
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(oldName, ".", "_"))
|
||||
newEnvVar := "ND_" + strings.ToUpper(strings.ReplaceAll(newName, ".", "_"))
|
||||
logWarning := func(oldName, newName string) {
|
||||
if newName != "" {
|
||||
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release. Please use the new '%s'", oldName, newName))
|
||||
@ -538,7 +498,7 @@ func logDeprecatedOptions(oldName, newName string) {
|
||||
// not available anymore
|
||||
func logRemovedOptions(options ...string) {
|
||||
for _, option := range options {
|
||||
envVar := envVarName(option)
|
||||
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_"))
|
||||
logWarning := func(option string) {
|
||||
log.Warn(fmt.Sprintf("Option '%s' is not available anymore and will be ignored. Please remove it from your config", option))
|
||||
}
|
||||
@ -559,193 +519,35 @@ func remapEnvVarKeysFromConfig() {
|
||||
continue
|
||||
}
|
||||
stripped := strings.TrimPrefix(key, "nd_")
|
||||
canonicalKey := ndKeyToCanonical(key)
|
||||
canonicalKey := strings.ReplaceAll(stripped, "_", ".")
|
||||
displayNDKey := "ND_" + strings.ToUpper(stripped)
|
||||
canonicalName := canonicalOptionName(canonicalKey)
|
||||
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, cmp.Or(canonicalName, toPascalCase(canonicalKey)),
|
||||
displayNDKey, displayCanonical,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
viper.Set(canonicalKey, viper.Get(key))
|
||||
// Unknown keys get no advice here, logUnknownOptions reports them instead
|
||||
if canonicalName != "" {
|
||||
_, _ = 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, canonicalName,
|
||||
)
|
||||
}
|
||||
_, _ = 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) {
|
||||
// viper.Set outranks the config file, so an explicit replacement must win over the legacy value
|
||||
if viper.IsSet(legacyName) && !explicitlySet(newName) {
|
||||
if viper.IsSet(legacyName) {
|
||||
viper.Set(newName, viper.Get(legacyName))
|
||||
}
|
||||
}
|
||||
|
||||
// explicitlySet reports whether the user provided the option, ignoring defaults,
|
||||
// which viper.IsSet counts as set. The ND_ spelling is also accepted in the config
|
||||
// file, and remapEnvVarKeysFromConfig has already moved it out of InConfig's reach.
|
||||
func explicitlySet(name string) bool {
|
||||
envVar := envVarName(name)
|
||||
return viper.InConfig(name) || os.Getenv(envVar) != "" || viper.InConfig(strings.ToLower(envVar))
|
||||
}
|
||||
|
||||
func envVarName(option string) string {
|
||||
if option == "" {
|
||||
return ""
|
||||
}
|
||||
return "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_"))
|
||||
}
|
||||
|
||||
func logUnknownOptions() {
|
||||
for _, key := range unknownConfigKeys() {
|
||||
msg := fmt.Sprintf("Option '%s' is not recognized and will be ignored", key)
|
||||
if matches := suggestOptions(key); len(matches) > 0 {
|
||||
msg += fmt.Sprintf(". Did you mean '%s'?", strings.Join(matches, "' or '"))
|
||||
}
|
||||
log.Warn(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// suggestOptions returns the known options sharing the last segment with key,
|
||||
// catching options written outside their section.
|
||||
func suggestOptions(key string) []string {
|
||||
key = strings.ToLower(key)
|
||||
leaf := leafKey(key)
|
||||
canonical, _ := configKeys()
|
||||
var matches []string
|
||||
for known, name := range canonical {
|
||||
// Removed options are known only so they get their own warning, never suggest them
|
||||
if known != key && leafKey(known) == leaf && !slices.Contains(removedOptions, name) {
|
||||
matches = append(matches, name)
|
||||
}
|
||||
}
|
||||
slices.Sort(matches)
|
||||
return matches
|
||||
}
|
||||
|
||||
func leafKey(key string) string {
|
||||
return key[strings.LastIndex(key, ".")+1:]
|
||||
}
|
||||
|
||||
// unknownConfigKeys returns config file keys that don't match any known option, so
|
||||
// typos and options written outside their section don't fail silently.
|
||||
func unknownConfigKeys() []string {
|
||||
// INI files keep the original [default] section alongside the merged one
|
||||
skipDefault := strings.EqualFold(filepath.Ext(viper.ConfigFileUsed()), ".ini")
|
||||
|
||||
var unknown []string
|
||||
for _, key := range viper.AllKeys() {
|
||||
if !viper.InConfig(key) || canonicalOptionName(key) != "" {
|
||||
continue
|
||||
}
|
||||
if skipDefault && strings.HasPrefix(key, "default.") {
|
||||
continue
|
||||
}
|
||||
// Only ND_-prefixed keys that remapEnvVarKeysFromConfig could resolve are valid
|
||||
if strings.HasPrefix(key, "nd_") && canonicalOptionName(ndKeyToCanonical(key)) != "" {
|
||||
continue
|
||||
}
|
||||
unknown = append(unknown, key)
|
||||
}
|
||||
slices.Sort(unknown)
|
||||
return asWrittenInConfigFile(unknown)
|
||||
}
|
||||
|
||||
func ndKeyToCanonical(key string) string {
|
||||
return strings.ReplaceAll(strings.TrimPrefix(key, "nd_"), "_", ".")
|
||||
}
|
||||
|
||||
// canonicalOptionName returns the documented spelling of a known option key, or ""
|
||||
// if it matches no option. Subkeys of free-form maps have no fixed spelling.
|
||||
func canonicalOptionName(key string) string {
|
||||
keys, prefixes := configKeys()
|
||||
if name, ok := keys[key]; ok {
|
||||
return name
|
||||
}
|
||||
if slices.ContainsFunc(prefixes, func(p string) bool { return strings.HasPrefix(key, p) }) {
|
||||
return toPascalCase(key)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// asWrittenInConfigFile restores the casing the keys have in the config file, as
|
||||
// viper lowercases every key it loads.
|
||||
func asWrittenInConfigFile(keys []string) []string {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(viper.ConfigFileUsed())
|
||||
if err != nil {
|
||||
return keys
|
||||
}
|
||||
casing := map[string]string{}
|
||||
for _, match := range configFileKeyRx.FindAllStringSubmatch(string(data), -1) {
|
||||
for segment := range strings.SplitSeq(match[1], ".") {
|
||||
lower := strings.ToLower(segment)
|
||||
casing[lower] = cmp.Or(casing[lower], segment)
|
||||
}
|
||||
}
|
||||
return slice.Map(keys, func(key string) string {
|
||||
segments := strings.Split(key, ".")
|
||||
for i, s := range segments {
|
||||
segments[i] = cmp.Or(casing[s], s)
|
||||
}
|
||||
return strings.Join(segments, ".")
|
||||
})
|
||||
}
|
||||
|
||||
// Matches keys and section headers in all supported config formats.
|
||||
var configFileKeyRx = regexp.MustCompile(`(?m)^\s*\[?\s*"?([\w.]+)"?\s*[]=:]`)
|
||||
|
||||
// configKeys maps every accepted option name, lowercased, to its canonical spelling,
|
||||
// plus the prefixes of free-form map options (Tags, DevLogLevels).
|
||||
var configKeys = sync.OnceValues(func() (map[string]string, []string) {
|
||||
keys := map[string]string{}
|
||||
var prefixes []string
|
||||
|
||||
var collect func(t reflect.Type, prefix string)
|
||||
collect = func(t reflect.Type, prefix string) {
|
||||
for field := range t.Fields() {
|
||||
// `conf:"-"` marks values computed during Load, not settable in the config
|
||||
if !field.IsExported() || field.Tag.Get("conf") == "-" {
|
||||
continue
|
||||
}
|
||||
name := prefix + field.Name
|
||||
if field.Type.Kind() == reflect.Struct && !reflect.PointerTo(field.Type).Implements(textUnmarshalerType) {
|
||||
collect(field.Type, name+".")
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(name)
|
||||
keys[lower] = name
|
||||
if field.Type.Kind() == reflect.Map {
|
||||
prefixes = append(prefixes, lower+".")
|
||||
}
|
||||
}
|
||||
}
|
||||
collect(reflect.TypeFor[configOptions](), "")
|
||||
|
||||
for _, o := range deprecatedOptions {
|
||||
keys[strings.ToLower(o.name)] = o.name
|
||||
}
|
||||
for _, o := range removedOptions {
|
||||
keys[strings.ToLower(o)] = o
|
||||
}
|
||||
return keys, prefixes
|
||||
})
|
||||
|
||||
var textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]()
|
||||
|
||||
// parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it
|
||||
// would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default]
|
||||
// section into the root level.
|
||||
@ -818,20 +620,11 @@ func validatePurgeMissingOption() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateByteSize(name, value string) func() error {
|
||||
return func() error {
|
||||
size, err := humanize.ParseBytes(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid %s %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", name, value, err)
|
||||
}
|
||||
if size == 0 {
|
||||
return fmt.Errorf("invalid %s %q: must be greater than zero", name, value)
|
||||
}
|
||||
if size > math.MaxInt64 {
|
||||
return fmt.Errorf("invalid %s %q: value is too large", name, value)
|
||||
}
|
||||
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 {
|
||||
@ -974,7 +767,6 @@ func setViperDefaults() {
|
||||
viper.SetDefault("matcher.fuzzythreshold", 85)
|
||||
viper.SetDefault("recentlyaddedbymodtime", false)
|
||||
viper.SetDefault("prefersorttags", false)
|
||||
viper.SetDefault("enablenaturalsorting", 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", "")
|
||||
@ -1002,14 +794,12 @@ func setViperDefaults() {
|
||||
viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval)
|
||||
viper.SetDefault("enableartworkupload", true)
|
||||
viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize)
|
||||
viper.SetDefault("maximagesize", consts.DefaultMaxImageSize)
|
||||
viper.SetDefault("enablesharing", true)
|
||||
viper.SetDefault("shareurl", "")
|
||||
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
|
||||
viper.SetDefault("defaultdownloadableshare", false)
|
||||
viper.SetDefault("gatrackingid", "")
|
||||
viper.SetDefault("enableinsightscollector", true)
|
||||
viper.SetDefault("enablescheduleddbanalyze", true)
|
||||
viper.SetDefault("enablelogredacting", true)
|
||||
viper.SetDefault("authrequestlimit", 5)
|
||||
viper.SetDefault("authwindowlength", 20*time.Second)
|
||||
@ -1058,8 +848,6 @@ func setViperDefaults() {
|
||||
viper.SetDefault("listenbrainz.baseurl", consts.DefaultListenBrainzBaseURL)
|
||||
viper.SetDefault("listenbrainz.artistalgorithm", consts.DefaultListenBrainzArtistAlgorithm)
|
||||
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
|
||||
viper.SetDefault("jellyfin.enabled", false)
|
||||
viper.SetDefault("jellyfin.servername", "")
|
||||
viper.SetDefault("enablescrobblehistory", true)
|
||||
viper.SetDefault("httpheaders.frameoptions", "DENY")
|
||||
viper.SetDefault("backup.path", "")
|
||||
@ -1089,19 +877,10 @@ func setViperDefaults() {
|
||||
viper.SetDefault("devuishowconfig", true)
|
||||
viper.SetDefault("devneweventstream", true)
|
||||
viper.SetDefault("devoffsetoptimize", 50000)
|
||||
// Half the pool: streams may take up to this many connections, leaving the rest for the scanner,
|
||||
// scrobbles and the UI. See MaxOpenConns.
|
||||
viper.SetDefault("jellyfin.maxconcurrentstreams", max(2, MaxOpenConns()/2))
|
||||
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2))
|
||||
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
||||
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||
viper.SetDefault("devartworkthrottlebuffered", true)
|
||||
// Half the CPU count (min 2), so local resolution scales with the host but stays under the
|
||||
// SQLite pool (MaxOpenConns) — leaving connections for the scanner, scrobbles and the UI.
|
||||
viper.SetDefault("devartworkworkerconcurrency", max(2, runtime.NumCPU()/2))
|
||||
// External RPS gates outbound calls to third-party services (per service); it is bounded by
|
||||
// their tolerance, not the host, so it stays a small constant regardless of CPU count.
|
||||
viper.SetDefault("devartworkexternalmaxrps", 2)
|
||||
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
|
||||
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
|
||||
viper.SetDefault("devexternalscanner", true)
|
||||
@ -1112,6 +891,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("devenablepluginsinsights", true)
|
||||
viper.SetDefault("devplugincompilationtimeout", time.Minute)
|
||||
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
|
||||
viper.SetDefault("devoptimizedb", true)
|
||||
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
|
||||
viper.SetDefault("devenablemediafileprobe", true)
|
||||
}
|
||||
@ -1168,14 +948,3 @@ func getConfigFile(cfgFile string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// MaxOpenConns is the size of the shared SQLite connection pool, used by every subsystem (scanner,
|
||||
// Subsonic, Jellyfin, native API, UI).
|
||||
//
|
||||
// It bounds concurrent *readers*: SQLite serializes writers on a single database-wide write lock, so
|
||||
// more connections buy no write parallelism. A connection is held while blocked on disk I/O or on a
|
||||
// slow HTTP client, neither of which is CPU-bound — the CPU-bound knob is DevScannerThreads — so the
|
||||
// count is only loosely related to core count, and the floor is what matters on small machines.
|
||||
func MaxOpenConns() int {
|
||||
return max(4, runtime.NumCPU())
|
||||
}
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
package conf_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/spf13/viper"
|
||||
@ -60,19 +58,6 @@ var _ = Describe("Configuration", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("scheduled DB analysis", func() {
|
||||
It("is enabled by default", func() {
|
||||
conf.Load(true)
|
||||
Expect(conf.Server.EnableScheduledDBAnalyze).To(BeTrue())
|
||||
})
|
||||
|
||||
It("can be disabled", func() {
|
||||
viper.Set("enablescheduleddbanalyze", false)
|
||||
conf.Load(true)
|
||||
Expect(conf.Server.EnableScheduledDBAnalyze).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ValidateURL", func() {
|
||||
It("accepts a valid http URL", func() {
|
||||
fn := conf.ValidateURL("TestOption", "http://example.com/path")
|
||||
@ -180,123 +165,6 @@ var _ = Describe("Configuration", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("unknownConfigKeys", func() {
|
||||
BeforeEach(func() {
|
||||
viper.Reset()
|
||||
conf.SetViperDefaults()
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("loglevel", "error")
|
||||
conf.ResetConf()
|
||||
})
|
||||
|
||||
It("reports misplaced and misspelled options, as spelled in the config file", func() {
|
||||
conf.InitConfig(filepath.Join("testdata", "cfg_unknown_keys.toml"), false)
|
||||
conf.Load(true)
|
||||
|
||||
Expect(conf.UnknownConfigKeys()).To(ConsistOf(
|
||||
"ArtistSplitExceptions", "EnableDownlods", "Whatever.Foo",
|
||||
))
|
||||
})
|
||||
|
||||
DescribeTable("recovers the original casing in all supported formats",
|
||||
func(file string) {
|
||||
conf.InitConfig(filepath.Join("testdata", file), false)
|
||||
conf.Load(true)
|
||||
|
||||
Expect(conf.UnknownConfigKeys()).To(ConsistOf("NotAnOption"))
|
||||
},
|
||||
Entry("TOML", "cfg_unknown_casing.toml"),
|
||||
Entry("YAML", "cfg_unknown_casing.yaml"),
|
||||
Entry("JSON", "cfg_unknown_casing.json"),
|
||||
Entry("INI", "cfg_unknown_casing.ini"),
|
||||
)
|
||||
|
||||
It("does not report valid, deprecated or free-form keys", func() {
|
||||
conf.InitConfig(filepath.Join("testdata", "cfg.toml"), false)
|
||||
conf.Load(true)
|
||||
|
||||
Expect(conf.UnknownConfigKeys()).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("does not report the [default] section of INI files", func() {
|
||||
conf.InitConfig(filepath.Join("testdata", "cfg.ini"), false)
|
||||
conf.Load(true)
|
||||
|
||||
Expect(conf.UnknownConfigKeys()).To(BeEmpty())
|
||||
})
|
||||
|
||||
DescribeTable("SuggestOptions",
|
||||
func(key string, expected []string) {
|
||||
Expect(conf.SuggestOptions(key)).To(Equal(expected))
|
||||
},
|
||||
Entry("suggests the section of a misplaced option", "artistsplitexceptions",
|
||||
[]string{"Scanner.ArtistSplitExceptions"}),
|
||||
Entry("suggests the section of a misplaced nested option", "backup.fuzzythreshold",
|
||||
[]string{"Matcher.FuzzyThreshold"}),
|
||||
Entry("suggests every section defining the option", "schedule",
|
||||
[]string{"Backup.Schedule", "Scanner.Schedule"}),
|
||||
Entry("suggests nothing for a typo", "enabledownlods", nil),
|
||||
)
|
||||
|
||||
It("does not report ND_-prefixed keys, as they are remapped", func() {
|
||||
conf.InitConfig(filepath.Join("testdata", "cfg_nd_keys.toml"), false)
|
||||
conf.Load(true)
|
||||
|
||||
Expect(conf.UnknownConfigKeys()).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("reports ND_-prefixed keys that remap to no known option", func() {
|
||||
conf.InitConfig(filepath.Join("testdata", "cfg_nd_bogus.toml"), false)
|
||||
conf.Load(true)
|
||||
|
||||
Expect(conf.UnknownConfigKeys()).To(ConsistOf("ND_TOTALLY_BOGUS_OPTION"))
|
||||
Expect(conf.Server.Scanner.Schedule).To(Equal("@every 1h"))
|
||||
})
|
||||
|
||||
It("migrates every deprecated option that has a replacement", func() {
|
||||
conf.InitConfig(filepath.Join("testdata", "cfg_deprecated_search.toml"), false)
|
||||
conf.Load(true)
|
||||
|
||||
Expect(conf.Server.Search.FullString).To(BeTrue())
|
||||
Expect(conf.UnknownConfigKeys()).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("warns about each unrecognized option at startup", func() {
|
||||
var logBuf bytes.Buffer
|
||||
log.SetOutput(&logBuf)
|
||||
DeferCleanup(func() { log.SetOutput(GinkgoWriter) })
|
||||
|
||||
conf.InitConfig(filepath.Join("testdata", "cfg_warning_output.toml"), false)
|
||||
conf.Load(true)
|
||||
|
||||
Expect(logBuf.String()).To(ContainSubstring(
|
||||
"Option 'ArtistSplitExceptions' is not recognized and will be ignored. " +
|
||||
"Did you mean 'Scanner.ArtistSplitExceptions'?"))
|
||||
Expect(logBuf.String()).To(ContainSubstring(
|
||||
"Option 'EnableDownlods' is not recognized and will be ignored"))
|
||||
Expect(logBuf.String()).ToNot(ContainSubstring("ArtistJoiner"))
|
||||
})
|
||||
|
||||
Context("with runtime-computed and removed options in the config", func() {
|
||||
BeforeEach(func() {
|
||||
conf.InitConfig(filepath.Join("testdata", "cfg_runtime_fields.toml"), false)
|
||||
conf.Load(true)
|
||||
})
|
||||
|
||||
It("reports values computed during Load, which the config cannot set", func() {
|
||||
Expect(conf.UnknownConfigKeys()).To(ContainElements("ConfigFile", "LastFM.Languages"))
|
||||
})
|
||||
|
||||
It("never suggests a removed option", func() {
|
||||
Expect(conf.SuggestOptions("id")).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("keeps an explicit replacement over the deprecated value", func() {
|
||||
Expect(conf.Server.Search.FullString).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("logFatal", func() {
|
||||
var invalidPath string
|
||||
BeforeEach(func() {
|
||||
@ -336,10 +204,19 @@ var _ = Describe("Configuration", func() {
|
||||
|
||||
})
|
||||
|
||||
Describe("ValidateByteSize", func() {
|
||||
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) {
|
||||
Expect(conf.ValidateByteSize("MaxImageSize", input)()).To(Succeed())
|
||||
conf.Server.MaxImageUploadSize = input
|
||||
Expect(conf.ValidateMaxImageUploadSize()).To(Succeed())
|
||||
},
|
||||
Entry("megabytes", "10MB"),
|
||||
Entry("gigabytes", "1GB"),
|
||||
@ -350,39 +227,14 @@ var _ = Describe("Configuration", func() {
|
||||
|
||||
DescribeTable("rejects invalid size values",
|
||||
func(input string) {
|
||||
Expect(conf.ValidateByteSize("MaxImageSize", input)()).To(MatchError(ContainSubstring("invalid MaxImageSize")))
|
||||
conf.Server.MaxImageUploadSize = input
|
||||
Expect(conf.ValidateMaxImageUploadSize()).To(MatchError(ContainSubstring("invalid MaxImageUploadSize")))
|
||||
},
|
||||
Entry("garbage string", "not-a-size"),
|
||||
Entry("negative-looking", "-10MB"),
|
||||
Entry("zero", "0"),
|
||||
Entry("zero with unit", "0MB"),
|
||||
Entry("overflows int64", "9223372036854775808"),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("MaxImageSize floor", func() {
|
||||
BeforeEach(func() {
|
||||
viper.Reset()
|
||||
conf.SetViperDefaults()
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("loglevel", "error")
|
||||
conf.ResetConf()
|
||||
})
|
||||
|
||||
It("is raised to MaxImageUploadSize when configured lower", func() {
|
||||
viper.SetDefault("maximagesize", "5MB")
|
||||
viper.SetDefault("maximageuploadsize", "50MB")
|
||||
conf.Load(true)
|
||||
Expect(conf.Server.MaxImageSize).To(Equal("50MB"))
|
||||
})
|
||||
|
||||
It("keeps a larger MaxImageSize unchanged", func() {
|
||||
viper.SetDefault("maximagesize", "30MB")
|
||||
conf.Load(true)
|
||||
Expect(conf.Server.MaxImageSize).To(Equal("30MB"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("EnforceNonRootUser", func() {
|
||||
It("defaults to false", func() {
|
||||
conf.Load(true)
|
||||
|
||||
@ -14,7 +14,7 @@ var NormalizeSearchBackend = normalizeSearchBackend
|
||||
|
||||
var ToPascalCase = toPascalCase
|
||||
|
||||
var ValidateByteSize = validateByteSize
|
||||
var ValidateMaxImageUploadSize = validateMaxImageUploadSize
|
||||
|
||||
func SetRuntimeInfoForTest(goos string, euid int) func() {
|
||||
oldGOOS := currentGOOS
|
||||
@ -32,7 +32,3 @@ func SetLogFatal(f func(...any)) func() {
|
||||
logFatal = f
|
||||
return func() { logFatal = old }
|
||||
}
|
||||
|
||||
var UnknownConfigKeys = unknownConfigKeys
|
||||
|
||||
var SuggestOptions = suggestOptions
|
||||
|
||||
2
conf/testdata/cfg_deprecated_search.toml
vendored
2
conf/testdata/cfg_deprecated_search.toml
vendored
@ -1,2 +0,0 @@
|
||||
MusicFolder = "/toml/music"
|
||||
SearchFullString = true
|
||||
3
conf/testdata/cfg_nd_bogus.toml
vendored
3
conf/testdata/cfg_nd_bogus.toml
vendored
@ -1,3 +0,0 @@
|
||||
MusicFolder = "/toml/music"
|
||||
ND_TOTALLY_BOGUS_OPTION = true
|
||||
ND_SCANNER_SCHEDULE = "@every 1h"
|
||||
10
conf/testdata/cfg_runtime_fields.toml
vendored
10
conf/testdata/cfg_runtime_fields.toml
vendored
@ -1,10 +0,0 @@
|
||||
MusicFolder = "/toml/music"
|
||||
SearchFullString = true
|
||||
ConfigFile = "/somewhere/else"
|
||||
ID = "oops"
|
||||
|
||||
[Search]
|
||||
FullString = false
|
||||
|
||||
[LastFM]
|
||||
Languages = ["pt"]
|
||||
3
conf/testdata/cfg_unknown_casing.ini
vendored
3
conf/testdata/cfg_unknown_casing.ini
vendored
@ -1,3 +0,0 @@
|
||||
[default]
|
||||
MusicFolder = /ini/music
|
||||
NotAnOption = true
|
||||
4
conf/testdata/cfg_unknown_casing.json
vendored
4
conf/testdata/cfg_unknown_casing.json
vendored
@ -1,4 +0,0 @@
|
||||
{
|
||||
"MusicFolder": "/json/music",
|
||||
"NotAnOption": true
|
||||
}
|
||||
2
conf/testdata/cfg_unknown_casing.toml
vendored
2
conf/testdata/cfg_unknown_casing.toml
vendored
@ -1,2 +0,0 @@
|
||||
MusicFolder = "/toml/music"
|
||||
NotAnOption = true
|
||||
2
conf/testdata/cfg_unknown_casing.yaml
vendored
2
conf/testdata/cfg_unknown_casing.yaml
vendored
@ -1,2 +0,0 @@
|
||||
MusicFolder: /yaml/music
|
||||
NotAnOption: true
|
||||
18
conf/testdata/cfg_unknown_keys.toml
vendored
18
conf/testdata/cfg_unknown_keys.toml
vendored
@ -1,18 +0,0 @@
|
||||
MusicFolder = "/toml/music"
|
||||
|
||||
# Valid option, but written at the root level instead of under Scanner
|
||||
ArtistSplitExceptions = ["AC/DC", "Tyler, the creator"]
|
||||
|
||||
# Misspelled option
|
||||
EnableDownlods = true
|
||||
|
||||
# Unknown section
|
||||
[Whatever]
|
||||
Foo = "bar"
|
||||
|
||||
# Valid options, must not be reported
|
||||
[Scanner]
|
||||
ArtistJoiner = " • "
|
||||
|
||||
[Tags.custom]
|
||||
aliases = ["toml", "test"]
|
||||
7
conf/testdata/cfg_warning_output.toml
vendored
7
conf/testdata/cfg_warning_output.toml
vendored
@ -1,7 +0,0 @@
|
||||
MusicFolder = "/toml/music"
|
||||
LogLevel = "warn"
|
||||
ArtistSplitExceptions = ["AC/DC"]
|
||||
EnableDownlods = true
|
||||
|
||||
[Scanner]
|
||||
ArtistJoiner = " • "
|
||||
@ -20,28 +20,15 @@ const (
|
||||
LastScanErrorKey = "LastScanError"
|
||||
LastScanTypeKey = "LastScanType"
|
||||
LastScanStartTimeKey = "LastScanStartTime"
|
||||
LastDBAnalyzeAtKey = "LastDBAnalyzeAt"
|
||||
LastDBAnalyzeAttemptAtKey = "LastDBAnalyzeAttemptAt"
|
||||
DBAnalyzePendingKey = "DBAnalyzePending"
|
||||
DBAnalyzeFailureCountKey = "DBAnalyzeFailureCount"
|
||||
// ArtConfFingerprintPropertyKey is the model.PropertyRepository key Backfill compares against
|
||||
// to detect artwork-affecting config changes across restarts.
|
||||
ArtConfFingerprintPropertyKey = "ArtConfFingerprint"
|
||||
|
||||
UIAuthorizationHeader = "X-ND-Authorization"
|
||||
UIClientUniqueIDHeader = "X-ND-Client-Unique-Id"
|
||||
JWTSecretKey = "JWTSecret"
|
||||
JWTPublicSecretKey = "JWTPublicSecret"
|
||||
JWTIssuer = "ND"
|
||||
DefaultSessionTimeout = 48 * time.Hour
|
||||
CookieExpiry = 365 * 24 * 3600 // One year
|
||||
|
||||
DBAnalyzeCheckSchedule = "@every 30m"
|
||||
DBAnalyzeMaxAge = 24 * time.Hour
|
||||
|
||||
ArtworkStaleAbsentRecheckSchedule = "@every 1h"
|
||||
ArtworkPruneSchedule = "@daily"
|
||||
ArtworkPostBackfillPruneDelay = 10 * time.Minute
|
||||
OptimizeDBSchedule = "@every 24h"
|
||||
|
||||
// DefaultEncryptionKey This is the encryption key used if none is specified in the `PasswordEncryptionKey` option
|
||||
// Never ever change this! Or it will break all Navidrome installations that don't set the config option
|
||||
@ -57,11 +44,6 @@ const (
|
||||
URLPathSubsonicAPI = "/rest"
|
||||
URLPathPublic = "/share"
|
||||
URLPathPublicImages = URLPathPublic + "/img"
|
||||
URLPathJellyfinAPI = "/jellyfin"
|
||||
|
||||
// JellyfinServerIDKey is the Property key for the stable, persisted server Id reported by the
|
||||
// Jellyfin API. Jellyfin clients cache this value, so it must survive process restarts.
|
||||
JellyfinServerIDKey = "JellyfinServerID"
|
||||
|
||||
// DefaultUILoginBackgroundURL uses Navidrome curated background images collection,
|
||||
// available at https://unsplash.com/collections/20072696/navidrome
|
||||
@ -87,9 +69,6 @@ const (
|
||||
I18nFolder = "i18n"
|
||||
ScanIgnoreFile = ".ndignore"
|
||||
ArtworkFolder = "artwork"
|
||||
// HashedArtworkFolder is a subtree of ArtworkFolder, kept apart from the name-addressed
|
||||
// upload folders beside it so Prune's sweep never reaches them.
|
||||
HashedArtworkFolder = "hashed"
|
||||
|
||||
PlaceholderArtistArt = "artist-placeholder.webp"
|
||||
PlaceholderAlbumArt = "album-placeholder.webp"
|
||||
@ -112,7 +91,6 @@ const (
|
||||
const (
|
||||
DefaultUICoverArtSize = 300
|
||||
DefaultMaxImageUploadSize = "10MB"
|
||||
DefaultMaxImageSize = "20MB"
|
||||
)
|
||||
|
||||
// Prometheus options
|
||||
@ -201,7 +179,7 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
var HTTPUserAgent = "Navidrome/" + Version + " - https://github.com/navidrome"
|
||||
var HTTPUserAgent = "Navidrome" + "/" + Version
|
||||
|
||||
var (
|
||||
VariousArtists = "Various Artists"
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
name=$RC_SVCNAME
|
||||
command="/opt/navidrome/${RC_SVCNAME}"
|
||||
command_args="--datafolder /opt/navidrome"
|
||||
command_args="-datafolder /opt/navidrome"
|
||||
command_user="${RC_SVCNAME}"
|
||||
pidfile="/var/run/${RC_SVCNAME}.pid"
|
||||
output_log="/opt/navidrome/${RC_SVCNAME}.log"
|
||||
|
||||
@ -1,13 +1,9 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@ -26,43 +22,11 @@ type PluginLoader interface {
|
||||
LoadMediaAgent(name string) (Interface, bool)
|
||||
}
|
||||
|
||||
// agentCooldown is the default cooldown duration for an agent that returns a RetryLaterError without a specific
|
||||
// RetryIn duration.
|
||||
const agentCooldown = time.Minute
|
||||
|
||||
// errUnsupported marks an agent that does not implement the requested method: it never ran,
|
||||
// so it neither answered nor throttled.
|
||||
var errUnsupported = errors.New("agent does not support this method")
|
||||
|
||||
// Agents is a meta-agent that aggregates multiple built-in and plugin agents. It tries each enabled agent in order
|
||||
// until one returns valid data.
|
||||
type Agents struct {
|
||||
ds model.DataStore
|
||||
pluginLoader PluginLoader
|
||||
cooldowns cooldowns
|
||||
}
|
||||
|
||||
// cooldowns remembers, across dispatches, which agents asked to be left alone and until when.
|
||||
type cooldowns struct {
|
||||
mu sync.RWMutex
|
||||
until map[string]time.Time
|
||||
}
|
||||
|
||||
func (c *cooldowns) active(name string) bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return time.Now().Before(c.until[name])
|
||||
}
|
||||
|
||||
// park keeps whichever deadline is later, so a call still in flight when a longer cooldown
|
||||
// starts cannot cut it short when it finally answers.
|
||||
func (c *cooldowns) park(name string, d time.Duration) {
|
||||
until := time.Now().Add(d)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if until.After(c.until[name]) {
|
||||
c.until[name] = until
|
||||
}
|
||||
}
|
||||
|
||||
// GetAgents returns the singleton instance of Agents
|
||||
@ -77,7 +41,6 @@ func createAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents {
|
||||
return &Agents{
|
||||
ds: ds,
|
||||
pluginLoader: pluginLoader,
|
||||
cooldowns: cooldowns{until: map[string]time.Time{}},
|
||||
}
|
||||
}
|
||||
|
||||
@ -127,19 +90,12 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent {
|
||||
} else if isPlugin {
|
||||
validAgents = append(validAgents, enabledAgent{name: name, isPlugin: true})
|
||||
} else {
|
||||
log.Debug("Unknown agent ignored", "name", name, "available", availableAgentNames(availablePlugins))
|
||||
log.Debug("Unknown agent ignored", "name", name)
|
||||
}
|
||||
}
|
||||
return validAgents
|
||||
}
|
||||
|
||||
// availableAgentNames returns every name accepted by the Agents config option.
|
||||
func availableAgentNames(plugins []string) []string {
|
||||
names := append(slices.Collect(maps.Keys(Map)), plugins...)
|
||||
slices.Sort(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func (a *Agents) getAgent(ea enabledAgent) Interface {
|
||||
if ea.isPlugin {
|
||||
// Try to load WASM plugin agent (if plugin loader is available)
|
||||
@ -168,42 +124,6 @@ func (a *Agents) AgentName() string {
|
||||
return "agents"
|
||||
}
|
||||
|
||||
// ArtistImageAgent pairs an enabled agent's name with its ArtistImageRetriever capability.
|
||||
type ArtistImageAgent struct {
|
||||
Name string
|
||||
Retriever ArtistImageRetriever
|
||||
}
|
||||
|
||||
// AlbumImageAgent pairs an enabled agent's name with its AlbumImageRetriever capability.
|
||||
type AlbumImageAgent struct {
|
||||
Name string
|
||||
Retriever AlbumImageRetriever
|
||||
}
|
||||
|
||||
// ArtistImageAgents returns the enabled agents implementing ArtistImageRetriever,
|
||||
// in conf.Server.Agents order (same order the aggregate dispatch uses).
|
||||
func (a *Agents) ArtistImageAgents() []ArtistImageAgent {
|
||||
var result []ArtistImageAgent
|
||||
for _, ea := range a.getEnabledAgentNames() {
|
||||
if retriever, ok := a.getAgent(ea).(ArtistImageRetriever); ok {
|
||||
result = append(result, ArtistImageAgent{Name: ea.name, Retriever: retriever})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// AlbumImageAgents returns the enabled agents implementing AlbumImageRetriever,
|
||||
// in conf.Server.Agents order (same order the aggregate dispatch uses).
|
||||
func (a *Agents) AlbumImageAgents() []AlbumImageAgent {
|
||||
var result []AlbumImageAgent
|
||||
for _, ea := range a.getEnabledAgentNames() {
|
||||
if retriever, ok := a.getAgent(ea).(AlbumImageRetriever); ok {
|
||||
result = append(result, AlbumImageAgent{Name: ea.name, Retriever: retriever})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
|
||||
switch id {
|
||||
case consts.UnknownArtistID:
|
||||
@ -215,7 +135,7 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str
|
||||
return callAgentMethod(ctx, a, "GetArtistMBID", func(ag Interface) (string, error) {
|
||||
retriever, ok := ag.(ArtistMBIDRetriever)
|
||||
if !ok {
|
||||
return "", errUnsupported
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return retriever.GetArtistMBID(ctx, id, name)
|
||||
})
|
||||
@ -232,7 +152,7 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin
|
||||
return callAgentMethod(ctx, a, "GetArtistURL", func(ag Interface) (string, error) {
|
||||
retriever, ok := ag.(ArtistURLRetriever)
|
||||
if !ok {
|
||||
return "", errUnsupported
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return retriever.GetArtistURL(ctx, id, name, mbid)
|
||||
})
|
||||
@ -249,7 +169,7 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
|
||||
return callAgentMethod(ctx, a, "GetArtistBiography", func(ag Interface) (string, error) {
|
||||
retriever, ok := ag.(ArtistBiographyRetriever)
|
||||
if !ok {
|
||||
return "", errUnsupported
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return retriever.GetArtistBiography(ctx, id, name, mbid)
|
||||
})
|
||||
@ -268,11 +188,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
|
||||
overLimit := int(float64(limit) * conf.Server.DevExternalArtistFetchMultiplier)
|
||||
|
||||
start := time.Now()
|
||||
attempts := newAttempts(&a.cooldowns)
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
if attempts.skip(enabledAgent.name) {
|
||||
continue
|
||||
}
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
@ -285,7 +201,6 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
|
||||
continue
|
||||
}
|
||||
similar, err := retriever.GetSimilarArtists(ctx, id, name, mbid, overLimit)
|
||||
attempts.record(enabledAgent.name, err)
|
||||
if len(similar) > 0 && err == nil {
|
||||
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
||||
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start))
|
||||
@ -295,7 +210,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
|
||||
return similar, err
|
||||
}
|
||||
}
|
||||
return nil, attempts.noResultErr()
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]ExternalImage, error) {
|
||||
@ -309,7 +224,7 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]
|
||||
return callAgentSliceMethod(ctx, a, "GetArtistImages", func(ag Interface) ([]ExternalImage, error) {
|
||||
retriever, ok := ag.(ArtistImageRetriever)
|
||||
if !ok {
|
||||
return nil, errUnsupported
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return retriever.GetArtistImages(ctx, id, name, mbid)
|
||||
})
|
||||
@ -330,7 +245,7 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str
|
||||
return callAgentSliceMethod(ctx, a, "GetArtistTopSongs", func(ag Interface) ([]Song, error) {
|
||||
retriever, ok := ag.(ArtistTopSongsRetriever)
|
||||
if !ok {
|
||||
return nil, errUnsupported
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit)
|
||||
})
|
||||
@ -344,7 +259,7 @@ func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*
|
||||
return callAgentMethod(ctx, a, "GetAlbumInfo", func(ag Interface) (*AlbumInfo, error) {
|
||||
retriever, ok := ag.(AlbumInfoRetriever)
|
||||
if !ok {
|
||||
return nil, errUnsupported
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return retriever.GetAlbumInfo(ctx, name, artist, mbid)
|
||||
})
|
||||
@ -358,7 +273,7 @@ func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string)
|
||||
return callAgentSliceMethod(ctx, a, "GetAlbumImages", func(ag Interface) ([]ExternalImage, error) {
|
||||
retriever, ok := ag.(AlbumImageRetriever)
|
||||
if !ok {
|
||||
return nil, errUnsupported
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return retriever.GetAlbumImages(ctx, name, artist, mbid)
|
||||
})
|
||||
@ -369,7 +284,7 @@ func (a *Agents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, m
|
||||
return callAgentSliceMethod(ctx, a, "GetSimilarSongsByTrack", func(ag Interface) ([]Song, error) {
|
||||
retriever, ok := ag.(SimilarSongsByTrackRetriever)
|
||||
if !ok {
|
||||
return nil, errUnsupported
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return retriever.GetSimilarSongsByTrack(ctx, id, name, artist, mbid, count)
|
||||
})
|
||||
@ -380,7 +295,7 @@ func (a *Agents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, m
|
||||
return callAgentSliceMethod(ctx, a, "GetSimilarSongsByAlbum", func(ag Interface) ([]Song, error) {
|
||||
retriever, ok := ag.(SimilarSongsByAlbumRetriever)
|
||||
if !ok {
|
||||
return nil, errUnsupported
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return retriever.GetSimilarSongsByAlbum(ctx, id, name, artist, mbid, count)
|
||||
})
|
||||
@ -398,61 +313,16 @@ func (a *Agents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid str
|
||||
return callAgentSliceMethod(ctx, a, "GetSimilarSongsByArtist", func(ag Interface) ([]Song, error) {
|
||||
retriever, ok := ag.(SimilarSongsByArtistRetriever)
|
||||
if !ok {
|
||||
return nil, errUnsupported
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return retriever.GetSimilarSongsByArtist(ctx, id, name, mbid, count)
|
||||
})
|
||||
}
|
||||
|
||||
// agentAttempts tallies what the enabled agents did in one dispatch.
|
||||
type agentAttempts struct {
|
||||
cooldowns *cooldowns
|
||||
throttled bool
|
||||
answered bool
|
||||
}
|
||||
|
||||
func newAttempts(c *cooldowns) agentAttempts {
|
||||
return agentAttempts{cooldowns: c}
|
||||
}
|
||||
|
||||
// skip reports whether name is still cooling down, counting it as throttled for this dispatch.
|
||||
func (t *agentAttempts) skip(name string) bool {
|
||||
if !t.cooldowns.active(name) {
|
||||
return false
|
||||
}
|
||||
t.throttled = true
|
||||
return true
|
||||
}
|
||||
|
||||
// record files one agent's outcome, parking it when it asked to be retried later.
|
||||
func (t *agentAttempts) record(name string, err error) {
|
||||
switch retry, isRetryLater := errors.AsType[*RetryLaterError](err); {
|
||||
case errors.Is(err, errUnsupported):
|
||||
case isRetryLater:
|
||||
t.cooldowns.park(name, cmp.Or(retry.RetryIn, agentCooldown))
|
||||
t.throttled = true
|
||||
default:
|
||||
t.answered = true
|
||||
}
|
||||
}
|
||||
|
||||
// noResultErr tells a retryable empty dispatch (nobody answered) from a definitive miss.
|
||||
func (t *agentAttempts) noResultErr() error {
|
||||
if t.throttled && !t.answered {
|
||||
return ErrRetryLater
|
||||
}
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
// callAgent tries each enabled agent in order until found reports a usable result.
|
||||
func callAgent[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error), found func(T) bool) (T, error) {
|
||||
func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (T, error) {
|
||||
var zero T
|
||||
start := time.Now()
|
||||
attempts := newAttempts(&agents.cooldowns)
|
||||
for _, enabledAgent := range agents.getEnabledAgentNames() {
|
||||
if attempts.skip(enabledAgent.name) {
|
||||
continue
|
||||
}
|
||||
ag := agents.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
@ -461,29 +331,41 @@ func callAgent[T any](ctx context.Context, agents *Agents, methodName string, fn
|
||||
break
|
||||
}
|
||||
result, err := fn(ag)
|
||||
attempts.record(enabledAgent.name, err)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if found(result) {
|
||||
if result != zero {
|
||||
log.Debug(ctx, "Got result", "method", methodName, "agent", ag.AgentName(), "elapsed", time.Since(start))
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
return zero, attempts.noResultErr()
|
||||
}
|
||||
|
||||
func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (T, error) {
|
||||
return callAgent(ctx, agents, methodName, fn, func(result T) bool {
|
||||
var zero T
|
||||
return result != zero
|
||||
})
|
||||
return zero, ErrNotFound
|
||||
}
|
||||
|
||||
func callAgentSliceMethod[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) ([]T, error)) ([]T, error) {
|
||||
return callAgent(ctx, agents, methodName, fn, func(results []T) bool { return len(results) > 0 })
|
||||
start := time.Now()
|
||||
for _, enabledAgent := range agents.getEnabledAgentNames() {
|
||||
ag := agents.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
results, err := fn(ag)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(results) > 0 {
|
||||
log.Debug(ctx, "Got results", "method", methodName, "agent", ag.AgentName(), "count", len(results), "elapsed", time.Since(start))
|
||||
return results, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
var _ Interface = (*Agents)(nil)
|
||||
|
||||
@ -3,8 +3,6 @@ package agents
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
@ -16,29 +14,6 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("cooldowns", func() {
|
||||
// Calls to one agent overlap, so a short cooldown can land after a long one started.
|
||||
It("keeps the longer deadline when a shorter park lands after it", func() {
|
||||
c := cooldowns{until: map[string]time.Time{}}
|
||||
|
||||
c.park("fake", time.Hour)
|
||||
c.park("fake", time.Millisecond)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
Expect(c.active("fake")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("extends the deadline when the later park is longer", func() {
|
||||
c := cooldowns{until: map[string]time.Time{}}
|
||||
|
||||
c.park("fake", time.Millisecond)
|
||||
c.park("fake", time.Hour)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
Expect(c.active("fake")).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Agents", func() {
|
||||
var ctx context.Context
|
||||
var cancel context.CancelFunc
|
||||
@ -59,10 +34,10 @@ var _ = Describe("Agents", func() {
|
||||
})
|
||||
|
||||
It("calls the placeholder GetArtistImages", func() {
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "1", Title: "One"}, {ID: "2", Title: "Two"}})
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "1", Title: "One", MbzReleaseTrackID: "111"}, {ID: "2", Title: "Two", MbzReleaseTrackID: "222"}})
|
||||
songs, err := ag.GetArtistTopSongs(ctx, "123", "John Doe", "mb123", 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(ConsistOf([]Song{{ID: "1", Name: "One"}, {ID: "2", Name: "Two"}}))
|
||||
Expect(songs).To(ConsistOf([]Song{{Name: "One", MBID: "111"}, {Name: "Two", MBID: "222"}}))
|
||||
})
|
||||
})
|
||||
|
||||
@ -92,22 +67,6 @@ var _ = Describe("Agents", func() {
|
||||
Expect(ags).ToNot(ContainElement("disabled"))
|
||||
})
|
||||
|
||||
Describe("availableAgentNames", func() {
|
||||
It("combines built-in agents with the given plugins", func() {
|
||||
names := availableAgentNames([]string{"apple-music"})
|
||||
Expect(names).To(ContainElements("apple-music", LocalAgentName, "fake", "empty"))
|
||||
})
|
||||
|
||||
It("returns the names sorted", func() {
|
||||
names := availableAgentNames([]string{"zz-plugin", "aa-plugin"})
|
||||
Expect(slices.IsSorted(names)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("works when there are no plugins", func() {
|
||||
Expect(availableAgentNames(nil)).To(ContainElement(LocalAgentName))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetArtistMBID", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetArtistMBID(ctx, "123", "test")).To(Equal("mbid"))
|
||||
@ -201,102 +160,6 @@ var _ = Describe("Agents", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("cooldown", func() {
|
||||
It("skips an agent that returned RetryLaterError until the deadline", func() {
|
||||
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
||||
_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
|
||||
|
||||
// Immediately after: agent is skipped, not called
|
||||
mock.Err = nil
|
||||
calls := mock.Calls
|
||||
_, err = ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
Expect(mock.Calls).To(Equal(calls))
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
|
||||
})
|
||||
|
||||
// Providers that throttle without saying for how long (Last.fm sends no delay at all)
|
||||
// must still be parked, or the aggregate keeps calling them on every request.
|
||||
It("parks an agent that asked to be retried without a delay", func() {
|
||||
mock.Err = ErrRetryLater
|
||||
_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
|
||||
|
||||
mock.Err = nil
|
||||
calls := mock.Calls
|
||||
_, err = ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
Expect(mock.Calls).To(Equal(calls), "the default cooldown must outlast the request")
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("calls the agent again once the cooldown expires", func() {
|
||||
mock.Err = &RetryLaterError{RetryIn: 10 * time.Millisecond}
|
||||
_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
|
||||
|
||||
mock.Err = nil
|
||||
Eventually(func() (string, error) {
|
||||
return ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
}, 5*time.Second, 10*time.Millisecond).Should(Equal("bio"))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound, not ErrRetryLater, when agents failed for other reasons", func() {
|
||||
mock.Err = errors.New("boom")
|
||||
_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
Expect(errors.Is(err, ErrNotFound)).To(BeTrue())
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeFalse())
|
||||
})
|
||||
|
||||
// ErrRetryLater tells the caller "nobody answered, do not cache this". A definitive
|
||||
// answer from any other agent is an answer, throttled peer or not.
|
||||
It("returns ErrNotFound when another agent answered with a definitive miss", func() {
|
||||
other := &mockAgent{Err: ErrNotFound}
|
||||
Register("fake2", func(model.DataStore) Interface { return other })
|
||||
conf.Server.Agents = "fake,fake2"
|
||||
ag = createAgents(ds, nil)
|
||||
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
||||
|
||||
_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
Expect(errors.Is(err, ErrNotFound)).To(BeTrue())
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeFalse())
|
||||
|
||||
// The cooldown was still recorded for the throttled agent
|
||||
calls := mock.Calls
|
||||
_, _ = ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
Expect(mock.Calls).To(Equal(calls))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when another agent answered with an empty slice", func() {
|
||||
empty := &testImageAgent{Name: "emptyImages"}
|
||||
Register("emptyImages", func(model.DataStore) Interface { return empty })
|
||||
conf.Server.Agents = "fake,emptyImages"
|
||||
ag = createAgents(ds, nil)
|
||||
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
||||
|
||||
_, err := ag.GetArtistImages(ctx, "123", "test", "mb123")
|
||||
Expect(errors.Is(err, ErrNotFound)).To(BeTrue())
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater from GetSimilarArtists when only cooling agents remain", func() {
|
||||
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
||||
_, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 2)
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns ErrNotFound from GetSimilarArtists when another agent answered", func() {
|
||||
other := &mockAgent{Err: ErrNotFound}
|
||||
Register("fake2", func(model.DataStore) Interface { return other })
|
||||
conf.Server.Agents = "fake,fake2"
|
||||
ag = createAgents(ds, nil)
|
||||
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
||||
|
||||
_, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 2)
|
||||
Expect(errors.Is(err, ErrNotFound)).To(BeTrue())
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetArtistImages", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetArtistImages(ctx, "123", "test", "mb123")).To(Equal([]ExternalImage{{
|
||||
@ -499,70 +362,11 @@ var _ = Describe("Agents", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Image retriever enumeration", func() {
|
||||
var ag *Agents
|
||||
var artistImg, artistImg2 *testImageAgent
|
||||
var albumImg, albumImg2 *testAlbumImageAgent
|
||||
|
||||
BeforeEach(func() {
|
||||
artistImg = &testImageAgent{Name: "artistImg"}
|
||||
artistImg2 = &testImageAgent{Name: "artistImg2"}
|
||||
albumImg = &testAlbumImageAgent{name: "albumImg"}
|
||||
albumImg2 = &testAlbumImageAgent{name: "albumImg2"}
|
||||
Register("artistImg", func(model.DataStore) Interface { return artistImg })
|
||||
Register("artistImg2", func(model.DataStore) Interface { return artistImg2 })
|
||||
Register("albumImg", func(model.DataStore) Interface { return albumImg })
|
||||
Register("albumImg2", func(model.DataStore) Interface { return albumImg2 })
|
||||
Register("noImages", func(model.DataStore) Interface { return &emptyAgent{} })
|
||||
})
|
||||
|
||||
Describe("ArtistImageAgents", func() {
|
||||
It("returns only ArtistImageRetriever agents, named, in configured order", func() {
|
||||
conf.Server.Agents = "artistImg,noImages,artistImg2"
|
||||
ag = createAgents(ds, nil)
|
||||
|
||||
result := ag.ArtistImageAgents()
|
||||
Expect(result).To(HaveLen(2))
|
||||
Expect(result[0].Name).To(Equal("artistImg"))
|
||||
Expect(result[0].Retriever).To(BeIdenticalTo(artistImg))
|
||||
Expect(result[1].Name).To(Equal("artistImg2"))
|
||||
Expect(result[1].Retriever).To(BeIdenticalTo(artistImg2))
|
||||
})
|
||||
|
||||
It("is empty when external services are disabled", func() {
|
||||
conf.Server.Agents = "" // what disableExternalServices() sets when EnableExternalServices=false
|
||||
ag = createAgents(ds, nil)
|
||||
Expect(ag.ArtistImageAgents()).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("AlbumImageAgents", func() {
|
||||
It("returns only AlbumImageRetriever agents, named, in configured order", func() {
|
||||
conf.Server.Agents = "albumImg,noImages,albumImg2"
|
||||
ag = createAgents(ds, nil)
|
||||
|
||||
result := ag.AlbumImageAgents()
|
||||
Expect(result).To(HaveLen(2))
|
||||
Expect(result[0].Name).To(Equal("albumImg"))
|
||||
Expect(result[0].Retriever).To(BeIdenticalTo(albumImg))
|
||||
Expect(result[1].Name).To(Equal("albumImg2"))
|
||||
Expect(result[1].Retriever).To(BeIdenticalTo(albumImg2))
|
||||
})
|
||||
|
||||
It("is empty when external services are disabled", func() {
|
||||
conf.Server.Agents = "" // what disableExternalServices() sets when EnableExternalServices=false
|
||||
ag = createAgents(ds, nil)
|
||||
Expect(ag.AlbumImageAgents()).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockAgent struct {
|
||||
Args []any
|
||||
Err error
|
||||
Calls int
|
||||
Args []any
|
||||
Err error
|
||||
}
|
||||
|
||||
func (a *mockAgent) AgentName() string {
|
||||
@ -587,7 +391,6 @@ func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (stri
|
||||
|
||||
func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string) (string, error) {
|
||||
a.Args = []any{id, name, mbid}
|
||||
a.Calls++
|
||||
if a.Err != nil {
|
||||
return "", a.Err
|
||||
}
|
||||
@ -694,17 +497,3 @@ func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid strin
|
||||
t.Args = []any{id, name, mbid}
|
||||
return t.Images, t.Err
|
||||
}
|
||||
|
||||
type testAlbumImageAgent struct {
|
||||
name string
|
||||
Images []ExternalImage
|
||||
Err error
|
||||
Args []any
|
||||
}
|
||||
|
||||
func (t *testAlbumImageAgent) AgentName() string { return t.name }
|
||||
|
||||
func (t *testAlbumImageAgent) GetAlbumImages(_ context.Context, name, artist, mbid string) ([]ExternalImage, error) {
|
||||
t.Args = []any{name, artist, mbid}
|
||||
return t.Images, t.Err
|
||||
}
|
||||
|
||||
@ -3,9 +3,6 @@ package agents
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gohugoio/hashstructure"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@ -55,49 +52,9 @@ func (s Song) Equals(other Song) bool {
|
||||
return h1 == h2
|
||||
}
|
||||
|
||||
// ErrNotFound means the provider answered and had nothing. Return the underlying error
|
||||
// for a fault instead, or callers that back off on faults will treat it as definitive.
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// ErrRetryLater is the zero-delay RetryLaterError: the provider is temporarily unavailable
|
||||
// or throttling us, but did not say for how long. Both errors.Is(err, ErrRetryLater) and
|
||||
// errors.AsType[*RetryLaterError] match it and every delay-carrying variant.
|
||||
// Treat it as immutable; build a new RetryLaterError to name a delay.
|
||||
var ErrRetryLater = &RetryLaterError{}
|
||||
|
||||
// RetryLaterError asks callers to back off, optionally for the delay the provider requested.
|
||||
type RetryLaterError struct {
|
||||
RetryIn time.Duration
|
||||
}
|
||||
|
||||
func (e *RetryLaterError) Error() string {
|
||||
if e.RetryIn > 0 {
|
||||
return fmt.Sprintf("retry later (in %s)", e.RetryIn)
|
||||
}
|
||||
return "retry later"
|
||||
}
|
||||
|
||||
func (e *RetryLaterError) Is(target error) bool {
|
||||
_, ok := target.(*RetryLaterError)
|
||||
return ok
|
||||
}
|
||||
|
||||
// MaxRetryIn caps a delay parsed from a provider, so a bogus value cannot park it indefinitely.
|
||||
const MaxRetryIn = time.Hour
|
||||
const maxRetryInSeconds = int(MaxRetryIn / time.Second)
|
||||
|
||||
// ParseRetryIn reads a provider's delay given in seconds, from a header or a plugin token.
|
||||
// Anything unparseable or non-positive means unspecified.
|
||||
func ParseRetryIn(seconds string) time.Duration {
|
||||
// Clamp in seconds: scaling first would wrap a huge value past int64 nanoseconds,
|
||||
// turning "wait an age" into a fraction of a second. Parse at a fixed width so the
|
||||
// cap holds on the 32-bit targets we ship, where a plain Atoi would overflow first.
|
||||
secs, err := strconv.ParseInt(seconds, 10, 64)
|
||||
if err != nil || secs <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(min(secs, int64(maxRetryInSeconds))) * time.Second
|
||||
}
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
)
|
||||
|
||||
// AlbumInfoRetriever provides album info (no images)
|
||||
type AlbumInfoRetriever interface {
|
||||
|
||||
@ -1,42 +1,27 @@
|
||||
package agents_test
|
||||
package agents
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("RetryLaterError", func() {
|
||||
It("matches the ErrRetryLater sentinel via errors.Is", func() {
|
||||
err := &agents.RetryLaterError{RetryIn: 30 * time.Second}
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
||||
var _ = Describe("Song.Equals", func() {
|
||||
base := Song{ID: "1", Name: "S", Artists: []Artist{{ID: "x", Name: "A"}}}
|
||||
It("true for identical songs incl Artists", func() {
|
||||
Expect(base.Equals(base)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("matches through errors.Join and wrapping", func() {
|
||||
err := fmt.Errorf("calling LB: %w", errors.Join(errors.New("http 429"), &agents.RetryLaterError{}))
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
||||
It("false when Artists differ", func() {
|
||||
other := base
|
||||
other.Artists = []Artist{{ID: "y", Name: "B"}}
|
||||
Expect(base.Equals(other)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("exposes the delay through the wrapped error", func() {
|
||||
err := errors.Join(errors.New("http 429"), &agents.RetryLaterError{RetryIn: 42 * time.Second})
|
||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(retry.RetryIn).To(Equal(42 * time.Second))
|
||||
It("false when a scalar differs", func() {
|
||||
other := base
|
||||
other.Name = "T"
|
||||
Expect(base.Equals(other)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("matches the sentinel too, reporting no delay", func() {
|
||||
retry, ok := errors.AsType[*agents.RetryLaterError](agents.ErrRetryLater)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(retry.RetryIn).To(BeZero())
|
||||
})
|
||||
|
||||
It("is the same sentinel as scrobbler.ErrRetryLater", func() {
|
||||
Expect(errors.Is(scrobbler.ErrRetryLater, agents.ErrRetryLater)).To(BeTrue())
|
||||
Expect(errors.Is(&agents.RetryLaterError{}, scrobbler.ErrRetryLater)).To(BeTrue())
|
||||
It("true when both have empty Artists and equal scalars", func() {
|
||||
a := Song{ID: "1", Name: "S"}
|
||||
Expect(a.Equals(a)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
@ -5,8 +5,6 @@ import (
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
const LocalAgentName = "local"
|
||||
@ -39,51 +37,14 @@ func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return songsFrom(top), nil
|
||||
}
|
||||
|
||||
func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
|
||||
seed, err := p.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var result []Song
|
||||
for _, s := range top {
|
||||
result = append(result, Song{
|
||||
Name: s.Title,
|
||||
MBID: s.MbzReleaseTrackID,
|
||||
})
|
||||
}
|
||||
// Tag ids derive from (name, value), so the seed's genre ids need no extra query.
|
||||
genreIDs := slice.Map(seed.Tags.Flatten(model.TagGenre), func(t model.Tag) string { return t.ID })
|
||||
if len(genreIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// Ask for extra so we can drop the seed itself and still fill the count.
|
||||
candidates, err := p.ds.MediaFile(ctx).GetRandom(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
persistence.SongGenres.ByID(genreIDs),
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
Max: count + 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filtered := make(model.MediaFiles, 0, len(candidates))
|
||||
for _, s := range candidates {
|
||||
if s.ID == id {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, s)
|
||||
if len(filtered) >= count {
|
||||
break
|
||||
}
|
||||
}
|
||||
return songsFrom(filtered), nil
|
||||
}
|
||||
|
||||
func songsFrom(mfs model.MediaFiles) []Song {
|
||||
if len(mfs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return slice.Map(mfs, func(mf model.MediaFile) Song {
|
||||
return Song{ID: mf.ID, Name: mf.Title}
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@ -1,96 +0,0 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("localAgent GetSimilarSongsByTrack", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var mfRepo *tests.MockMediaFileRepo
|
||||
var agent *localAgent
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
mfRepo = &tests.MockMediaFileRepo{}
|
||||
ds = &tests.MockDataStore{MockedMediaFile: mfRepo}
|
||||
agent = &localAgent{ds: ds}
|
||||
})
|
||||
|
||||
It("excludes the seed track from its own similars", func() {
|
||||
seed := model.MediaFile{ID: "seed-1", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
related := model.MediaFile{ID: "rel-1", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
// SetData keys by ID; a duplicate "seed-1" entry would clobber the real seed.
|
||||
mfRepo.SetData(model.MediaFiles{seed, related})
|
||||
|
||||
songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-1", "Seed", "", "", 10)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
names := slice.Map(songs, func(s Song) string { return s.Name })
|
||||
Expect(names).ToNot(ContainElement("Seed"))
|
||||
})
|
||||
|
||||
// The mock ignores QueryOptions.Filters, so assert the predicate itself: otherwise this spec
|
||||
// would pass just as well with no genre filter at all.
|
||||
It("queries the indexed genre join for the seed's own genres, skipping missing files", func() {
|
||||
rock := model.NewTag(model.TagGenre, "Rock")
|
||||
seed := model.MediaFile{ID: "seed-4", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
mfRepo.SetData(model.MediaFiles{seed})
|
||||
|
||||
_, err := agent.GetSimilarSongsByTrack(ctx, "seed-4", "Seed", "", "", 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, sqlErr := mfRepo.Options.Filters.ToSql()
|
||||
Expect(sqlErr).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("media_file_tags"), "must use the indexed join, not a json_tree scan")
|
||||
Expect(sql).To(ContainSubstring("missing"))
|
||||
Expect(args).To(ContainElement(false), "must exclude missing files, not select them")
|
||||
Expect(args).To(ContainElement(rock.ID), "must filter on the seed's own genre tag id")
|
||||
Expect(args).ToNot(ContainElement(model.NewTag(model.TagGenre, "Jazz").ID))
|
||||
})
|
||||
|
||||
It("returns the library id so the matcher can resolve the song", func() {
|
||||
seed := model.MediaFile{ID: "seed-3", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
// Without the id the matcher falls through to its MBID/title phases and resolves nothing,
|
||||
// so the local fallback silently returns an empty mix.
|
||||
related := model.MediaFile{ID: "rel-3", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
mfRepo.SetData(model.MediaFiles{seed, related})
|
||||
|
||||
songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-3", "Seed", "", "", 10)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(ContainElement(Song{ID: "rel-3", Name: "Related"}))
|
||||
})
|
||||
|
||||
It("asks for one extra candidate so dropping the seed still fills the count", func() {
|
||||
// The mock returns rows sorted by id, so the seed comes first and would consume the only
|
||||
// slot if the query did not over-fetch.
|
||||
seed := model.MediaFile{ID: "a-seed", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
related := model.MediaFile{ID: "b-rel", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
mfRepo.SetData(model.MediaFiles{seed, related})
|
||||
|
||||
songs, err := agent.GetSimilarSongsByTrack(ctx, "a-seed", "Seed", "", "", 1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].Name).To(Equal("Related"))
|
||||
})
|
||||
|
||||
It("returns nil when the seed track has no genres", func() {
|
||||
seed := model.MediaFile{ID: "seed-2", Title: "NoGenre"}
|
||||
mfRepo.SetData(model.MediaFiles{seed})
|
||||
|
||||
songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-2", "NoGenre", "", "", 10)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(BeEmpty())
|
||||
// Without the early return an empty tag filter would scan the whole library.
|
||||
Expect(mfRepo.Options).To(Equal(model.QueryOptions{}), "must not query at all")
|
||||
})
|
||||
})
|
||||
@ -1,27 +0,0 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Song.Equals", func() {
|
||||
base := Song{ID: "1", Name: "S", Artists: []Artist{{ID: "x", Name: "A"}}}
|
||||
It("true for identical songs incl Artists", func() {
|
||||
Expect(base.Equals(base)).To(BeTrue())
|
||||
})
|
||||
It("false when Artists differ", func() {
|
||||
other := base
|
||||
other.Artists = []Artist{{ID: "y", Name: "B"}}
|
||||
Expect(base.Equals(other)).To(BeFalse())
|
||||
})
|
||||
It("false when a scalar differs", func() {
|
||||
other := base
|
||||
other.Name = "T"
|
||||
Expect(base.Equals(other)).To(BeFalse())
|
||||
})
|
||||
It("true when both have empty Artists and equal scalars", func() {
|
||||
a := Song{ID: "1", Name: "S"}
|
||||
Expect(a.Equals(a)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
@ -14,7 +14,6 @@ import (
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
@ -22,7 +21,7 @@ import (
|
||||
type Archiver interface {
|
||||
ZipAlbum(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||
ZipArtist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||
ZipShare(ctx context.Context, s *model.Share, w io.Writer) error
|
||||
ZipShare(ctx context.Context, id string, w io.Writer) error
|
||||
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||
}
|
||||
|
||||
@ -41,13 +40,7 @@ func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitra
|
||||
}
|
||||
|
||||
func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||
// Match by album-artist participation, not the deprecated album_artist_id
|
||||
// column (first album artist only), so co-album-artists are included too.
|
||||
filter := squirrel.And{
|
||||
persistence.ParticipantIDFilter("media_file", id, model.RoleAlbumArtist),
|
||||
squirrel.Eq{"missing": false},
|
||||
}
|
||||
return a.zipAlbums(ctx, id, format, bitrate, out, filter)
|
||||
return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_artist_id": id})
|
||||
}
|
||||
|
||||
func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer) error {
|
||||
@ -107,14 +100,16 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc
|
||||
return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file)
|
||||
}
|
||||
|
||||
// ZipShare takes an already-loaded share: Share.Load records a visit, so
|
||||
// loading it again here would count every download twice.
|
||||
func (a *archiver) ZipShare(ctx context.Context, s *model.Share, out io.Writer) error {
|
||||
func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error {
|
||||
s, err := a.shares.Load(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !s.Downloadable {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
|
||||
return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false)
|
||||
return a.zipMediaFiles(ctx, id, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false)
|
||||
}
|
||||
|
||||
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||
|
||||
@ -11,7 +11,6 @@ import (
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@ -70,11 +69,8 @@ var _ = Describe("Archiver", func() {
|
||||
|
||||
mfRepo := &mockMediaFileRepository{}
|
||||
mfRepo.On("GetAll", []model.QueryOptions{{
|
||||
Filters: squirrel.And{
|
||||
persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist),
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
Sort: "album",
|
||||
Filters: squirrel.Eq{"album_artist_id": "1"},
|
||||
Sort: "album",
|
||||
}}).Return(mfs, nil)
|
||||
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
@ -134,16 +130,13 @@ var _ = Describe("Archiver", func() {
|
||||
Tracks: mfs,
|
||||
}
|
||||
|
||||
sh.On("Load", mock.Anything, "1").Return(share, nil)
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipShare(context.Background(), share, out)
|
||||
err := arch.ZipShare(context.Background(), "1", out)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
// Share.Load records a visit; re-loading here would double-count
|
||||
// every download.
|
||||
sh.AssertNotCalled(GinkgoT(), "Load", mock.Anything, mock.Anything)
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
|
||||
@ -1,131 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
// externalName mirrors the normalization the aggregate provider applies, so agent searches match.
|
||||
func externalName(name string) string {
|
||||
if conf.Server.DevPreserveUnicodeInExternalCalls {
|
||||
return name
|
||||
}
|
||||
return str.Clear(name)
|
||||
}
|
||||
|
||||
// bestImageURL returns the largest fetchable image URL. Only one is returned and its failure ends
|
||||
// the agent's turn, so an unfetchable candidate must never win: url.Parse alone accepts anything.
|
||||
func bestImageURL(imgs []agents.ExternalImage) *url.URL {
|
||||
var best *url.URL
|
||||
var bestSize int
|
||||
for i := range imgs {
|
||||
if imgs[i].URL == "" {
|
||||
continue
|
||||
}
|
||||
u, err := url.Parse(imgs[i].URL)
|
||||
if err != nil || !u.IsAbs() || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
continue
|
||||
}
|
||||
if best == nil || imgs[i].Size > bestSize {
|
||||
best, bestSize = u, imgs[i].Size
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// longerRetry keeps whichever external failure asks for the longer wait, so one provider's
|
||||
// short delay cannot shorten another's.
|
||||
func longerRetry(a, b error) error {
|
||||
if a == nil {
|
||||
return b
|
||||
}
|
||||
var ra, rb *agents.RetryLaterError
|
||||
if errors.As(b, &rb) && (!errors.As(a, &ra) || rb.RetryIn > ra.RetryIn) {
|
||||
return b
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// fetchArtistImage tries each enabled artist-image agent in order. The error is non-nil only when no
|
||||
// agent succeeded and at least one failed transiently.
|
||||
func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar model.Artist) (io.ReadCloser, string, error) {
|
||||
// Synthetic artists would otherwise get an unrelated agent result assigned to them.
|
||||
switch ar.ID {
|
||||
case consts.UnknownArtistID, consts.VariousArtistsID:
|
||||
traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, Detail: "synthetic artist"})
|
||||
return nil, "", nil
|
||||
}
|
||||
name := externalName(ar.Name)
|
||||
imageAgents := ag.ArtistImageAgents()
|
||||
if len(imageAgents) == 0 {
|
||||
traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped,
|
||||
Detail: "no enabled agent provides artist images"})
|
||||
return nil, "", nil
|
||||
}
|
||||
var extErr error
|
||||
for _, a := range imageAgents {
|
||||
reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) {
|
||||
imgs, err := a.Retriever.GetArtistImages(ctx, ar.ID, name, ar.MbzArtistID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
u := bestImageURL(imgs)
|
||||
if u == nil {
|
||||
return nil, "", agents.ErrNotFound
|
||||
}
|
||||
return fromURL(ctx, u)
|
||||
})
|
||||
recordAgent(ctx, a.Name, reader, path, err)
|
||||
if reader != nil {
|
||||
return reader, a.Name, nil
|
||||
}
|
||||
if isTransientExternal(err) {
|
||||
extErr = longerRetry(extErr, err)
|
||||
log.Debug(ctx, "Artwork: External artist-image lookup failed", "agent", a.Name, "artist", ar.Name, err)
|
||||
}
|
||||
}
|
||||
return nil, "", extErr
|
||||
}
|
||||
|
||||
// fetchAlbumImage is the album counterpart of fetchArtistImage.
|
||||
func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (io.ReadCloser, string, error) {
|
||||
name, artist := externalName(al.Name), externalName(al.AlbumArtist)
|
||||
imageAgents := ag.AlbumImageAgents()
|
||||
if len(imageAgents) == 0 {
|
||||
traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped,
|
||||
Detail: "no enabled agent provides album images"})
|
||||
return nil, "", nil
|
||||
}
|
||||
var extErr error
|
||||
for _, a := range imageAgents {
|
||||
reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) {
|
||||
imgs, err := a.Retriever.GetAlbumImages(ctx, name, artist, al.MbzAlbumID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
u := bestImageURL(imgs)
|
||||
if u == nil {
|
||||
return nil, "", agents.ErrNotFound
|
||||
}
|
||||
return fromURL(ctx, u)
|
||||
})
|
||||
recordAgent(ctx, a.Name, reader, path, err)
|
||||
if reader != nil {
|
||||
return reader, a.Name, nil
|
||||
}
|
||||
if isTransientExternal(err) {
|
||||
extErr = longerRetry(extErr, err)
|
||||
log.Debug(ctx, "Artwork: External album-image lookup failed", "agent", a.Name, "album", al.Name, err)
|
||||
}
|
||||
}
|
||||
return nil, "", extErr
|
||||
}
|
||||
@ -1,323 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// fakeImageAgent is a built-in agent stub implementing both image retrievers; it
|
||||
// records call counts so per-agent ordering and short-circuiting can be asserted.
|
||||
type fakeImageAgent struct {
|
||||
name string
|
||||
imgs []agents.ExternalImage
|
||||
err error
|
||||
artistCalls int
|
||||
albumCalls int
|
||||
gotArtistName string
|
||||
gotAlbumName string
|
||||
// block, when set, holds every lookup until closed, standing in for a slow/rate-limited agent.
|
||||
block chan struct{}
|
||||
// mu guards the call counters: the worker resolves several items concurrently.
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (f *fakeImageAgent) AgentName() string { return f.name }
|
||||
|
||||
func (f *fakeImageAgent) GetArtistImages(_ context.Context, _, name, _ string) ([]agents.ExternalImage, error) {
|
||||
if f.block != nil {
|
||||
<-f.block
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.artistCalls++
|
||||
f.gotArtistName = name
|
||||
f.mu.Unlock()
|
||||
return f.imgs, f.err
|
||||
}
|
||||
|
||||
func (f *fakeImageAgent) GetAlbumImages(_ context.Context, name, _, _ string) ([]agents.ExternalImage, error) {
|
||||
f.albumCalls++
|
||||
f.gotAlbumName = name
|
||||
return f.imgs, f.err
|
||||
}
|
||||
|
||||
// imageAgents registers the fakes as built-in agents and enables them in order. The fakes
|
||||
// ignore the DataStore, so reusing the process-wide GetAgents singleton across tests is safe.
|
||||
func imageAgents(fakes ...*fakeImageAgent) *agents.Agents {
|
||||
names := make([]string, 0, len(fakes))
|
||||
for _, f := range fakes {
|
||||
fake := f
|
||||
agents.Register(fake.name, func(model.DataStore) agents.Interface { return fake })
|
||||
names = append(names, fake.name)
|
||||
}
|
||||
conf.Server.Agents = strings.Join(names, ",")
|
||||
return agents.GetAgents(&tests.MockDataStore{}, nil)
|
||||
}
|
||||
|
||||
var _ = Describe("agent images", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
srv *httptest.Server
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = context.Background()
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("image-bytes"))
|
||||
}))
|
||||
DeferCleanup(srv.Close)
|
||||
})
|
||||
|
||||
img := func(path string, size int) agents.ExternalImage {
|
||||
return agents.ExternalImage{URL: srv.URL + path, Size: size}
|
||||
}
|
||||
|
||||
Describe("bestImageURL", func() {
|
||||
It("picks the largest-Size URL and skips empty ones", func() {
|
||||
u := bestImageURL([]agents.ExternalImage{
|
||||
{URL: "http://x/small", Size: 10},
|
||||
{URL: "", Size: 9999},
|
||||
{URL: "http://x/big", Size: 100},
|
||||
})
|
||||
Expect(u).ToNot(BeNil())
|
||||
Expect(u.String()).To(Equal("http://x/big"))
|
||||
})
|
||||
|
||||
It("skips a malformed largest URL and falls back to a valid smaller one", func() {
|
||||
u := bestImageURL([]agents.ExternalImage{
|
||||
{URL: "http://x/valid", Size: 10},
|
||||
{URL: "http://x/%zz", Size: 100}, // invalid percent-escape, largest
|
||||
})
|
||||
Expect(u).ToNot(BeNil())
|
||||
Expect(u.String()).To(Equal("http://x/valid"))
|
||||
})
|
||||
|
||||
It("returns nil when there is no non-empty URL", func() {
|
||||
Expect(bestImageURL(nil)).To(BeNil())
|
||||
Expect(bestImageURL([]agents.ExternalImage{{URL: "", Size: 5}})).To(BeNil())
|
||||
})
|
||||
|
||||
// Plugins hand these over as free-form strings, and url.Parse accepts them all. An
|
||||
// unfetchable candidate that wins here ends the agent's turn before its valid images run.
|
||||
DescribeTable("skips a candidate that cannot be fetched",
|
||||
func(badURL string) {
|
||||
u := bestImageURL([]agents.ExternalImage{
|
||||
{URL: badURL, Size: 100}, // largest, and first
|
||||
{URL: "https://cdn.example.com/ok.jpg", Size: 10},
|
||||
})
|
||||
Expect(u).ToNot(BeNil())
|
||||
Expect(u.String()).To(Equal("https://cdn.example.com/ok.jpg"))
|
||||
},
|
||||
Entry("a relative path", "images/big.jpg"),
|
||||
Entry("a root-relative path", "/images/big.jpg"),
|
||||
Entry("a scheme we cannot fetch", "ftp://host/big.jpg"),
|
||||
Entry("a scheme-relative URL", "//host/big.jpg"),
|
||||
Entry("a URL with no host", "http:///big.jpg"),
|
||||
)
|
||||
|
||||
// Size is often 0 for every candidate, and only a strictly larger one replaces the first,
|
||||
// so an unfetchable entry in first position would otherwise stick.
|
||||
It("skips an unfetchable first candidate when every Size is zero", func() {
|
||||
u := bestImageURL([]agents.ExternalImage{
|
||||
{URL: "images/rel.jpg"},
|
||||
{URL: "https://cdn.example.com/ok.jpg"},
|
||||
})
|
||||
Expect(u).ToNot(BeNil())
|
||||
Expect(u.String()).To(Equal("https://cdn.example.com/ok.jpg"))
|
||||
})
|
||||
|
||||
It("returns nil when no candidate is fetchable", func() {
|
||||
Expect(bestImageURL([]agents.ExternalImage{
|
||||
{URL: "images/a.jpg", Size: 10},
|
||||
{URL: "ftp://host/b.jpg", Size: 20},
|
||||
})).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fetchArtistImage", func() {
|
||||
It("returns the first agent's image and its name", func() {
|
||||
a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}}
|
||||
ag := imageAgents(a)
|
||||
|
||||
r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "Artist"})
|
||||
Expect(r).ToNot(BeNil())
|
||||
defer r.Close()
|
||||
Expect(name).To(Equal("agentA"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("skips the external lookup for synthetic artists", func() {
|
||||
a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}}
|
||||
ag := imageAgents(a)
|
||||
|
||||
for _, id := range []string{consts.UnknownArtistID, consts.VariousArtistsID} {
|
||||
r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: id, Name: "Various Artists"})
|
||||
Expect(r).To(BeNil())
|
||||
Expect(name).To(BeEmpty())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
Expect(a.artistCalls).To(Equal(0), "synthetic artists never reach the agents")
|
||||
})
|
||||
|
||||
It("records a skipped external candidate when no agent provides artist images", func() {
|
||||
ag := imageAgents()
|
||||
t := &ChainTrace{}
|
||||
|
||||
r, _, err := fetchArtistImage(withTrace(ctx, t), ag, passthroughGate, model.Artist{ID: "ar1"})
|
||||
Expect(r).To(BeNil())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped,
|
||||
Detail: "no enabled agent provides artist images"}}),
|
||||
"a configured external token must never be silently absent from the chain")
|
||||
})
|
||||
|
||||
It("records a skipped external candidate for synthetic artists", func() {
|
||||
a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}}
|
||||
ag := imageAgents(a)
|
||||
t := &ChainTrace{}
|
||||
|
||||
_, _, _ = fetchArtistImage(withTrace(ctx, t), ag, passthroughGate,
|
||||
model.Artist{ID: consts.VariousArtistsID, Name: "Various Artists"})
|
||||
Expect(t.Steps()).To(HaveLen(1))
|
||||
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeSkipped))
|
||||
Expect(t.Steps()[0].Detail).To(ContainSubstring("synthetic"))
|
||||
})
|
||||
|
||||
It("clears typographic characters from the query name unless preserving unicode", func() {
|
||||
conf.Server.DevPreserveUnicodeInExternalCalls = false
|
||||
a := &fakeImageAgent{name: "agentA"}
|
||||
ag := imageAgents(a)
|
||||
|
||||
_, _, _ = fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "AC’DC"})
|
||||
Expect(a.gotArtistName).To(Equal(str.Clear("AC’DC")))
|
||||
})
|
||||
|
||||
It("falls through to a later agent, and its success beats the earlier error", func() {
|
||||
a := &fakeImageAgent{name: "agentA", err: errBreakerOpen}
|
||||
b := &fakeImageAgent{name: "agentB", imgs: []agents.ExternalImage{img("/b", 50)}}
|
||||
ag := imageAgents(a, b)
|
||||
|
||||
r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
|
||||
Expect(r).ToNot(BeNil())
|
||||
defer r.Close()
|
||||
Expect(name).To(Equal("agentB"))
|
||||
Expect(err).ToNot(HaveOccurred(), "a later hit clears an earlier agent's error")
|
||||
Expect(a.artistCalls).To(Equal(1))
|
||||
Expect(b.artistCalls).To(Equal(1))
|
||||
})
|
||||
|
||||
It("reports a clean miss when every agent finds nothing", func() {
|
||||
a := &fakeImageAgent{name: "agentA"} // no images, no error -> not found
|
||||
b := &fakeImageAgent{name: "agentB", err: agents.ErrNotFound}
|
||||
ag := imageAgents(a, b)
|
||||
|
||||
r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
|
||||
Expect(r).To(BeNil())
|
||||
Expect(name).To(BeEmpty())
|
||||
Expect(err).ToNot(HaveOccurred(), "not-found is definitive, never a transient failure")
|
||||
})
|
||||
|
||||
It("reports an error when one agent fails transiently and the rest find nothing", func() {
|
||||
a := &fakeImageAgent{name: "agentA", err: agents.ErrNotFound}
|
||||
b := &fakeImageAgent{name: "agentB", err: context.DeadlineExceeded}
|
||||
ag := imageAgents(a, b)
|
||||
|
||||
r, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
|
||||
Expect(r).To(BeNil())
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
// The worker reschedules on this delay, so it is only honored if the agent loop
|
||||
// returns it. Two throttled agents: the longest wait is the one that must survive.
|
||||
It("returns the longest retry delay the providers asked for", func() {
|
||||
a := &fakeImageAgent{name: "agentA", err: &agents.RetryLaterError{RetryIn: 10 * time.Second}}
|
||||
b := &fakeImageAgent{name: "agentB", err: &agents.RetryLaterError{RetryIn: 5 * time.Second}}
|
||||
ag := imageAgents(a, b)
|
||||
|
||||
r, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
|
||||
Expect(r).To(BeNil())
|
||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(retry.RetryIn).To(Equal(10 * time.Second))
|
||||
})
|
||||
|
||||
It("returns no delay when the provider did not ask for one", func() {
|
||||
ag := imageAgents(&fakeImageAgent{name: "agentA", err: errors.New("boom")})
|
||||
|
||||
_, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
|
||||
Expect(err).To(HaveOccurred())
|
||||
_, ok := errors.AsType[*agents.RetryLaterError](err)
|
||||
Expect(ok).To(BeFalse(), "a plain failure must not look like a throttle")
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fetchAlbumImage", func() {
|
||||
It("returns the winning agent's image and name", func() {
|
||||
a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}}
|
||||
ag := imageAgents(a)
|
||||
|
||||
r, name, err := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album", AlbumArtist: "Artist"})
|
||||
Expect(r).ToNot(BeNil())
|
||||
defer r.Close()
|
||||
Expect(name).To(Equal("agentA"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(a.albumCalls).To(Equal(1))
|
||||
})
|
||||
|
||||
It("records a skipped external candidate when no agent provides album images", func() {
|
||||
ag := imageAgents()
|
||||
t := &ChainTrace{}
|
||||
|
||||
r, _, err := fetchAlbumImage(withTrace(ctx, t), ag, passthroughGate, model.Album{Name: "Album"})
|
||||
Expect(r).To(BeNil())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped,
|
||||
Detail: "no enabled agent provides album images"}}),
|
||||
"a configured external token must never be silently absent from the chain")
|
||||
})
|
||||
|
||||
It("reports an error when the only agent fails transiently", func() {
|
||||
a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded}
|
||||
ag := imageAgents(a)
|
||||
|
||||
r, _, err := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album"})
|
||||
Expect(r).To(BeNil())
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("gate naming", func() {
|
||||
It("invokes the gate once per agent, keyed by agent name", func() {
|
||||
a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded}
|
||||
b := &fakeImageAgent{name: "agentB", imgs: []agents.ExternalImage{img("/b", 1)}}
|
||||
ag := imageAgents(a, b)
|
||||
|
||||
var gatedNames []string
|
||||
gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
||||
gatedNames = append(gatedNames, name)
|
||||
return f()
|
||||
}
|
||||
|
||||
r, _, _ := fetchArtistImage(ctx, ag, gate, model.Artist{ID: "ar1"})
|
||||
Expect(r).ToNot(BeNil())
|
||||
defer r.Close()
|
||||
Expect(gatedNames).To(Equal([]string{"agentA", "agentB"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,441 +1,134 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
_ "image/gif"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"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/resources"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
var ErrUnavailable = errors.New("artwork unavailable")
|
||||
|
||||
// errStaleSource means the backing file's mtime no longer matches RefMtime, so the stored hash may be stale.
|
||||
var errStaleSource = errors.New("artwork: source file changed since resolution")
|
||||
|
||||
// Image is one servable artwork response.
|
||||
type Image struct {
|
||||
io.ReadCloser
|
||||
Hash string // pixel identity; "" for placeholders
|
||||
ETag string // representation validator; "" means Hash applies (full-size original)
|
||||
LastUpdated time.Time
|
||||
Placeholder bool
|
||||
}
|
||||
|
||||
// representationTag varies with dimensions and encode settings, so a config change invalidates
|
||||
// a revalidating client's cache even though the pixel hash is unchanged.
|
||||
func representationTag(hash string, size int, square bool) string {
|
||||
return fmt.Sprintf("%s.%d.%v.%s", hash, size, square, formatQualityTag())
|
||||
}
|
||||
|
||||
type Artwork interface {
|
||||
// Get returns ErrUnavailable when there is nothing to serve and model.ErrNotFound when
|
||||
// the id resolves to nothing, so the caller can pick placeholder vs 404.
|
||||
Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error)
|
||||
// GetOrPlaceholder accepts an artwork token or a raw entity id, falling back to the
|
||||
// kind's placeholder image (never resized, Placeholder=true).
|
||||
GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error)
|
||||
Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error)
|
||||
GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error)
|
||||
}
|
||||
|
||||
func NewArtwork(ds model.DataStore, cache cache.FileCache, store *ImageStore, ffm ffmpeg.FFmpeg) Artwork {
|
||||
return &service{ds: ds, cache: cache, store: store, ffmpeg: ffm}
|
||||
func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, provider external.Provider) Artwork {
|
||||
return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider}
|
||||
}
|
||||
|
||||
// entityExists reports whether the entity an artwork id points at is still there: state rows
|
||||
// outlive a deleted entity until the next prune, so a servable row is not evidence of its owner.
|
||||
func entityExists(ctx context.Context, ds model.DataStore, artID model.ArtworkID) bool {
|
||||
var found bool
|
||||
var err error
|
||||
switch artID.Kind {
|
||||
case model.KindArtistArtwork:
|
||||
found, err = ds.Artist(ctx).Exists(artID.ID)
|
||||
case model.KindAlbumArtwork:
|
||||
found, err = ds.Album(ctx).Exists(artID.ID)
|
||||
case model.KindMediaFileArtwork:
|
||||
found, err = ds.MediaFile(ctx).Exists(artID.ID)
|
||||
case model.KindPlaylistArtwork:
|
||||
found, err = ds.Playlist(ctx).Exists(artID.ID)
|
||||
case model.KindRadioArtwork:
|
||||
found, err = ds.Radio(ctx).Exists(artID.ID)
|
||||
case model.KindDiscArtwork:
|
||||
albumID, _, perr := model.ParseDiscArtworkID(artID.ID)
|
||||
if perr != nil {
|
||||
return false
|
||||
}
|
||||
found, err = ds.Album(ctx).Exists(albumID)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return err == nil && found
|
||||
type artwork struct {
|
||||
ds model.DataStore
|
||||
cache cache.FileCache
|
||||
ffmpeg ffmpeg.FFmpeg
|
||||
provider external.Provider
|
||||
}
|
||||
|
||||
type service struct {
|
||||
ds model.DataStore
|
||||
cache cache.FileCache
|
||||
store *ImageStore
|
||||
ffmpeg ffmpeg.FFmpeg
|
||||
type artworkReader interface {
|
||||
cache.Item
|
||||
LastUpdated() time.Time
|
||||
Reader(ctx context.Context) (io.ReadCloser, string, error)
|
||||
}
|
||||
|
||||
func (s *service) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error) {
|
||||
artID, err := s.parseArtworkID(ctx, id)
|
||||
var img *Image
|
||||
func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) {
|
||||
artID, err := a.getArtworkId(ctx, id)
|
||||
if err == nil {
|
||||
img, err = s.Get(ctx, artID, size, square)
|
||||
reader, lastUpdate, err = a.Get(ctx, artID, size, square)
|
||||
}
|
||||
// Only a resolvable entity with no art gets a placeholder; an unknown id must stay
|
||||
// ErrNotFound so callers can still answer 404 / Subsonic error 70.
|
||||
if errors.Is(err, ErrUnavailable) {
|
||||
return placeholderImage(artID.Kind), nil
|
||||
if artID.Kind == model.KindArtistArtwork {
|
||||
reader, _ = resources.FS().Open(consts.PlaceholderArtistArt)
|
||||
} else {
|
||||
reader, _ = resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||
}
|
||||
return reader, consts.ServerStart, nil
|
||||
}
|
||||
return img, err
|
||||
return reader, lastUpdate, err
|
||||
}
|
||||
|
||||
func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
||||
if artID.ID == "" {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if size < 0 {
|
||||
size = 0 // a negative size means full-size, not a giant (OOM) resize rectangle
|
||||
}
|
||||
switch artID.Kind {
|
||||
case model.KindDiscArtwork:
|
||||
return s.serveDisc(ctx, artID, size, square)
|
||||
case model.KindMediaFileArtwork:
|
||||
return s.serveMediaFile(ctx, artID, size, square)
|
||||
default:
|
||||
return s.serveEntity(ctx, artID, size, square)
|
||||
}
|
||||
}
|
||||
|
||||
// requestRecheckAge throttles view-triggered rechecks so reopening a genuinely-absent page can't
|
||||
// hammer external services; below StaleAbsentAge to catch younger absences.
|
||||
const requestRecheckAge = time.Hour
|
||||
|
||||
func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
||||
ia, err := s.ds.Artwork(ctx).GetItemArtwork(artID.Kind, artID.ID, model.ImageTypePrimary)
|
||||
switch {
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
return s.provisional(ctx, artID, size, square)
|
||||
case err != nil:
|
||||
return nil, err
|
||||
case ia.Hash == "":
|
||||
// Inserts an immediately-eligible recheck for a settled absent row.
|
||||
if time.Since(ia.AttemptedAt) > requestRecheckAge {
|
||||
s.enqueue(ctx, artID, model.ArtworkPriorityBump)
|
||||
}
|
||||
return nil, ErrUnavailable
|
||||
default:
|
||||
return s.serveHash(ctx, artID, ia, size, square)
|
||||
}
|
||||
}
|
||||
|
||||
// serveSource is the one place bytes become an Image. hash is the pixel identity ("" for disc art)
|
||||
// and doubles as the full-size validator, so an ETag is only needed when resized or hash is "".
|
||||
func (s *service) serveSource(ctx context.Context, key, hash string, lastUpdate time.Time,
|
||||
size int, square bool, open func() (io.ReadCloser, error),
|
||||
) (*Image, error) {
|
||||
if size == 0 && !square {
|
||||
rc, err := open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rc == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
img := &Image{ReadCloser: rc, Hash: hash, LastUpdated: lastUpdate}
|
||||
if hash == "" {
|
||||
img.ETag = representationTag(key, size, square)
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
stream, err := s.cache.Get(ctx, &resizedItem{
|
||||
hash: key, size: size, square: square, ffmpeg: s.ffmpeg, open: open,
|
||||
})
|
||||
func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) {
|
||||
artReader, err := a.getArtworkReader(ctx, artID, size, square)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
return &Image{ReadCloser: stream, Hash: hash, ETag: representationTag(key, size, square), LastUpdated: lastUpdate}, nil
|
||||
}
|
||||
|
||||
// serveHash serves the bytes of a found state row. A mismatch/open error is dangling, but a
|
||||
// cancelled request is not: it must not enqueue a re-resolution.
|
||||
func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *model.ItemArtwork, size int, square bool) (*Image, error) {
|
||||
// Only this path can hand back a deleted entity's bytes; the others load their entity anyway.
|
||||
if !entityExists(ctx, s.ds, artID) {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
art, err := s.ds.Artwork(ctx).GetImage(ia.Hash)
|
||||
r, err := a.cache.Get(ctx, artReader)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return s.dangling(ctx, artID)
|
||||
if !errors.Is(err, context.Canceled) && !errors.Is(err, ErrUnavailable) {
|
||||
log.Error(ctx, "Error accessing image cache", "id", artID, "size", size, err)
|
||||
}
|
||||
return nil, err
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
img, err := s.serveSource(ctx, ia.Hash, ia.Hash, ia.UpdatedAt, size, square,
|
||||
func() (io.ReadCloser, error) { return openOriginal(ia, art.Mime, s.store) })
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil, err
|
||||
}
|
||||
log.Warn(ctx, "Artwork: Could not serve image", "artID", artID, "size", size, err)
|
||||
return s.dangling(ctx, artID)
|
||||
}
|
||||
return img, nil
|
||||
return r, artReader.LastUpdated(), nil
|
||||
}
|
||||
|
||||
// openOriginal enforces the mtime invariant: bytes are never served under a hash they no longer match.
|
||||
func openOriginal(ia *model.ItemArtwork, mime string, store *ImageStore) (io.ReadCloser, error) {
|
||||
if isFileBacked(ia.Source) {
|
||||
f, err := os.Open(ia.SourcePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, err
|
||||
}
|
||||
if ia.RefMtime != 0 && info.ModTime().UnixNano() != ia.RefMtime {
|
||||
f.Close()
|
||||
log.Debug("Artwork: Backing file changed since resolution", "path", ia.SourcePath,
|
||||
"hash", ia.Hash, "resolvedMtime", ia.RefMtime, "currentMtime", info.ModTime().UnixNano())
|
||||
return nil, errStaleSource
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
// Store-backed bytes still carry the source's mtime, to detect edits to embedded art.
|
||||
if ia.SourcePath != "" && ia.RefMtime != 0 {
|
||||
info, err := os.Stat(ia.SourcePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.ModTime().UnixNano() != ia.RefMtime {
|
||||
log.Debug("Artwork: Source file changed since resolution", "path", ia.SourcePath,
|
||||
"hash", ia.Hash, "resolvedMtime", ia.RefMtime, "currentMtime", info.ModTime().UnixNano())
|
||||
return nil, errStaleSource
|
||||
}
|
||||
}
|
||||
return store.Open(ia.Hash, mime)
|
||||
}
|
||||
|
||||
// provisional serves local bytes for an entity with no state row, enqueuing the worker but
|
||||
// never writing a state row itself.
|
||||
func (s *service) provisional(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
||||
item := model.ArtworkQueueItem{ItemKind: artID.Kind.Prefix(), ItemID: artID.ID, ImageType: model.ImageTypePrimary}
|
||||
res, err := newLocalResolver(s.ds, s.ffmpeg).resolve(ctx, item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.enqueue(ctx, artID, model.ArtworkPriorityBump)
|
||||
log.Debug(ctx, "Artwork: Provisional read-through, no state row yet", "artID", artID,
|
||||
"source", res.source, "hit", res.reader != nil)
|
||||
return s.serveResolution(ctx, res, size, square)
|
||||
}
|
||||
|
||||
// serveResolution turns a local resolution's bytes into a servable Image (byte-hash only, no decode).
|
||||
func (s *service) serveResolution(ctx context.Context, res resolution, size int, square bool) (*Image, error) {
|
||||
if res.reader == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
defer res.reader.Close()
|
||||
data, err := readCapped(res.reader)
|
||||
if err != nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
hash, err := hashImage(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
// Keyed by the byte-hash, so the entry lines up with the worker's eventual store entry.
|
||||
return s.serveSource(ctx, hash, hash, unixMtime(res.refMtime), size, square,
|
||||
func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(data)), nil })
|
||||
}
|
||||
|
||||
func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
||||
// The setting is not in the config fingerprint, so honor it at serve time: a direct mf- URL
|
||||
// must fall back to disc/album instead of serving stale persisted embedded art.
|
||||
if !conf.Server.EnableMediaFileCoverArt {
|
||||
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
|
||||
}
|
||||
ia, err := s.ds.Artwork(ctx).GetItemArtwork(model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary)
|
||||
switch {
|
||||
case err == nil && ia.Hash != "":
|
||||
return s.serveHash(ctx, artID, ia, size, square)
|
||||
case err == nil:
|
||||
// absent row: fall through
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
// no row: fall through
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
noRow := errors.Is(err, model.ErrNotFound)
|
||||
|
||||
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if noRow && conf.Server.EnableMediaFileCoverArt && mf.HasCoverArt {
|
||||
return s.provisionalEmbedded(ctx, artID, *mf, size, square)
|
||||
}
|
||||
// Mirror MediaFile.CoverArtID: a track defers to its disc art, which falls back to the album.
|
||||
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
|
||||
}
|
||||
|
||||
// provisionalEmbedded serves a track's embedded art immediately, leaving the state row to the worker.
|
||||
func (s *service) provisionalEmbedded(ctx context.Context, artID model.ArtworkID, mf model.MediaFile, size int, square bool) (*Image, error) {
|
||||
lib, err := loadLibraryView(ctx, s.ds, mf.LibraryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, ok := resolveEmbedded(ctx, lib, s.ffmpeg, mf.Path)
|
||||
s.enqueue(ctx, artID, model.ArtworkPriorityBump)
|
||||
if !ok {
|
||||
// Eligible but unextractable: fall back the way CoverArtID does, not to a placeholder.
|
||||
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
|
||||
}
|
||||
return s.serveResolution(ctx, res, size, square)
|
||||
}
|
||||
|
||||
// serveDisc reads disc art through with no state row and no enqueue, falling back to the album cover.
|
||||
func (s *service) serveDisc(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
||||
dr, err := newDiscArtworkReader(ctx, s.ds, artID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Single-disc albums run the chain too: a disc can carry art distinct from the album cover.
|
||||
selectImage := func() (io.ReadCloser, error) {
|
||||
res, err := dr.selectImage(ctx, s.ffmpeg, conf.Server.DiscArtPriority, &chainState{})
|
||||
return res.reader, err
|
||||
}
|
||||
albumArtID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: dr.album.ID}
|
||||
// Disc art has no state row, hence no content hash: keying on id, album mtime and
|
||||
// DiscArtPriority lets a warm cache answer without running the chain or touching the disk.
|
||||
key := fmt.Sprintf("%s|%d|%s", artID.ID, dr.cacheTime().UnixNano(), conf.Server.DiscArtPriority)
|
||||
img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square, selectImage)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil, err
|
||||
}
|
||||
return s.Get(ctx, albumArtID, size, square)
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// dangling enqueues a re-resolution and reports unavailable, leaving the state row untouched.
|
||||
func (s *service) dangling(ctx context.Context, artID model.ArtworkID) (*Image, error) {
|
||||
log.Debug(ctx, "Artwork: State row points at bytes we cannot serve, re-resolving", "artID", artID)
|
||||
s.enqueue(ctx, artID, model.ArtworkPriorityScan)
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
|
||||
func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) {
|
||||
err := s.ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
||||
ItemKind: artID.Kind.Prefix(),
|
||||
ItemID: artID.ID,
|
||||
ImageType: model.ImageTypePrimary,
|
||||
Priority: priority,
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Artwork: Could not enqueue re-resolution", "artID", artID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func placeholderImage(kind model.Kind) *Image {
|
||||
path := consts.PlaceholderAlbumArt
|
||||
if kind == model.KindArtistArtwork {
|
||||
path = consts.PlaceholderArtistArt
|
||||
}
|
||||
r, _ := resources.FS().Open(path)
|
||||
return &Image{ReadCloser: r, Placeholder: true}
|
||||
}
|
||||
|
||||
type coverArtIDGetter interface {
|
||||
type coverArtGetter interface {
|
||||
CoverArtID() model.ArtworkID
|
||||
}
|
||||
|
||||
// parseArtworkID accepts an artwork token or a raw entity id, resolving the latter to its CoverArtID.
|
||||
func (s *service) parseArtworkID(ctx context.Context, id string) (model.ArtworkID, error) {
|
||||
func (a *artwork) getArtworkId(ctx context.Context, id string) (model.ArtworkID, error) {
|
||||
if id == "" {
|
||||
return model.ArtworkID{}, ErrUnavailable
|
||||
}
|
||||
if artID, err := model.ParseArtworkID(id); err == nil {
|
||||
artID, err := model.ParseArtworkID(id)
|
||||
if err == nil {
|
||||
return artID, nil
|
||||
}
|
||||
entity, err := model.GetEntityByID(ctx, s.ds, id)
|
||||
|
||||
log.Trace(ctx, "ArtworkID invalid. Trying to figure out kind based on the ID", "id", id)
|
||||
entity, err := model.GetEntityByID(ctx, a.ds, id)
|
||||
if err != nil {
|
||||
return model.ArtworkID{}, err
|
||||
}
|
||||
if e, ok := entity.(coverArtIDGetter); ok {
|
||||
return e.CoverArtID(), nil
|
||||
if e, ok := entity.(coverArtGetter); ok {
|
||||
artID = e.CoverArtID()
|
||||
}
|
||||
return model.ArtworkID{}, model.ErrNotFound
|
||||
switch e := entity.(type) {
|
||||
case *model.Artist:
|
||||
log.Trace(ctx, "ID is for an Artist", "id", id, "name", e.Name, "artist", e.Name)
|
||||
case *model.Album:
|
||||
log.Trace(ctx, "ID is for an Album", "id", id, "name", e.Name, "artist", e.AlbumArtist)
|
||||
case *model.MediaFile:
|
||||
log.Trace(ctx, "ID is for a MediaFile", "id", id, "title", e.Title, "album", e.Album)
|
||||
case *model.Playlist:
|
||||
log.Trace(ctx, "ID is for a Playlist", "id", id, "name", e.Name)
|
||||
}
|
||||
return artID, nil
|
||||
}
|
||||
|
||||
// TracingResolver is the CLI's read-only view of resolution: it walks the priority chain, records
|
||||
// the walk and reports the winning source, without ever writing artwork state.
|
||||
type TracingResolver struct {
|
||||
inner *resolver
|
||||
trace *ChainTrace
|
||||
}
|
||||
|
||||
// NewTracingResolver builds a TracingResolver that records its priority-chain walk. Without live
|
||||
// it gets no agents at all, so neither a chain nor any fallback added later can reach a provider;
|
||||
// with it, one item is at most one call per agent, so the rate limiter and breaker are bypassed.
|
||||
func NewTracingResolver(ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, t *ChainTrace, live bool) *TracingResolver {
|
||||
inner := newLocalResolver(ds, ffm)
|
||||
if live {
|
||||
inner = newResolver(ds, ag, ffm, passthroughGate)
|
||||
func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, size int, square bool) (artworkReader, error) {
|
||||
var artReader artworkReader
|
||||
var err error
|
||||
if size > 0 || square {
|
||||
artReader, err = resizedFromOriginal(ctx, a, artID, size, square)
|
||||
} else {
|
||||
switch artID.Kind {
|
||||
case model.KindArtistArtwork:
|
||||
artReader, err = newArtistArtworkReader(ctx, a, artID, a.provider)
|
||||
case model.KindAlbumArtwork:
|
||||
artReader, err = newAlbumArtworkReader(ctx, a, artID, a.provider)
|
||||
case model.KindMediaFileArtwork:
|
||||
artReader, err = newMediafileArtworkReader(ctx, a, artID)
|
||||
case model.KindPlaylistArtwork:
|
||||
artReader, err = newPlaylistArtworkReader(ctx, a, artID)
|
||||
case model.KindDiscArtwork:
|
||||
artReader, err = newDiscArtworkReader(ctx, a, artID)
|
||||
case model.KindRadioArtwork:
|
||||
artReader, err = newRadioArtworkReader(ctx, a, artID)
|
||||
default:
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
}
|
||||
return &TracingResolver{inner: inner, trace: t}
|
||||
}
|
||||
|
||||
// Resolve walks kind's sources for id, recording the walk, and reports the winning source
|
||||
// ("" when none produced an image).
|
||||
func (r *TracingResolver) Resolve(ctx context.Context, kind model.Kind, id string) (string, error) {
|
||||
switch kind {
|
||||
case model.KindArtistArtwork:
|
||||
return r.explain(ctx, r.inner.resolveArtist, id)
|
||||
case model.KindAlbumArtwork:
|
||||
return r.explain(ctx, r.inner.resolveAlbum, id)
|
||||
case model.KindDiscArtwork:
|
||||
return r.explain(ctx, r.inner.resolveDisc, id)
|
||||
case model.KindMediaFileArtwork:
|
||||
return r.explain(ctx, r.inner.resolveMediaFile, id)
|
||||
}
|
||||
return "", fmt.Errorf("artwork: %s artwork has no chain to explain", kind)
|
||||
}
|
||||
|
||||
// explain discards the bytes: nothing downstream persists this resolution, so nothing else
|
||||
// would close the reader either.
|
||||
func (r *TracingResolver) explain(ctx context.Context, resolve func(context.Context, string) (resolution, error), id string) (string, error) {
|
||||
res, err := resolve(withTrace(ctx, r.trace), id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if res.reader != nil {
|
||||
_ = res.reader.Close()
|
||||
}
|
||||
return res.source, nil
|
||||
}
|
||||
|
||||
func unixMtime(mtime int64) time.Time {
|
||||
if mtime <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.Unix(0, mtime) // RefMtime is unix-nanoseconds
|
||||
return artReader, err
|
||||
}
|
||||
|
||||
628
core/artwork/artwork_internal_test.go
Normal file
628
core/artwork/artwork_internal_test.go
Normal file
@ -0,0 +1,628 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"image"
|
||||
"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"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Artwork", func() {
|
||||
var aw *artwork
|
||||
var ds model.DataStore
|
||||
var ffmpeg *tests.MockFFmpeg
|
||||
var folderRepo *fakeFolderRepo
|
||||
ctx := log.NewContext(context.TODO())
|
||||
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers, alSingleDisc model.Album
|
||||
var arMultipleCovers model.Artist
|
||||
var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.ImageCacheSize = "0" // Disable cache
|
||||
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: ""}}
|
||||
alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}}
|
||||
alSingleDisc = model.Album{ID: "888", Name: "Single disc", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}}
|
||||
arMultipleCovers = model.Artist{ID: "777", Name: "All options"}
|
||||
alMultipleCovers = model.Album{
|
||||
ID: "666",
|
||||
Name: "All options",
|
||||
EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3",
|
||||
FolderIDs: []string{"f1"},
|
||||
AlbumArtistID: "777",
|
||||
}
|
||||
mfWithEmbed = model.MediaFile{ID: "22", Path: "tests/fixtures/test.mp3", HasCoverArt: true, AlbumID: "222"}
|
||||
mfAnotherWithEmbed = model.MediaFile{ID: "23", Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true, AlbumID: "666"}
|
||||
mfWithoutEmbed = model.MediaFile{ID: "44", Path: "tests/fixtures/test.ogg", AlbumID: "444"}
|
||||
mfCorruptedCover = model.MediaFile{ID: "45", Path: "tests/fixtures/test.ogg", HasCoverArt: true, AlbumID: "444"}
|
||||
|
||||
cache := GetImageCache()
|
||||
ffmpeg = tests.NewMockFFmpeg("content from ffmpeg")
|
||||
aw = NewArtwork(ds, cache, ffmpeg, nil).(*artwork)
|
||||
})
|
||||
|
||||
Describe("albumArtworkReader", func() {
|
||||
Context("ID not found", func() {
|
||||
It("returns ErrNotFound if album is not in the DB", func() {
|
||||
_, err := newAlbumArtworkReader(ctx, aw, model.MustParseArtworkID("al-NOT-FOUND"), nil)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
Context("Embed images", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = nil
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alOnlyEmbed,
|
||||
alEmbedNotFound,
|
||||
})
|
||||
})
|
||||
It("returns embed cover", func() {
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alOnlyEmbed.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("tests/fixtures/artist/an-album/test.mp3"))
|
||||
})
|
||||
It("returns ErrUnavailable if embed path is not available", func() {
|
||||
ffmpeg.Error = errors.New("not available")
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alEmbedNotFound.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, _, err = aw.Reader(ctx)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
})
|
||||
})
|
||||
Context("External images", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alOnlyExternal,
|
||||
alExternalNotFound,
|
||||
})
|
||||
})
|
||||
It("returns external cover", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"front.png"},
|
||||
}}
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alOnlyExternal.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("tests/fixtures/artist/an-album/front.png"))
|
||||
})
|
||||
It("returns ErrUnavailable if external file is not available", func() {
|
||||
folderRepo.result = []model.Folder{}
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alExternalNotFound.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, _, err = aw.Reader(ctx)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
})
|
||||
})
|
||||
Context("Multiple covers", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg", "front.png", "artist.png"},
|
||||
}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alMultipleCovers,
|
||||
})
|
||||
})
|
||||
DescribeTable("CoverArtPriority",
|
||||
func(priority string, expected string) {
|
||||
conf.Server.CoverArtPriority = priority
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(expected))
|
||||
},
|
||||
Entry(nil, " folder.* , cover.*,embedded,front.*", "tests/fixtures/artist/an-album/cover.jpg"),
|
||||
Entry(nil, "front.* , cover.*, embedded ,folder.*", "tests/fixtures/artist/an-album/front.png"),
|
||||
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{{
|
||||
LibraryPath: testFileLibPath(repoRoot),
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"artist.png"},
|
||||
}}
|
||||
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{
|
||||
arMultipleCovers,
|
||||
})
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alMultipleCovers,
|
||||
})
|
||||
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
mfAnotherWithEmbed,
|
||||
})
|
||||
})
|
||||
DescribeTable("ArtistArtPriority",
|
||||
func(priority string, expected string) {
|
||||
conf.Server.ArtistArtPriority = priority
|
||||
aw, err := newArtistArtworkReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
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"),
|
||||
)
|
||||
})
|
||||
})
|
||||
Describe("mediafileArtworkReader", func() {
|
||||
Context("ID not found", func() {
|
||||
It("returns ErrNotFound if mediafile is not in the DB", func() {
|
||||
_, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-NOT-FOUND"))
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
Context("Embed images", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"front.png"},
|
||||
}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alOnlyEmbed,
|
||||
alOnlyExternal,
|
||||
alSingleDisc,
|
||||
})
|
||||
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
mfWithEmbed,
|
||||
mfWithoutEmbed,
|
||||
mfCorruptedCover,
|
||||
})
|
||||
})
|
||||
It("returns embed cover", func() {
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, mfWithEmbed.CoverArtID())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("tests/fixtures/test.mp3"))
|
||||
})
|
||||
It("returns embed cover if successfully extracted by ffmpeg", func() {
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
r, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
data, _ := io.ReadAll(r)
|
||||
Expect(data).ToNot(BeEmpty())
|
||||
Expect(path).To(Equal("tests/fixtures/test.ogg"))
|
||||
})
|
||||
It("returns album cover if cannot read embed artwork", func() {
|
||||
// Force fromTag to fail
|
||||
mfCorruptedCover.Path = "tests/fixtures/DOES_NOT_EXIST.ogg"
|
||||
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfCorruptedCover)).To(Succeed())
|
||||
// Simulate ffmpeg error
|
||||
ffmpeg.Error = errors.New("not available")
|
||||
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("al-444_0"))
|
||||
})
|
||||
It("returns album cover if media file has no cover art", func() {
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithoutEmbed.ID))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("al-444_0"))
|
||||
})
|
||||
It("falls back to disc cover art when media file has a disc number on a multi-disc album", func() {
|
||||
mfWithDisc := model.MediaFile{ID: "46", Path: "tests/fixtures/test.ogg", AlbumID: "444", DiscNumber: 2}
|
||||
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfWithDisc)).To(Succeed())
|
||||
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithDisc.ID))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should fall back to disc art, which itself falls back to album art
|
||||
Expect(path).To(Equal("dc-444:2_0"))
|
||||
})
|
||||
It("falls back to album cover art for single-disc albums even with a disc number", func() {
|
||||
mfOnSingleDisc := model.MediaFile{ID: "47", Path: "tests/fixtures/test.ogg", AlbumID: "888", DiscNumber: 1}
|
||||
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfOnSingleDisc)).To(Succeed())
|
||||
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfOnSingleDisc.ID))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Single-disc album should skip disc art and go straight to album art
|
||||
Expect(path).To(Equal("al-888_0"))
|
||||
})
|
||||
})
|
||||
})
|
||||
Describe("playlistArtworkReader", func() {
|
||||
Describe("findPlaylistSidecarPath", func() {
|
||||
It("discovers sidecar image next to playlist file", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u")
|
||||
imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg")
|
||||
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
|
||||
|
||||
result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath)
|
||||
Expect(result).To(Equal(imgPath))
|
||||
})
|
||||
|
||||
It("returns empty string when no sidecar image exists", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u")
|
||||
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
|
||||
|
||||
result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath)
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns empty string when playlist has no path", func() {
|
||||
result := findPlaylistSidecarPath(GinkgoT().Context(), "")
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("finds sidecar with different case base name", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
plsPath := filepath.Join(tmpDir, "myplaylist.m3u")
|
||||
imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg")
|
||||
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
|
||||
|
||||
result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath)
|
||||
Expect(result).To(Equal(imgPath))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fromPlaylistExternalImage", func() {
|
||||
It("opens local path from ExternalImageURL", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(tmpDir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("external image data"), 0600)).To(Succeed())
|
||||
|
||||
reader := &playlistArtworkReader{
|
||||
pl: model.Playlist{ExternalImageURL: imgPath},
|
||||
}
|
||||
r, path, err := reader.fromPlaylistExternalImage(ctx)()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
data, _ := io.ReadAll(r)
|
||||
Expect(string(data)).To(Equal("external image data"))
|
||||
r.Close()
|
||||
})
|
||||
|
||||
It("returns nil when ExternalImageURL is empty", func() {
|
||||
reader := &playlistArtworkReader{
|
||||
pl: model.Playlist{ExternalImageURL: ""},
|
||||
}
|
||||
r, path, err := reader.fromPlaylistExternalImage(ctx)()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns error when local file does not exist", func() {
|
||||
reader := &playlistArtworkReader{
|
||||
pl: model.Playlist{ExternalImageURL: "/non/existent/path/cover.jpg"},
|
||||
}
|
||||
r, _, err := reader.fromPlaylistExternalImage(ctx)()
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
})
|
||||
|
||||
It("skips HTTP URL when EnableM3UExternalAlbumArt is false", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
|
||||
reader := &playlistArtworkReader{
|
||||
pl: model.Playlist{ExternalImageURL: "https://example.com/cover.jpg"},
|
||||
}
|
||||
r, path, err := reader.fromPlaylistExternalImage(ctx)()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("still opens local path when EnableM3UExternalAlbumArt is false", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(tmpDir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("local image"), 0600)).To(Succeed())
|
||||
|
||||
reader := &playlistArtworkReader{
|
||||
pl: model.Playlist{ExternalImageURL: imgPath},
|
||||
}
|
||||
r, path, err := reader.fromPlaylistExternalImage(ctx)()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("resizedArtworkReader", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg", "front.png"},
|
||||
}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alMultipleCovers,
|
||||
})
|
||||
})
|
||||
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)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("webp"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(15))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||
})
|
||||
It("returns WebP if original image is not a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(format).To(Equal("webp"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
})
|
||||
When("EnableWebPEncoding is false and square is false", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.EnableWebPEncoding = false
|
||||
})
|
||||
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 a JPG", func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
})
|
||||
When("EnableWebPEncoding is false and square is true", func() {
|
||||
var alCover model.Album
|
||||
|
||||
BeforeEach(func() {
|
||||
conf.Server.EnableWebPEncoding = false
|
||||
})
|
||||
It("returns PNG for square mode", func() {
|
||||
dirName := createImage("png", false, 200)
|
||||
alCover = model.Album{
|
||||
ID: "444",
|
||||
Name: "Only external",
|
||||
FolderIDs: []string{"tmp"},
|
||||
}
|
||||
folderRepo.result = []model.Folder{{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"
|
||||
r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), 200, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
})
|
||||
When("Requested size is larger than original", func() {
|
||||
It("clamps size to original dimensions", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
// front.png is 16x16, requesting 99999 should return at original size
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 99999, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, _, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should be clamped to original size (16), not 99999
|
||||
Expect(img.Bounds().Size().X).To(Equal(16))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(16))
|
||||
})
|
||||
|
||||
It("clamps square size to original dimensions", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
// front.png is 16x16, requesting 99999 with square should return 16x16 square
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 99999, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, _, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should be clamped to original size (16), not 99999
|
||||
Expect(img.Bounds().Size().X).To(Equal(16))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(16))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func createImage(format string, landscape bool, size int) string {
|
||||
var img image.Image
|
||||
|
||||
if landscape {
|
||||
img = image.NewRGBA(image.Rect(0, 0, size, size/2))
|
||||
} else {
|
||||
img = image.NewRGBA(image.Rect(0, 0, size/2, size))
|
||||
}
|
||||
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
f, _ := os.Create(filepath.Join(tmpDir, "cover."+format))
|
||||
defer f.Close()
|
||||
switch format {
|
||||
case "png":
|
||||
_ = png.Encode(f, img)
|
||||
case "jpg":
|
||||
_ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75})
|
||||
}
|
||||
|
||||
return tmpDir
|
||||
}
|
||||
@ -11,26 +11,13 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/core/storage"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
func TestArtwork(t *testing.T) {
|
||||
// Runs unconditionally: the two leaks below are pre-existing and out of this
|
||||
// package's control, so they're ignored by exact top-function instead.
|
||||
defer goleak.VerifyNone(t,
|
||||
goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"),
|
||||
// notify's own init() starts a singleton tree the moment it's imported (via
|
||||
// core/storage/local or plugins); recursive on darwin, nonrecursive on linux.
|
||||
goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"),
|
||||
goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).dispatch"),
|
||||
goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).internal"),
|
||||
)
|
||||
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
@ -38,6 +25,7 @@ func TestArtwork(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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 }
|
||||
@ -81,37 +69,3 @@ func (s *osDirStorage) FS() (storage.MusicFS, error) {
|
||||
}
|
||||
return osDirFS{os.DirFS(s.root)}, nil
|
||||
}
|
||||
|
||||
// fakeFolderRepo covers the three FolderRepository methods the resolvers reach for. The zero value
|
||||
// answers as an unremarkable library does; the fields drive the album-root lookup and its failures.
|
||||
type fakeFolderRepo struct {
|
||||
model.FolderRepository
|
||||
result []model.Folder
|
||||
err error
|
||||
parentResult *model.Folder
|
||||
getErr error
|
||||
getCallCount int
|
||||
// hasOtherAudio is returned by HasAudioOutsideFolders (the album-root check).
|
||||
// False means the parent qualifies as an album root.
|
||||
hasOtherAudio bool
|
||||
otherAudioErr error
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) {
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
|
||||
return f.hasOtherAudio, f.otherAudioErr
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) Get(string) (*model.Folder, error) {
|
||||
f.getCallCount++
|
||||
if f.getErr != nil {
|
||||
return nil, f.getErr
|
||||
}
|
||||
if f.parentResult != nil {
|
||||
return f.parentResult, nil
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
@ -1,523 +1,57 @@
|
||||
package artwork
|
||||
package artwork_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Artwork", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
artRepo *tests.MockArtworkRepo
|
||||
queueRepo *tests.MockArtworkQueueRepo
|
||||
albumRepo *tests.MockAlbumRepo
|
||||
mfRepo *tests.MockMediaFileRepo
|
||||
folderRepo *fakeFolderRepo
|
||||
libRepo *tests.MockLibraryRepo
|
||||
ffm *tests.MockFFmpeg
|
||||
store *ImageStore
|
||||
imgCache cache.FileCache
|
||||
svc Artwork
|
||||
repoRoot string
|
||||
coverBytes []byte
|
||||
seedEntity func(kind, id string)
|
||||
)
|
||||
|
||||
primaryKey := func(kind, id string) string { return kind + "|" + id + "|" + model.ImageTypePrimary }
|
||||
|
||||
seedFoundStore := func(kind, id string, imgBytes []byte) string {
|
||||
hash, err := hashImage(bytes.NewReader(imgBytes))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(store.Write(hash, "image/jpeg", bytes.NewReader(imgBytes))).To(Succeed())
|
||||
Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed())
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed())
|
||||
seedEntity(kind, id)
|
||||
return hash
|
||||
}
|
||||
|
||||
// Without its owning entity, a state row is not served at all.
|
||||
seedEntity = func(kind, id string) {
|
||||
GinkgoHelper()
|
||||
switch kind {
|
||||
case "al":
|
||||
Expect(albumRepo.Put(&model.Album{ID: id, Name: "Album"})).To(Succeed())
|
||||
case "mf":
|
||||
Expect(mfRepo.Put(&model.MediaFile{ID: id})).To(Succeed())
|
||||
}
|
||||
}
|
||||
|
||||
readAll := func(img *Image) []byte {
|
||||
GinkgoHelper()
|
||||
defer img.Close()
|
||||
data, err := io.ReadAll(img)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return data
|
||||
}
|
||||
var aw artwork.Artwork
|
||||
var ds model.DataStore
|
||||
var ffmpeg *tests.MockFFmpeg
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = context.Background()
|
||||
var err error
|
||||
repoRoot, err = os.Getwd()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
coverBytes, err = os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
conf.Server.ImageCacheSize = "0" // Disable cache
|
||||
cache := artwork.GetImageCache()
|
||||
ffmpeg = tests.NewMockFFmpeg("content from ffmpeg")
|
||||
aw = artwork.NewArtwork(ds, cache, ffmpeg, nil)
|
||||
})
|
||||
|
||||
conf.Server.EnableWebPEncoding = false
|
||||
conf.Server.CoverArtQuality = 75
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
conf.Server.DiscArtPriority = "cover.*"
|
||||
conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir())
|
||||
Context("GetOrPlaceholder", func() {
|
||||
Context("Empty ID", func() {
|
||||
It("returns placeholder if album is not in the DB", func() {
|
||||
r, _, err := aw.GetOrPlaceholder(context.Background(), "", 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
artRepo = tests.CreateMockArtworkRepo()
|
||||
queueRepo = tests.CreateMockArtworkQueueRepo()
|
||||
albumRepo = tests.CreateMockAlbumRepo()
|
||||
mfRepo = tests.CreateMockMediaFileRepo()
|
||||
folderRepo = &fakeFolderRepo{}
|
||||
libRepo = &tests.MockLibraryRepo{}
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtwork: artRepo,
|
||||
MockedArtworkQueue: queueRepo,
|
||||
MockedAlbum: albumRepo,
|
||||
MockedMediaFile: mfRepo,
|
||||
MockedFolder: folderRepo,
|
||||
MockedLibrary: libRepo,
|
||||
}
|
||||
ffm = tests.NewMockFFmpeg("")
|
||||
store = NewImageStore(GinkgoT().TempDir())
|
||||
imgCache = cache.NewFileCache("ServingTest", "100MB", "images", 0,
|
||||
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
|
||||
return arg.(artworkReader).Reader(ctx)
|
||||
ph, err := resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
phBytes, err := io.ReadAll(ph)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
result, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(result).To(Equal(phBytes))
|
||||
})
|
||||
Eventually(func() bool { return imgCache.Available(ctx) }, 10*time.Second).Should(BeTrue())
|
||||
svc = NewArtwork(ds, imgCache, store, ffm)
|
||||
})
|
||||
|
||||
Describe("found state", func() {
|
||||
It("serves a store-backed found image sized (cache miss resizes, second call is a cache hit)", func() {
|
||||
seedFoundStore("al", "al1", coverBytes)
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 100, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// A resized response versions its ETag with the encode settings, not the pixel hash.
|
||||
Expect(img.ETag).To(Equal(representationTag(img.Hash, 100, false)))
|
||||
Expect(img.ETag).ToNot(Equal(img.Hash))
|
||||
resized := readAll(img)
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(resized))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(cfg.Width).To(Equal(100))
|
||||
|
||||
// Deleting the store file proves the warm entry serves without touching the original.
|
||||
hash, _ := hashImage(bytes.NewReader(coverBytes))
|
||||
Expect(os.Remove(store.path(hash, "image/jpeg"))).To(Succeed())
|
||||
Eventually(func(g Gomega) {
|
||||
img2, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 100, false)
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
g.Expect(readAll(img2)).To(Equal(resized))
|
||||
}).Should(Succeed())
|
||||
})
|
||||
|
||||
It("treats a negative size as a full-size request, not a giant resize", func() {
|
||||
seedFoundStore("al", "alneg", coverBytes)
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("al-alneg"), -2000000000, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(img)).To(Equal(coverBytes), "original bytes, no resize (would OOM)")
|
||||
})
|
||||
|
||||
It("streams a file-backed found image at full size", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(dir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||
mtime := fileMtime(imgPath)
|
||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed())
|
||||
seedEntity("al", "al2")
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al2", Hash: "aaaaaaaaaaaaaaaa",
|
||||
Source: "folder", SourcePath: imgPath, RefMtime: mtime,
|
||||
})).To(Succeed())
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
})
|
||||
|
||||
It("treats a full-size mtime mismatch as dangling: unavailable, re-enqueued at Scan, state untouched", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(dir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed())
|
||||
seedEntity("al", "al3")
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al3", Hash: "bbbbbbbbbbbbbbbb",
|
||||
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
||||
})).To(Succeed())
|
||||
|
||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al3"), 0, false)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(queueRepo.Data[primaryKey("al", "al3")].Priority).To(Equal(model.ArtworkPriorityScan))
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb"))
|
||||
})
|
||||
|
||||
It("enforces the mtime rule on the sized (loader) path too", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(dir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed())
|
||||
seedEntity("al", "al3b")
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al3b", Hash: "cccccccccccccccc",
|
||||
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
||||
})).To(Succeed())
|
||||
|
||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al3b"), 100, false)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(queueRepo.Data[primaryKey("al", "al3b")].Priority).To(Equal(model.ArtworkPriorityScan))
|
||||
})
|
||||
|
||||
// State rows outlive a deleted entity until the next prune.
|
||||
It("refuses to serve a found row whose entity is gone", func() {
|
||||
hash := seedFoundStore("al", "alzz", coverBytes)
|
||||
Expect(hash).ToNot(BeEmpty())
|
||||
albumRepo.SetData(model.Albums{}) // the album is deleted; its artwork row survives
|
||||
|
||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-alzz"), 0, false)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
})
|
||||
|
||||
It("does not re-enqueue a recently-attempted absent state", func() {
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now(),
|
||||
})).To(Succeed())
|
||||
|
||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al4"), 0, false)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(queueRepo.Data).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("promotes a stale absent state at Bump priority on view", func() {
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al4b", AttemptedAt: time.Now().Add(-2 * requestRecheckAge),
|
||||
})).To(Succeed())
|
||||
|
||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al4b"), 0, false)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(queueRepo.Data[primaryKey("al", "al4b")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4b", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("provisional read-through", func() {
|
||||
It("serves local folder art, enqueues a Bump, and writes no state row", func() {
|
||||
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
|
||||
albumRepo.SetData(model.Albums{{ID: "al5", Name: "Album", FolderIDs: []string{"f1"}}})
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al5"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
|
||||
Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("returns ErrUnavailable and enqueues a Bump when nothing local resolves", func() {
|
||||
folderRepo.result = nil
|
||||
albumRepo.SetData(model.Albums{{ID: "al6", Name: "Album"}})
|
||||
|
||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al6"), 0, false)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(queueRepo.Data[primaryKey("al", "al6")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("media file", func() {
|
||||
It("serves a track's own found art", func() {
|
||||
seedFoundStore("mf", "mf1", coverBytes)
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
})
|
||||
|
||||
It("ignores a resolved mf row and delegates to the album when per-track art is disabled", func() {
|
||||
conf.Server.EnableMediaFileCoverArt = false
|
||||
seedFoundStore("mf", "mf7", []byte("stale embedded track art"))
|
||||
seedFoundStore("al", "albz", coverBytes)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "mf7", AlbumID: "albz"}})
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf7"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(img)).To(Equal(coverBytes), "album art, not the persisted embedded art")
|
||||
})
|
||||
|
||||
It("delegates to the album when the track's state is absent", func() {
|
||||
seedFoundStore("al", "albm", coverBytes)
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed())
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "mf2", AlbumID: "albm"}})
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf2"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
_, mfEnq := queueRepo.Data[primaryKey("mf", "mf2")]
|
||||
Expect(mfEnq).To(BeFalse())
|
||||
})
|
||||
|
||||
It("delegates to the album (no enqueue) when the track is not embedded-eligible", func() {
|
||||
conf.Server.EnableMediaFileCoverArt = true
|
||||
seedFoundStore("al", "albn", coverBytes)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "mf3", AlbumID: "albn", HasCoverArt: false}})
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf3"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
_, mfEnq := queueRepo.Data[primaryKey("mf", "mf3")]
|
||||
Expect(mfEnq).To(BeFalse())
|
||||
})
|
||||
|
||||
It("extracts embedded art provisionally and enqueues the track when eligible", func() {
|
||||
conf.Server.EnableMediaFileCoverArt = true
|
||||
mfRepo.SetData(model.MediaFiles{{
|
||||
ID: "mf4", AlbumID: "albo", HasCoverArt: true,
|
||||
Path: "tests/fixtures/artist/an-album/test.mp3", LibraryID: 0,
|
||||
}})
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf4"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
||||
Expect(queueRepo.Data[primaryKey("mf", "mf4")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("delegates a multi-disc track to its disc art, not straight to the album", func() {
|
||||
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
|
||||
albumRepo.SetData(model.Albums{{ID: "aldd", Name: "Album", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "One", 2: "Two"}}})
|
||||
seedFoundStore("al", "aldd", []byte("album-art-distinct")) // album's own found art differs
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "mf5", AlbumID: "aldd", DiscNumber: 1, HasCoverArt: false}})
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf5"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// The disc-folder image, not the album's own art: proof it routed through serveDisc.
|
||||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
})
|
||||
|
||||
It("falls back to the album when an eligible track's embedded art will not extract", func() {
|
||||
conf.Server.EnableMediaFileCoverArt = true
|
||||
// HasCoverArt is set, but the file is not audio, so nothing extracts.
|
||||
mfRepo.SetData(model.MediaFiles{{
|
||||
ID: "mfbad", AlbumID: "albad", LibraryID: 0, HasCoverArt: true,
|
||||
Path: "tests/fixtures/artist/an-album/front.png",
|
||||
}})
|
||||
albumRepo.SetData(model.Albums{{ID: "albad", Name: "Album"}})
|
||||
seedFoundStore("al", "albad", coverBytes)
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mfbad"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(img)).To(Equal(coverBytes), "a placeholder here would be worse than the album cover")
|
||||
})
|
||||
|
||||
It("routes a single-disc track through disc resolution too", func() {
|
||||
// DiscArtPriority applies to single-disc albums too, over the album's own found art.
|
||||
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
|
||||
albumRepo.SetData(model.Albums{{ID: "alsd", Name: "Album", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}}})
|
||||
seedFoundStore("al", "alsd", []byte("album-art-distinct"))
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "mf6", AlbumID: "alsd", DiscNumber: 1, HasCoverArt: false}})
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf6"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("disc", func() {
|
||||
It("serves a local disc-folder image", func() {
|
||||
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
|
||||
albumRepo.SetData(model.Albums{{ID: "aldc", Name: "Album", FolderIDs: []string{"f1"}}})
|
||||
|
||||
img, err := svc.Get(ctx, model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc", 1), nil), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
})
|
||||
|
||||
// The resize cache keys on id + album mtime, not on the bytes, so dropping the source
|
||||
// between the two requests is what shows a warm hit never touches the filesystem.
|
||||
It("serves a sized disc image from cache without re-reading the source", func() {
|
||||
folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}}
|
||||
albumRepo.SetData(model.Albums{{ID: "aldc3", Name: "Album", FolderIDs: []string{"f1"}}})
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc3", 1), nil)
|
||||
|
||||
first, err := svc.Get(ctx, discID, 64, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
warmed := readAll(first)
|
||||
Expect(warmed).ToNot(BeEmpty())
|
||||
|
||||
folderRepo.result = nil
|
||||
|
||||
second, err := svc.Get(ctx, discID, 64, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(second)).To(Equal(warmed), "a warm sized request must not touch the source")
|
||||
})
|
||||
|
||||
// A disc image can change without the album row changing, so the key folds in ImagesUpdatedAt.
|
||||
It("invalidates the cached image when the folder's images change", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"},
|
||||
ImagesUpdatedAt: time.Now().Add(-time.Hour),
|
||||
}}
|
||||
albumRepo.SetData(model.Albums{{ID: "aldc4", Name: "Album", FolderIDs: []string{"f1"}}})
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc4", 1), nil)
|
||||
|
||||
first, err := svc.Get(ctx, discID, 64, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
firstKey := first.ETag
|
||||
readAll(first)
|
||||
|
||||
folderRepo.result[0].ImagesUpdatedAt = time.Now()
|
||||
second, err := svc.Get(ctx, discID, 64, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
readAll(second)
|
||||
Expect(second.ETag).ToNot(Equal(firstKey), "a replaced image must not keep the old cache entry")
|
||||
})
|
||||
|
||||
// Disc art has no content hash, so without an explicit validator every ETag would be empty.
|
||||
It("gives a full-size disc image a validator that tracks the source", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"},
|
||||
ImagesUpdatedAt: time.Now().Add(-time.Hour),
|
||||
}}
|
||||
albumRepo.SetData(model.Albums{{ID: "aldc5", Name: "Album", FolderIDs: []string{"f1"}}})
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc5", 1), nil)
|
||||
|
||||
first, err := svc.Get(ctx, discID, 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
readAll(first)
|
||||
Expect(first.ETag).ToNot(BeEmpty())
|
||||
|
||||
folderRepo.result[0].ImagesUpdatedAt = time.Now()
|
||||
second, err := svc.Get(ctx, discID, 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
readAll(second)
|
||||
Expect(second.ETag).ToNot(Equal(first.ETag), "a replaced image must not revalidate as unchanged")
|
||||
})
|
||||
|
||||
It("falls back to album art when no disc image matches", func() {
|
||||
folderRepo.result = nil
|
||||
albumRepo.SetData(model.Albums{{ID: "aldc2", Name: "Album"}})
|
||||
seedFoundStore("al", "aldc2", coverBytes)
|
||||
|
||||
img, err := svc.Get(ctx, model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc2", 1), nil), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetOrPlaceholder", func() {
|
||||
It("accepts a raw entity id and serves its cover art", func() {
|
||||
albumRepo.SetData(model.Albums{{ID: "rawal", Name: "Album"}})
|
||||
seedFoundStore("al", "rawal", coverBytes)
|
||||
|
||||
img, err := svc.GetOrPlaceholder(ctx, "rawal", 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Placeholder).To(BeFalse())
|
||||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
})
|
||||
|
||||
It("falls back to the album placeholder ignoring size and square", func() {
|
||||
img, err := svc.GetOrPlaceholder(ctx, "", 300, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Placeholder).To(BeTrue())
|
||||
Expect(img.Hash).To(BeEmpty())
|
||||
Expect(img.LastUpdated).To(BeZero())
|
||||
|
||||
ph, err := resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
phBytes, _ := io.ReadAll(ph)
|
||||
Expect(readAll(img)).To(Equal(phBytes))
|
||||
})
|
||||
|
||||
It("falls back to the artist placeholder for an absent artist", func() {
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed())
|
||||
|
||||
img, err := svc.GetOrPlaceholder(ctx, "ar-arph", 300, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Placeholder).To(BeTrue())
|
||||
|
||||
ph, err := resources.FS().Open(consts.PlaceholderArtistArt)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
phBytes, _ := io.ReadAll(ph)
|
||||
Expect(readAll(img)).To(Equal(phBytes))
|
||||
})
|
||||
|
||||
// "No art" and "no such entity" are different answers: clients 404 only on the latter.
|
||||
It("reports not-found rather than a placeholder for an id with no entity", func() {
|
||||
_, err := svc.GetOrPlaceholder(ctx, "al-nosuchalbum", 0, false)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
|
||||
_, err = svc.GetOrPlaceholder(ctx, "nosuchrawid", 0, false)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Context("Get", func() {
|
||||
Context("Empty ID", func() {
|
||||
It("returns an ErrUnavailable error", func() {
|
||||
_, _, err := aw.Get(context.Background(), model.ArtworkID{}, 0, false)
|
||||
Expect(err).To(MatchError(artwork.ErrUnavailable))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func fileMtime(path string) int64 {
|
||||
GinkgoHelper()
|
||||
info, err := os.Stat(path)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return info.ModTime().UnixNano()
|
||||
}
|
||||
|
||||
var _ = Describe("EntityExists", func() {
|
||||
var ctx context.Context
|
||||
var ds *tests.MockDataStore
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
albumRepo := tests.CreateMockAlbumRepo()
|
||||
albumRepo.SetData(model.Albums{{ID: "al1"}})
|
||||
artistRepo := tests.CreateMockArtistRepo()
|
||||
artistRepo.SetData(model.Artists{{ID: "ar1"}})
|
||||
radioRepo := tests.CreateMockedRadioRepo()
|
||||
Expect(radioRepo.Put(&model.Radio{ID: "ra1", Name: "R"})).To(Succeed())
|
||||
ds = &tests.MockDataStore{MockedAlbum: albumRepo, MockedArtist: artistRepo, MockedRadio: radioRepo}
|
||||
})
|
||||
|
||||
DescribeTable("reports whether the owning entity is still there",
|
||||
func(id string, expected bool) {
|
||||
Expect(entityExists(ctx, ds, model.MustParseArtworkID(id))).To(Equal(expected))
|
||||
},
|
||||
Entry("existing album", "al-al1", true),
|
||||
Entry("deleted album", "al-gone", false),
|
||||
Entry("existing artist", "ar-ar1", true),
|
||||
Entry("deleted artist", "ar-gone", false),
|
||||
Entry("existing radio", "ra-ra1", true),
|
||||
Entry("deleted radio", "ra-gone", false),
|
||||
// Disc art has no entity of its own; it stands or falls with its album.
|
||||
Entry("disc of an existing album", "dc-al1:1", true),
|
||||
Entry("disc of a deleted album", "dc-gone:1", false),
|
||||
Entry("malformed disc id", "dc-nodiscnum", false),
|
||||
)
|
||||
})
|
||||
|
||||
189
core/artwork/benchmark_e2e_test.go
Normal file
189
core/artwork/benchmark_e2e_test.go
Normal file
@ -0,0 +1,189 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
)
|
||||
|
||||
// setupE2EBenchmark creates an artwork instance with a real album cover image on disk,
|
||||
// backed by either a real file cache or disabled cache depending on cacheSize.
|
||||
// Note: This benchmarks artwork.Get() directly (not the full HTTP handler), which covers
|
||||
// the critical path (source selection, decode, resize, encode, cache). This is a deliberate
|
||||
// spec deviation — the full HTTP round-trip benchmark requires significant infrastructure
|
||||
// (DB, scanner, fake filesystem) and can be added later if HTTP overhead proves significant.
|
||||
//
|
||||
// Depends on fakeFolderRepo defined in reader_artist_test.go (same package, compiled together).
|
||||
func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID, func()) {
|
||||
b.Helper()
|
||||
cleanup := configtest.SetupConfig()
|
||||
b.Cleanup(cleanup)
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "artwork-bench-*")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a realistic cover image on disk
|
||||
coverPath := filepath.Join(tmpDir, "cover.jpg")
|
||||
coverImg := generateGradientImage(1000, 1000)
|
||||
f, err := os.Create(coverPath)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if err := jpeg.Encode(f, coverImg, &jpeg.Options{Quality: 90}); err != nil {
|
||||
f.Close()
|
||||
b.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
// Configure cache
|
||||
conf.Server.ImageCacheSize = cacheSize
|
||||
conf.Server.CacheFolder = conf.NewDir(tmpDir)
|
||||
conf.Server.CoverArtQuality = 75
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
|
||||
// Set up mock data store with album pointing to our cover.
|
||||
// Set UpdatedAt so CoverArtID().LastUpdate is consistent across calls.
|
||||
album := model.Album{
|
||||
ID: "bench-album-1",
|
||||
Name: "Benchmark Album",
|
||||
FolderIDs: []string{"f1"},
|
||||
UpdatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
folderRepo := &fakeFolderRepo{
|
||||
result: []model.Folder{{
|
||||
Path: tmpDir,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}},
|
||||
}
|
||||
ds := &tests.MockDataStore{
|
||||
MockedTranscoding: &tests.MockTranscodingRepo{},
|
||||
MockedFolder: folderRepo,
|
||||
}
|
||||
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{album})
|
||||
|
||||
artID := album.CoverArtID()
|
||||
|
||||
imgCache := cache.NewFileCache("BenchImage", cacheSize, "bench-images", 0,
|
||||
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
|
||||
r, _, err := arg.(artworkReader).Reader(ctx)
|
||||
return r, err
|
||||
})
|
||||
|
||||
// Wait for cache init if enabled
|
||||
if cacheSize != "0" {
|
||||
for !imgCache.Available(context.Background()) && !imgCache.Disabled(context.Background()) {
|
||||
runtime.Gosched() // Yield to allow background init goroutine to run
|
||||
}
|
||||
}
|
||||
|
||||
ffmpeg := tests.NewMockFFmpeg("fallback content")
|
||||
aw := NewArtwork(ds, imgCache, ffmpeg, nil)
|
||||
|
||||
cleanupAll := func() {
|
||||
os.RemoveAll(tmpDir)
|
||||
}
|
||||
return aw, artID, cleanupAll
|
||||
}
|
||||
|
||||
func BenchmarkArtworkGetE2E(b *testing.B) {
|
||||
cacheConfigs := []struct {
|
||||
name string
|
||||
cacheSize string
|
||||
}{
|
||||
{"no_cache", "0"},
|
||||
{"with_cache", "100MB"},
|
||||
}
|
||||
sizes := []int{0, 300}
|
||||
|
||||
for _, cc := range cacheConfigs {
|
||||
for _, size := range sizes {
|
||||
b.Run(fmt.Sprintf("%s/size_%d", cc.name, size), func(b *testing.B) {
|
||||
aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize)
|
||||
defer cleanup()
|
||||
|
||||
// Warm the cache on first call if cache is enabled
|
||||
if cc.cacheSize != "0" {
|
||||
r, _, err := aw.Get(context.Background(), artID, size, size > 0)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
_, _ = io.ReadAll(r)
|
||||
r.Close()
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
r, _, err := aw.Get(context.Background(), artID, size, size > 0)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
_, _ = io.ReadAll(r)
|
||||
r.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkArtworkGetE2EConcurrent(b *testing.B) {
|
||||
cacheConfigs := []struct {
|
||||
name string
|
||||
cacheSize string
|
||||
}{
|
||||
{"no_cache", "0"},
|
||||
{"with_cache", "100MB"},
|
||||
}
|
||||
concurrencyLevels := []int{10, 50}
|
||||
|
||||
for _, cc := range cacheConfigs {
|
||||
for _, n := range concurrencyLevels {
|
||||
b.Run(fmt.Sprintf("%s/goroutines_%d", cc.name, n), func(b *testing.B) {
|
||||
aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize)
|
||||
defer cleanup()
|
||||
|
||||
// Warm cache
|
||||
if cc.cacheSize != "0" {
|
||||
r, _, _ := aw.Get(context.Background(), artID, 300, true)
|
||||
if r != nil {
|
||||
_, _ = io.ReadAll(r)
|
||||
r.Close()
|
||||
}
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(n)
|
||||
for range n {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
r, _, err := aw.Get(context.Background(), artID, 300, true)
|
||||
if err != nil {
|
||||
b.Error(err)
|
||||
return
|
||||
}
|
||||
_, _ = io.ReadAll(r)
|
||||
r.Close()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"testing"
|
||||
@ -46,11 +45,3 @@ func generateGradientImage(width, height int) *image.RGBA {
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// gradientNRGBA mirrors generateGradientImage in the type makeThumbnail hands the encoders.
|
||||
func gradientNRGBA(size int) *image.NRGBA {
|
||||
src := generateGradientImage(size, size)
|
||||
dst := image.NewNRGBA(src.Bounds())
|
||||
draw.Draw(dst, dst.Bounds(), src, src.Bounds().Min, draw.Src)
|
||||
return dst
|
||||
}
|
||||
|
||||
@ -1,208 +0,0 @@
|
||||
// Package blurhash implements the blurhash encoding (https://github.com/woltapp/blurhash),
|
||||
// parameterized to match Jellyfin so clients see equivalent hashes.
|
||||
package blurhash
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"image"
|
||||
"image/draw"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
|
||||
|
||||
// maxInputSize: larger inputs are slower with no visible difference in the result.
|
||||
const maxInputSize = 128
|
||||
|
||||
// components picks x/y component counts targeting ~16 near-square tiles.
|
||||
func components(width, height int) (int, int) {
|
||||
xf := math.Sqrt(16.0 * float64(width) / float64(height))
|
||||
yf := xf * float64(height) / float64(width)
|
||||
return min(int(xf)+1, 9), min(int(yf)+1, 9)
|
||||
}
|
||||
|
||||
// Encode returns the blurhash of img, deriving the component counts from its aspect ratio.
|
||||
func Encode(img image.Image) (string, error) {
|
||||
if img.Bounds().Dx() == 0 || img.Bounds().Dy() == 0 {
|
||||
return "", errors.New("blurhash: empty image")
|
||||
}
|
||||
// Pre-downscale: its rounding can flip a component count, and the hash is a client cache key.
|
||||
xComp, yComp := components(img.Bounds().Dx(), img.Bounds().Dy())
|
||||
src := pixelsOf(downscale(img))
|
||||
w, h := src.w, src.h
|
||||
|
||||
cosX := make([][]float64, xComp)
|
||||
for i := range cosX {
|
||||
cosX[i] = make([]float64, w)
|
||||
for x := range cosX[i] {
|
||||
cosX[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(w))
|
||||
}
|
||||
}
|
||||
cosY := make([][]float64, yComp)
|
||||
for j := range cosY {
|
||||
cosY[j] = make([]float64, h)
|
||||
for y := range cosY[j] {
|
||||
cosY[j][y] = math.Cos(math.Pi * float64(j) * float64(y) / float64(h))
|
||||
}
|
||||
}
|
||||
|
||||
lin := srgbToLinearTable()
|
||||
factors := make([][3]float64, xComp*yComp)
|
||||
linR := make([]float64, w)
|
||||
linG := make([]float64, w)
|
||||
linB := make([]float64, w)
|
||||
rowR := make([]float64, xComp)
|
||||
rowG := make([]float64, xComp)
|
||||
rowB := make([]float64, xComp)
|
||||
for y := range h {
|
||||
row := src.pix[y*src.stride:]
|
||||
for x := range w {
|
||||
p := x * 4
|
||||
r, g, b := row[p], row[p+1], row[p+2]
|
||||
if src.straight {
|
||||
r, g, b = premultiply(r, g, b, row[p+3])
|
||||
}
|
||||
linR[x], linG[x], linB[x] = lin[r], lin[g], lin[b]
|
||||
}
|
||||
// The basis is separable, so a row costs xComp dot products plus one fold over yComp,
|
||||
// rather than xComp*yComp multiply-accumulates per pixel.
|
||||
for i := range xComp {
|
||||
var sr, sg, sb float64
|
||||
for x, c := range cosX[i] {
|
||||
sr += c * linR[x]
|
||||
sg += c * linG[x]
|
||||
sb += c * linB[x]
|
||||
}
|
||||
rowR[i], rowG[i], rowB[i] = sr, sg, sb
|
||||
}
|
||||
for j := range yComp {
|
||||
cy := cosY[j][y]
|
||||
for i := range xComp {
|
||||
f := &factors[j*xComp+i]
|
||||
f[0] += cy * rowR[i]
|
||||
f[1] += cy * rowG[i]
|
||||
f[2] += cy * rowB[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
for idx := range factors {
|
||||
norm := 2.0
|
||||
if idx == 0 {
|
||||
norm = 1.0
|
||||
}
|
||||
scale := norm / float64(w*h)
|
||||
factors[idx][0] *= scale
|
||||
factors[idx][1] *= scale
|
||||
factors[idx][2] *= scale
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(encode83((xComp-1)+(yComp-1)*9, 1))
|
||||
|
||||
// Derived counts are at least 1x9, so there is always at least one AC factor.
|
||||
ac := factors[1:]
|
||||
actualMax := 0.0
|
||||
for _, f := range ac {
|
||||
actualMax = max(actualMax, math.Abs(f[0]), math.Abs(f[1]), math.Abs(f[2]))
|
||||
}
|
||||
quantMax := int(max(0, min(82, math.Floor(actualMax*166-0.5))))
|
||||
maxVal := float64(quantMax+1) / 166
|
||||
sb.WriteString(encode83(quantMax, 1))
|
||||
|
||||
dc := factors[0]
|
||||
sb.WriteString(encode83(linearToSRGB(dc[0])<<16|linearToSRGB(dc[1])<<8|linearToSRGB(dc[2]), 4))
|
||||
for _, f := range ac {
|
||||
sb.WriteString(encode83(quantAC(f[0], maxVal)*19*19+quantAC(f[1], maxVal)*19+quantAC(f[2], maxVal), 2))
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// pixels is direct Pix access for the pixel loop, avoiding a per-pixel allocation via image.At.
|
||||
type pixels struct {
|
||||
pix []uint8
|
||||
stride int
|
||||
w, h int
|
||||
// straight marks non-premultiplied alpha, which the loop premultiplies to keep the hash
|
||||
// identical to the one an equivalent *image.RGBA produces.
|
||||
straight bool
|
||||
}
|
||||
|
||||
// pixelsOf accepts the two types the artwork pipeline produces without copying, and converts
|
||||
// anything else.
|
||||
func pixelsOf(img image.Image) pixels {
|
||||
b := img.Bounds()
|
||||
switch src := img.(type) {
|
||||
case *image.RGBA:
|
||||
return pixels{pix: src.Pix, stride: src.Stride, w: b.Dx(), h: b.Dy()}
|
||||
case *image.NRGBA:
|
||||
return pixels{pix: src.Pix, stride: src.Stride, w: b.Dx(), h: b.Dy(), straight: true}
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
|
||||
draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src)
|
||||
return pixels{pix: dst.Pix, stride: dst.Stride, w: b.Dx(), h: b.Dy()}
|
||||
}
|
||||
|
||||
func premultiply(r, g, b, a uint8) (uint8, uint8, uint8) {
|
||||
if a == 255 {
|
||||
return r, g, b
|
||||
}
|
||||
return uint8(uint32(r) * uint32(a) / 255), uint8(uint32(g) * uint32(a) / 255), uint8(uint32(b) * uint32(a) / 255)
|
||||
}
|
||||
|
||||
var srgbToLinearTable = sync.OnceValue(func() *[256]float64 {
|
||||
var t [256]float64
|
||||
for i := range t {
|
||||
t[i] = srgbToLinear(i)
|
||||
}
|
||||
return &t
|
||||
})
|
||||
|
||||
func downscale(img image.Image) image.Image {
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
if w <= maxInputSize && h <= maxInputSize {
|
||||
return img
|
||||
}
|
||||
scale := float64(maxInputSize) / float64(max(w, h))
|
||||
dst := image.NewRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale))))
|
||||
xdraw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
|
||||
return dst
|
||||
}
|
||||
|
||||
func quantAC(v, maxVal float64) int {
|
||||
return int(max(0, min(18, math.Floor(signPow(v/maxVal, 0.5)*9+9.5))))
|
||||
}
|
||||
|
||||
func signPow(v, exp float64) float64 {
|
||||
return math.Copysign(math.Pow(math.Abs(v), exp), v)
|
||||
}
|
||||
|
||||
func srgbToLinear(v int) float64 {
|
||||
f := float64(v) / 255
|
||||
if f <= 0.04045 {
|
||||
return f / 12.92
|
||||
}
|
||||
return math.Pow((f+0.055)/1.055, 2.4)
|
||||
}
|
||||
|
||||
func linearToSRGB(v float64) int {
|
||||
v = min(max(0, v), 1)
|
||||
if v <= 0.0031308 {
|
||||
return int(v*12.92*255 + 0.5)
|
||||
}
|
||||
return int((1.055*math.Pow(v, 1/2.4)-0.055)*255 + 0.5)
|
||||
}
|
||||
|
||||
// encode83 encodes value as a fixed-width, big-endian base83 string of the given length.
|
||||
func encode83(value, length int) string {
|
||||
b := make([]byte, length)
|
||||
for i := length - 1; i >= 0; i-- {
|
||||
b[i] = alphabet[value%83]
|
||||
value /= 83
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
package blurhash_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestBlurHash(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "BlurHash Suite")
|
||||
}
|
||||
@ -1,137 +0,0 @@
|
||||
package blurhash_test
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core/artwork/blurhash"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
|
||||
|
||||
func decode83(s string) int {
|
||||
v := 0
|
||||
for _, c := range s {
|
||||
v = v*83 + strings.IndexRune(alphabet, c)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func solidImage(w, h int, c color.NRGBA) image.Image {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, w, h))
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
img.SetNRGBA(x, y, c)
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func gradientImage(w, h int) image.Image {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, w, h))
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
img.SetNRGBA(x, y, color.NRGBA{R: uint8(255 * x / w), G: uint8(255 * y / h), B: 128, A: 255})
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
var _ = Describe("Encode input types", func() {
|
||||
// The pipeline hands Encode an *image.NRGBA; reading it must stay equivalent to the
|
||||
// premultiplied *image.RGBA it used to receive, or every hash silently shifts.
|
||||
buildPair := func(alpha uint8) (*image.NRGBA, *image.RGBA) {
|
||||
const size = 40
|
||||
nrgba := image.NewNRGBA(image.Rect(0, 0, size, size))
|
||||
rgba := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
for y := range size {
|
||||
for x := range size {
|
||||
c := color.NRGBA{
|
||||
R: uint8(255 * x / size), G: uint8(255 * y / size),
|
||||
B: uint8((x + y) * 255 / (2 * size)), A: alpha,
|
||||
}
|
||||
nrgba.SetNRGBA(x, y, c)
|
||||
rgba.Set(x, y, c) // image.RGBA.Set premultiplies
|
||||
}
|
||||
}
|
||||
return nrgba, rgba
|
||||
}
|
||||
|
||||
DescribeTable("gives an NRGBA the same hash as the premultiplied RGBA it replaces",
|
||||
func(alpha uint8) {
|
||||
nrgba, rgba := buildPair(alpha)
|
||||
fromNRGBA, err := blurhash.Encode(nrgba)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
fromRGBA, err := blurhash.Encode(rgba)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fromNRGBA).To(Equal(fromRGBA))
|
||||
},
|
||||
Entry("opaque", uint8(255)),
|
||||
Entry("partly transparent", uint8(128)),
|
||||
Entry("fully transparent, which premultiplication crushes to black", uint8(0)),
|
||||
)
|
||||
})
|
||||
|
||||
var _ = Describe("Encode", func() {
|
||||
// The size flag encodes (xComp-1) + (yComp-1)*9.
|
||||
DescribeTable("derives component counts from aspect ratio (Jellyfin formula)",
|
||||
func(w, h, expectedX, expectedY int) {
|
||||
hash, err := blurhash.Encode(gradientImage(w, h))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decode83(hash[:1])).To(Equal((expectedX - 1) + (expectedY-1)*9))
|
||||
},
|
||||
Entry("square album art", 60, 60, 5, 5),
|
||||
Entry("smallest square", 1, 1, 5, 5),
|
||||
Entry("landscape 16:9", 192, 108, 6, 4),
|
||||
Entry("portrait 9:16", 108, 192, 4, 6),
|
||||
Entry("extreme landscape capped at 9", 1000, 10, 9, 1),
|
||||
Entry("extreme portrait capped at 9", 10, 1000, 1, 9),
|
||||
)
|
||||
|
||||
It("rejects an empty image", func() {
|
||||
_, err := blurhash.Encode(image.NewNRGBA(image.Rect(0, 0, 0, 0)))
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("produces the spec-mandated length", func() {
|
||||
// 1 (size flag) + 1 (max AC) + 4 (DC) + 2 per AC component; a square derives 5x5
|
||||
h, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{R: 10, G: 20, B: 30, A: 255}))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(h).To(HaveLen(4 + 2 + 2*(5*5-1)))
|
||||
})
|
||||
|
||||
It("stores the average color in the DC component", func() {
|
||||
h, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 200, G: 100, B: 50, A: 255}))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
dc := decode83(h[2:6])
|
||||
Expect(dc >> 16).To(BeNumerically("~", 200, 1))
|
||||
Expect((dc >> 8) & 0xFF).To(BeNumerically("~", 100, 1))
|
||||
Expect(dc & 0xFF).To(BeNumerically("~", 50, 1))
|
||||
})
|
||||
|
||||
It("is deterministic", func() {
|
||||
img := gradientImage(64, 64)
|
||||
h1, err1 := blurhash.Encode(img)
|
||||
h2, err2 := blurhash.Encode(img)
|
||||
Expect(err1).ToNot(HaveOccurred())
|
||||
Expect(err2).ToNot(HaveOccurred())
|
||||
Expect(h1).To(Equal(h2))
|
||||
})
|
||||
|
||||
It("produces different hashes for different images", func() {
|
||||
h1, _ := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 255, A: 255}))
|
||||
h2, _ := blurhash.Encode(gradientImage(16, 16))
|
||||
Expect(h1).ToNot(Equal(h2))
|
||||
})
|
||||
|
||||
It("downscales large images internally without changing the result materially", func() {
|
||||
big, err := blurhash.Encode(solidImage(1000, 1000, color.NRGBA{R: 60, G: 120, B: 180, A: 255}))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
small, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 60, G: 120, B: 180, A: 255}))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(big[2:6]).To(Equal(small[2:6]))
|
||||
})
|
||||
})
|
||||
162
core/artwork/cache_warmer.go
Normal file
162
core/artwork/cache_warmer.go
Normal file
@ -0,0 +1,162 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils/pl"
|
||||
)
|
||||
|
||||
type CacheWarmer interface {
|
||||
PreCache(artID model.ArtworkID)
|
||||
}
|
||||
|
||||
// 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 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 {
|
||||
return &noopCacheWarmer{}
|
||||
}
|
||||
|
||||
// If the file cache is disabled, return a NOOP implementation
|
||||
if cache.Disabled(context.Background()) {
|
||||
log.Debug("Image cache disabled. Cache warmer will not run")
|
||||
return &noopCacheWarmer{}
|
||||
}
|
||||
|
||||
a := &cacheWarmer{
|
||||
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
|
||||
ctx := request.WithUser(context.TODO(), model.User{IsAdmin: true})
|
||||
go a.run(ctx)
|
||||
return a
|
||||
}
|
||||
|
||||
type cacheWarmer 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) {
|
||||
if a.cache.Disabled(context.Background()) {
|
||||
return
|
||||
}
|
||||
a.mutex.Lock()
|
||||
defer a.mutex.Unlock()
|
||||
a.buffer[artID] = struct{}{}
|
||||
a.sendWakeSignal()
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) sendWakeSignal() {
|
||||
// Don't block if the previous signal was not read yet
|
||||
select {
|
||||
case a.wakeSignal <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) run(ctx context.Context) {
|
||||
for {
|
||||
a.waitSignal(ctx, 10*time.Second)
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if a.cache.Disabled(ctx) {
|
||||
a.mutex.Lock()
|
||||
pending := len(a.buffer)
|
||||
a.buffer = make(map[model.ArtworkID]struct{})
|
||||
a.mutex.Unlock()
|
||||
if pending > 0 {
|
||||
log.Trace(ctx, "Cache disabled, discarding precache buffer", "bufferLen", pending)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// If cache not available, keep waiting
|
||||
if !a.cache.Available(ctx) {
|
||||
a.mutex.Lock()
|
||||
bufferLen := len(a.buffer)
|
||||
a.mutex.Unlock()
|
||||
if bufferLen > 0 {
|
||||
log.Trace(ctx, "Cache not available, buffering precache request", "bufferLen", bufferLen)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
a.mutex.Lock()
|
||||
|
||||
// If there's nothing to send, keep waiting
|
||||
if len(a.buffer) == 0 {
|
||||
a.mutex.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
batch := slices.Collect(maps.Keys(a.buffer))
|
||||
a.buffer = make(map[model.ArtworkID]struct{})
|
||||
a.mutex.Unlock()
|
||||
|
||||
a.processBatch(ctx, batch)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) waitSignal(ctx context.Context, timeout time.Duration) {
|
||||
select {
|
||||
case <-time.After(timeout):
|
||||
case <-a.wakeSignal:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) processBatch(ctx context.Context, batch []model.ArtworkID) {
|
||||
log.Trace(ctx, "PreCaching a new batch of artwork", "batchSize", len(batch))
|
||||
input := pl.FromSlice(ctx, batch)
|
||||
errs := pl.Sink(ctx, 4, input, a.doCacheImage)
|
||||
for err := range errs {
|
||||
log.Debug(ctx, "Error warming cache", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
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)
|
||||
}
|
||||
_, err = io.Copy(io.Discard, r)
|
||||
r.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
func NoopCacheWarmer() CacheWarmer {
|
||||
return &noopCacheWarmer{}
|
||||
}
|
||||
|
||||
type noopCacheWarmer struct{}
|
||||
|
||||
func (a *noopCacheWarmer) PreCache(model.ArtworkID) {}
|
||||
245
core/artwork/cache_warmer_test.go
Normal file
245
core/artwork/cache_warmer_test.go
Normal file
@ -0,0 +1,245 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("CacheWarmer", func() {
|
||||
var (
|
||||
fc *mockFileCache
|
||||
aw *mockArtwork
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
fc = &mockFileCache{}
|
||||
aw = &mockArtwork{}
|
||||
})
|
||||
|
||||
Context("initialization", func() {
|
||||
It("returns noop when cache is disabled", func() {
|
||||
fc.SetDisabled(true)
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*noopCacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns noop when ImageCacheSize is 0", func() {
|
||||
conf.Server.ImageCacheSize = "0"
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*noopCacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns noop when EnableArtworkPrecache is false", func() {
|
||||
conf.Server.EnableArtworkPrecache = false
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*noopCacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns real implementation when properly configured", func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*cacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("buffer management", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
})
|
||||
|
||||
It("drops buffered items when cache becomes disabled", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-test"))
|
||||
fc.SetDisabled(true)
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("adds multiple items to buffer", func() {
|
||||
fc.SetReady(false) // Make cache unavailable so items stay in buffer
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-2"))
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
Expect(len(cw.buffer)).To(Equal(2))
|
||||
})
|
||||
|
||||
It("deduplicates items in buffer", func() {
|
||||
fc.SetReady(false) // Make cache unavailable so items stay in buffer
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
Expect(len(cw.buffer)).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
Context("error handling", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
})
|
||||
|
||||
It("continues processing after artwork retrieval error", func() {
|
||||
aw.err = errors.New("artwork error")
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-error"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("continues processing after cache error", func() {
|
||||
fc.err = errors.New("cache error")
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-error"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
Context("background processing", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
})
|
||||
|
||||
It("processes items in batches", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
for i := range 5 {
|
||||
cw.PreCache(model.MustParseArtworkID(fmt.Sprintf("al-%d", i)))
|
||||
}
|
||||
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("wakes up on new items", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
|
||||
// Add first batch
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
|
||||
// Add second batch
|
||||
cw.PreCache(model.MustParseArtworkID("al-2"))
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("pre-caches UICoverArtSize", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
|
||||
Eventually(func() []int {
|
||||
return aw.getCachedSizes()
|
||||
}).Should(ContainElements(conf.Server.UICoverArtSize))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockArtwork struct {
|
||||
err error
|
||||
mu sync.Mutex
|
||||
cachedSizes []int
|
||||
}
|
||||
|
||||
func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) {
|
||||
if m.err != nil {
|
||||
return nil, time.Time{}, m.err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.cachedSizes = append(m.cachedSizes, size)
|
||||
m.mu.Unlock()
|
||||
return io.NopCloser(strings.NewReader("test")), time.Now(), nil
|
||||
}
|
||||
|
||||
func (m *mockArtwork) getCachedSizes() []int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
result := make([]int, len(m.cachedSizes))
|
||||
copy(result, m.cachedSizes)
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) {
|
||||
return m.Get(ctx, model.ArtworkID{}, size, square)
|
||||
}
|
||||
|
||||
type mockFileCache struct {
|
||||
disabled atomic.Bool
|
||||
ready atomic.Bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *mockFileCache) Get(ctx context.Context, item cache.Item) (*cache.CachedStream, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return &cache.CachedStream{Reader: io.NopCloser(strings.NewReader("cached"))}, nil
|
||||
}
|
||||
|
||||
func (f *mockFileCache) Available(ctx context.Context) bool {
|
||||
return f.ready.Load() && !f.disabled.Load()
|
||||
}
|
||||
|
||||
func (f *mockFileCache) Disabled(ctx context.Context) bool {
|
||||
return f.disabled.Load()
|
||||
}
|
||||
|
||||
func (f *mockFileCache) SetDisabled(v bool) {
|
||||
f.disabled.Store(v)
|
||||
f.ready.Store(true)
|
||||
}
|
||||
|
||||
func (f *mockFileCache) SetReady(v bool) {
|
||||
f.ready.Store(v)
|
||||
}
|
||||
@ -1,281 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"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/slice"
|
||||
)
|
||||
|
||||
// discArtworkReader resolves disc-level artwork from a library's folder images
|
||||
// and embedded tags. It is used by the serving path's provisional disc read-through.
|
||||
type discArtworkReader struct {
|
||||
album model.Album
|
||||
discNumber int
|
||||
imgFiles []string // library-relative, forward-slash, no leading slash
|
||||
discFoldersRel map[string]bool // library-relative folder paths
|
||||
isMultiFolder bool
|
||||
firstTrackRel string // library-relative; for fromTag / ffmpeg via lib.Abs
|
||||
lib libraryView
|
||||
// Newest ImagesUpdatedAt across the album's and this disc's folders: an image can be
|
||||
// replaced without the album row changing, so this is what makes a cache key notice it.
|
||||
imagesUpdatedAt time.Time
|
||||
}
|
||||
|
||||
// cacheTime is the disc image's validity stamp: any of these moving means the selection may
|
||||
// have changed.
|
||||
func (d *discArtworkReader) cacheTime() time.Time {
|
||||
return utils.TimeNewest(d.album.UpdatedAt, d.album.ImportedAt, d.imagesUpdatedAt)
|
||||
}
|
||||
|
||||
func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.ArtworkID) (*discArtworkReader, error) {
|
||||
albumID, discNumber, err := model.ParseDiscArtworkID(artID.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err)
|
||||
}
|
||||
|
||||
al, err := ds.Album(ctx).Get(albumID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, imgFiles, albumImagesAt, err := loadAlbumFoldersPaths(ctx, ds, *al)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var imagesUpdatedAt time.Time
|
||||
if albumImagesAt != nil {
|
||||
imagesUpdatedAt = *albumImagesAt
|
||||
}
|
||||
|
||||
// Query mediafiles for this album + disc to find folder associations and first track
|
||||
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Sort: "track_number",
|
||||
Order: "ASC",
|
||||
Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lib, err := loadLibraryView(ctx, 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
|
||||
for _, mf := range mfs {
|
||||
if mf.Path != "" {
|
||||
firstTrackRel = filepath.ToSlash(mf.Path)
|
||||
break
|
||||
}
|
||||
}
|
||||
folderIDs := slice.Unique(slice.Map(mfs, func(mf model.MediaFile) string { return mf.FolderID }))
|
||||
|
||||
// Resolve folder IDs to library-relative paths
|
||||
discFoldersRel := make(map[string]bool)
|
||||
if len(folderIDs) > 0 {
|
||||
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"folder.id": folderIDs},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, f := range folders {
|
||||
rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/")
|
||||
discFoldersRel[rel] = true
|
||||
imagesUpdatedAt = utils.TimeNewest(imagesUpdatedAt, f.ImagesUpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
return &discArtworkReader{
|
||||
album: *al,
|
||||
discNumber: discNumber,
|
||||
imgFiles: imgFiles,
|
||||
discFoldersRel: discFoldersRel,
|
||||
isMultiFolder: len(al.FolderIDs) > 1,
|
||||
firstTrackRel: firstTrackRel,
|
||||
lib: lib,
|
||||
imagesUpdatedAt: imagesUpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// discCandidate is one DiscArtPriority entry. skip is set when the entry maps to no source at
|
||||
// all, so a chain walk can say why instead of leaving a configured entry unaccounted for.
|
||||
type discCandidate struct {
|
||||
pattern string
|
||||
resolve func() (resolution, bool)
|
||||
skip string
|
||||
}
|
||||
|
||||
func (d *discArtworkReader) discCandidates(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []discCandidate {
|
||||
folder := func(sf sourceFunc) func() (resolution, bool) {
|
||||
return func() (resolution, bool) { return resolveFolderSource(d.lib, sf) }
|
||||
}
|
||||
var cc []discCandidate
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" {
|
||||
continue
|
||||
}
|
||||
c := discCandidate{pattern: pattern}
|
||||
switch {
|
||||
case pattern == "embedded":
|
||||
c.resolve = func() (resolution, bool) {
|
||||
return resolveEmbedded(ctx, d.lib, ffmpeg, d.firstTrackRel)
|
||||
}
|
||||
case pattern == externalCandidate:
|
||||
c.skip = "external sources are not supported for disc artwork"
|
||||
case pattern == "discsubtitle":
|
||||
subtitle := strings.TrimSpace(d.album.Discs[d.discNumber])
|
||||
if subtitle == "" {
|
||||
c.skip = "disc has no subtitle"
|
||||
} else {
|
||||
c.resolve = folder(d.fromDiscSubtitle(ctx, subtitle))
|
||||
}
|
||||
case len(d.imgFiles) == 0:
|
||||
c.skip = "no images in album folder"
|
||||
default:
|
||||
c.resolve = folder(d.fromExternalFile(ctx, pattern))
|
||||
}
|
||||
cc = append(cc, c)
|
||||
}
|
||||
return cc
|
||||
}
|
||||
|
||||
// selectImage walks the DiscArtPriority entries and returns the first that yields an image.
|
||||
// chain records the walk; the serving path passes an untraced one and pays nothing for it.
|
||||
func (d *discArtworkReader) selectImage(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string,
|
||||
chain *chainState) (resolution, error) {
|
||||
for _, c := range d.discCandidates(ctx, ffmpeg, priority) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
if c.skip != "" {
|
||||
chain.record(c.pattern, OutcomeSkipped, c.skip)
|
||||
continue
|
||||
}
|
||||
start := time.Now()
|
||||
res, ok := c.resolve()
|
||||
log.Trace(ctx, "Artwork: Tried a disc artwork candidate", "albumID", d.album.ID,
|
||||
"disc", d.discNumber, "pattern", c.pattern, "hit", ok, "path", res.sourcePath,
|
||||
"elapsed", time.Since(start))
|
||||
if res, ok = chain.try(c.pattern, res, ok); ok {
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
return chain.exhausted(), nil
|
||||
}
|
||||
|
||||
// fromDiscSubtitle returns a sourceFunc that matches image files whose stem
|
||||
// (filename without extension) equals the disc subtitle (case-insensitive).
|
||||
func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
for _, file := range d.imgFiles {
|
||||
stem := utils.BaseName(file)
|
||||
if !strings.EqualFold(stem, subtitle) {
|
||||
continue
|
||||
}
|
||||
f, err := d.lib.FS.Open(file)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Artwork: Could not open disc art file", "file", file, err)
|
||||
continue
|
||||
}
|
||||
return f, file, nil
|
||||
}
|
||||
return nil, "", fmt.Errorf("disc %d: no image file matching subtitle %q", d.discNumber, subtitle)
|
||||
}
|
||||
}
|
||||
|
||||
// filepath.Match's '\' escape is excluded on purpose: treating it 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. Caller must lowercase both args and have already verified the match.
|
||||
func extractDiscNumber(pattern, filename string) (int, bool) {
|
||||
metaIdx := strings.IndexAny(pattern, globMetaChars)
|
||||
if metaIdx < 0 {
|
||||
return 0, false
|
||||
}
|
||||
prefix := pattern[:metaIdx]
|
||||
if !strings.HasPrefix(filename, prefix) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
start := len(prefix)
|
||||
end := start
|
||||
for end < len(filename) && filename[end] >= '0' && filename[end] <= '9' {
|
||||
end++
|
||||
}
|
||||
if end == start {
|
||||
return 0, false
|
||||
}
|
||||
num, err := strconv.Atoi(filename[start:end])
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return num, true
|
||||
}
|
||||
|
||||
// fromExternalFile matches image files against a (lowercase) glob pattern. A numbered
|
||||
// filename whose number equals the target disc wins over any unnumbered candidate.
|
||||
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 := strings.ToLower(path.Base(file))
|
||||
match, err := filepath.Match(pattern, name)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Artwork: Error matching disc art file to pattern", "pattern", pattern, "file", file)
|
||||
continue
|
||||
}
|
||||
if !match {
|
||||
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, "Artwork: Could not open disc art file", "file", file, err)
|
||||
continue
|
||||
}
|
||||
return f, file, nil
|
||||
}
|
||||
}
|
||||
|
||||
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, "Artwork: Could not open disc art file", "file", file, err)
|
||||
continue
|
||||
}
|
||||
return f, file, nil
|
||||
}
|
||||
return nil, "", fmt.Errorf("disc %d: pattern '%s' not matched by files", d.discNumber, pattern)
|
||||
}
|
||||
}
|
||||
@ -1,127 +0,0 @@
|
||||
// Package dominant extracts an image's dominant colour, for use as a flat placeholder while the
|
||||
// real artwork loads.
|
||||
package dominant
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
const (
|
||||
// 4 bits per channel: coarse enough that near-identical pixels land together, fine enough that
|
||||
// distinct colours stay apart.
|
||||
bits = 4
|
||||
nBins = 1 << (3 * bits)
|
||||
// Only the heaviest bins can win, and merging is O(n^2) over whatever survives.
|
||||
maxBins = 64
|
||||
// Oklab distance below which two bins are the same colour to the eye. Merging matters because a
|
||||
// gradient splits across adjacent bins and would otherwise lose to a smaller flat region.
|
||||
mergeDist = 0.10
|
||||
)
|
||||
|
||||
type bin struct {
|
||||
r, g, b float64
|
||||
n float64
|
||||
}
|
||||
|
||||
// Color returns the dominant colour as "#rrggbb", or "" when the image has no pixels. It reports
|
||||
// presence, not salience: a mostly white sleeve returns white.
|
||||
func Color(img image.Image) string {
|
||||
var bins [nBins]bin
|
||||
total := 0
|
||||
eachPixel(img, func(r, g, b uint8) {
|
||||
i := int(r>>(8-bits))<<(2*bits) | int(g>>(8-bits))<<bits | int(b>>(8-bits))
|
||||
bins[i].r += float64(r)
|
||||
bins[i].g += float64(g)
|
||||
bins[i].b += float64(b)
|
||||
bins[i].n++
|
||||
total++
|
||||
})
|
||||
if total == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
used := make([]bin, 0, 32)
|
||||
for i := range bins {
|
||||
if bins[i].n > 0 {
|
||||
used = append(used, bins[i])
|
||||
}
|
||||
}
|
||||
sort.Slice(used, func(i, j int) bool { return used[i].n > used[j].n })
|
||||
if len(used) > maxBins {
|
||||
used = used[:maxBins]
|
||||
}
|
||||
|
||||
merged := make([]bin, 0, len(used))
|
||||
for _, b := range used {
|
||||
if i := nearest(merged, b); i >= 0 {
|
||||
merged[i].r += b.r
|
||||
merged[i].g += b.g
|
||||
merged[i].b += b.b
|
||||
merged[i].n += b.n
|
||||
continue
|
||||
}
|
||||
merged = append(merged, b)
|
||||
}
|
||||
|
||||
best := merged[0]
|
||||
for _, m := range merged[1:] {
|
||||
if m.n > best.n {
|
||||
best = m
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("#%02x%02x%02x",
|
||||
uint8(best.r/best.n+0.5), uint8(best.g/best.n+0.5), uint8(best.b/best.n+0.5))
|
||||
}
|
||||
|
||||
func nearest(merged []bin, b bin) int {
|
||||
bl, ba, bb := oklab(b.r/b.n, b.g/b.n, b.b/b.n)
|
||||
for i, m := range merged {
|
||||
ml, ma, mb := oklab(m.r/m.n, m.g/m.n, m.b/m.n)
|
||||
if math.Sqrt((bl-ml)*(bl-ml)+(ba-ma)*(ba-ma)+(bb-mb)*(bb-mb)) < mergeDist {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// eachPixel walks the image, taking the NRGBA fast path the artwork pipeline always hits: both hash
|
||||
// encoders already read the shared thumbnail in that form.
|
||||
func eachPixel(img image.Image, fn func(r, g, b uint8)) {
|
||||
if p, ok := img.(*image.NRGBA); ok {
|
||||
for y := range p.Rect.Dy() {
|
||||
row := p.Pix[y*p.Stride : y*p.Stride+p.Rect.Dx()*4]
|
||||
for x := 0; x < len(row); x += 4 {
|
||||
fn(row[x], row[x+1], row[x+2])
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
b := img.Bounds()
|
||||
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||
for x := b.Min.X; x < b.Max.X; x++ {
|
||||
r, g, bl, _ := img.At(x, y).RGBA()
|
||||
fn(uint8(r>>8), uint8(g>>8), uint8(bl>>8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func srgbToLinear(v float64) float64 {
|
||||
v /= 255
|
||||
if v <= 0.04045 {
|
||||
return v / 12.92
|
||||
}
|
||||
return math.Pow((v+0.055)/1.055, 2.4)
|
||||
}
|
||||
|
||||
func oklab(r, g, b float64) (float64, float64, float64) {
|
||||
lr, lg, lb := srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)
|
||||
l := math.Cbrt(0.4122214708*lr + 0.5363325363*lg + 0.0514459929*lb)
|
||||
m := math.Cbrt(0.2119034982*lr + 0.6806995451*lg + 0.1073969566*lb)
|
||||
s := math.Cbrt(0.0883024619*lr + 0.2817188376*lg + 0.6299787005*lb)
|
||||
return 0.2104542553*l + 0.7936177850*m - 0.0040720468*s,
|
||||
1.9779984951*l - 2.4285922050*m + 0.4505937099*s,
|
||||
0.0259040371*l + 0.7827717662*m - 0.8086757660*s
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
package dominant_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestDominant(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Dominant Suite")
|
||||
}
|
||||
@ -1,91 +0,0 @@
|
||||
package dominant_test
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"github.com/navidrome/navidrome/core/artwork/dominant"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// fill paints rect with c onto img.
|
||||
func fill(img *image.NRGBA, r image.Rectangle, c color.NRGBA) {
|
||||
for y := r.Min.Y; y < r.Max.Y; y++ {
|
||||
for x := r.Min.X; x < r.Max.X; x++ {
|
||||
img.SetNRGBA(x, y, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newImg(w, h int, c color.NRGBA) *image.NRGBA {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, w, h))
|
||||
fill(img, img.Bounds(), c)
|
||||
return img
|
||||
}
|
||||
|
||||
var _ = Describe("Color", func() {
|
||||
It("returns a solid image's own colour", func() {
|
||||
Expect(dominant.Color(newImg(20, 20, color.NRGBA{0x33, 0x66, 0x99, 255}))).To(Equal("#336699"))
|
||||
})
|
||||
|
||||
It("returns empty for an image with no pixels", func() {
|
||||
Expect(dominant.Color(image.NewNRGBA(image.Rect(0, 0, 0, 0)))).To(Equal(""))
|
||||
})
|
||||
|
||||
// Presence, not salience: this is a placeholder, so the large field wins even though the small
|
||||
// patch is the more interesting colour.
|
||||
It("picks the largest area, not the most vivid one", func() {
|
||||
img := newImg(20, 20, color.NRGBA{0xfa, 0xfa, 0xfa, 255})
|
||||
fill(img, image.Rect(0, 0, 4, 4), color.NRGBA{0xff, 0x00, 0x00, 255})
|
||||
Expect(dominant.Color(img)).To(Equal("#fafafa"))
|
||||
})
|
||||
|
||||
It("reports a near-black cover as near-black", func() {
|
||||
img := newImg(20, 20, color.NRGBA{0x05, 0x05, 0x05, 255})
|
||||
fill(img, image.Rect(0, 0, 5, 5), color.NRGBA{0x00, 0xff, 0x00, 255})
|
||||
Expect(dominant.Color(img)).To(Equal("#050505"))
|
||||
})
|
||||
|
||||
// A gradient splits across many quantisation bins. Without merging, each slice is smaller than
|
||||
// the flat block and the block would win despite covering far less of the image.
|
||||
It("merges a gradient's bins so it beats a smaller flat block", func() {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 40, 40))
|
||||
for y := range 40 {
|
||||
for x := range 40 {
|
||||
// 30 columns of blue gradient == 75% of the image
|
||||
if x < 30 {
|
||||
img.SetNRGBA(x, y, color.NRGBA{0x10, 0x20, uint8(0xa0 + x), 255})
|
||||
} else {
|
||||
img.SetNRGBA(x, y, color.NRGBA{0xff, 0xcc, 0x00, 255})
|
||||
}
|
||||
}
|
||||
}
|
||||
got := dominant.Color(img)
|
||||
Expect(got).To(HavePrefix("#1020"), "expected the blue gradient, got "+got)
|
||||
})
|
||||
|
||||
It("is deterministic", func() {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 30, 30))
|
||||
for y := range 30 {
|
||||
for x := range 30 {
|
||||
img.SetNRGBA(x, y, color.NRGBA{uint8(x * 7), uint8(y * 5), uint8(x + y), 255})
|
||||
}
|
||||
}
|
||||
first := dominant.Color(img)
|
||||
for range 5 {
|
||||
Expect(dominant.Color(img)).To(Equal(first))
|
||||
}
|
||||
})
|
||||
|
||||
It("handles images that are not NRGBA", func() {
|
||||
src := newImg(10, 10, color.NRGBA{0x20, 0x40, 0x60, 255})
|
||||
rgba := image.NewRGBA(src.Bounds())
|
||||
for y := range 10 {
|
||||
for x := range 10 {
|
||||
rgba.Set(x, y, src.At(x, y))
|
||||
}
|
||||
}
|
||||
Expect(dominant.Color(rgba)).To(Equal("#204060"))
|
||||
})
|
||||
})
|
||||
@ -1,349 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Covers the enqueue → drain → serve chain; per-source resolution rules live in the unit suites.
|
||||
var _ = Describe("Acquisition → serve loop", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
artRepo *tests.MockArtworkRepo
|
||||
queueRepo *tests.MockArtworkQueueRepo
|
||||
albumRepo *tests.MockAlbumRepo
|
||||
artistRepo *tests.MockArtistRepo
|
||||
mfRepo *tests.MockMediaFileRepo
|
||||
plRepo *tests.MockPlaylistRepo
|
||||
radioRepo *tests.MockedRadioRepo
|
||||
folderRepo *fakeFolderRepo
|
||||
libRepo *tests.MockLibraryRepo
|
||||
store *artwork.ImageStore
|
||||
svc artwork.Artwork
|
||||
worker *artwork.Worker
|
||||
coverBytes []byte
|
||||
)
|
||||
|
||||
itemFound := func(kind model.Kind, id string) func() bool {
|
||||
return func() bool {
|
||||
ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||
return err == nil && ia.Hash != ""
|
||||
}
|
||||
}
|
||||
itemAbsent := func(kind model.Kind, id string) func() bool {
|
||||
return func() bool {
|
||||
ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||
return err == nil && ia.Hash == ""
|
||||
}
|
||||
}
|
||||
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
|
||||
bump := func(kind, id string) {
|
||||
GinkgoHelper()
|
||||
Expect(ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
||||
ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary,
|
||||
Priority: model.ArtworkPriorityBump,
|
||||
})).To(Succeed())
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = context.Background()
|
||||
repoRoot, err := os.Getwd()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
coverBytes = readFixture(coverFixture)
|
||||
|
||||
conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir())
|
||||
conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir())
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
conf.Server.ArtistArtPriority = "artist.png" // keeps artist resolution offline
|
||||
conf.Server.EnableMediaFileCoverArt = true
|
||||
conf.Server.DevArtworkWorkerConcurrency = 1
|
||||
|
||||
folderRepo = &fakeFolderRepo{}
|
||||
libRepo = &tests.MockLibraryRepo{}
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: repoRoot}})
|
||||
artRepo = tests.CreateMockArtworkRepo()
|
||||
queueRepo = tests.CreateMockArtworkQueueRepo()
|
||||
albumRepo = tests.CreateMockAlbumRepo()
|
||||
artistRepo = tests.CreateMockArtistRepo()
|
||||
mfRepo = tests.CreateMockMediaFileRepo()
|
||||
plRepo = tests.CreateMockPlaylistRepo()
|
||||
radioRepo = tests.CreateMockedRadioRepo()
|
||||
radioRepo.Data = map[string]*model.Radio{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtwork: artRepo,
|
||||
MockedArtworkQueue: queueRepo,
|
||||
MockedAlbum: albumRepo,
|
||||
MockedArtist: artistRepo,
|
||||
MockedMediaFile: mfRepo,
|
||||
MockedPlaylist: plRepo,
|
||||
MockedRadio: radioRepo,
|
||||
MockedFolder: folderRepo,
|
||||
MockedLibrary: libRepo,
|
||||
}
|
||||
ffm := tests.NewMockFFmpeg("")
|
||||
store = artwork.NewImageStore(GinkgoT().TempDir())
|
||||
// size=0 requests stream originals, so this reader is never called (serving_test covers resizing).
|
||||
imgCache := cache.NewFileCache("ArtworkPipelineE2E", "100MB", "images", 0,
|
||||
func(context.Context, cache.Item) (io.Reader, error) {
|
||||
return nil, errors.New("resize not exercised in e2e")
|
||||
})
|
||||
Eventually(func() bool { return imgCache.Available(ctx) }, 10*time.Second).Should(BeTrue())
|
||||
|
||||
svc = artwork.NewArtwork(ds, imgCache, store, ffm)
|
||||
worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache)
|
||||
})
|
||||
|
||||
seedFolderAlbum := func(albumID string) {
|
||||
folderRepo.result = []model.Folder{{Path: albumFolderPath, ImageFiles: []string{"cover.jpg"}}}
|
||||
albumRepo.SetData(model.Albums{{ID: albumID, Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}})
|
||||
}
|
||||
|
||||
It("acquires and serves a cover whose format has no registered decoder (#5950)", func() {
|
||||
libDir := GinkgoT().TempDir()
|
||||
Expect(os.MkdirAll(filepath.Join(libDir, "an-album"), 0755)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(libDir, "an-album", "cover.jxl"), jxlFixture, 0600)).To(Succeed())
|
||||
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: libDir}})
|
||||
folderRepo.result = []model.Folder{{Path: "an-album", ImageFiles: []string{"cover.jxl"}}}
|
||||
albumRepo.SetData(model.Albums{{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}})
|
||||
|
||||
bump("al", "al1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Placeholder).To(BeFalse())
|
||||
Expect(readAll(img)).To(Equal(jxlFixture))
|
||||
})
|
||||
|
||||
It("acquires album folder art and serves the exact bytes under its hash", func() {
|
||||
seedFolderAlbum("al1")
|
||||
bump("al", "al1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Hash).To(Equal(ia.Hash))
|
||||
Expect(img.Placeholder).To(BeFalse())
|
||||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
})
|
||||
|
||||
It("acquires an artist's uploaded image and serves it", func() {
|
||||
name := writeUpload(consts.EntityArtist, "artist-e2e.png", artistPngFixture)
|
||||
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: name}})
|
||||
bump("ar", "ar1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindArtistArtwork, "ar1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("upload"))
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("ar-ar1"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Hash).To(Equal(ia.Hash))
|
||||
Expect(readAll(img)).To(Equal(readFixture(artistPngFixture)))
|
||||
})
|
||||
|
||||
It("generates a playlist grid from its tracks' album art and serves it from the store", func() {
|
||||
seedFolderAlbum("al1")
|
||||
plRepo.SetData(model.Playlists{{ID: "pl1", Name: "Playlist"}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"al1"}}
|
||||
bump("pl", "pl1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindPlaylistArtwork, "pl1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("generated"))
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("pl-pl1"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Hash).To(Equal(ia.Hash))
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(art.Mime).To(Equal("image/png"))
|
||||
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
||||
})
|
||||
|
||||
It("acquires a radio station's uploaded image and serves it", func() {
|
||||
name := writeUpload(consts.EntityRadio, "radio-e2e.jpg", coverFixture)
|
||||
radioRepo.Data["ra1"] = &model.Radio{ID: "ra1", Name: "Station", UploadedImage: name}
|
||||
bump("ra", "ra1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("upload"))
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Hash).To(Equal(ia.Hash))
|
||||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
})
|
||||
|
||||
It("serves an unresolved track provisionally, then upgrades to the worker's state row", func() {
|
||||
mfRepo.SetData(model.MediaFiles{{
|
||||
ID: "mf1", AlbumID: "al1", HasCoverArt: true, LibraryID: 0, Path: mp3Fixture,
|
||||
}})
|
||||
|
||||
provisional, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(provisional.Placeholder).To(BeFalse())
|
||||
Expect(provisional.Hash).ToNot(BeEmpty())
|
||||
provisionalBytes := readAll(provisional)
|
||||
Expect(len(provisionalBytes)).To(BeNumerically(">", 0))
|
||||
|
||||
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound), "provisional serving must not write a state row")
|
||||
|
||||
// The provisional read enqueued a Bump; drain it.
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindMediaFileArtwork, "mf1"))
|
||||
ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("embedded"))
|
||||
Expect(ia.Hash).To(Equal(provisional.Hash))
|
||||
|
||||
resolved, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resolved.Hash).To(Equal(ia.Hash))
|
||||
Expect(readAll(resolved)).To(Equal(provisionalBytes))
|
||||
})
|
||||
|
||||
It("stores dimensions, mime and a real blurhash alongside the acquired bytes", func() {
|
||||
seedFolderAlbum("al1")
|
||||
bump("al", "al1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(art.Mime).To(Equal("image/jpeg"))
|
||||
Expect(art.Width).To(BeNumerically(">", 0))
|
||||
Expect(art.Height).To(BeNumerically(">", 0))
|
||||
Expect(art.SizeBytes).To(BeNumerically("==", len(coverBytes)))
|
||||
// Never a synthesized value: both hashes are encoded from the real pixels.
|
||||
Expect(art.BlurHash).ToNot(BeEmpty())
|
||||
Expect(art.ThumbHash).ToNot(BeEmpty())
|
||||
raw, err := base64.StdEncoding.DecodeString(art.ThumbHash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(raw)).To(BeNumerically(">=", 5))
|
||||
})
|
||||
|
||||
It("acquires GIF artwork, whose decoder only core/artwork's blank import registers", func() {
|
||||
writeUploadedImage(consts.EntityRadio, "station.gif", gifFixture)
|
||||
radioRepo.Data["ra1"] = &model.Radio{ID: "ra1", Name: "Station", UploadedImage: "station.gif"}
|
||||
bump("ra", "ra1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(art.Mime).To(Equal("image/gif"))
|
||||
Expect(art.Width).To(BeNumerically("==", 4))
|
||||
})
|
||||
|
||||
It("deduplicates byte-identical art across entities onto one image row", func() {
|
||||
folderRepo.result = []model.Folder{{Path: albumFolderPath, ImageFiles: []string{"cover.jpg"}}}
|
||||
albumRepo.SetData(model.Albums{
|
||||
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0},
|
||||
{ID: "al2", Name: "Same Cover", FolderIDs: []string{"f1"}, LibraryID: 0},
|
||||
})
|
||||
bump("al", "al1")
|
||||
bump("al", "al2")
|
||||
runWorkerUntil(ctx, worker, func() bool {
|
||||
return itemFound(model.KindAlbumArtwork, "al1")() && itemFound(model.KindAlbumArtwork, "al2")()
|
||||
})
|
||||
|
||||
ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia1.Hash).To(Equal(ia2.Hash), "identical bytes must share one content hash")
|
||||
Expect(readAll(mustGet(svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false)))).To(Equal(coverBytes))
|
||||
})
|
||||
|
||||
It("stops serving a file-backed image once its source file changes underneath", func() {
|
||||
name := writeUpload(consts.EntityRadio, "radio-stale.jpg", coverFixture)
|
||||
radioRepo.Data["ra1"] = &model.Radio{ID: "ra1", Name: "Station", UploadedImage: name}
|
||||
bump("ra", "ra1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
staleHash := ia.Hash
|
||||
|
||||
path := model.UploadedImagePath(consts.EntityRadio, name)
|
||||
Expect(os.WriteFile(path, readFixture(artistPngFixture), 0o600)).To(Succeed())
|
||||
newer := time.Now().Add(2 * time.Second)
|
||||
Expect(os.Chtimes(path, newer, newer)).To(Succeed())
|
||||
|
||||
// The mtime no longer matches the state row, so the stale bytes are not served.
|
||||
_, err = svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false)
|
||||
Expect(err).To(MatchError(artwork.ErrUnavailable))
|
||||
|
||||
// That failed read enqueued a re-resolution.
|
||||
runWorkerUntil(ctx, worker, func() bool {
|
||||
cur, gerr := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
return gerr == nil && cur.Hash != "" && cur.Hash != staleHash
|
||||
})
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readAll(img)).To(Equal(readFixture(artistPngFixture)))
|
||||
})
|
||||
|
||||
It("records an absent state for an entity with no art and reports it unavailable", func() {
|
||||
albumRepo.SetData(model.Albums{{ID: "alx", Name: "Artless", LibraryID: 0}})
|
||||
bump("al", "alx")
|
||||
runWorkerUntil(ctx, worker, itemAbsent(model.KindAlbumArtwork, "alx"))
|
||||
|
||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-alx"), 0, false)
|
||||
Expect(err).To(MatchError(artwork.ErrUnavailable))
|
||||
|
||||
img, err := svc.GetOrPlaceholder(ctx, "al-alx", 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Placeholder).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
func mustGet(img *artwork.Image, err error) *artwork.Image {
|
||||
GinkgoHelper()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return img
|
||||
}
|
||||
|
||||
// Raw bytes on purpose: encoding a GIF here would register image/gif in the test binary, masking
|
||||
// jxlFixture is a JPEG XL bare codestream header: a real image format, with no stdlib decoder.
|
||||
var jxlFixture = []byte{0xff, 0x0a, 0x00, 0x10, 0x00}
|
||||
|
||||
// the production import the spec above guards.
|
||||
var gifFixture = []byte{
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x04, 0x00, 0x04, 0x00, 0x80, 0x00,
|
||||
0x00, 0x2e, 0x86, 0xc1, 0xf4, 0xd0, 0x3f, 0x2c, 0x00, 0x00, 0x00, 0x00,
|
||||
0x04, 0x00, 0x04, 0x00, 0x00, 0x02, 0x05, 0x44, 0x7c, 0x67, 0xb8, 0x05,
|
||||
0x00, 0x3b,
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package e2e
|
||||
package artworke2e_test
|
||||
|
||||
import (
|
||||
"testing/fstest"
|
||||
@ -9,11 +9,14 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// The in-memory library FS cannot satisfy the os.Open(SourcePath) used to serve folder art, so
|
||||
// folder scenarios assert on the worker's state row (Source + SourcePath) instead of the bytes.
|
||||
const (
|
||||
defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external"
|
||||
defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded"
|
||||
)
|
||||
|
||||
var _ = Describe("Album artwork resolution", func() {
|
||||
BeforeEach(func() {
|
||||
setupResolutionHarness()
|
||||
setupHarness()
|
||||
})
|
||||
|
||||
When("an album has a single folder with cover.jpg at the album root", func() {
|
||||
@ -25,10 +28,12 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-root"),
|
||||
"Artist/Album/cover.jpg": imageFile("album-root"),
|
||||
})
|
||||
scan()
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -50,16 +55,16 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
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": smallPNG("album-root"),
|
||||
"Artist/Album/CD1/cover.jpg": smallPNG("disc1"),
|
||||
"Artist/Album/CD2/cover.jpg": smallPNG("disc2"),
|
||||
"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: the two disc subfolders should form one multi-disc album")
|
||||
expectAlbumFolderCover(al, "Artist/Album/cover.jpg")
|
||||
"sanity check: scanner should treat the two disc subfolders as one multi-disc album")
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -81,12 +86,14 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
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": smallPNG("album-root"),
|
||||
"Artist/Album/CD1/folder.jpg": smallPNG("disc1"),
|
||||
"Artist/Album/CD2/folder.jpg": smallPNG("disc2"),
|
||||
"Artist/Album/folder.jpg": imageFile("album-root"),
|
||||
"Artist/Album/CD1/folder.jpg": imageFile("disc1"),
|
||||
"Artist/Album/CD2/folder.jpg": imageFile("disc2"),
|
||||
})
|
||||
scan()
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg")
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -102,10 +109,12 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-root"),
|
||||
"Artist/Album/cover.jpg": imageFile("album-root"),
|
||||
})
|
||||
scan()
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -124,12 +133,14 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
setLayout(fstest.MapFS{
|
||||
"Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
||||
"Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
||||
"Album/cover.jpg": smallPNG("album-root"),
|
||||
"Album/CD1/folder.jpg": smallPNG("disc1"),
|
||||
"Album/CD2/folder.jpg": smallPNG("disc2"),
|
||||
"Album/cover.jpg": imageFile("album-root"),
|
||||
"Album/CD1/folder.jpg": imageFile("disc1"),
|
||||
"Album/CD2/folder.jpg": imageFile("disc2"),
|
||||
})
|
||||
scan()
|
||||
expectAlbumFolderCover(firstAlbum(), "Album/cover.jpg")
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -142,14 +153,14 @@ var _ = Describe("Album artwork resolution", 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": smallPNG("external"),
|
||||
"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")
|
||||
|
||||
ia := acquire(model.KindAlbumArtwork, firstAlbum().ID)
|
||||
Expect(ia.Source).To(Equal("embedded"))
|
||||
Expect(storedBytes(ia)).To(Equal(embeddedArtBytes))
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes))
|
||||
})
|
||||
})
|
||||
|
||||
@ -165,9 +176,8 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
scan()
|
||||
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
|
||||
|
||||
ia := acquire(model.KindAlbumArtwork, firstAlbum().ID)
|
||||
Expect(ia.Source).To(Equal("embedded"))
|
||||
Expect(storedBytes(ia)).To(Equal(embeddedArtBytes))
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes))
|
||||
})
|
||||
})
|
||||
|
||||
@ -180,10 +190,12 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*, folder.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/Cover.JPG": smallPNG("case-insensitive"),
|
||||
"Artist/Album/Cover.JPG": imageFile("case-insensitive"),
|
||||
})
|
||||
scan()
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/Cover.JPG")
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("case-insensitive")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -197,25 +209,30 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": smallPNG("primary"),
|
||||
"Artist/Album/cover.1.jpg": smallPNG("secondary"),
|
||||
"Artist/Album/cover.jpg": imageFile("primary"),
|
||||
"Artist/Album/cover.1.jpg": imageFile("secondary"),
|
||||
})
|
||||
scan()
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
|
||||
|
||||
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 — settles absent)
|
||||
It("settles absent", func() {
|
||||
// └── 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()
|
||||
expectAlbumAbsent(firstAlbum())
|
||||
|
||||
al := firstAlbum()
|
||||
_, err := readArtworkOrErr(model.NewArtworkID(model.KindAlbumArtwork, al.ID, &al.UpdatedAt))
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
@ -231,10 +248,12 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/folder.jpg": smallPNG("folder"),
|
||||
"Artist/Album/folder.jpg": imageFile("folder"),
|
||||
})
|
||||
scan()
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg")
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -247,10 +266,12 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/front.jpg": smallPNG("front"),
|
||||
"Artist/Album/front.jpg": imageFile("front"),
|
||||
})
|
||||
scan()
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/front.jpg")
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("front")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -265,12 +286,14 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": smallPNG("cover"),
|
||||
"Artist/Album/folder.jpg": smallPNG("folder"),
|
||||
"Artist/Album/front.jpg": smallPNG("front"),
|
||||
"Artist/Album/cover.jpg": imageFile("cover"),
|
||||
"Artist/Album/folder.jpg": imageFile("folder"),
|
||||
"Artist/Album/front.jpg": imageFile("front"),
|
||||
})
|
||||
scan()
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -284,11 +307,13 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/folder.jpg": smallPNG("folder"),
|
||||
"Artist/Album/front.jpg": smallPNG("front"),
|
||||
"Artist/Album/folder.jpg": imageFile("folder"),
|
||||
"Artist/Album/front.jpg": imageFile("front"),
|
||||
})
|
||||
scan()
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg")
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -303,12 +328,14 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.2.jpg": smallPNG("second"),
|
||||
"Artist/Album/cover.jpg": smallPNG("primary"),
|
||||
"Artist/Album/cover.1.jpg": smallPNG("first"),
|
||||
"Artist/Album/cover.2.jpg": imageFile("second"),
|
||||
"Artist/Album/cover.jpg": imageFile("primary"),
|
||||
"Artist/Album/cover.1.jpg": imageFile("first"),
|
||||
})
|
||||
scan()
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -321,10 +348,12 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
conf.Server.CoverArtPriority = "bogus.*, cover.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": smallPNG("cover"),
|
||||
"Artist/Album/cover.jpg": imageFile("cover"),
|
||||
})
|
||||
scan()
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -342,16 +371,21 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
It("does not use the artist image as album art", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/folder.jpg": smallPNG("artist-thumbnail"),
|
||||
"Artist/folder.jpg": imageFile("artist-thumbnail"),
|
||||
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}),
|
||||
"Artist/Album B/cover.jpg": smallPNG("album-b"),
|
||||
"Artist/Album B/cover.jpg": imageFile("album-b"),
|
||||
})
|
||||
scan()
|
||||
|
||||
// Album B first: the acquire in expectAlbumAbsent would settle Album B too.
|
||||
expectAlbumFolderCover(albumByName("Album B"), "Artist/Album B/cover.jpg")
|
||||
expectAlbumAbsent(albumByName("Album A"))
|
||||
alA := albumByName("Album A")
|
||||
_, err := readArtworkOrErr(alA.CoverArtID())
|
||||
Expect(err).To(HaveOccurred(),
|
||||
"Album A has no images of its own, so it must fall through to the placeholder "+
|
||||
"instead of inheriting the artist folder's folder.jpg")
|
||||
|
||||
alB := albumByName("Album B")
|
||||
Expect(readArtwork(alB.CoverArtID())).To(Equal(imageBytes("album-b")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -368,58 +402,21 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
It("does not use the artist image as album art for the spread album", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/folder.jpg": smallPNG("artist-thumbnail"),
|
||||
"Artist/folder.jpg": imageFile("artist-thumbnail"),
|
||||
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}),
|
||||
"Artist/Album B/cover.jpg": smallPNG("album-b"),
|
||||
"Artist/Album B/cover.jpg": imageFile("album-b"),
|
||||
})
|
||||
scan()
|
||||
|
||||
alA := albumByName("Album A")
|
||||
Expect(alA.FolderIDs).To(HaveLen(2),
|
||||
"sanity check: the two sibling folders should form one spread album")
|
||||
expectAlbumAbsent(alA)
|
||||
})
|
||||
})
|
||||
|
||||
// albumRootParent refuses the library root as an album root (parent.ParentID == "").
|
||||
When("a multi-disc album sits directly at the library root with a cover.jpg beside it", func() {
|
||||
// (library root)
|
||||
// ├── cover.jpg ← must NOT be adopted
|
||||
// ├── CD1/
|
||||
// │ └── 01 - Track.mp3
|
||||
// └── CD2/
|
||||
// └── 01 - Track.mp3
|
||||
It("does not adopt the library-root image as album art", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"cover.jpg": smallPNG("library-root"),
|
||||
"CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"album": "Rootless", "disc": "1"}),
|
||||
"CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"album": "Rootless", "disc": "2"}),
|
||||
})
|
||||
scan()
|
||||
expectAlbumAbsent(firstAlbum())
|
||||
})
|
||||
})
|
||||
|
||||
// The shallower artist-folder cover.jpg would win the basename tie, but albumRootParent skips
|
||||
// the parent folder for a single-folder album that has images of its own.
|
||||
When("a single-folder album has its own cover.jpg and the artist folder has one too", func() {
|
||||
// Artist/
|
||||
// ├── cover.jpg ← shallower, but must NOT win
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── cover.jpg ← should win
|
||||
It("prefers the album's own cover over the shallower artist-folder cover", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/cover.jpg": smallPNG("artist-image"),
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-own"),
|
||||
})
|
||||
scan()
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
|
||||
"sanity check: scanner should treat the two sibling folders as one spread album")
|
||||
_, err := readArtworkOrErr(alA.CoverArtID())
|
||||
Expect(err).To(HaveOccurred(),
|
||||
"the spread album has no images of its own, so it must fall through to the "+
|
||||
"placeholder instead of inheriting the artist folder's folder.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
@ -433,13 +430,13 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
// ├── Album A bonus/
|
||||
// │ └── 02 - Track.mp3 (album: "Album A")
|
||||
// └── Album B/
|
||||
// └── 01 - Track.mp3 (other-album audio: rejects the artist folder as a root)
|
||||
// └── 01 - Track.mp3
|
||||
It("prefers the album's own art over the artist image", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/cover.jpg": smallPNG("artist-image"),
|
||||
"Artist/cover.jpg": imageFile("artist-image"),
|
||||
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album A/front.jpg": smallPNG("album-a-front"),
|
||||
"Artist/Album A/front.jpg": imageFile("album-a-front"),
|
||||
"Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}),
|
||||
})
|
||||
@ -447,8 +444,8 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
|
||||
alA := albumByName("Album A")
|
||||
Expect(alA.FolderIDs).To(HaveLen(2),
|
||||
"sanity check: the two sibling folders should form one spread album")
|
||||
expectAlbumFolderCover(alA, "Artist/Album A/front.jpg")
|
||||
"sanity check: scanner should treat the two sibling folders as one spread album")
|
||||
Expect(readArtwork(alA.CoverArtID())).To(Equal(imageBytes("album-a-front")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -461,10 +458,12 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
conf.Server.CoverArtPriority = "embedded, cover.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": smallPNG("cover"),
|
||||
"Artist/Album/cover.jpg": imageFile("cover"),
|
||||
})
|
||||
scan()
|
||||
expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package e2e
|
||||
package artworke2e_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
@ -8,7 +8,6 @@ import (
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@ -17,11 +16,9 @@ import (
|
||||
// Doc reference:
|
||||
// https://www.navidrome.org/docs/usage/library/artwork/#artists
|
||||
// Default ArtistArtPriority is "artist.*, album/artist.*, external".
|
||||
// Library-folder images are file-backed (asserted on the worker state row); uploaded and
|
||||
// image-folder images are real files on disk (asserted byte-for-byte).
|
||||
var _ = Describe("Artist artwork resolution", func() {
|
||||
BeforeEach(func() {
|
||||
setupResolutionHarness()
|
||||
setupHarness()
|
||||
})
|
||||
|
||||
When("the artist folder contains an artist.jpg", func() {
|
||||
@ -33,10 +30,13 @@ var _ = Describe("Artist artwork resolution", 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": smallPNG("artist-folder"),
|
||||
"Artist/artist.jpg": imageFile("artist-folder"),
|
||||
})
|
||||
scan()
|
||||
expectArtistFolder(soleArtist(), "Artist/artist.jpg")
|
||||
|
||||
ar := soleArtist()
|
||||
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -49,10 +49,13 @@ var _ = Describe("Artist artwork resolution", 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": smallPNG("album-artist"),
|
||||
"Artist/Album/artist.jpg": imageFile("album-artist"),
|
||||
})
|
||||
scan()
|
||||
expectArtistFolder(soleArtist(), "Artist/Album/artist.jpg")
|
||||
|
||||
ar := soleArtist()
|
||||
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -66,94 +69,14 @@ var _ = Describe("Artist artwork resolution", 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": smallPNG("artist-folder"),
|
||||
"Artist/Album/artist.jpg": smallPNG("album-artist"),
|
||||
"Artist/artist.jpg": imageFile("artist-folder"),
|
||||
"Artist/Album/artist.jpg": imageFile("album-artist"),
|
||||
})
|
||||
scan()
|
||||
expectArtistFolder(soleArtist(), "Artist/artist.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
When("ArtistArtPriority has no album/ fallback", func() {
|
||||
// Artist/
|
||||
// ├── artist.jpg ← must resolve via the artist folder itself
|
||||
// └── Album/
|
||||
// └── 01 - Track.mp3
|
||||
It("still resolves the artist folder and returns artist.*", func() {
|
||||
conf.Server.ArtistArtPriority = "artist.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||
"Artist/artist.jpg": smallPNG("artist-folder"),
|
||||
})
|
||||
scan()
|
||||
expectArtistFolder(soleArtist(), "Artist/artist.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
When("the artist's only album has its tracks in disc subfolders", func() {
|
||||
// Artist/
|
||||
// ├── artist.jpg ← wins (artist.* before album/artist.*)
|
||||
// └── Album/
|
||||
// ├── artist.jpg
|
||||
// ├── CD1/01 - Track.mp3
|
||||
// └── CD2/02 - Track.mp3
|
||||
It("prefers the artist-folder image over the album-folder one", func() {
|
||||
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album"}),
|
||||
"Artist/Album/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album"}),
|
||||
"Artist/artist.jpg": smallPNG("artist-folder"),
|
||||
"Artist/Album/artist.jpg": smallPNG("album-artist"),
|
||||
})
|
||||
scan()
|
||||
expectArtistFolder(soleArtist(), "Artist/artist.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
When("one album has disc subfolders and another sits at artist level", func() {
|
||||
// Artist/
|
||||
// ├── artist.jpg ← wins
|
||||
// ├── Album1/
|
||||
// │ ├── artist.jpg
|
||||
// │ ├── CD1/01 - Track.mp3
|
||||
// │ └── CD2/02 - Track.mp3
|
||||
// └── Album2/03 - Track.mp3
|
||||
It("prefers the artist-folder image over the album-folder one", func() {
|
||||
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album1/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album1"}),
|
||||
"Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}),
|
||||
"Artist/Album2/03 - Track.mp3": trackFile(3, "Track 3", map[string]any{"albumartist": "Artist", "album": "Album2"}),
|
||||
"Artist/artist.jpg": smallPNG("artist-folder"),
|
||||
"Artist/Album1/artist.jpg": smallPNG("album-artist"),
|
||||
})
|
||||
scan()
|
||||
expectArtistFolder(soleArtist(), "Artist/artist.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
When("every album of the artist has its tracks in disc subfolders", func() {
|
||||
// Artist/
|
||||
// ├── artist.jpg ← wins
|
||||
// ├── Album1/
|
||||
// │ ├── artist.jpg
|
||||
// │ ├── CD1/01 - Track.mp3
|
||||
// │ └── CD2/02 - Track.mp3
|
||||
// └── Album2/
|
||||
// ├── CD1/03 - Track.mp3
|
||||
// └── CD2/04 - Track.mp3
|
||||
It("prefers the artist-folder image over the album-folder one", func() {
|
||||
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album1/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album1"}),
|
||||
"Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}),
|
||||
"Artist/Album2/CD1/03 - Track.mp3": trackFile(3, "Track 3", map[string]any{"albumartist": "Artist", "album": "Album2"}),
|
||||
"Artist/Album2/CD2/04 - Track.mp3": trackFile(4, "Track 4", map[string]any{"albumartist": "Artist", "album": "Album2"}),
|
||||
"Artist/artist.jpg": smallPNG("artist-folder"),
|
||||
"Artist/Album1/artist.jpg": smallPNG("album-artist"),
|
||||
})
|
||||
scan()
|
||||
expectArtistFolder(soleArtist(), "Artist/artist.jpg")
|
||||
ar := soleArtist()
|
||||
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -171,19 +94,18 @@ var _ = Describe("Artist artwork resolution", 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": smallPNG("artist-folder"),
|
||||
"Artist/artist.jpg": imageFile("artist-folder"),
|
||||
})
|
||||
scan()
|
||||
ar := soleArtist()
|
||||
|
||||
uploaded := ar.ID + "_upload.jpg"
|
||||
writeUploadedImage(consts.EntityArtist, uploaded, pngBytes("artist-uploaded"))
|
||||
writeUploadedImage(consts.EntityArtist, uploaded, imageBytes("artist-uploaded"))
|
||||
ar.UploadedImage = uploaded
|
||||
Expect(rds.Artist(rctx).Put(&ar)).To(Succeed())
|
||||
Expect(ds.Artist(ctx).Put(&ar)).To(Succeed())
|
||||
|
||||
ia := acquire(model.KindArtistArtwork, ar.ID)
|
||||
Expect(ia.Source).To(Equal("upload"))
|
||||
Expect(serveBytes(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).To(Equal(pngBytes("artist-uploaded")))
|
||||
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-uploaded")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -196,36 +118,13 @@ var _ = Describe("Artist artwork resolution", 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": smallPNG("album-artist"),
|
||||
"Artist/Album/artist.jpg": imageFile("album-artist"),
|
||||
})
|
||||
scan()
|
||||
expectArtistFolder(soleArtist(), "Artist/Album/artist.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
// resolveArtist only samples albums where this artist is the SOLE album artist, so a
|
||||
// collaboration or compilation never donates its images as the artist's own.
|
||||
When("the artist's only album is credited to two album artists", func() {
|
||||
// Artist/
|
||||
// └── Collab Album/ (album artists: "Artist" + a collaborator)
|
||||
// ├── 01 - Track.mp3
|
||||
// └── artist.jpg ← must NOT become the artist image
|
||||
It("ignores the album's images and settles absent", func() {
|
||||
conf.Server.ArtistArtPriority = "album/artist.*"
|
||||
// " / " is a default artists split separator, so this single tag yields two album artists.
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Collab Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist / Collaborator"}),
|
||||
"Artist/Collab Album/artist.jpg": smallPNG("collab-artist"),
|
||||
})
|
||||
scan()
|
||||
Expect(firstAlbum().Participants[model.RoleAlbumArtist]).To(HaveLen(2),
|
||||
"sanity check: the album must be credited to two album artists")
|
||||
|
||||
ar := soleArtist()
|
||||
ia := acquire(model.KindArtistArtwork, ar.ID)
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
Expect(serveErr(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).
|
||||
To(MatchError(artwork.ErrUnavailable))
|
||||
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -238,7 +137,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||
// └── 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"), pngBytes("image-folder"), 0o600)).To(Succeed())
|
||||
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.*"
|
||||
|
||||
@ -248,16 +147,15 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||
scan()
|
||||
|
||||
ar := soleArtist()
|
||||
ia := acquire(model.KindArtistArtwork, ar.ID)
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
Expect(serveBytes(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).To(Equal(pngBytes("image-folder")))
|
||||
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||
Expect(readArtwork(artID)).To(Equal(imageBytes("image-folder")))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func soleArtist() model.Artist {
|
||||
GinkgoHelper()
|
||||
artists, err := rds.Artist(rctx).GetAll(model.QueryOptions{
|
||||
artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"artist.name": "Artist"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@ -1,20 +1,18 @@
|
||||
package e2e
|
||||
package artworke2e_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Disc art is a serve-time read through the library FS (no worker state row), so per-disc images
|
||||
// are asserted byte-for-byte, while album-root covers are asserted on the state row.
|
||||
var _ = Describe("Disc artwork resolution", func() {
|
||||
BeforeEach(func() {
|
||||
setupResolutionHarness()
|
||||
setupHarness()
|
||||
})
|
||||
|
||||
When("the album is single-disc with a disc1.jpg in the only folder", func() {
|
||||
@ -26,25 +24,32 @@ var _ = Describe("Disc artwork resolution", 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": smallPNG("disc1-image"),
|
||||
"Artist/Album/disc1.jpg": imageFile("disc1-image"),
|
||||
})
|
||||
scan()
|
||||
expectDiscImage(firstAlbum(), 1, "disc1-image")
|
||||
|
||||
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 — nothing to serve)
|
||||
It("reports the disc lookup as unavailable", func() {
|
||||
// └── 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()
|
||||
Expect(serveErr(discArtID(firstAlbum(), 1))).To(MatchError(artwork.ErrUnavailable))
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||
_, err := readArtworkOrErr(discID)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
@ -58,10 +63,13 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-cover"),
|
||||
"Artist/Album/cover.jpg": imageFile("album-cover"),
|
||||
})
|
||||
scan()
|
||||
expectDiscImage(firstAlbum(), 1, "album-cover")
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -75,11 +83,14 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||
conf.Server.DiscArtPriority = "disc*.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/disc1.jpg": smallPNG("disc-one"),
|
||||
"Artist/Album/disc10.jpg": smallPNG("disc-ten"),
|
||||
"Artist/Album/disc1.jpg": imageFile("disc-one"),
|
||||
"Artist/Album/disc10.jpg": imageFile("disc-ten"),
|
||||
})
|
||||
scan()
|
||||
expectDiscImage(firstAlbum(), 1, "disc-one")
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("disc-one")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -97,11 +108,14 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||
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": smallPNG("disc-1"),
|
||||
"Artist/Album/CD2/disc2.jpg": smallPNG("disc-2"),
|
||||
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
|
||||
"Artist/Album/CD2/disc2.jpg": imageFile("disc-2"),
|
||||
})
|
||||
scan()
|
||||
expectDiscImage(firstAlbum(), 2, "disc-2")
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("disc-2")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -122,11 +136,14 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||
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": smallPNG("disc-1"),
|
||||
"Artist/Album/CD2/cd2.png": smallPNG("cd-2"),
|
||||
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
|
||||
"Artist/Album/CD2/cd2.png": imageFile("cd-2"),
|
||||
})
|
||||
scan()
|
||||
expectDiscImage(firstAlbum(), 2, "cd-2")
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("cd-2")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -144,11 +161,14 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||
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": smallPNG("disc1-cover"),
|
||||
"Artist/Album/CD2/cover.jpg": smallPNG("disc2-cover"),
|
||||
"Artist/Album/CD1/cover.jpg": imageFile("disc1-cover"),
|
||||
"Artist/Album/CD2/cover.jpg": imageFile("disc2-cover"),
|
||||
})
|
||||
scan()
|
||||
expectDiscImage(firstAlbum(), 1, "disc1-cover")
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-cover")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -168,15 +188,17 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||
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": smallPNG("disc-1"),
|
||||
"Artist/Album/CD2/cd2.png": smallPNG("cd-2"),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-cover"),
|
||||
"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} {
|
||||
expectDiscImage(al, n, "album-cover")
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -201,15 +223,17 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||
"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": smallPNG("disc-1"),
|
||||
"Artist/Album/disc2/cd2.png": smallPNG("cd-2"),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-root"),
|
||||
"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()
|
||||
expectDiscImage(al, 1, "disc-1")
|
||||
expectDiscImage(al, 2, "cd-2")
|
||||
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")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -222,10 +246,13 @@ var _ = Describe("Disc artwork resolution", 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": smallPNG("bonus-tracks"),
|
||||
"Artist/Album/Bonus Tracks.jpg": imageFile("bonus-tracks"),
|
||||
})
|
||||
scan()
|
||||
expectDiscImage(firstAlbum(), 1, "bonus-tracks")
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("bonus-tracks")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -259,22 +286,26 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||
"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": smallPNG("album-root-cover"),
|
||||
"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"] = smallPNG(fmt.Sprintf("disc-%02d-folder", discNum))
|
||||
layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", discNum))
|
||||
}
|
||||
setLayout(layout)
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
expectAlbumFolderCover(al, "(2001) The Golden Road/cover.jpg")
|
||||
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover")))
|
||||
|
||||
for i := range discNames {
|
||||
discNum := i + 1
|
||||
expectDiscImage(al, discNum, fmt.Sprintf("disc-%02d-folder", discNum))
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -297,20 +328,24 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
layout := fstest.MapFS{
|
||||
"Album/cover.jpg": smallPNG("album-root-cover"),
|
||||
"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"] = smallPNG(fmt.Sprintf("disc-%02d-folder", i))
|
||||
layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", i))
|
||||
}
|
||||
setLayout(layout)
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
expectAlbumFolderCover(al, "Album/cover.jpg")
|
||||
|
||||
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover")))
|
||||
|
||||
for i := 1; i <= 3; i++ {
|
||||
expectDiscImage(al, i, fmt.Sprintf("disc-%02d-folder", 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -324,10 +359,13 @@ var _ = Describe("Disc artwork resolution", 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": smallPNG("cover"),
|
||||
"Artist/Album/cover.jpg": imageFile("cover"),
|
||||
})
|
||||
scan()
|
||||
expectDiscImage(firstAlbum(), 1, "cover")
|
||||
|
||||
al := firstAlbum()
|
||||
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||
Expect(readArtwork(discID)).To(Equal(imageBytes("cover")))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,83 +0,0 @@
|
||||
// Package e2e exercises the artwork pipeline end to end: the real Worker drains the queue and the
|
||||
// real Service serves the result, over a real ImageStore and real library files.
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/navidrome/navidrome/adapters/gotaglib" // registers the "taglib" local-storage extractor
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
_ "github.com/navidrome/navidrome/core/storage/local"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"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 Pipeline E2E Suite")
|
||||
}
|
||||
|
||||
// Fixtures relative to the project root (tests.Init chdirs there).
|
||||
const (
|
||||
coverFixture = "tests/fixtures/artist/an-album/cover.jpg"
|
||||
mp3Fixture = "tests/fixtures/artist/an-album/test.mp3"
|
||||
artistPngFixture = "tests/fixtures/artist/an-album/artist.png"
|
||||
albumFolderPath = "tests/fixtures/artist/an-album"
|
||||
)
|
||||
|
||||
func readFixture(rel string) []byte {
|
||||
GinkgoHelper()
|
||||
data, err := os.ReadFile(rel)
|
||||
Expect(err).ToNot(HaveOccurred(), "reading fixture %q", rel)
|
||||
return data
|
||||
}
|
||||
|
||||
func readAll(img *artwork.Image) []byte {
|
||||
GinkgoHelper()
|
||||
Expect(img).ToNot(BeNil())
|
||||
defer img.Close()
|
||||
data, err := io.ReadAll(img)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return data
|
||||
}
|
||||
|
||||
func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bool) {
|
||||
GinkgoHelper()
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- worker.Run(runCtx) }()
|
||||
Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue())
|
||||
cancel()
|
||||
Eventually(done, 2*time.Second).Should(Receive(BeNil()))
|
||||
}
|
||||
|
||||
type fakeFolderRepo struct {
|
||||
model.FolderRepository
|
||||
result []model.Folder
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { return f.result, nil }
|
||||
|
||||
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) Get(string) (*model.Folder, error) { return nil, model.ErrNotFound }
|
||||
|
||||
func writeUpload(entityType, name, srcFixture string) string {
|
||||
GinkgoHelper()
|
||||
dst := model.UploadedImagePath(entityType, name)
|
||||
Expect(os.MkdirAll(filepath.Dir(dst), 0o755)).To(Succeed())
|
||||
Expect(os.WriteFile(dst, readFixture(srcFixture), 0o600)).To(Succeed())
|
||||
return name
|
||||
}
|
||||
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)
|
||||
@ -1,4 +1,4 @@
|
||||
package e2e
|
||||
package artworke2e_test
|
||||
|
||||
import (
|
||||
"testing/fstest"
|
||||
@ -17,11 +17,13 @@ import (
|
||||
// 2. For multi-disc albums, disc-level artwork
|
||||
// 3. Album cover art
|
||||
//
|
||||
// Embedded art lands in the content-addressed store (asserted byte-for-byte); disc-level art is a
|
||||
// serve-time read through the library FS.
|
||||
var _ = Describe("MediaFile artwork resolution", func() {
|
||||
// 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() {
|
||||
setupResolutionHarness()
|
||||
setupHarness()
|
||||
})
|
||||
|
||||
When("a multi-disc album track has no embedded art", func() {
|
||||
@ -40,14 +42,14 @@ var _ = Describe("MediaFile artwork resolution", func() {
|
||||
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": smallPNG("disc-1"),
|
||||
"Artist/Album/CD2/disc2.jpg": smallPNG("disc-2"),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-root"),
|
||||
"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(serveBytes(mf.CoverArtID())).To(Equal(pngBytes("disc-2")))
|
||||
Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("disc-2")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -61,12 +63,12 @@ var _ = Describe("MediaFile artwork resolution", func() {
|
||||
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-cover"),
|
||||
"Artist/Album/cover.jpg": imageFile("album-cover"),
|
||||
})
|
||||
scan()
|
||||
|
||||
mf := mediafileOn("Artist/Album/01 - Track.mp3")
|
||||
Expect(serveBytes(mf.CoverArtID())).To(Equal(pngBytes("album-cover")))
|
||||
Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-cover")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -84,60 +86,19 @@ var _ = Describe("MediaFile artwork resolution", func() {
|
||||
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": smallPNG("album-root"),
|
||||
"Artist/Album/cover.jpg": imageFile("album-root"),
|
||||
})
|
||||
scan()
|
||||
|
||||
mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3")
|
||||
Expect(serveBytes(mf.CoverArtID())).To(Equal(pngBytes("album-root")))
|
||||
})
|
||||
})
|
||||
|
||||
When("a track has its own embedded art", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// └── 01 - Track.mp3 ← has embedded picture (wins over every fallback)
|
||||
It("resolves the track's embedded image into the store", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
||||
})
|
||||
scan()
|
||||
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
|
||||
|
||||
mf := mediafileOn("Artist/Album/01 - Track.mp3")
|
||||
ia := acquire(model.KindMediaFileArtwork, mf.ID)
|
||||
Expect(ia.Source).To(Equal("embedded"))
|
||||
Expect(storedBytes(ia)).To(Equal(embeddedArtBytes))
|
||||
})
|
||||
})
|
||||
|
||||
When("EnableMediaFileCoverArt is turned off after the track was scanned", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3 ← has embedded picture (must NOT be served)
|
||||
// └── cover.jpg ← wins (per-track art disabled at serve time)
|
||||
It("serves the album cover instead of the track's embedded art", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-cover"),
|
||||
})
|
||||
scan()
|
||||
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
|
||||
|
||||
// The setting is not part of the artwork fingerprint, so it must be honored at serve time.
|
||||
conf.Server.EnableMediaFileCoverArt = false
|
||||
mf := mediafileOn("Artist/Album/01 - Track.mp3")
|
||||
trackArtID := model.NewArtworkID(model.KindMediaFileArtwork, mf.ID, nil)
|
||||
Expect(serveBytes(trackArtID)).To(Equal(pngBytes("album-cover")))
|
||||
Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-root")))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func mediafileOn(relPath string) model.MediaFile {
|
||||
GinkgoHelper()
|
||||
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{
|
||||
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Like{"media_file.path": relPath},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@ -1,16 +1,13 @@
|
||||
package e2e
|
||||
package artworke2e_test
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -20,13 +17,14 @@ import (
|
||||
// 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. Absent
|
||||
// 5. Album placeholder image
|
||||
//
|
||||
// The library is an in-memory FS, but uploaded/sidecar/local-external images are real files on
|
||||
// disk — the resolver reads them via os.Open, so those tests place them in a real tempdir.
|
||||
// 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() {
|
||||
setupResolutionHarness()
|
||||
setupHarness()
|
||||
})
|
||||
|
||||
When("a playlist has an uploaded image", func() {
|
||||
@ -35,12 +33,11 @@ var _ = Describe("Playlist artwork resolution", func() {
|
||||
// └── playlist/
|
||||
// └── pl-1_upload.jpg ← matched by UploadedImagePath() (highest priority)
|
||||
It("returns the uploaded image bytes", func() {
|
||||
writeUploadedImage(consts.EntityPlaylist, "pl-1_upload.jpg", pngBytes("playlist-upload"))
|
||||
writeUploadedImage(consts.EntityPlaylist, "pl-1_upload.jpg", imageBytes("playlist-upload"))
|
||||
|
||||
pl := putPlaylist(model.Playlist{ID: "pl-1", Name: "Test", UploadedImage: "pl-1_upload.jpg"})
|
||||
|
||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||
Expect(ia.Source).To(Equal("upload"))
|
||||
Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("playlist-upload")))
|
||||
Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("playlist-upload")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -51,14 +48,12 @@ var _ = Describe("Playlist artwork resolution", func() {
|
||||
It("returns the sidecar image", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
m3uPath := filepath.Join(dir, "MyList.m3u")
|
||||
Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0o600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(dir, "MyList.jpg"), pngBytes("sidecar"), 0o600)).To(Succeed())
|
||||
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})
|
||||
|
||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("sidecar")))
|
||||
Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -69,12 +64,12 @@ var _ = Describe("Playlist artwork resolution", func() {
|
||||
It("matches case-insensitively", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
m3uPath := filepath.Join(dir, "MyList.m3u")
|
||||
Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0o600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(dir, "MyList.PNG"), pngBytes("sidecar-png"), 0o600)).To(Succeed())
|
||||
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(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("sidecar-png")))
|
||||
Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar-png")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -85,39 +80,31 @@ var _ = Describe("Playlist artwork resolution", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = false // local paths bypass the toggle
|
||||
dir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(dir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, pngBytes("external-local"), 0o600)).To(Succeed())
|
||||
Expect(os.WriteFile(imgPath, imageBytes("external-local"), 0600)).To(Succeed())
|
||||
|
||||
pl := putPlaylist(model.Playlist{ID: "pl-4", Name: "WithExt", ExternalImageURL: imgPath})
|
||||
|
||||
Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("external-local")))
|
||||
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 — the http source is gated off, so resolution settles absent)
|
||||
It("skips the URL and settles absent", 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"})
|
||||
|
||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
Expect(serveErr(pl.CoverArtID())).To(MatchError(artwork.ErrUnavailable))
|
||||
|
||||
img, err := rsvc.GetOrPlaceholder(rctx, pl.CoverArtID().String(), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer img.Close()
|
||||
Expect(img.Placeholder).To(BeTrue())
|
||||
Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes()))
|
||||
})
|
||||
})
|
||||
|
||||
When("a playlist has no images and no tracks", func() {
|
||||
// (no uploaded/sidecar/external image and no album art to sample)
|
||||
It("settles absent", 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"})
|
||||
|
||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
Expect(serveErr(pl.CoverArtID())).To(MatchError(artwork.ErrUnavailable))
|
||||
Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes()))
|
||||
})
|
||||
})
|
||||
|
||||
@ -126,79 +113,37 @@ var _ = Describe("Playlist artwork resolution", func() {
|
||||
// Artist/
|
||||
// ├── AlbumA/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── cover.png ← tile 1 source
|
||||
// │ └── cover.png (real PNG — wins as tile 1 source)
|
||||
// └── AlbumB/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── cover.png ← tile 2 source
|
||||
// Playlist "pl-7" references tracks from both albums, so the worker generates a tiled
|
||||
// cover from 2 distinct album art tiles (mirrored to fill the 2x2 grid).
|
||||
// └── 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": smallPNG("albumA"),
|
||||
"Artist/AlbumA/cover.png": realPNG("albumA"),
|
||||
"Artist/AlbumB/01 - Track.mp3": trackFile(1, "TB", map[string]any{"album": "AlbumB"}),
|
||||
"Artist/AlbumB/cover.png": smallPNG("albumB"),
|
||||
"Artist/AlbumB/cover.png": realPNG("albumB"),
|
||||
})
|
||||
scan()
|
||||
|
||||
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{})
|
||||
// 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(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
||||
Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed())
|
||||
|
||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||
Expect(ia.Source).To(Equal("generated"))
|
||||
data := storedBytes(ia)
|
||||
// The tiled cover is a PNG-encoded image; exact bytes vary (random album order).
|
||||
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}))
|
||||
// Two tiles are mirrored into the 2x2 grid as [A B B A], so opposite corners match.
|
||||
q := gridQuadrants(data)
|
||||
Expect(q[0]).To(Equal(q[3]))
|
||||
Expect(q[1]).To(Equal(q[2]))
|
||||
Expect(q[0]).ToNot(Equal(q[1]))
|
||||
})
|
||||
})
|
||||
|
||||
When("a playlist has tracks from four albums, each with its own cover", func() {
|
||||
// Library:
|
||||
// Artist/
|
||||
// ├── AlbumA/{01 - Track.mp3, cover.png} ← tile 1
|
||||
// ├── AlbumB/{01 - Track.mp3, cover.png} ← tile 2
|
||||
// ├── AlbumC/{01 - Track.mp3, cover.png} ← tile 3
|
||||
// └── AlbumD/{01 - Track.mp3, cover.png} ← tile 4
|
||||
It("fills all four grid quadrants with distinct album art", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
layout := fstest.MapFS{}
|
||||
for _, name := range []string{"AlbumA", "AlbumB", "AlbumC", "AlbumD"} {
|
||||
layout["Artist/"+name+"/01 - Track.mp3"] = trackFile(1, "T"+name, map[string]any{"album": name})
|
||||
layout["Artist/"+name+"/cover.png"] = smallPNG(name)
|
||||
}
|
||||
setLayout(layout)
|
||||
scan()
|
||||
|
||||
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfs).To(HaveLen(4))
|
||||
ids := slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID })
|
||||
|
||||
pl := model.Playlist{ID: "pl-8", Name: "Four", OwnerID: "admin-1"}
|
||||
pl.AddMediaFilesByID(ids)
|
||||
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
||||
|
||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||
Expect(ia.Source).To(Equal("generated"))
|
||||
q := gridQuadrants(storedBytes(ia))
|
||||
Expect([]color.RGBA{q[0], q[1], q[2], q[3]}).To(HaveLen(4))
|
||||
Expect(q[0]).ToNot(Equal(q[1]))
|
||||
Expect(q[0]).ToNot(Equal(q[2]))
|
||||
Expect(q[0]).ToNot(Equal(q[3]))
|
||||
Expect(q[1]).ToNot(Equal(q[2]))
|
||||
Expect(q[1]).ToNot(Equal(q[3]))
|
||||
Expect(q[2]).ToNot(Equal(q[3]))
|
||||
Expect(len(data)).To(BeNumerically(">", 1000))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -208,6 +153,6 @@ func putPlaylist(pl model.Playlist) model.Playlist {
|
||||
if pl.OwnerID == "" {
|
||||
pl.OwnerID = "admin-1"
|
||||
}
|
||||
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
||||
Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed())
|
||||
return pl
|
||||
}
|
||||
|
||||
@ -1,18 +1,15 @@
|
||||
package e2e
|
||||
package artworke2e_test
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Radio art is uploaded-image-only, with no fallback. Uploads are real files on disk, so they
|
||||
// serve back byte-for-byte; a radio with no upload settles absent.
|
||||
var _ = Describe("Radio artwork resolution", func() {
|
||||
BeforeEach(func() {
|
||||
setupResolutionHarness()
|
||||
setupHarness()
|
||||
})
|
||||
|
||||
When("a radio has an uploaded image", func() {
|
||||
@ -21,25 +18,25 @@ var _ = Describe("Radio artwork resolution", func() {
|
||||
// └── radio/
|
||||
// └── rd-1_logo.jpg ← matched by UploadedImagePath()
|
||||
It("returns the uploaded image bytes", func() {
|
||||
writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", pngBytes("radio-logo"))
|
||||
rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"}
|
||||
Expect(rds.Radio(rctx).Put(&rd)).To(Succeed())
|
||||
writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", imageBytes("radio-logo"))
|
||||
|
||||
ia := acquire(model.KindRadioArtwork, rd.ID)
|
||||
Expect(ia.Source).To(Equal("upload"))
|
||||
Expect(serveBytes(model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil))).To(Equal(pngBytes("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 — the resolver has no sources to fall back to)
|
||||
It("settles absent", 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(rds.Radio(rctx).Put(&rd)).To(Succeed())
|
||||
Expect(ds.Radio(ctx).Put(&rd)).To(Succeed())
|
||||
|
||||
ia := acquire(model.KindRadioArtwork, rd.ID)
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
Expect(serveErr(model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil))).To(MatchError(artwork.ErrUnavailable))
|
||||
artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil)
|
||||
_, err := readArtworkOrErr(artID)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,365 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"io"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
_ "github.com/navidrome/navidrome/adapters/gotaglib"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"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/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/navidrome/navidrome/tests/harness"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"go.senan.xyz/taglib"
|
||||
)
|
||||
|
||||
const fakeLibScheme = "artworkfake"
|
||||
const fakeLibPath = fakeLibScheme + ":///music"
|
||||
|
||||
const (
|
||||
defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external"
|
||||
defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded"
|
||||
)
|
||||
|
||||
var (
|
||||
rctx context.Context
|
||||
rds *tests.MockDataStore
|
||||
rstore *artwork.ImageStore
|
||||
rsvc artwork.Artwork
|
||||
rworker *artwork.Worker
|
||||
fakeFS *storagetest.FakeFS
|
||||
)
|
||||
|
||||
// The go-sqlite3 singleton holds the file open for the whole suite, and Windows cannot unlink a
|
||||
// file with a live handle, so the DB cannot live in Ginkgo's per-spec TempDir.
|
||||
var suiteDBTempDir string
|
||||
|
||||
// Migrating the schema costs ~400ms, so it runs once per suite and specs reset by truncating.
|
||||
var userTables []string
|
||||
|
||||
var _ = BeforeSuite(func() {
|
||||
suiteDBTempDir = GinkgoT().TempDir()
|
||||
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-resolution-e2e.db") + "?_journal_mode=WAL"
|
||||
conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir())
|
||||
db.Db().SetMaxOpenConns(1)
|
||||
db.Init(request.WithUser(context.Background(), model.User{ID: "admin-1", IsAdmin: true}))
|
||||
|
||||
userTables = harness.ResettableTables()
|
||||
})
|
||||
|
||||
var _ = AfterSuite(func() {
|
||||
db.Close(context.Background())
|
||||
})
|
||||
|
||||
func setupResolutionHarness() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
tempDir := GinkgoT().TempDir()
|
||||
conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-resolution-e2e.db") + "?_journal_mode=WAL"
|
||||
conf.Server.DataFolder = conf.NewDir(tempDir)
|
||||
conf.Server.MusicFolder = fakeLibPath
|
||||
conf.Server.DevExternalScanner = false
|
||||
conf.Server.ImageCacheSize = "0"
|
||||
conf.Server.EnableExternalServices = false
|
||||
conf.Server.EnableMediaFileCoverArt = true
|
||||
conf.Server.DevArtworkWorkerConcurrency = 1
|
||||
|
||||
rctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin-1", UserName: "admin", IsAdmin: true})
|
||||
harness.TruncateDB(userTables)
|
||||
|
||||
rds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||
|
||||
adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"}
|
||||
Expect(rds.User(rctx).Put(&adminUser)).To(Succeed())
|
||||
|
||||
lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath}
|
||||
Expect(rds.Library(rctx).Put(&lib)).To(Succeed())
|
||||
Expect(rds.User(rctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())
|
||||
|
||||
loadEmbeddedFixture()
|
||||
|
||||
fakeFS = &storagetest.FakeFS{}
|
||||
storagetest.Register(fakeLibScheme, fakeFS)
|
||||
|
||||
ffm := tests.NewMockFFmpeg("")
|
||||
rstore = artwork.NewImageStore(filepath.Join(tempDir, consts.HashedArtworkFolder))
|
||||
// size=0 requests stream originals, so this reader is never called (serving_test covers resizing).
|
||||
imgCache := cache.NewFileCache("ArtworkResolutionE2E", "100MB", "images", 0,
|
||||
func(context.Context, cache.Item) (io.Reader, error) {
|
||||
return nil, fmt.Errorf("resize not exercised in e2e")
|
||||
})
|
||||
Eventually(func() bool { return imgCache.Available(rctx) }, 10*time.Second).Should(BeTrue())
|
||||
|
||||
rsvc = artwork.NewArtwork(rds, imgCache, rstore, ffm)
|
||||
rworker = artwork.NewWorker(rds, rstore, agents.GetAgents(rds, nil), ffm, events.NoopBroker(), imgCache)
|
||||
}
|
||||
|
||||
// setLayout paths must be relative and forward-slash.
|
||||
func setLayout(files fstest.MapFS) {
|
||||
GinkgoHelper()
|
||||
fakeFS.SetFiles(files)
|
||||
}
|
||||
|
||||
func scan() {
|
||||
GinkgoHelper()
|
||||
s := scanner.New(rctx, rds, events.NoopBroker(),
|
||||
playlists.NewPlaylists(rds, artwork.NewUploader(rds)), metrics.NewNoopInstance())
|
||||
_, err := s.ScanAll(rctx, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
func acquire(kind model.Kind, id string) model.ItemArtwork {
|
||||
GinkgoHelper()
|
||||
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
|
||||
Expect(rds.ArtworkQueue(rctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
||||
ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary,
|
||||
Priority: model.ArtworkPriorityBump,
|
||||
})).To(Succeed())
|
||||
var ia *model.ItemArtwork
|
||||
runResolutionWorkerUntil(func() bool {
|
||||
got, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ia = got
|
||||
return true
|
||||
})
|
||||
return *ia
|
||||
}
|
||||
|
||||
func runResolutionWorkerUntil(until func() bool) {
|
||||
GinkgoHelper()
|
||||
runCtx, cancel := context.WithCancel(rctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- rworker.Run(runCtx) }()
|
||||
Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue())
|
||||
cancel()
|
||||
Eventually(done, 2*time.Second).Should(Receive(BeNil()))
|
||||
}
|
||||
|
||||
func serveBytes(artID model.ArtworkID) []byte {
|
||||
GinkgoHelper()
|
||||
img, err := rsvc.Get(rctx, artID, 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer img.Close()
|
||||
data, err := io.ReadAll(img)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return data
|
||||
}
|
||||
|
||||
func serveErr(artID model.ArtworkID) error {
|
||||
img, err := rsvc.Get(rctx, artID, 0, false)
|
||||
if img != nil {
|
||||
img.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func libFileBytes(suffix string) []byte {
|
||||
GinkgoHelper()
|
||||
var match string
|
||||
for name := range fakeFS.MapFS {
|
||||
if strings.HasSuffix(name, suffix) {
|
||||
Expect(match).To(BeEmpty(), "suffix %q is ambiguous: %q and %q", suffix, match, name)
|
||||
match = name
|
||||
}
|
||||
}
|
||||
Expect(match).ToNot(BeEmpty(), "no library file ends with %q", suffix)
|
||||
return fakeFS.MapFS[match].Data
|
||||
}
|
||||
|
||||
// Serving before acquiring is deliberate: with no state row the request resolves through the
|
||||
// library FS, while a settled folder row is read with os.Open, which the in-memory FS cannot serve.
|
||||
func expectAlbumFolderCover(al model.Album, suffix string) {
|
||||
GinkgoHelper()
|
||||
requireNoStateRow(model.KindAlbumArtwork, al.ID)
|
||||
Expect(serveBytes(al.CoverArtID())).To(Equal(libFileBytes(suffix)))
|
||||
ia := acquire(model.KindAlbumArtwork, al.ID)
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix(suffix))
|
||||
}
|
||||
|
||||
// A drain settles every ready item, so byte-level folder assertions must precede any acquire.
|
||||
func requireNoStateRow(kind model.Kind, id string) {
|
||||
GinkgoHelper()
|
||||
_, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound),
|
||||
"assert %s %q before acquiring any other entity in this spec", kind, id)
|
||||
}
|
||||
|
||||
func expectAlbumAbsent(al model.Album) {
|
||||
GinkgoHelper()
|
||||
ia := acquire(model.KindAlbumArtwork, al.ID)
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
Expect(serveErr(al.CoverArtID())).To(MatchError(artwork.ErrUnavailable))
|
||||
}
|
||||
|
||||
func expectArtistFolder(ar model.Artist, suffix string) {
|
||||
GinkgoHelper()
|
||||
requireNoStateRow(model.KindArtistArtwork, ar.ID)
|
||||
Expect(serveBytes(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).To(Equal(libFileBytes(suffix)))
|
||||
ia := acquire(model.KindArtistArtwork, ar.ID)
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix(suffix))
|
||||
}
|
||||
|
||||
func writeUploadedImage(entity, filename string, data []byte) {
|
||||
GinkgoHelper()
|
||||
dst := model.UploadedImagePath(entity, filename)
|
||||
Expect(os.MkdirAll(filepath.Dir(dst), 0o755)).To(Succeed())
|
||||
Expect(os.WriteFile(dst, data, 0o600)).To(Succeed())
|
||||
}
|
||||
|
||||
func discArtID(al model.Album, disc int) model.ArtworkID {
|
||||
return model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, disc), &al.UpdatedAt)
|
||||
}
|
||||
|
||||
// Disc art is a pure serve-time read through the library FS: no worker, no state row.
|
||||
func expectDiscImage(al model.Album, disc int, label string) {
|
||||
GinkgoHelper()
|
||||
Expect(serveBytes(discArtID(al, disc))).To(Equal(pngBytes(label)))
|
||||
}
|
||||
|
||||
// Samples in rect() order: top-left, top-right, bottom-left, bottom-right.
|
||||
func gridQuadrants(data []byte) [4]color.RGBA {
|
||||
GinkgoHelper()
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
b := img.Bounds()
|
||||
qw, qh := b.Dx()/4, b.Dy()/4
|
||||
at := func(x, y int) color.RGBA {
|
||||
c := color.RGBAModel.Convert(img.At(b.Min.X+x, b.Min.Y+y))
|
||||
return c.(color.RGBA)
|
||||
}
|
||||
return [4]color.RGBA{at(qw, qh), at(3*qw, qh), at(qw, 3*qh), at(3*qw, 3*qh)}
|
||||
}
|
||||
|
||||
// Store-backed sources only (embedded/generated); file-backed ones assert on ia.SourcePath.
|
||||
func storedBytes(ia model.ItemArtwork) []byte {
|
||||
GinkgoHelper()
|
||||
art, err := rds.Artwork(rctx).GetImage(ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
r, err := rstore.Open(ia.Hash, art.Mime)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer r.Close()
|
||||
data, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return data
|
||||
}
|
||||
|
||||
// The pixel color derives from label, so each label yields distinct, still-decodable bytes.
|
||||
func smallPNG(label string) *fstest.MapFile {
|
||||
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}
|
||||
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
||||
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()}
|
||||
}
|
||||
|
||||
func pngBytes(label string) []byte {
|
||||
GinkgoHelper()
|
||||
return smallPNG(label).Data
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// FakeFS's JSON-encoded tags aren't taglib-readable, so embedded-art specs swap in these real MP3
|
||||
// bytes after scanning. Loaded lazily: tests.Init must chdir to the project root first.
|
||||
var (
|
||||
embeddedFixtureOnce sync.Once
|
||||
embeddedArtFixture []byte
|
||||
embeddedArtBytes []byte
|
||||
)
|
||||
|
||||
func loadEmbeddedFixture() {
|
||||
embeddedFixtureOnce.Do(func() {
|
||||
embeddedArtFixture = readFixture(mp3Fixture)
|
||||
embeddedArtBytes = extractEmbeddedArt(embeddedArtFixture)
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func replaceWithRealMP3(relPath string) {
|
||||
GinkgoHelper()
|
||||
fakeFS.MapFS[relPath] = &fstest.MapFile{Data: embeddedArtFixture}
|
||||
}
|
||||
|
||||
func firstAlbum() model.Album {
|
||||
GinkgoHelper()
|
||||
albums, err := rds.Album(rctx).GetAll(model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums))
|
||||
return albums[0]
|
||||
}
|
||||
|
||||
func albumByName(name string) model.Album {
|
||||
GinkgoHelper()
|
||||
albums, err := rds.Album(rctx).GetAll(model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for _, al := range albums {
|
||||
if al.Name == name {
|
||||
return al
|
||||
}
|
||||
}
|
||||
Fail(fmt.Sprintf("album %q not found among %d albums", name, len(albums)))
|
||||
return model.Album{}
|
||||
}
|
||||
120
core/artwork/e2e/suite_test.go
Normal file
120
core/artwork/e2e/suite_test.go
Normal file
@ -0,0 +1,120 @@
|
||||
package artworke2e_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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]
|
||||
}
|
||||
|
||||
func albumByName(name string) model.Album {
|
||||
GinkgoHelper()
|
||||
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for _, al := range albums {
|
||||
if al.Name == name {
|
||||
return al
|
||||
}
|
||||
}
|
||||
Fail(fmt.Sprintf("album %q not found among %d albums", name, len(albums)))
|
||||
return model.Album{}
|
||||
}
|
||||
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.
@ -1,214 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// The e2e specs cover which image wins for a layout; these pin what a layout cannot reach: that
|
||||
// the album-root parent is only fetched when it could qualify, and what happens when that fails.
|
||||
var _ = Describe("loadAlbumFoldersPaths", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
repo *fakeFolderRepo
|
||||
album model.Album
|
||||
now time.Time
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
now = time.Now().Truncate(time.Second)
|
||||
repo = &fakeFolderRepo{}
|
||||
ds = &tests.MockDataStore{MockedFolder: repo}
|
||||
album = model.Album{
|
||||
ID: "album1",
|
||||
Name: "Album",
|
||||
FolderIDs: []string{"folder1", "folder2", "folder3"},
|
||||
}
|
||||
})
|
||||
|
||||
It("does not query the parent when it is already one of the album's folders", func() {
|
||||
repo.result = []model.Folder{
|
||||
{ID: "folder1", Path: "Artist", Name: "Album", ParentID: "folder2",
|
||||
ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}},
|
||||
{ID: "folder2", Path: "", Name: "Artist", ImagesUpdatedAt: now},
|
||||
}
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(ConsistOf("Artist/Album/cover.jpg"))
|
||||
Expect(repo.getCallCount).To(BeZero())
|
||||
})
|
||||
|
||||
It("does not query the parent when the album's folders have different parents", func() {
|
||||
repo.result = []model.Folder{
|
||||
{ID: "folder1", Path: "Artist1/Album", Name: "part1", ParentID: "parentA",
|
||||
ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}},
|
||||
{ID: "folder2", Path: "Artist2/Album", Name: "part2", ParentID: "parentB",
|
||||
ImagesUpdatedAt: now},
|
||||
}
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(ConsistOf("Artist1/Album/part1/cover.jpg"))
|
||||
Expect(repo.getCallCount).To(BeZero())
|
||||
})
|
||||
|
||||
It("does not query the parent for a single-folder album that has images of its own", func() {
|
||||
repo.result = []model.Folder{
|
||||
{ID: "folder1", Path: "Artist", Name: "Album", ParentID: "artistFolder",
|
||||
ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}},
|
||||
}
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(ConsistOf("Artist/Album/cover.jpg"))
|
||||
Expect(repo.getCallCount).To(BeZero())
|
||||
})
|
||||
|
||||
It("does not promote the library root, so its images never become album art", func() {
|
||||
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},
|
||||
}
|
||||
repo.parentResult = &model.Folder{ID: "rootFolder", Name: ".", ImageFiles: []string{"unrelated.jpg"}}
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(ConsistOf("AlbumPart1/cover.jpg"))
|
||||
Expect(repo.getCallCount).To(Equal(1))
|
||||
})
|
||||
|
||||
It("does not promote a parent that holds another album's audio", func() {
|
||||
repo.result = []model.Folder{
|
||||
{ID: "folder1", Path: "Artist/Album", Name: "CD1", ParentID: "albumFolder",
|
||||
ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}},
|
||||
{ID: "folder2", Path: "Artist/Album", Name: "CD2", ParentID: "albumFolder",
|
||||
ImagesUpdatedAt: now},
|
||||
}
|
||||
repo.parentResult = &model.Folder{ID: "albumFolder", Path: "Artist", Name: "Album",
|
||||
ParentID: "artistFolder", ImageFiles: []string{"artist.jpg"}}
|
||||
repo.hasOtherAudio = true
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(ConsistOf("Artist/Album/CD1/cover.jpg"))
|
||||
})
|
||||
|
||||
It("promotes the album root parent into the returned paths", func() {
|
||||
repo.result = []model.Folder{
|
||||
{ID: "folder1", Path: "Artist/Album", Name: "CD1", ParentID: "albumFolder",
|
||||
ImagesUpdatedAt: now},
|
||||
{ID: "folder2", Path: "Artist/Album", Name: "CD2", ParentID: "albumFolder",
|
||||
ImagesUpdatedAt: now},
|
||||
}
|
||||
repo.parentResult = &model.Folder{ID: "albumFolder", Path: "Artist", Name: "Album",
|
||||
ParentID: "artistFolder", ImageFiles: []string{"cover.jpg"}}
|
||||
|
||||
paths, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(ConsistOf("Artist/Album/cover.jpg"))
|
||||
Expect(paths).To(HaveLen(3))
|
||||
})
|
||||
|
||||
It("propagates errors from the album-root check", func() {
|
||||
repo.result = []model.Folder{
|
||||
{ID: "folder1", Path: "Artist/Album", Name: "disc1", ParentID: "albumFolder",
|
||||
ImagesUpdatedAt: now},
|
||||
}
|
||||
repo.parentResult = &model.Folder{ID: "albumFolder", Path: "Artist", Name: "Album",
|
||||
ParentID: "artistFolder", ImageFiles: []string{"cover.jpg"}}
|
||||
repo.otherAudioErr = errors.New("db connection failed")
|
||||
|
||||
_, _, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).To(MatchError("db connection failed"))
|
||||
})
|
||||
|
||||
It("propagates non-ErrNotFound errors from the parent folder lookup", func() {
|
||||
repo.result = []model.Folder{
|
||||
{ID: "folder1", Path: "Artist/Album", Name: "CD1", ParentID: "parentFolder",
|
||||
ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}},
|
||||
{ID: "folder2", Path: "Artist/Album", Name: "CD2", ParentID: "parentFolder",
|
||||
ImagesUpdatedAt: now},
|
||||
}
|
||||
repo.getErr = errors.New("db connection failed")
|
||||
|
||||
_, _, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).To(MatchError("db connection failed"))
|
||||
Expect(repo.getCallCount).To(Equal(1))
|
||||
})
|
||||
|
||||
It("continues when the parent folder has been deleted", func() {
|
||||
repo.result = []model.Folder{
|
||||
{ID: "folder1", Path: "Artist/Album", Name: "CD1", ParentID: "missingParent",
|
||||
ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}},
|
||||
{ID: "folder2", Path: "Artist/Album", Name: "CD2", ParentID: "missingParent",
|
||||
ImagesUpdatedAt: now},
|
||||
}
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(ConsistOf("Artist/Album/CD1/cover.jpg"))
|
||||
Expect(repo.getCallCount).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
// folderImages is the sort that decides which of several same-named images wins, so it is pinned
|
||||
// directly rather than through a layout that can only show the winner.
|
||||
var _ = Describe("folderImages", func() {
|
||||
It("prefers base filenames over numeric-suffixed ones", func() {
|
||||
imgFiles, _ := folderImages([]model.Folder{
|
||||
{Path: "Artist", Name: "Album", ImageFiles: []string{"cover.1.jpg", "cover.jpg", "cover.2.jpg"}},
|
||||
})
|
||||
|
||||
Expect(imgFiles).To(HaveExactElements(
|
||||
"Artist/Album/cover.jpg", "Artist/Album/cover.1.jpg", "Artist/Album/cover.2.jpg"))
|
||||
})
|
||||
|
||||
It("prefers shallower paths when the base filenames tie", func() {
|
||||
imgFiles, _ := folderImages([]model.Folder{
|
||||
{Path: "Artist/Album", Name: "CD1", ImageFiles: []string{"cover.jpg"}},
|
||||
{Path: "Artist", Name: "Album", ImageFiles: []string{"cover.jpg"}},
|
||||
})
|
||||
|
||||
Expect(imgFiles).To(HaveExactElements("Artist/Album/cover.jpg", "Artist/Album/CD1/cover.jpg"))
|
||||
})
|
||||
|
||||
It("sorts case-insensitively", func() {
|
||||
imgFiles, _ := folderImages([]model.Folder{
|
||||
{Path: "Artist", Name: "Album", ImageFiles: []string{"Cover.jpg", "back.JPG"}},
|
||||
})
|
||||
|
||||
Expect(imgFiles).To(HaveExactElements("Artist/Album/back.JPG", "Artist/Album/Cover.jpg"))
|
||||
})
|
||||
|
||||
It("reports the newest ImagesUpdatedAt across the folders", func() {
|
||||
now := time.Now().Truncate(time.Second)
|
||||
newest := now.Add(5 * time.Minute)
|
||||
|
||||
_, updatedAt := folderImages([]model.Folder{
|
||||
{Path: "Artist", Name: "Album", ImagesUpdatedAt: now},
|
||||
{Path: "Artist/Album", Name: "CD1", ImagesUpdatedAt: newest},
|
||||
})
|
||||
|
||||
Expect(updatedAt).To(Equal(newest))
|
||||
})
|
||||
})
|
||||
@ -1,210 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
const (
|
||||
maxArtistFolderTraversalDepth = 3
|
||||
)
|
||||
|
||||
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 needs forward slashes; filepath.Rel returns backslashes on Windows.
|
||||
rel = filepath.ToSlash(rel)
|
||||
current := artistFolder
|
||||
var unreadable error
|
||||
for range maxArtistFolderTraversalDepth {
|
||||
reader, hit, err := findImageInFolder(ctx, libFS, rel, current, pattern)
|
||||
if err == nil {
|
||||
return reader, hit, nil
|
||||
}
|
||||
if errors.Is(err, errSourceUnreadable) {
|
||||
unreadable = err
|
||||
}
|
||||
if rel == "." {
|
||||
break // reached library root
|
||||
}
|
||||
rel = path.Dir(rel)
|
||||
current = filepath.Dir(current)
|
||||
}
|
||||
if unreadable != nil {
|
||||
return nil, "", unreadable
|
||||
}
|
||||
return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories (within library)`, pattern, artistFolder)
|
||||
}
|
||||
}
|
||||
|
||||
// findImageInFolder returns the first image matching pattern; absFolder is only used for
|
||||
// the returned display path and log messages.
|
||||
func findImageInFolder(ctx context.Context, libFS fs.FS, relFolder, absFolder, pattern string) (io.ReadCloser, string, error) {
|
||||
log.Trace(ctx, "Artwork: 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, "Artwork: Error matching artist image pattern", "pattern", pattern, "folder", absFolder, err)
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
imagePaths := slice.Filter(matches, model.IsImageFile)
|
||||
|
||||
// Prefer base filenames over numeric-suffixed ones (artist.jpg before artist.1.jpg)
|
||||
slices.SortFunc(imagePaths, compareImageFiles)
|
||||
|
||||
var openErr error
|
||||
for _, p := range imagePaths {
|
||||
f, err := libFS.Open(p)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Artwork: Could not open cover art file", "file", p, err)
|
||||
openErr = fmt.Errorf("%w: %s: %w", errSourceUnreadable, p, err)
|
||||
continue
|
||||
}
|
||||
_, name := path.Split(p)
|
||||
return f, filepath.Join(absFolder, name), nil
|
||||
}
|
||||
if openErr != nil {
|
||||
return nil, "", openErr
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// loadArtistAlbumRoots returns one path per album — the deepest folder holding
|
||||
// all of that album's tracks — so an album split into disc subfolders can't
|
||||
// pull the artist folder's common prefix below the artist level.
|
||||
func loadArtistAlbumRoots(ctx context.Context, ds model.DataStore, albums model.Albums) ([]string, []string, *time.Time, error) {
|
||||
var folderIDs []string
|
||||
for _, album := range albums {
|
||||
folderIDs = append(folderIDs, album.FolderIDs...)
|
||||
}
|
||||
folders, err := loadFolders(ctx, ds, folderIDs)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
pathByID := slice.ToMap(folders, func(f model.Folder) (string, string) {
|
||||
return f.ID, f.AbsolutePath()
|
||||
})
|
||||
var roots []string
|
||||
for _, album := range albums {
|
||||
var albumPaths []string
|
||||
for _, fid := range album.FolderIDs {
|
||||
if p, ok := pathByID[fid]; ok {
|
||||
albumPaths = append(albumPaths, p)
|
||||
}
|
||||
}
|
||||
if len(albumPaths) > 0 {
|
||||
roots = append(roots, commonDir(albumPaths))
|
||||
}
|
||||
}
|
||||
|
||||
imgFiles, updatedAt := folderImages(folders)
|
||||
return roots, imgFiles, &updatedAt, nil
|
||||
}
|
||||
|
||||
// commonDir returns the deepest directory containing all paths. Trailing
|
||||
// separators keep the comparison on segment boundaries, so a shared name
|
||||
// fragment (".../Album" and ".../Album2") is never read as a shared directory.
|
||||
func commonDir(paths []string) string {
|
||||
sep := string(filepath.Separator)
|
||||
common := str.LongestCommonPrefix(slice.Map(paths, func(p string) string { return p + sep }))
|
||||
if !strings.HasSuffix(common, sep) {
|
||||
common, _ = filepath.Split(common)
|
||||
}
|
||||
return filepath.Clean(common)
|
||||
}
|
||||
|
||||
func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) {
|
||||
if len(albums) == 0 {
|
||||
return "", time.Time{}, nil
|
||||
}
|
||||
libID := albums[0].LibraryID // TODO: Support albums spanning multiple libraries
|
||||
|
||||
// paths holds one root per album: two or more distinct roots already meet at
|
||||
// the artist folder, while a single root is an album folder needing a climb.
|
||||
roots := slices.Compact(slices.Sorted(slices.Values(paths)))
|
||||
folderPath := commonDir(roots)
|
||||
if len(roots) < 2 {
|
||||
folderPath = filepath.Dir(folderPath)
|
||||
}
|
||||
|
||||
// TODO: Hacky, but the easiest way to get the folder ID ATM
|
||||
libPath := core.AbsolutePath(ctx, ds, libID, "")
|
||||
folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath)
|
||||
|
||||
log.Trace(ctx, "Artwork: Calculating artist folder details", "folderPath", folderPath, "folderID", folderID,
|
||||
"libPath", libPath, "libID", libID, "albumPaths", paths)
|
||||
|
||||
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}})
|
||||
if err != nil || len(folders) == 0 {
|
||||
log.Warn(ctx, "Artwork: Could not find folder for artist", "folderPath", folderPath, "id", folderID,
|
||||
"libPath", libPath, "libID", libID, err)
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
return folderPath, folders[0].ImagesUpdatedAt, nil
|
||||
}
|
||||
|
||||
// findImageInArtistFolder matches an image by MBID or artist name (case-insensitive), "" if none.
|
||||
func findImageInArtistFolder(folder, mbzArtistID, artistName string) string {
|
||||
entries, err := os.ReadDir(folder)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, candidate := range []string{mbzArtistID, artistName} {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
base := utils.BaseName(name)
|
||||
if strings.EqualFold(base, candidate) && model.IsImageFile(name) {
|
||||
return filepath.Join(folder, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@ -1,117 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// commonDir and loadArtistFolder decide how far above the albums the artist folder sits. A layout
|
||||
// can only show the image that won, so the arithmetic is pinned here.
|
||||
var _ = Describe("commonDir", func() {
|
||||
It("returns the folder itself for a single path", func() {
|
||||
Expect(commonDir([]string{filepath.FromSlash("/music/artist/album")})).
|
||||
To(Equal(filepath.FromSlash("/music/artist/album")))
|
||||
})
|
||||
|
||||
It("returns the deepest shared folder", func() {
|
||||
Expect(commonDir([]string{
|
||||
filepath.FromSlash("/music/artist/album/cd1"),
|
||||
filepath.FromSlash("/music/artist/album/cd2"),
|
||||
})).To(Equal(filepath.FromSlash("/music/artist/album")))
|
||||
})
|
||||
|
||||
It("does not read a shared name fragment as a shared folder", func() {
|
||||
Expect(commonDir([]string{
|
||||
filepath.FromSlash("/music/artist/Album"),
|
||||
filepath.FromSlash("/music/artist/Album2"),
|
||||
})).To(Equal(filepath.FromSlash("/music/artist")))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("loadArtistFolder", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
repo *fakeFolderRepo
|
||||
albums model.Albums
|
||||
updatedAt time.Time
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
DeferCleanup(stubCoreAbsolutePath())
|
||||
|
||||
updatedAt = time.Now().Truncate(time.Second).Add(5 * time.Minute)
|
||||
repo = &fakeFolderRepo{result: []model.Folder{{ImagesUpdatedAt: updatedAt}}}
|
||||
ds = &tests.MockDataStore{MockedFolder: repo}
|
||||
albums = model.Albums{{LibraryID: 1, ID: "album1", Name: "Album 1"}}
|
||||
})
|
||||
|
||||
It("returns empty when the artist has no albums", func() {
|
||||
folder, upd, err := loadArtistFolder(ctx, ds, model.Albums{}, []string{"/dummy/path"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(folder).To(BeEmpty())
|
||||
Expect(upd).To(BeZero())
|
||||
})
|
||||
|
||||
It("climbs above the album folder when the artist has a single album root", func() {
|
||||
folder, upd, err := loadArtistFolder(ctx, ds, albums,
|
||||
[]string{filepath.FromSlash("/music/artist/album1")})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(folder).To(Equal(filepath.FromSlash("/music/artist")))
|
||||
Expect(upd).To(Equal(updatedAt))
|
||||
})
|
||||
|
||||
It("climbs above the shared folder when two albums live in the same one", func() {
|
||||
folder, upd, err := loadArtistFolder(ctx, ds, albums, []string{
|
||||
filepath.FromSlash("/music/artist/split"),
|
||||
filepath.FromSlash("/music/artist/split"),
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(folder).To(Equal(filepath.FromSlash("/music/artist")))
|
||||
Expect(upd).To(Equal(updatedAt))
|
||||
})
|
||||
|
||||
It("stops at the folder where distinct album roots already meet", func() {
|
||||
folder, upd, err := loadArtistFolder(ctx, ds, albums, []string{
|
||||
filepath.FromSlash("/music/artist/album1"),
|
||||
filepath.FromSlash("/music/artist/album2"),
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(folder).To(Equal(filepath.FromSlash("/music/artist")))
|
||||
Expect(upd).To(Equal(updatedAt))
|
||||
})
|
||||
|
||||
It("returns the error when the folder lookup fails", func() {
|
||||
repo.err = errors.New("fake error")
|
||||
|
||||
folder, upd, err := loadArtistFolder(ctx, ds, albums, []string{
|
||||
filepath.FromSlash("/music/artist/album1"),
|
||||
filepath.FromSlash("/music/artist/album2"),
|
||||
})
|
||||
|
||||
Expect(err).To(MatchError(ContainSubstring("fake error")))
|
||||
Expect(folder).To(BeEmpty())
|
||||
Expect(upd).To(BeZero())
|
||||
})
|
||||
})
|
||||
|
||||
func stubCoreAbsolutePath() func() {
|
||||
original := core.AbsolutePath
|
||||
core.AbsolutePath = func(context.Context, model.DataStore, int, string) string {
|
||||
return filepath.FromSlash("/music")
|
||||
}
|
||||
return func() { core.AbsolutePath = original }
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"testing/fstest"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// unreadableFS globs like its embedded MapFS but refuses to open anything. Injecting the error
|
||||
// keeps this independent of the filesystem: os.Chmod does not restrict read access on Windows.
|
||||
type unreadableFS struct{ fstest.MapFS }
|
||||
|
||||
func (u unreadableFS) Open(string) (fs.File, error) { return nil, fs.ErrPermission }
|
||||
|
||||
var _ = Describe("findImageInFolder", func() {
|
||||
var ctx context.Context
|
||||
var files fstest.MapFS
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
files = fstest.MapFS{"artist.jpg": &fstest.MapFile{Data: []byte("img")}}
|
||||
})
|
||||
|
||||
It("returns the first matching image", func() {
|
||||
r, hit, err := findImageInFolder(ctx, files, ".", "/lib", "artist.*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer r.Close()
|
||||
Expect(hit).To(HaveSuffix("artist.jpg"))
|
||||
})
|
||||
|
||||
// The glob matched, so the image exists; failing to open it says nothing about whether the
|
||||
// artist has one, and must not let the resolver settle on absent.
|
||||
It("reports a matched but unreadable image as unreadable, not as a miss", func() {
|
||||
_, _, err := findImageInFolder(ctx, unreadableFS{files}, ".", "/lib", "artist.*")
|
||||
Expect(err).To(MatchError(errSourceUnreadable))
|
||||
})
|
||||
|
||||
It("reports a plain miss when nothing matches", func() {
|
||||
_, _, err := findImageInFolder(ctx, files, ".", "/lib", "nothing.*")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(errors.Is(err, errSourceUnreadable)).To(BeFalse(), "no match is definitive, not transient")
|
||||
})
|
||||
})
|
||||
@ -1,154 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
const (
|
||||
breakerThreshold = 5
|
||||
breakerProbeAfter = time.Minute
|
||||
// breakerRecoveries is how many consecutive answers an open breaker needs before it trusts the
|
||||
// provider again. One is not enough: a provider that is rate-limiting or blocking us still
|
||||
// answers the occasional request, and closing on the first of those puts the agent straight
|
||||
// back to full rate, which is what earns the next block.
|
||||
breakerRecoveries = 3
|
||||
)
|
||||
|
||||
var errBreakerOpen = errors.New("artwork: external circuit breaker open")
|
||||
|
||||
// gateFunc gates one named external fetch (rate limit + circuit breaker per name).
|
||||
type gateFunc = func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error)
|
||||
|
||||
func passthroughGate(_ string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
||||
return f()
|
||||
}
|
||||
|
||||
// isTransientExternal reports whether an external failure is worth retrying; a not-found
|
||||
// (from either package) is a definitive answer, not a fault.
|
||||
func isTransientExternal(err error) bool {
|
||||
return err != nil && !errors.Is(err, agents.ErrNotFound) && !errors.Is(err, model.ErrNotFound)
|
||||
}
|
||||
|
||||
// extGate is one agent's rate limiter and circuit breaker, so a failing provider backs off
|
||||
// in isolation from the others.
|
||||
type extGate struct {
|
||||
limiter *rate.Limiter
|
||||
breaker *breaker
|
||||
}
|
||||
|
||||
// gate runs a named external step through that agent's rate limiter and circuit breaker.
|
||||
func (w *Worker) gate(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
||||
g := w.gateFor(name)
|
||||
allowed, gen := g.breaker.allow()
|
||||
if !allowed {
|
||||
log.Debug(w.runCtx, "Artwork: Skipping agent, circuit breaker open", "agent", name)
|
||||
return nil, "", errBreakerOpen
|
||||
}
|
||||
// Timed separately so a throttled agent isn't mistaken for a slow provider.
|
||||
waitStart := time.Now()
|
||||
if err := g.limiter.Wait(w.runCtx); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
callStart := time.Now()
|
||||
r, path, err := f()
|
||||
g.breaker.record(name, gen, err)
|
||||
log.Trace(w.runCtx, "Artwork: External agent call", "agent", name, "hit", r != nil,
|
||||
"limiterWait", callStart.Sub(waitStart), "elapsed", time.Since(callStart), err)
|
||||
return r, path, err
|
||||
}
|
||||
|
||||
// gateFor lazily creates the per-name gate on first use.
|
||||
func (w *Worker) gateFor(name string) *extGate {
|
||||
w.gatesMu.Lock()
|
||||
defer w.gatesMu.Unlock()
|
||||
if g, ok := w.gates[name]; ok {
|
||||
return g
|
||||
}
|
||||
rps := conf.Server.DevArtworkExternalMaxRPS
|
||||
limit := rate.Inf
|
||||
if rps > 0 {
|
||||
limit = rate.Limit(rps)
|
||||
}
|
||||
g := &extGate{limiter: rate.NewLimiter(limit, max(1, rps)), breaker: newBreaker()}
|
||||
w.gates[name] = g
|
||||
return g
|
||||
}
|
||||
|
||||
// breaker opens after breakerThreshold consecutive errors and admits a single probe once
|
||||
// breakerProbeAfter has elapsed; it closes after breakerRecoveries consecutive answers.
|
||||
type breaker struct {
|
||||
mu sync.Mutex
|
||||
failures int
|
||||
openedAt time.Time
|
||||
// recoveries counts consecutive good answers while open; a single failure discards them.
|
||||
recoveries int
|
||||
// generation identifies the current open episode, so an answer from a call admitted before
|
||||
// the breaker opened cannot be mistaken for evidence that it has recovered.
|
||||
generation int
|
||||
}
|
||||
|
||||
func newBreaker() *breaker { return &breaker{} }
|
||||
|
||||
// allow reports whether a call may proceed, and the open episode it was admitted under: zero
|
||||
// when the breaker was closed, the current generation when admitted as a half-open probe.
|
||||
func (b *breaker) allow() (bool, int) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.failures < breakerThreshold {
|
||||
return true, 0
|
||||
}
|
||||
if time.Since(b.openedAt) >= breakerProbeAfter {
|
||||
b.openedAt = time.Now() // start a fresh probe window so only one caller passes
|
||||
return true, b.generation
|
||||
}
|
||||
return false, 0
|
||||
}
|
||||
|
||||
func (b *breaker) record(name string, gen int, err error) {
|
||||
// A cancelled run says nothing about the provider, so it neither counts nor clears.
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if isTransientExternal(err) {
|
||||
b.recoveries = 0
|
||||
b.failures++
|
||||
if b.failures == breakerThreshold {
|
||||
b.openedAt = time.Now()
|
||||
b.generation++
|
||||
log.Warn("Artwork: Circuit breaker opened for agent", "agent", name,
|
||||
"consecutiveFailures", b.failures, "probeAfter", breakerProbeAfter, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if b.failures < breakerThreshold {
|
||||
b.failures = 0
|
||||
return
|
||||
}
|
||||
// Only a probe from this open episode is evidence of recovery. The worker drains concurrently,
|
||||
// so answers keep arriving from calls admitted before the breaker opened; counting those would
|
||||
// close it with no probe interval elapsed, which is the burst this exists to prevent.
|
||||
if gen == 0 || gen != b.generation {
|
||||
return
|
||||
}
|
||||
// A not-found counts because the provider did answer, but on its own it is thin evidence that
|
||||
// a provider which just blocked us is well.
|
||||
b.recoveries++
|
||||
if b.recoveries < breakerRecoveries {
|
||||
return
|
||||
}
|
||||
log.Info("Artwork: Circuit breaker closed for agent", "agent", name,
|
||||
"consecutiveAnswers", b.recoveries)
|
||||
b.failures, b.recoveries = 0, 0
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// allowed drops the generation token when a caller only cares about admission.
|
||||
func allowed(b *breaker) bool { ok, _ := b.allow(); return ok }
|
||||
|
||||
var _ = Describe("breaker", func() {
|
||||
// The worker drains concurrently, so when the breaker opens there are already calls past
|
||||
// allow(), queued in the rate limiter or waiting on a response. Their answers arrive
|
||||
// afterwards. Counting those as recovery closes the breaker with no probe interval elapsed,
|
||||
// which is the burst the ramp exists to prevent. No clock is involved: the race is an
|
||||
// ordering, so it is reproduced by making the calls in the order concurrency produces.
|
||||
It("ignores answers from calls admitted before it opened", func() {
|
||||
b := newBreaker()
|
||||
|
||||
// A batch clears allow() while the breaker is still closed.
|
||||
for range breakerThreshold + breakerRecoveries {
|
||||
ok, gen := b.allow()
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(gen).To(BeZero(), "admitted with the breaker closed, so not a probe")
|
||||
}
|
||||
|
||||
// The fast failures in that batch open it.
|
||||
for range breakerThreshold {
|
||||
b.record("agentA", 0, errors.New("blocked"))
|
||||
}
|
||||
Expect(allowed(b)).To(BeFalse(), "breaker is open")
|
||||
|
||||
// The slower answers from the same batch land now.
|
||||
for range breakerRecoveries {
|
||||
b.record("agentA", 0, nil)
|
||||
}
|
||||
|
||||
Expect(allowed(b)).To(BeFalse(),
|
||||
"answers from calls admitted before the breaker opened must not close it")
|
||||
})
|
||||
})
|
||||
@ -1,51 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/core/artwork/blurhash"
|
||||
"github.com/navidrome/navidrome/core/artwork/thumbhash"
|
||||
)
|
||||
|
||||
// hashEncoders are the two placeholder hashes decodeArtwork computes from one shared thumbnail.
|
||||
var hashEncoders = []struct {
|
||||
name string
|
||||
encode func(image.Image) error
|
||||
}{
|
||||
{"blurhash", func(img image.Image) error { _, err := blurhash.Encode(img); return err }},
|
||||
{"thumbhash", func(img image.Image) error { _, err := thumbhash.Encode(img); return err }},
|
||||
}
|
||||
|
||||
func benchEncoder(b *testing.B, encode func(image.Image) error, img image.Image) {
|
||||
b.Helper()
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if err := encode(img); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkHashEncodersAtInputSize is the bar: both encoders are handed the identical image
|
||||
// makeThumbnail produces, so neither is measured with a conversion the other avoids.
|
||||
func BenchmarkHashEncodersAtInputSize(b *testing.B) {
|
||||
img := gradientNRGBA(thumbnailSize)
|
||||
for _, e := range hashEncoders {
|
||||
b.Run(e.name, func(b *testing.B) { benchEncoder(b, e.encode, img) })
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkHashEncoders sweeps past the pipeline's input size, where each package's own defensive
|
||||
// downscale starts to dominate. thumbnailSize itself is covered by the benchmark above.
|
||||
func BenchmarkHashEncoders(b *testing.B) {
|
||||
for _, size := range []int{300, 600, 900, 1200, 1500} {
|
||||
img := gradientNRGBA(size)
|
||||
for _, e := range hashEncoders {
|
||||
b.Run(fmt.Sprintf("%s/%dx%d", e.name, size, size), func(b *testing.B) {
|
||||
benchEncoder(b, e.encode, img)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,236 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/zeebo/xxh3"
|
||||
)
|
||||
|
||||
// StaleAbsentAge is how long an absent state is trusted before a recheck retries it.
|
||||
const StaleAbsentAge = 30 * 24 * time.Hour
|
||||
|
||||
// StaleAbsentRecheckBatch caps how many absent states each hourly tick re-queues per kind,
|
||||
// oldest first, so external agents see a flat drip instead of a daily burst.
|
||||
const StaleAbsentRecheckBatch = 100
|
||||
|
||||
// RecheckKinds omits media files: they resolve embedded-only, at scan or on view.
|
||||
var RecheckKinds = []model.Kind{
|
||||
model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork,
|
||||
}
|
||||
|
||||
// KeepsState reports whether a kind is recorded in item_artwork and the artwork queue. Disc
|
||||
// artwork is read through on every request and cached by content key, so it has neither.
|
||||
func KeepsState(kind model.Kind) bool { return kind != model.KindDiscArtwork }
|
||||
|
||||
// RefreshableKinds is every kind Refresh can clear and re-queue, so it holds exactly the kinds
|
||||
// KeepsState admits. Media files are absent from RecheckKinds but belong here: the worker
|
||||
// resolves them, it just never revisits them on its own.
|
||||
var RefreshableKinds = append(slices.Clone(RecheckKinds), model.KindMediaFileArtwork)
|
||||
|
||||
// hasRecheckPath reports whether a periodic job will revisit this kind, making an absent settle recoverable.
|
||||
func hasRecheckPath(prefix string) bool {
|
||||
kind, ok := model.ParseKind(prefix)
|
||||
return ok && slices.Contains(RecheckKinds, kind)
|
||||
}
|
||||
|
||||
// artworkEpoch invalidates all resolution state when bumped; bump it whenever resolution semantics change.
|
||||
const artworkEpoch = 1
|
||||
|
||||
// FingerprintInput is one config value the fingerprint covers, named after the setting it came from.
|
||||
type FingerprintInput struct {
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
|
||||
// FingerprintInputs is the single listing of what ConfigFingerprint hashes.
|
||||
func FingerprintInputs() []FingerprintInput {
|
||||
return []FingerprintInput{
|
||||
{"CoverArtPriority", conf.Server.CoverArtPriority},
|
||||
{"ArtistArtPriority", conf.Server.ArtistArtPriority},
|
||||
{"ArtistImageFolder", conf.Server.ArtistImageFolder},
|
||||
{"Agents", conf.Server.Agents},
|
||||
{"EnableExternalServices", strconv.FormatBool(conf.Server.EnableExternalServices)},
|
||||
{"EnableM3UExternalAlbumArt", strconv.FormatBool(conf.Server.EnableM3UExternalAlbumArt)},
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigFingerprint covers the inputs that affect resolution outcomes; a change invalidates stored state.
|
||||
func ConfigFingerprint() string {
|
||||
values := slice.Map(FingerprintInputs(), func(i FingerprintInput) string { return i.Value })
|
||||
raw := fmt.Sprintf("%s|%d", strings.Join(values, "|"), artworkEpoch)
|
||||
return fmt.Sprintf("%016x", xxh3.Hash([]byte(raw)))
|
||||
}
|
||||
|
||||
// backfillSummary is what a backfill enqueued. MaxExternalLookups is an upper estimate for one
|
||||
// attempt per item, not a bound: a local hit ends the walk, and a retry asks the agents again.
|
||||
type backfillSummary struct {
|
||||
Ran bool
|
||||
PerKind map[string]int64
|
||||
Items int64
|
||||
MaxExternalLookups int64
|
||||
}
|
||||
|
||||
// backfill enqueues artwork resolution for every entity when the config fingerprint changed.
|
||||
func backfill(ctx context.Context, ds model.DataStore, agentCount func() ImageAgentCount) (backfillSummary, error) {
|
||||
start := time.Now()
|
||||
ctx = auth.WithAdminUser(ctx, ds)
|
||||
current := ConfigFingerprint()
|
||||
props := ds.Property(ctx)
|
||||
stored, err := props.DefaultGet(consts.ArtConfFingerprintPropertyKey, "")
|
||||
if err != nil {
|
||||
return backfillSummary{}, err
|
||||
}
|
||||
if stored == current {
|
||||
return backfillSummary{}, nil
|
||||
}
|
||||
|
||||
// Artists first: few entities, most external-dependent, so they get a queue headstart.
|
||||
kinds := []struct {
|
||||
kind model.Kind
|
||||
fetch func() ([]string, error)
|
||||
}{
|
||||
{model.KindArtistArtwork, func() ([]string, error) { return ds.Artist(ctx).GetAllIDs() }},
|
||||
{model.KindAlbumArtwork, func() ([]string, error) { return ds.Album(ctx).GetAllIDs() }},
|
||||
{model.KindPlaylistArtwork, func() ([]string, error) { return ds.Playlist(ctx).GetAllIDs() }},
|
||||
{model.KindRadioArtwork, func() ([]string, error) { return ds.Radio(ctx).GetAllIDs() }},
|
||||
}
|
||||
// Counted here, not by the caller: building the agent list constructs every enabled agent, and
|
||||
// an unchanged fingerprint returns above without ever needing the number.
|
||||
agents := agentCount()
|
||||
summary := backfillSummary{Ran: true, PerKind: map[string]int64{}}
|
||||
for _, k := range kinds {
|
||||
ids, err := k.fetch()
|
||||
if err != nil {
|
||||
return backfillSummary{}, err
|
||||
}
|
||||
if err := enqueueBackfillKind(ctx, ds, k.kind, ids); err != nil {
|
||||
return backfillSummary{}, err
|
||||
}
|
||||
n := int64(len(ids))
|
||||
summary.PerKind[k.kind.Prefix()] = n
|
||||
summary.Items += n
|
||||
summary.MaxExternalLookups += n * ExternalLookupsPerItem(k.kind, agents)
|
||||
}
|
||||
|
||||
if err := props.Put(consts.ArtConfFingerprintPropertyKey, current); err != nil {
|
||||
return backfillSummary{}, err
|
||||
}
|
||||
log.Info(ctx, "Artwork: Config fingerprint changed, backfill enqueued", "items", summary.Items,
|
||||
"byKind", summary.PerKind, "maxExternalLookups", summary.MaxExternalLookups,
|
||||
"elapsed", time.Since(start))
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind model.Kind, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
items := slice.Map(ids, func(id string) model.ArtworkQueueItem {
|
||||
return model.ArtworkQueueItem{
|
||||
ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill,
|
||||
}
|
||||
})
|
||||
return ds.ArtworkQueue(ctx).Enqueue(items...)
|
||||
}
|
||||
|
||||
func enqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error {
|
||||
cutoff := time.Now().Add(-StaleAbsentAge)
|
||||
queue := ds.ArtworkQueue(ctx)
|
||||
for _, kind := range RecheckKinds {
|
||||
if _, err := queue.EnqueueStaleAbsent(kind, cutoff, StaleAbsentRecheckBatch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enqueueMissingAll is the safety net for entities a scan never enqueued (added between scans, or scanner off).
|
||||
func enqueueMissingAll(ctx context.Context, ds model.DataStore) error {
|
||||
queue := ds.ArtworkQueue(ctx)
|
||||
for _, kind := range RecheckKinds {
|
||||
if _, err := queue.EnqueueAllMissing(kind, model.ArtworkPriorityRecheck); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ItemName resolves a kind+id to the entity's display name, and errors when the item
|
||||
// does not exist. Callers use it to reject ids that would otherwise orphan a queue row.
|
||||
func ItemName(ctx context.Context, ds model.DataStore, kind model.Kind, id string) (string, error) {
|
||||
switch kind {
|
||||
case model.KindArtistArtwork:
|
||||
ar, err := ds.Artist(ctx).Get(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ar.Name, nil
|
||||
case model.KindAlbumArtwork:
|
||||
al, err := ds.Album(ctx).Get(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return al.Name, nil
|
||||
case model.KindPlaylistArtwork:
|
||||
pls, err := ds.Playlist(ctx).Get(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return pls.Name, nil
|
||||
case model.KindRadioArtwork:
|
||||
rd, err := ds.Radio(ctx).Get(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rd.Name, nil
|
||||
case model.KindMediaFileArtwork:
|
||||
mf, err := ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return mf.Title, nil
|
||||
case model.KindDiscArtwork:
|
||||
return discArtworkName(ctx, ds, id)
|
||||
}
|
||||
return "", fmt.Errorf("unsupported kind %q", kind.Prefix())
|
||||
}
|
||||
|
||||
func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string, error) {
|
||||
albumID, discNumber, err := model.ParseDiscArtworkID(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
al, err := ds.Album(ctx).Get(albumID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := fmt.Sprintf("%s (disc %d)", al.Name, discNumber)
|
||||
// The subtitle is itself a DiscArtPriority candidate, so name it where the chain can be read against it.
|
||||
if subtitle := strings.TrimSpace(al.Discs[discNumber]); subtitle != "" {
|
||||
name += ": " + subtitle
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// Refresh drops an item's resolved artwork state and re-queues it at Bump priority.
|
||||
func Refresh(ctx context.Context, ds model.DataStore, kind model.Kind, id string) error {
|
||||
if err := ds.Artwork(ctx).DeleteForItems(kind, []string{id}); err != nil {
|
||||
return fmt.Errorf("clearing artwork state: %w", err)
|
||||
}
|
||||
item := model.ArtworkQueueItem{ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump}
|
||||
if err := ds.ArtworkQueue(ctx).Enqueue(item); err != nil {
|
||||
return fmt.Errorf("enqueuing artwork refresh: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -1,387 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// visibilityPlaylistDS models playlist_repository's userFilter: a private playlist is only
|
||||
// visible when the ctx carries an admin, so headless work must wrap ctx with one first.
|
||||
type visibilityPlaylistDS struct {
|
||||
*tests.MockDataStore
|
||||
private model.Playlist
|
||||
tracks model.PlaylistTrackRepository
|
||||
}
|
||||
|
||||
func (v *visibilityPlaylistDS) Playlist(ctx context.Context) model.PlaylistRepository {
|
||||
repo := tests.CreateMockPlaylistRepo()
|
||||
repo.TracksRepo = v.tracks
|
||||
if u, ok := request.UserFrom(ctx); ok && u.IsAdmin {
|
||||
repo.SetData(model.Playlists{v.private})
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
func adminUserRepo() *tests.MockedUserRepo {
|
||||
repo := tests.CreateMockUserRepo()
|
||||
Expect(repo.Put(&model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed())
|
||||
return repo
|
||||
}
|
||||
|
||||
func noAgents() ImageAgentCount { return ImageAgentCount{} }
|
||||
|
||||
// orderTrackingQueueRepo records the item kind of each Enqueue call, so tests can
|
||||
// assert phase ordering (artists-first) that same-priority timestamps can't guarantee.
|
||||
type orderTrackingQueueRepo struct {
|
||||
*tests.MockArtworkQueueRepo
|
||||
callKinds []string
|
||||
}
|
||||
|
||||
func (o *orderTrackingQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error {
|
||||
if len(items) > 0 {
|
||||
o.callKinds = append(o.callKinds, items[0].ItemKind)
|
||||
}
|
||||
return o.MockArtworkQueueRepo.Enqueue(items...)
|
||||
}
|
||||
|
||||
var _ = Describe("RefreshableKinds", func() {
|
||||
// The two are meant to describe the same fact. Nothing but this test stops them from drifting,
|
||||
// and a drift would have `artwork explain` report state for a kind that keeps none.
|
||||
It("holds exactly the kinds that keep state", func() {
|
||||
for _, k := range []model.Kind{
|
||||
model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork,
|
||||
model.KindRadioArtwork, model.KindMediaFileArtwork, model.KindDiscArtwork,
|
||||
} {
|
||||
Expect(slices.Contains(RefreshableKinds, k)).To(Equal(KeepsState(k)), k.String())
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Housekeeping", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
queueRepo *orderTrackingQueueRepo
|
||||
propRepo *tests.MockedPropertyRepo
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = context.Background()
|
||||
conf.Server.CoverArtPriority = "embedded, folder"
|
||||
conf.Server.ArtistArtPriority = "artist.jpg"
|
||||
conf.Server.Agents = "spotify"
|
||||
conf.Server.EnableExternalServices = true
|
||||
|
||||
queueRepo = &orderTrackingQueueRepo{MockArtworkQueueRepo: tests.CreateMockArtworkQueueRepo()}
|
||||
propRepo = &tests.MockedPropertyRepo{}
|
||||
ds = &tests.MockDataStore{MockedArtworkQueue: queueRepo, MockedProperty: propRepo}
|
||||
})
|
||||
|
||||
seedEntities := func() {
|
||||
artistRepo := tests.CreateMockArtistRepo()
|
||||
artistRepo.SetData(model.Artists{{ID: "ar1"}, {ID: "ar2"}})
|
||||
ds.MockedArtist = artistRepo
|
||||
|
||||
albumRepo := tests.CreateMockAlbumRepo()
|
||||
albumRepo.SetData(model.Albums{{ID: "al1"}})
|
||||
ds.MockedAlbum = albumRepo
|
||||
|
||||
playlistRepo := tests.CreateMockPlaylistRepo()
|
||||
playlistRepo.SetData(model.Playlists{{ID: "pl1"}})
|
||||
ds.MockedPlaylist = playlistRepo
|
||||
|
||||
radioRepo := tests.CreateMockedRadioRepo()
|
||||
radioRepo.All = model.Radios{{ID: "ra1"}}
|
||||
ds.MockedRadio = radioRepo
|
||||
}
|
||||
|
||||
Describe("Fingerprint", func() {
|
||||
It("changes when a fingerprint-affecting config value changes", func() {
|
||||
f1 := ConfigFingerprint()
|
||||
conf.Server.CoverArtPriority = "folder, embedded"
|
||||
f2 := ConfigFingerprint()
|
||||
Expect(f1).NotTo(Equal(f2))
|
||||
})
|
||||
|
||||
It("changes when ArtistImageFolder changes", func() {
|
||||
conf.Server.ArtistImageFolder = "/before"
|
||||
f1 := ConfigFingerprint()
|
||||
conf.Server.ArtistImageFolder = "/after"
|
||||
Expect(ConfigFingerprint()).NotTo(Equal(f1))
|
||||
})
|
||||
|
||||
It("changes when EnableM3UExternalAlbumArt is toggled", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
f1 := ConfigFingerprint()
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
Expect(ConfigFingerprint()).NotTo(Equal(f1))
|
||||
})
|
||||
|
||||
// Pinned: a changed formula re-resolves every library on upgrade, flooding external providers.
|
||||
It("hashes a given config to a stable value", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*, embedded"
|
||||
conf.Server.ArtistArtPriority = "artist.*, external"
|
||||
conf.Server.ArtistImageFolder = ""
|
||||
conf.Server.Agents = "lastfm,spotify"
|
||||
conf.Server.EnableExternalServices = true
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
|
||||
Expect(ConfigFingerprint()).To(Equal("7b538a83a870c16d"))
|
||||
})
|
||||
|
||||
It("reports the config inputs it hashes, so a change can be traced to a setting", func() {
|
||||
conf.Server.Agents = "lastfm,spotify"
|
||||
conf.Server.CoverArtPriority = "cover.*, embedded"
|
||||
|
||||
Expect(FingerprintInputs()).To(ContainElements(
|
||||
FingerprintInput{Name: "Agents", Value: "lastfm,spotify"},
|
||||
FingerprintInput{Name: "CoverArtPriority", Value: "cover.*, embedded"},
|
||||
))
|
||||
})
|
||||
|
||||
It("does not change when the server version changes", func() {
|
||||
original := consts.Version
|
||||
DeferCleanup(func() { consts.Version = original })
|
||||
f1 := ConfigFingerprint()
|
||||
consts.Version = original + "-next"
|
||||
Expect(ConfigFingerprint()).To(Equal(f1),
|
||||
"the version must not invalidate artwork state: it would re-resolve every entity on every build")
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Backfill", func() {
|
||||
It("enqueues nothing and returns false when the stored fingerprint matches", func() {
|
||||
seedEntities()
|
||||
Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())).To(Succeed())
|
||||
|
||||
counted := false
|
||||
s, err := backfill(ctx, ds, func() ImageAgentCount {
|
||||
counted = true
|
||||
return ImageAgentCount{Artist: 3, Album: 2}
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s).To(Equal(backfillSummary{}))
|
||||
Expect(counted).To(BeFalse(), "building the agent list constructs every agent; an unchanged fingerprint must not pay for it")
|
||||
|
||||
count, err := queueRepo.Count()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeZero())
|
||||
})
|
||||
|
||||
It("runs the backfill when no fingerprint was ever stored", func() {
|
||||
seedEntities()
|
||||
|
||||
s, err := backfill(ctx, ds, noAgents)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Ran).To(BeTrue())
|
||||
|
||||
count, err := queueRepo.Count()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(int64(5))) // 2 artists + 1 album + 1 playlist + 1 radio
|
||||
|
||||
stored, err := propRepo.Get(consts.ArtConfFingerprintPropertyKey)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(stored).To(Equal(ConfigFingerprint()))
|
||||
})
|
||||
|
||||
It("enqueues a private playlist by resolving it under an admin context", func() {
|
||||
ds.MockedUser = adminUserRepo()
|
||||
vds := &visibilityPlaylistDS{
|
||||
MockDataStore: ds,
|
||||
private: model.Playlist{ID: "plPrivate", OwnerID: "admin"},
|
||||
tracks: &tests.MockPlaylistTrackRepo{},
|
||||
}
|
||||
|
||||
s, err := backfill(ctx, vds, noAgents)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Ran).To(BeTrue())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "plPrivate")).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("enqueues artists before albums/playlists/radios, all at Backfill priority", func() {
|
||||
seedEntities()
|
||||
Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
||||
|
||||
s, err := backfill(ctx, ds, noAgents)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Ran).To(BeTrue())
|
||||
|
||||
Expect(queueRepo.callKinds).ToNot(BeEmpty())
|
||||
firstOther := slices.IndexFunc(queueRepo.callKinds, func(k string) bool { return k != "ar" })
|
||||
Expect(firstOther).ToNot(Equal(0), "artists must be the first Enqueue call")
|
||||
if firstOther >= 0 {
|
||||
Expect(queueRepo.callKinds[firstOther:]).ToNot(ContainElement("ar"),
|
||||
"no artist Enqueue may follow another kind")
|
||||
}
|
||||
|
||||
for _, it := range queueRepo.Data {
|
||||
Expect(it.Priority).To(Equal(model.ArtworkPriorityBackfill))
|
||||
Expect(it.ItemKind).To(BeElementOf("ar", "al", "pl", "ra"))
|
||||
}
|
||||
})
|
||||
|
||||
It("reports what it enqueued, per kind and as an external-lookup ceiling", func() {
|
||||
conf.Server.ArtistArtPriority = "artist.*, external"
|
||||
conf.Server.CoverArtPriority = "cover.*, external"
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
seedEntities()
|
||||
|
||||
s, err := backfill(ctx, ds, func() ImageAgentCount { return ImageAgentCount{Artist: 3, Album: 2} })
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Ran).To(BeTrue())
|
||||
|
||||
Expect(s.PerKind).To(Equal(map[string]int64{"ar": 2, "al": 1, "pl": 1, "ra": 1}))
|
||||
Expect(s.Items).To(Equal(int64(5)))
|
||||
// 2 artists x 3 agents, 1 album x 2, 1 playlist grid x 2, and radios never fetch.
|
||||
Expect(s.MaxExternalLookups).To(Equal(int64(6 + 2 + PlaylistGridSamples*2)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("EnqueueStaleAbsentAll", func() {
|
||||
var artRepo *tests.MockArtworkRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
artRepo = tests.CreateMockArtworkRepo()
|
||||
ds.MockedArtwork = artRepo
|
||||
queueRepo.ItemArtworkSource = artRepo
|
||||
})
|
||||
|
||||
It("enqueues only absent entries older than the recheck window, across all kinds", func() {
|
||||
old := time.Now().Add(-StaleAbsentAge - time.Hour)
|
||||
recent := time.Now().Add(-StaleAbsentAge + time.Hour)
|
||||
|
||||
artRepo.ItemData["ar-stale"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
|
||||
artRepo.ItemData["al-stale"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
|
||||
artRepo.ItemData["pl-stale"] = model.ItemArtwork{ItemKind: "pl", ItemID: "pl1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
|
||||
artRepo.ItemData["ra-stale"] = model.ItemArtwork{ItemKind: "ra", ItemID: "ra1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
|
||||
artRepo.ItemData["ar-recent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar2", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: recent}
|
||||
artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al2", ImageType: model.ImageTypePrimary, Hash: "somehash", AttemptedAt: old}
|
||||
|
||||
err := enqueueStaleAbsentAll(ctx, ds)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(queueRepo.Data).To(HaveLen(4))
|
||||
for _, it := range queueRepo.Data {
|
||||
Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck))
|
||||
}
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar1")).ToNot(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al1")).ToNot(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "pl1")).ToNot(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ra", "ra1")).ToNot(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar2")).To(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).To(BeNil())
|
||||
})
|
||||
|
||||
It("caps each tick at the recheck batch, oldest attempts first", func() {
|
||||
for i := range StaleAbsentRecheckBatch + 1 {
|
||||
id := fmt.Sprintf("ar%d", i)
|
||||
artRepo.ItemData[id] = model.ItemArtwork{ItemKind: "ar", ItemID: id, ImageType: model.ImageTypePrimary,
|
||||
Hash: "", AttemptedAt: time.Now().Add(-StaleAbsentAge - time.Duration(i+1)*time.Minute)}
|
||||
}
|
||||
|
||||
Expect(enqueueStaleAbsentAll(ctx, ds)).To(Succeed())
|
||||
|
||||
Expect(queueRepo.Data).To(HaveLen(StaleAbsentRecheckBatch))
|
||||
// ar0 has the newest attempted_at of the cohort, so it is the one left out.
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar0")).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("EnqueueMissingAll", func() {
|
||||
var artRepo *tests.MockArtworkRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
artRepo = tests.CreateMockArtworkRepo()
|
||||
ds.MockedArtwork = artRepo
|
||||
queueRepo.ItemArtworkSource = artRepo
|
||||
queueRepo.ExistingIDs = map[string]map[string]bool{
|
||||
"al": {"al1": true, "al2": true},
|
||||
"ar": {"ar1": true},
|
||||
"pl": {"pl1": true},
|
||||
"ra": {"ra1": true},
|
||||
}
|
||||
})
|
||||
|
||||
It("enqueues only entities that have no item_artwork row, across all kinds", func() {
|
||||
artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "somehash", AttemptedAt: time.Now()}
|
||||
artRepo.ItemData["ar-absent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()}
|
||||
|
||||
err := enqueueMissingAll(ctx, ds)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
for _, it := range queueRepo.Data {
|
||||
Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck))
|
||||
}
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).ToNot(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "pl1")).ToNot(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ra", "ra1")).ToNot(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al1")).To(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar1")).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("ItemName", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
albumRepo := tests.CreateMockAlbumRepo()
|
||||
albumRepo.SetData(model.Albums{
|
||||
{ID: "al-1", Name: "Kid A"},
|
||||
{ID: "al-2", Name: "Sandinista!", Discs: model.Discs{2: "Side Three"}},
|
||||
})
|
||||
ds = &tests.MockDataStore{MockedAlbum: albumRepo}
|
||||
Expect(ds.Artist(ctx).(*tests.MockArtistRepo).Put(&model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed())
|
||||
})
|
||||
|
||||
It("returns the album name", func() {
|
||||
Expect(ItemName(ctx, ds, model.KindAlbumArtwork, "al-1")).To(Equal("Kid A"))
|
||||
})
|
||||
|
||||
It("returns the artist name", func() {
|
||||
Expect(ItemName(ctx, ds, model.KindArtistArtwork, "ar-1")).To(Equal("Radiohead"))
|
||||
})
|
||||
|
||||
It("errors for an unknown album", func() {
|
||||
_, err := ItemName(ctx, ds, model.KindAlbumArtwork, "nope")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("errors for an unsupported kind", func() {
|
||||
// model.Kind is a struct with unexported fields, so the zero value is the only
|
||||
// unsupported Kind constructible from outside package model.
|
||||
_, err := ItemName(ctx, ds, model.Kind{}, "al-1")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
Context("disc artwork", func() {
|
||||
It("names the album, the disc and its subtitle", func() {
|
||||
Expect(ItemName(ctx, ds, model.KindDiscArtwork, "al-2:2")).
|
||||
To(Equal("Sandinista! (disc 2): Side Three"))
|
||||
})
|
||||
|
||||
It("omits the subtitle when the disc has none", func() {
|
||||
Expect(ItemName(ctx, ds, model.KindDiscArtwork, "al-2:1")).
|
||||
To(Equal("Sandinista! (disc 1)"))
|
||||
})
|
||||
|
||||
It("rejects an id that is not <albumID>:<disc>", func() {
|
||||
_, err := ItemName(ctx, ds, model.KindDiscArtwork, "al-2")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,22 +1,30 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
)
|
||||
|
||||
// artworkReader is the cache.Item the image cache loader dispatches on: Reader
|
||||
// produces the (possibly resized) bytes to store under Key.
|
||||
type artworkReader interface {
|
||||
cache.Item
|
||||
Reader(ctx context.Context) (io.ReadCloser, error)
|
||||
type cacheKey struct {
|
||||
artID model.ArtworkID
|
||||
lastUpdate time.Time
|
||||
}
|
||||
|
||||
func (k *cacheKey) Key() string {
|
||||
return fmt.Sprintf(
|
||||
"%s-%s.%d",
|
||||
k.artID.Kind,
|
||||
k.artID.ID,
|
||||
k.lastUpdate.UnixMilli(),
|
||||
)
|
||||
}
|
||||
|
||||
type imageCache struct {
|
||||
@ -28,49 +36,9 @@ func GetImageCache() cache.FileCache {
|
||||
return &imageCache{
|
||||
FileCache: cache.NewFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems,
|
||||
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
|
||||
return arg.(artworkReader).Reader(ctx)
|
||||
r, _, err := arg.(artworkReader).Reader(ctx)
|
||||
return r, err
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// resizedItem is an artworkReader that resizes bytes opened by open() and caches the
|
||||
// result under a hash-derived key.
|
||||
type resizedItem struct {
|
||||
hash string
|
||||
size int
|
||||
square bool
|
||||
ffmpeg ffmpeg.FFmpeg
|
||||
open func() (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
// Key is the ETag namespaced for the cache, so the validator a client holds and the entry it
|
||||
// validates can never drift apart.
|
||||
func (r *resizedItem) Key() string {
|
||||
return "h-" + representationTag(r.hash, r.size, r.square)
|
||||
}
|
||||
|
||||
func (r *resizedItem) Reader(ctx context.Context) (io.ReadCloser, error) {
|
||||
orig, err := r.open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// An open() that reports "no image" as a nil reader would otherwise panic on the Close below.
|
||||
if orig == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
defer orig.Close()
|
||||
data, err := readCapped(orig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resized, _, err := resizeImageData(ctx, r.ffmpeg, data, r.size, r.square)
|
||||
if err != nil || resized == nil {
|
||||
// Resize failed or image already within bounds: serve the original bytes.
|
||||
return io.NopCloser(bytes.NewReader(data)), nil
|
||||
}
|
||||
if rc, ok := resized.(io.ReadCloser); ok {
|
||||
return rc, nil
|
||||
}
|
||||
return io.NopCloser(resized), nil
|
||||
}
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("resizedItem", func() {
|
||||
Describe("Reader", func() {
|
||||
newItem := func(open func() (io.ReadCloser, error)) *resizedItem {
|
||||
return &resizedItem{hash: "abc123", size: 300, open: open}
|
||||
}
|
||||
|
||||
It("reports a nil reader as unavailable instead of panicking on it", func() {
|
||||
// Every caller is expected to report "no image" as an error, but a nil reader reaches
|
||||
// the deferred Close as a nil interface, which takes the whole request down.
|
||||
_, err := newItem(func() (io.ReadCloser, error) { return nil, nil }).Reader(context.Background())
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
})
|
||||
|
||||
It("propagates the open error", func() {
|
||||
boom := errors.New("boom")
|
||||
_, err := newItem(func() (io.ReadCloser, error) { return nil, boom }).Reader(context.Background())
|
||||
Expect(err).To(MatchError(boom))
|
||||
})
|
||||
|
||||
It("serves the original bytes when they cannot be resized", func() {
|
||||
rc, err := newItem(func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("not an image")), nil
|
||||
}).Reader(context.Background())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer rc.Close()
|
||||
Expect(io.ReadAll(rc)).To(Equal([]byte("not an image")))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,155 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/zeebo/xxh3"
|
||||
)
|
||||
|
||||
// ImageStore is the content-addressed store for artwork images with no library file backing them.
|
||||
type ImageStore struct {
|
||||
root string
|
||||
}
|
||||
|
||||
func NewImageStore(rootDir string) *ImageStore {
|
||||
return &ImageStore{root: rootDir}
|
||||
}
|
||||
|
||||
func GetImageStore() *ImageStore {
|
||||
return NewImageStore(filepath.Join(conf.Server.DataFolder.String(), consts.ArtworkFolder, consts.HashedArtworkFolder))
|
||||
}
|
||||
|
||||
// extForMime must stay stable across OSes: extensions are baked into stored paths and re-derived on Open.
|
||||
func extForMime(m string) string {
|
||||
switch m {
|
||||
case "image/jpeg":
|
||||
return ".jpg"
|
||||
case "image/png":
|
||||
return ".png"
|
||||
case "image/gif":
|
||||
return ".gif"
|
||||
case "image/webp":
|
||||
return ".webp"
|
||||
}
|
||||
return ".img"
|
||||
}
|
||||
|
||||
func hashImage(r io.Reader) (string, error) {
|
||||
d := xxh3.New()
|
||||
if _, err := io.Copy(d, r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%016x", d.Sum64()), nil
|
||||
}
|
||||
|
||||
// validHash guards path sharding: a malformed hash would slice-panic or inject path separators.
|
||||
func validHash(hash string) bool {
|
||||
if len(hash) != 16 {
|
||||
return false
|
||||
}
|
||||
for _, c := range []byte(hash) {
|
||||
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *ImageStore) path(hash, mimeType string) string {
|
||||
return filepath.Join(s.root, hash[0:2], hash[2:4], hash+extForMime(mimeType))
|
||||
}
|
||||
|
||||
func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error {
|
||||
if !validHash(hash) {
|
||||
return fmt.Errorf("imagestore: invalid hash %q", hash)
|
||||
}
|
||||
dst := s.path(hash, mimeType)
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
// A touched mtime marks the file live so a concurrent prune spares it.
|
||||
now := time.Now()
|
||||
if err := os.Chtimes(dst, now, now); err == nil {
|
||||
return nil
|
||||
}
|
||||
// touch failed (likely pruned concurrently) — fall through and rewrite it
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(dst), "."+hash+".tmp*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err := io.Copy(tmp, r); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), dst)
|
||||
}
|
||||
|
||||
func (s *ImageStore) Open(hash, mimeType string) (io.ReadCloser, error) {
|
||||
if !validHash(hash) {
|
||||
return nil, fmt.Errorf("imagestore: invalid hash %q", hash)
|
||||
}
|
||||
return os.Open(s.path(hash, mimeType))
|
||||
}
|
||||
|
||||
// Sweep removes store files not accepted by keep. Files modified after cutoff are always
|
||||
// kept: their acquisition row may not be committed yet.
|
||||
func (s *ImageStore) Sweep(ctx context.Context, cutoff time.Time, keep func(hash, ext string) bool) (int, error) {
|
||||
removed, failed := 0, 0
|
||||
var lastErr error
|
||||
err := filepath.WalkDir(s.root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.ModTime().After(cutoff) {
|
||||
return nil
|
||||
}
|
||||
name := d.Name()
|
||||
remove := strings.HasPrefix(name, ".") // abandoned temp file past the grace window
|
||||
if !remove {
|
||||
ext := filepath.Ext(name)
|
||||
remove = !keep(strings.TrimSuffix(name, ext), ext)
|
||||
}
|
||||
if remove {
|
||||
// #nosec G122 -- path comes from WalkDir over our own store root, no attacker-controlled symlinks
|
||||
if err := os.Remove(path); err != nil {
|
||||
// One unremovable file must not strand the rest of the store until the next prune.
|
||||
failed, lastErr = failed+1, err
|
||||
return nil //nolint:nilerr // counted and reported in aggregate below
|
||||
}
|
||||
removed++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
// Aggregated: a store that has gone read-only would otherwise warn once per file, every prune.
|
||||
if failed > 0 {
|
||||
log.Warn(ctx, "Artwork: Could not remove store files", "count", failed, "swept", removed, lastErr)
|
||||
}
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return removed, nil
|
||||
}
|
||||
return removed, err
|
||||
}
|
||||
@ -1,228 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ImageStore", func() {
|
||||
var store *ImageStore
|
||||
var root string
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
root = GinkgoT().TempDir()
|
||||
store = NewImageStore(root)
|
||||
ctx = context.Background()
|
||||
})
|
||||
|
||||
It("hashes deterministically", func() {
|
||||
h1, err := hashImage(bytes.NewReader([]byte("some image bytes")))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
h2, _ := hashImage(bytes.NewReader([]byte("some image bytes")))
|
||||
Expect(h1).To(Equal(h2))
|
||||
Expect(h1).To(HaveLen(16))
|
||||
h3, _ := hashImage(bytes.NewReader([]byte("other bytes")))
|
||||
Expect(h3).ToNot(Equal(h1))
|
||||
})
|
||||
|
||||
It("writes sharded and reads back", func() {
|
||||
data := []byte("jpeg-bytes")
|
||||
h, _ := hashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||
|
||||
Expect(filepath.Join(root, h[0:2], h[2:4], h+".jpg")).To(BeAnExistingFile())
|
||||
|
||||
rc, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer rc.Close()
|
||||
got, _ := io.ReadAll(rc)
|
||||
Expect(got).To(Equal(data))
|
||||
})
|
||||
|
||||
It("is idempotent on duplicate writes and preserves the original content", func() {
|
||||
data := []byte("dup")
|
||||
h, _ := hashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed())
|
||||
// A duplicate write only touches mtime; passing different bytes under the same
|
||||
// hash proves the second reader is never consumed to overwrite the file.
|
||||
Expect(store.Write(h, "image/png", bytes.NewReader([]byte("not-dup")))).To(Succeed())
|
||||
|
||||
rc, err := store.Open(h, "image/png")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer rc.Close()
|
||||
got, err := io.ReadAll(rc)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got).To(Equal(data))
|
||||
})
|
||||
|
||||
It("refreshes the mtime on a duplicate write", func() {
|
||||
data := []byte("touch-me")
|
||||
h, _ := hashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed())
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed())
|
||||
|
||||
Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed())
|
||||
|
||||
info, err := os.Stat(store.path(h, "image/png"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(info.ModTime()).To(BeTemporally(">", time.Now().Add(-time.Minute)))
|
||||
})
|
||||
|
||||
It("rewrites the bytes when the existing file vanished before the liveness touch", func() {
|
||||
data := []byte("vanishing")
|
||||
h, _ := hashImage(bytes.NewReader(data))
|
||||
for range 10 {
|
||||
Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed())
|
||||
Expect(os.Remove(store.path(h, "image/png"))).To(Succeed())
|
||||
Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed())
|
||||
|
||||
rc, err := store.Open(h, "image/png")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
got, _ := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
Expect(got).To(Equal(data))
|
||||
}
|
||||
})
|
||||
|
||||
It("returns fs.ErrNotExist for missing images", func() {
|
||||
_, err := store.Open("beefbeefbeefbeef", "image/jpeg")
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("rejects invalid hashes instead of panicking", func() {
|
||||
for _, h := range []string{"", "ab", "BEEFBEEFBEEFBEEF", "../../../../etcpw", "beefbeefbeefbee/"} {
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader([]byte("x")))).To(MatchError(ContainSubstring("invalid hash")))
|
||||
_, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).To(MatchError(ContainSubstring("invalid hash")))
|
||||
}
|
||||
})
|
||||
|
||||
It("sweeps unknown files, keeps known ones", func() {
|
||||
d1 := []byte("keep-me")
|
||||
h1, _ := hashImage(bytes.NewReader(d1))
|
||||
Expect(store.Write(h1, "image/jpeg", bytes.NewReader(d1))).To(Succeed())
|
||||
d2 := []byte("orphan")
|
||||
h2, _ := hashImage(bytes.NewReader(d2))
|
||||
Expect(store.Write(h2, "image/jpeg", bytes.NewReader(d2))).To(Succeed())
|
||||
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
Expect(os.Chtimes(store.path(h2, "image/jpeg"), old, old)).To(Succeed())
|
||||
|
||||
removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(h, _ string) bool { return h == h1 })
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(removed).To(Equal(1))
|
||||
_, err = store.Open(h2, "image/jpeg")
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
rc, err := store.Open(h1, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("sweeps a stale mime variant of a known hash, keeps the current one", func() {
|
||||
data := []byte("same-bytes")
|
||||
h, _ := hashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed())
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed())
|
||||
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
|
||||
|
||||
// The recorded mime is image/jpeg, so the .png variant is obsolete.
|
||||
removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(hash, ext string) bool {
|
||||
return hash == h && ext == ".jpg"
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(removed).To(Equal(1))
|
||||
_, err = store.Open(h, "image/png")
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
rc, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("keeps young unknown files inside the grace window", func() {
|
||||
d := []byte("fresh-orphan")
|
||||
h, _ := hashImage(bytes.NewReader(d))
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(d))).To(Succeed())
|
||||
|
||||
removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(string, string) bool { return false })
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(removed).To(Equal(0))
|
||||
rc, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("removes abandoned temp files past the grace window, keeps fresh ones", func() {
|
||||
oldTmp := filepath.Join(root, ".old.tmp")
|
||||
Expect(os.WriteFile(oldTmp, []byte("x"), 0600)).To(Succeed())
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
Expect(os.Chtimes(oldTmp, old, old)).To(Succeed())
|
||||
|
||||
freshTmp := filepath.Join(root, ".fresh.tmp")
|
||||
Expect(os.WriteFile(freshTmp, []byte("y"), 0600)).To(Succeed())
|
||||
|
||||
removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(string, string) bool { return true })
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(removed).To(Equal(1))
|
||||
Expect(oldTmp).ToNot(BeAnExistingFile())
|
||||
Expect(freshTmp).To(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("keeps sweeping past a file it cannot remove", func() {
|
||||
tests.SkipOnWindows("uses Unix file permission bits")
|
||||
if os.Geteuid() == 0 {
|
||||
Skip("read-only dir cannot block root (e.g. tests in a container)")
|
||||
}
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
// "blocked" sorts before "ok", so the walk hits the unremovable file first.
|
||||
blockedDir := filepath.Join(root, "blocked")
|
||||
Expect(os.MkdirAll(blockedDir, 0755)).To(Succeed())
|
||||
blocked := filepath.Join(blockedDir, "a.jpg")
|
||||
Expect(os.WriteFile(blocked, []byte("x"), 0600)).To(Succeed())
|
||||
Expect(os.Chtimes(blocked, old, old)).To(Succeed())
|
||||
|
||||
okDir := filepath.Join(root, "ok")
|
||||
Expect(os.MkdirAll(okDir, 0755)).To(Succeed())
|
||||
reachable := filepath.Join(okDir, "b.jpg")
|
||||
Expect(os.WriteFile(reachable, []byte("y"), 0600)).To(Succeed())
|
||||
Expect(os.Chtimes(reachable, old, old)).To(Succeed())
|
||||
|
||||
Expect(os.Chmod(blockedDir, 0500)).To(Succeed())
|
||||
DeferCleanup(func() { _ = os.Chmod(blockedDir, 0755) })
|
||||
|
||||
removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(string, string) bool { return false })
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(removed).To(Equal(1))
|
||||
Expect(blocked).To(BeAnExistingFile())
|
||||
Expect(reachable).ToNot(BeAnExistingFile())
|
||||
})
|
||||
|
||||
// Prune holds the worker's write lock for the whole sweep, and shutdown waits on the
|
||||
// worker, so an uncancellable walk over a large store stalls it until SIGKILL.
|
||||
It("abandons the walk when the context is cancelled", func() {
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
for _, name := range []string{"a", "b", "c", "d"} {
|
||||
p := filepath.Join(root, name+".jpg")
|
||||
Expect(os.WriteFile(p, []byte("x"), 0600)).To(Succeed())
|
||||
Expect(os.Chtimes(p, old, old)).To(Succeed())
|
||||
}
|
||||
cancelCtx, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
|
||||
_, err := store.Sweep(cancelCtx, time.Now().Add(-time.Hour), func(string, string) bool { return false })
|
||||
Expect(err).To(MatchError(context.Canceled))
|
||||
matches, _ := filepath.Glob(filepath.Join(root, "*.jpg"))
|
||||
Expect(matches).To(HaveLen(4), "a cancelled sweep must not keep deleting")
|
||||
})
|
||||
})
|
||||
@ -2,9 +2,7 @@ package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core/storage"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@ -42,23 +40,5 @@ func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (librar
|
||||
if err != nil {
|
||||
return libraryView{}, err
|
||||
}
|
||||
return libraryView{FS: fs, absRoot: localOSRoot(lib.Path)}, nil
|
||||
}
|
||||
|
||||
// localOSRoot maps a library path to its on-disk root so Abs() yields paths os.Open/os.Stat accept:
|
||||
// a file:// URL becomes its parsed OS path (bare paths already are; non-local schemes stay unchanged).
|
||||
func localOSRoot(libPath string) string {
|
||||
if !strings.Contains(libPath, "://") {
|
||||
return libPath
|
||||
}
|
||||
u, err := url.Parse(libPath)
|
||||
if err != nil || u.Scheme != storage.LocalSchemaID {
|
||||
return libPath
|
||||
}
|
||||
// Windows drive URLs (file://C:/Music) put the volume in Host; rejoin it, matching
|
||||
// core/storage/local's newLocalStorage so os.Open/os.Stat get a valid path.
|
||||
if filepath.VolumeName(u.Host) != "" {
|
||||
return filepath.Join(u.Host, u.Path)
|
||||
}
|
||||
return u.Path
|
||||
return libraryView{FS: fs, absRoot: lib.Path}, nil
|
||||
}
|
||||
|
||||
@ -32,13 +32,6 @@ var _ = Describe("loadLibraryView", Ordered, func() {
|
||||
Expect(lib.absRoot).To(Equal("fake:///music"))
|
||||
})
|
||||
|
||||
It("normalizes a library path to an OS root that Abs can join for os.Open/os.Stat", func() {
|
||||
// file:// URLs become their parsed OS path; bare paths and non-local schemes are unchanged.
|
||||
Expect(localOSRoot("file:///music/library")).To(Equal("/music/library"))
|
||||
Expect(localOSRoot("/music/library")).To(Equal("/music/library"))
|
||||
Expect(localOSRoot("fake:///music")).To(Equal("fake:///music"))
|
||||
})
|
||||
|
||||
It("returns an error when the library does not exist", func() {
|
||||
_, err := loadLibraryView(ctx, ds, 999)
|
||||
Expect(err).To(HaveOccurred())
|
||||
|
||||
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