mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-19 15:57:11 +02:00
Compare commits
39 commits
mp3-xing-d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df77c462e8 |
||
|
|
a45adbbb2e |
||
|
|
be201a2d79 |
||
|
|
30663f7d4f |
||
|
|
8e306b1f61 |
||
|
|
4ae97ab59e |
||
|
|
803b2da8ee | ||
|
|
74e96a2bb7 | ||
|
|
497dfe2586 |
||
|
|
b70ba3a49e | ||
|
|
d5a2e1e813 | ||
|
|
b4d2c3e4af | ||
|
|
22ce85e5d5 | ||
|
|
aea60c6e99 | ||
|
|
b78c963931 |
||
|
|
52135913d4 |
||
|
|
caa2f8a0c0 |
||
|
|
95f67d2c4e |
||
|
|
758e64c999 |
||
|
|
0e1893530b |
||
|
|
e03ce87177 |
||
|
|
3a31f702b5 |
||
|
|
a62d1629f0 |
||
|
|
612c8290f9 |
||
|
|
ab5869123d |
||
|
|
4cdffd5633 |
||
|
|
ce484083bf |
||
|
|
46c432719f |
||
|
|
c22ce9ebb2 |
||
|
|
bb7d81a5ea |
||
|
|
4113d954ca |
||
|
|
6b05189190 | ||
|
|
b293b96256 |
||
|
|
83fff44c82 |
||
|
|
659d067aba |
||
|
|
336fa482f8 |
||
|
|
3f89baaec8 |
||
|
|
a0c4160376 | ||
|
|
69b496383d |
513 changed files with 15061 additions and 8575 deletions
34
.github/workflows/pipeline.yml
vendored
34
.github/workflows/pipeline.yml
vendored
|
|
@ -92,8 +92,23 @@ jobs:
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Resolve OpenAPI tool versions
|
||||||
|
id: api-tools
|
||||||
|
run: echo "key=$(grep -E '^(VACUUM|OAPI_CODEGEN|OASDIFF)_VERSION' Makefile | tr -d ' \n')" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Cache OpenAPI tools
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: bin
|
||||||
|
key: api-tools-${{ runner.os }}-${{ steps.api-tools.outputs.key }}
|
||||||
|
|
||||||
|
- name: Lint OpenAPI spec
|
||||||
|
run: make api-lint
|
||||||
|
|
||||||
- name: Run go generate
|
- name: Run go generate
|
||||||
run: go generate ./...
|
run: |
|
||||||
|
make api-gen
|
||||||
|
go generate ./...
|
||||||
- name: Verify no changes from go generate
|
- name: Verify no changes from go generate
|
||||||
run: |
|
run: |
|
||||||
git status --porcelain
|
git status --porcelain
|
||||||
|
|
@ -102,6 +117,12 @@ jobs:
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Check for breaking OpenAPI changes
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
run: |
|
||||||
|
git fetch --no-tags --depth=1 origin ${{ github.event.pull_request.base.sha }}
|
||||||
|
make api-diff API_DIFF_BASE=${{ github.event.pull_request.base.sha }}
|
||||||
|
|
||||||
validate-migrations:
|
validate-migrations:
|
||||||
name: Validate DB migrations
|
name: Validate DB migrations
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -498,13 +519,18 @@ jobs:
|
||||||
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
|
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
|
||||||
|
|
||||||
push-manifest-dockerhub:
|
push-manifest-dockerhub:
|
||||||
name: Push to Docker Hub
|
name: Push to Docker Hub (${{ matrix.repository }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
needs: [build, check-push-enabled]
|
needs: [build, check-push-enabled]
|
||||||
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
|
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
# Always push to DOCKER_HUB_REPO. The upstream repository also pushes to the navidrome/navidrome org repository.
|
||||||
|
repository: ${{ fromJSON(github.repository == 'navidrome/navidrome' && format('["{0}","navidrome/navidrome"]', vars.DOCKER_HUB_REPO) || format('["{0}"]', vars.DOCKER_HUB_REPO)) }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
|
@ -520,7 +546,7 @@ jobs:
|
||||||
id: docker
|
id: docker
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
hub_repository: ${{ vars.DOCKER_HUB_REPO }}
|
hub_repository: ${{ matrix.repository }}
|
||||||
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
|
||||||
|
|
@ -537,7 +563,7 @@ jobs:
|
||||||
|
|
||||||
- name: Inspect image in Docker Hub
|
- name: Inspect image in Docker Hub
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools inspect ${{ vars.DOCKER_HUB_REPO }}:${{ steps.docker.outputs.version }}
|
docker buildx imagetools inspect ${{ matrix.repository }}:${{ steps.docker.outputs.version }}
|
||||||
|
|
||||||
cleanup-digests:
|
cleanup-digests:
|
||||||
name: Cleanup digest artifacts
|
name: Cleanup digest artifacts
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ linters:
|
||||||
- asciicheck
|
- asciicheck
|
||||||
- bidichk
|
- bidichk
|
||||||
- bodyclose
|
- bodyclose
|
||||||
|
- containedctx
|
||||||
- copyloopvar
|
- copyloopvar
|
||||||
- dogsled
|
- dogsled
|
||||||
- durationcheck
|
- durationcheck
|
||||||
|
|
@ -57,6 +58,9 @@ linters:
|
||||||
- gosec
|
- gosec
|
||||||
path: _test\.go
|
path: _test\.go
|
||||||
text: "G703"
|
text: "G703"
|
||||||
|
- path: _test\.go
|
||||||
|
linters:
|
||||||
|
- containedctx
|
||||||
- path-except: 'db/migrations/'
|
- path-except: 'db/migrations/'
|
||||||
linters:
|
linters:
|
||||||
- forbidigo
|
- forbidigo
|
||||||
|
|
|
||||||
51
Makefile
51
Makefile
|
|
@ -18,9 +18,13 @@ endif
|
||||||
SUPPORTED_PLATFORMS ?= 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
|
SUPPORTED_PLATFORMS ?= 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
|
||||||
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
|
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
|
||||||
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
||||||
DOCKER_TAG ?= deluan/navidrome:develop
|
DOCKER_TAG ?= navidrome/navidrome:develop
|
||||||
|
|
||||||
GOLANGCI_LINT_VERSION ?= v2.13.2
|
GOLANGCI_LINT_VERSION ?= v2.14.0
|
||||||
|
VACUUM_VERSION ?= v0.30.6
|
||||||
|
OAPI_CODEGEN_VERSION ?= v2.8.0
|
||||||
|
OASDIFF_VERSION ?= v1.32.1
|
||||||
|
API_DIFF_BASE ?= origin/master
|
||||||
|
|
||||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||||
|
|
||||||
|
|
@ -92,6 +96,45 @@ install-golangci-lint: ##@Development Install golangci-lint if not present
|
||||||
fi
|
fi
|
||||||
.PHONY: install-golangci-lint
|
.PHONY: install-golangci-lint
|
||||||
|
|
||||||
|
install-api-tools: ##@Development Install OpenAPI tools (vacuum, oapi-codegen, oasdiff) into ./bin
|
||||||
|
@STAMP=bin/.api-tools-$(VACUUM_VERSION)-$(OAPI_CODEGEN_VERSION)-$(OASDIFF_VERSION); \
|
||||||
|
if [ ! -f $$STAMP ] || [ ! -x bin/vacuum ] || [ ! -x bin/oapi-codegen ] || [ ! -x bin/oasdiff ]; then \
|
||||||
|
echo "Installing OpenAPI tools..."; \
|
||||||
|
GOBIN=$(CURDIR)/bin go install github.com/daveshanley/vacuum@$(VACUUM_VERSION) && \
|
||||||
|
GOBIN=$(CURDIR)/bin go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@$(OAPI_CODEGEN_VERSION) && \
|
||||||
|
GOBIN=$(CURDIR)/bin go install github.com/oasdiff/oasdiff@$(OASDIFF_VERSION) && \
|
||||||
|
rm -f bin/.api-tools-* && touch $$STAMP; \
|
||||||
|
fi
|
||||||
|
.PHONY: install-api-tools
|
||||||
|
|
||||||
|
api-lint: install-api-tools ##@Development Lint the OpenAPI spec
|
||||||
|
./bin/vacuum lint -r api/.vacuum.yaml -d -q -b --fail-severity error api/openapi/openapi.yaml
|
||||||
|
.PHONY: api-lint
|
||||||
|
|
||||||
|
api-bundle: install-api-tools ##@Development Bundle the multi-file OpenAPI spec into api/bundled
|
||||||
|
./bin/vacuum bundle -q --composed -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.yaml
|
||||||
|
./bin/vacuum bundle -q --composed --format json -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.json
|
||||||
|
.PHONY: api-bundle
|
||||||
|
|
||||||
|
api-gen: api-bundle ##@Development Generate the API v1 server code from the bundled spec
|
||||||
|
./bin/oapi-codegen -config server/apiv1/oapi-codegen.yaml api/bundled/openapi.json
|
||||||
|
.PHONY: api-gen
|
||||||
|
|
||||||
|
api-diff: api-bundle ##@Development Fail on breaking OpenAPI changes against the merge-base with $(API_DIFF_BASE)
|
||||||
|
@git rev-parse --verify --quiet $(API_DIFF_BASE)^{commit} >/dev/null || { echo "Base ref $(API_DIFF_BASE) not found; set API_DIFF_BASE"; exit 1; }; \
|
||||||
|
BASE="$$(git merge-base HEAD $(API_DIFF_BASE) 2>/dev/null)"; \
|
||||||
|
if [ -z "$$BASE" ]; then \
|
||||||
|
echo "No merge-base with $(API_DIFF_BASE); falling back to its tip"; \
|
||||||
|
BASE=$(API_DIFF_BASE); \
|
||||||
|
fi; \
|
||||||
|
if git cat-file -e $$BASE:api/bundled/openapi.json 2>/dev/null; then \
|
||||||
|
git show $$BASE:api/bundled/openapi.json > $(CURDIR)/bin/api-base.json && \
|
||||||
|
./bin/oasdiff breaking $(CURDIR)/bin/api-base.json api/bundled/openapi.json --fail-on ERR --severity-levels api/.oasdiff-levels.txt; \
|
||||||
|
else \
|
||||||
|
echo "No bundled spec at $$BASE; skipping breaking-change check"; \
|
||||||
|
fi
|
||||||
|
.PHONY: api-diff
|
||||||
|
|
||||||
lint: install-golangci-lint ##@Development Lint Go code
|
lint: install-golangci-lint ##@Development Lint Go code
|
||||||
PATH=./bin:$$PATH golangci-lint run --timeout 5m
|
PATH=./bin:$$PATH golangci-lint run --timeout 5m
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
|
|
@ -111,7 +154,7 @@ wire: check_go_env ##@Development Update Dependency Injection
|
||||||
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
|
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
|
||||||
.PHONY: wire
|
.PHONY: wire
|
||||||
|
|
||||||
gen: check_go_env ##@Development Run go generate for code generation
|
gen: check_go_env api-gen ##@Development Run go generate for code generation
|
||||||
go generate ./...
|
go generate ./...
|
||||||
cd plugins/cmd/ndpgen && go run . -shared-types -input=../../types -output=../../pdk -go -rust
|
cd plugins/cmd/ndpgen && go run . -shared-types -input=../../types -output=../../pdk -go -rust
|
||||||
cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host -shared=../../types
|
cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host -shared=../../types
|
||||||
|
|
@ -180,7 +223,7 @@ docker-build: ##@Cross_Compilation Cross-compile for any supported platform (che
|
||||||
--output "./binaries" --target binary .
|
--output "./binaries" --target binary .
|
||||||
.PHONY: docker-build
|
.PHONY: docker-build
|
||||||
|
|
||||||
docker-image: ##@Cross_Compilation Build Docker image, tagged as `deluan/navidrome:develop`, override with DOCKER_TAG var. Use IMAGE_PLATFORMS to specify target platforms
|
docker-image: ##@Cross_Compilation Build Docker image, tagged as `navidrome/navidrome:develop`, override with DOCKER_TAG var. Use IMAGE_PLATFORMS to specify target platforms
|
||||||
@echo $(IMAGE_PLATFORMS) | grep -q "windows" && echo "ERROR: Windows is not supported for Docker builds" && exit 1 || true
|
@echo $(IMAGE_PLATFORMS) | grep -q "windows" && echo "ERROR: Windows is not supported for Docker builds" && exit 1 || true
|
||||||
@echo $(IMAGE_PLATFORMS) | grep -q "darwin" && echo "ERROR: macOS is not supported for Docker builds" && exit 1 || true
|
@echo $(IMAGE_PLATFORMS) | grep -q "darwin" && echo "ERROR: macOS is not supported for Docker builds" && exit 1 || true
|
||||||
@echo $(IMAGE_PLATFORMS) | grep -q "arm/v5" && echo "ERROR: Linux ARMv5 is not supported for Docker builds" && exit 1 || true
|
@echo $(IMAGE_PLATFORMS) | grep -q "arm/v5" && echo "ERROR: Linux ARMv5 is not supported for Docker builds" && exit 1 || true
|
||||||
|
|
|
||||||
17
README.md
17
README.md
|
|
@ -5,7 +5,7 @@
|
||||||
[](https://github.com/navidrome/navidrome/releases)
|
[](https://github.com/navidrome/navidrome/releases)
|
||||||
[](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
|
[](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
|
||||||
[](https://github.com/navidrome/navidrome/releases/latest)
|
[](https://github.com/navidrome/navidrome/releases/latest)
|
||||||
[](https://hub.docker.com/r/deluan/navidrome)
|
[](https://hub.docker.com/r/navidrome/navidrome)
|
||||||
[](https://discord.gg/xh7j7yF)
|
[](https://discord.gg/xh7j7yF)
|
||||||
[](https://www.reddit.com/r/navidrome/)
|
[](https://www.reddit.com/r/navidrome/)
|
||||||
[](CODE_OF_CONDUCT.md)
|
[](CODE_OF_CONDUCT.md)
|
||||||
|
|
@ -35,11 +35,18 @@ See instructions on the [project's website](https://www.navidrome.org/docs/insta
|
||||||
|
|
||||||
## Cloud Hosting
|
## Cloud Hosting
|
||||||
|
|
||||||
[PikaPods](https://www.pikapods.com) has partnered with us to offer you an
|
Several cloud hosting providers partner with us to offer [officially supported, cloud-hosted solutions](https://www.navidrome.org/docs/installation/managed). If you sign up with any of these providers, a share of the revenue funds the development of Navidrome at no additional cost for you.
|
||||||
[officially supported, cloud-hosted solution](https://www.navidrome.org/docs/installation/managed/#pikapods).
|
|
||||||
A share of the revenue helps fund the development of Navidrome at no additional cost for you.
|
<a href="https://www.pikapods.com/pods?run=navidrome"><img src="resources/hosting/pikapods.svg" alt="Run on PikaPods" width="200" height="40"></a>
|
||||||
|
<br>
|
||||||
|
<a href="https://zenith.hosting/host/navidrome?ref=navidrome"><img src="resources/hosting/zenith.svg" alt="Deploy with Zenith" width="200" height="40"></a>
|
||||||
|
<br>
|
||||||
|
<a href="https://store.elfhosted.com/product/navidrome/"><img src="resources/hosting/elfhosted.svg" alt="Deploy on ElfHosted" width="200" height="40"></a>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[](https://www.pikapods.com/pods?run=navidrome)
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ var _ = Describe("Extractor", func() {
|
||||||
info.FileInfo = testFileInfo{FileInfo: fileInfo}
|
info.FileInfo = testFileInfo{FileInfo: fileInfo}
|
||||||
|
|
||||||
metadata := metadata.New(path, info)
|
metadata := metadata.New(path, info)
|
||||||
return new(metadata.ToMediaFile(1, "folderID"))
|
return new(metadata.ToMediaFile(model.Library{ID: 1}, "folderID"))
|
||||||
}
|
}
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
|
|
||||||
|
|
@ -357,7 +357,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||||
var httpClient *tests.FakeHttpClient
|
var httpClient *tests.FakeHttpClient
|
||||||
var track *model.MediaFile
|
var track *model.MediaFile
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
|
||||||
httpClient = &tests.FakeHttpClient{}
|
httpClient = &tests.FakeHttpClient{}
|
||||||
client := newClient("API_KEY", "SECRET", httpClient)
|
client := newClient("API_KEY", "SECRET", httpClient)
|
||||||
agent = lastFMConstructor(ds)
|
agent = lastFMConstructor(ds)
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ var _ = Describe("auth_router", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
storedSessionKey := func(userID string) string {
|
storedSessionKey := func(userID string) string {
|
||||||
key, _ := userProps.Get(userID, sessionKeyProperty)
|
key, _ := userProps.Get(GinkgoT().Context(), userID, sessionKeyProperty)
|
||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ var _ = Describe("listenBrainzAgent", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
|
||||||
httpClient = &tests.FakeHttpClient{}
|
httpClient = &tests.FakeHttpClient{}
|
||||||
agent = listenBrainzConstructor(ds)
|
agent = listenBrainzConstructor(ds)
|
||||||
agent.client = newClient("http://localhost:8080", httpClient)
|
agent.client = newClient("http://localhost:8080", httpClient)
|
||||||
|
|
|
||||||
1
api/.oasdiff-levels.txt
Normal file
1
api/.oasdiff-levels.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
response-property-enum-value-added INFO
|
||||||
155
api/.vacuum.yaml
Normal file
155
api/.vacuum.yaml
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
extends: [[spectral:oas, recommended]]
|
||||||
|
rules:
|
||||||
|
# vacuum's `enumeration` function mis-resolves hyphenated `then.field` names,
|
||||||
|
# so the value check below targets `x-module` via `given` instead.
|
||||||
|
nd-operation-x-module-required:
|
||||||
|
description: Every operation belongs to exactly one capability module.
|
||||||
|
severity: error
|
||||||
|
given: $.paths[*][get,put,post,delete,patch]
|
||||||
|
then:
|
||||||
|
field: x-module
|
||||||
|
function: truthy
|
||||||
|
nd-operation-x-module:
|
||||||
|
description: Every operation's capability module is one of the known values.
|
||||||
|
severity: error
|
||||||
|
given: $.paths[*][get,put,post,delete,patch]['x-module']
|
||||||
|
then:
|
||||||
|
function: enumeration
|
||||||
|
functionOptions:
|
||||||
|
values:
|
||||||
|
- core
|
||||||
|
- streaming
|
||||||
|
- download
|
||||||
|
- artwork
|
||||||
|
- lyrics
|
||||||
|
- transcoding
|
||||||
|
- annotations
|
||||||
|
- playback
|
||||||
|
- queue
|
||||||
|
- custom-tags
|
||||||
|
- grouping
|
||||||
|
- playlists
|
||||||
|
- smart-playlists
|
||||||
|
- sync
|
||||||
|
- events
|
||||||
|
- jukebox
|
||||||
|
- sharing
|
||||||
|
- radio
|
||||||
|
- admin
|
||||||
|
nd-operation-stability-level-required:
|
||||||
|
description: Every operation declares its stability level, which the breaking-change gate relies on.
|
||||||
|
severity: error
|
||||||
|
given: $.paths[*][get,put,post,delete,patch]
|
||||||
|
then:
|
||||||
|
field: x-stability-level
|
||||||
|
function: truthy
|
||||||
|
nd-operation-stability-level:
|
||||||
|
description: Every operation's stability level is alpha, beta, or stable.
|
||||||
|
severity: error
|
||||||
|
given: $.paths[*][get,put,post,delete,patch]['x-stability-level']
|
||||||
|
then:
|
||||||
|
function: enumeration
|
||||||
|
functionOptions:
|
||||||
|
values:
|
||||||
|
- alpha
|
||||||
|
- beta
|
||||||
|
- stable
|
||||||
|
nd-operation-required-fields:
|
||||||
|
description: Operations need a stable operationId, summary, description and tags.
|
||||||
|
severity: error
|
||||||
|
given: $.paths[*][get,put,post,delete,patch]
|
||||||
|
then:
|
||||||
|
- field: operationId
|
||||||
|
function: truthy
|
||||||
|
- field: summary
|
||||||
|
function: truthy
|
||||||
|
- field: description
|
||||||
|
function: truthy
|
||||||
|
- field: tags
|
||||||
|
function: truthy
|
||||||
|
# Our schemas live in path/response files, not root components, so this
|
||||||
|
# walks every resolved `properties` map in the document via `$..` instead.
|
||||||
|
nd-schema-property-descriptions:
|
||||||
|
description: Every schema property is documented.
|
||||||
|
severity: error
|
||||||
|
given: $..properties[*]
|
||||||
|
then:
|
||||||
|
field: description
|
||||||
|
function: truthy
|
||||||
|
# patternProperties covers the full 4xx/5xx range; needs an explicit
|
||||||
|
# `properties` entry too, or `additionalProperties: false` rejects it.
|
||||||
|
nd-error-responses-are-problems:
|
||||||
|
description: 4xx and 5xx responses use application/problem+json.
|
||||||
|
severity: error
|
||||||
|
given: $.paths[*][*].responses
|
||||||
|
then:
|
||||||
|
function: schema
|
||||||
|
functionOptions:
|
||||||
|
forceValidationOnCurrentNode: true
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
patternProperties:
|
||||||
|
"^[45][0-9][0-9]$":
|
||||||
|
type: object
|
||||||
|
required: [content]
|
||||||
|
properties:
|
||||||
|
content:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
application/problem+json: {}
|
||||||
|
required: [application/problem+json]
|
||||||
|
additionalProperties: false
|
||||||
|
# Same filter limitation applies here: "is this a list endpoint" is expressed
|
||||||
|
# as a JSON Schema if/then on the operation object instead of a `given` filter.
|
||||||
|
nd-list-endpoints-paginate:
|
||||||
|
description: List endpoints declare the shared offset and limit parameters.
|
||||||
|
severity: error
|
||||||
|
given: $.paths[*].get
|
||||||
|
then:
|
||||||
|
function: schema
|
||||||
|
functionOptions:
|
||||||
|
forceValidationOnCurrentNode: true
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
if:
|
||||||
|
required: [responses]
|
||||||
|
properties:
|
||||||
|
responses:
|
||||||
|
type: object
|
||||||
|
required: ['200']
|
||||||
|
properties:
|
||||||
|
'200':
|
||||||
|
type: object
|
||||||
|
required: [content]
|
||||||
|
properties:
|
||||||
|
content:
|
||||||
|
type: object
|
||||||
|
required: [application/json]
|
||||||
|
properties:
|
||||||
|
application/json:
|
||||||
|
type: object
|
||||||
|
required: [schema]
|
||||||
|
properties:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [properties]
|
||||||
|
properties:
|
||||||
|
properties:
|
||||||
|
type: object
|
||||||
|
required: [items]
|
||||||
|
then:
|
||||||
|
required: [parameters]
|
||||||
|
properties:
|
||||||
|
parameters:
|
||||||
|
type: array
|
||||||
|
allOf:
|
||||||
|
- contains:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
const: offset
|
||||||
|
- contains:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
const: limit
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package ffmpeg
|
package api_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -9,9 +9,9 @@ import (
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFFMpeg(t *testing.T) {
|
func TestAPI(t *testing.T) {
|
||||||
tests.Init(t, true)
|
tests.Init(t, false)
|
||||||
log.SetLevel(log.LevelFatal)
|
log.SetLevel(log.LevelFatal)
|
||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
RunSpecs(t, "FFMpeg Suite")
|
RunSpecs(t, "API Spec Suite")
|
||||||
}
|
}
|
||||||
262
api/bundled/openapi.json
Normal file
262
api/bundled/openapi.json
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
{
|
||||||
|
"openapi": "3.0.3",
|
||||||
|
"info": {
|
||||||
|
"title": "Navidrome API",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /server` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n",
|
||||||
|
"license": {
|
||||||
|
"name": "GPL-3.0",
|
||||||
|
"url": "https://www.gnu.org/licenses/gpl-3.0.html"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"url": "/api/v1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"description": "Server discovery and the published OpenAPI document."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"/server": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getServerInfo",
|
||||||
|
"x-module": "core",
|
||||||
|
"x-stability-level": "alpha",
|
||||||
|
"tags": [
|
||||||
|
"server"
|
||||||
|
],
|
||||||
|
"summary": "Describe the server",
|
||||||
|
"description": "Returns the public server description. No authentication required.\nAuthenticated requests will additionally receive the implemented capability modules\nonce authentication is available.\n",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Server description.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ServerInfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"$ref": "#/components/responses/InternalError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/openapi.json": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getOpenAPISpecJSON",
|
||||||
|
"x-module": "core",
|
||||||
|
"x-stability-level": "alpha",
|
||||||
|
"tags": [
|
||||||
|
"server"
|
||||||
|
],
|
||||||
|
"summary": "Get the OpenAPI document (JSON)",
|
||||||
|
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The OpenAPI document.",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"$ref": "#/components/headers/ETag"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "OpenAPI 3.0 document."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"304": {
|
||||||
|
"$ref": "#/components/responses/NotModified"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/openapi.yaml": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getOpenAPISpecYAML",
|
||||||
|
"x-module": "core",
|
||||||
|
"x-stability-level": "alpha",
|
||||||
|
"tags": [
|
||||||
|
"server"
|
||||||
|
],
|
||||||
|
"summary": "Get the OpenAPI document (YAML)",
|
||||||
|
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The OpenAPI document.",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"$ref": "#/components/headers/ETag"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"application/yaml": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "OpenAPI 3.0 document."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"304": {
|
||||||
|
"$ref": "#/components/responses/NotModified"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"securitySchemes": {
|
||||||
|
"bearerAuth": {
|
||||||
|
"type": "http",
|
||||||
|
"scheme": "bearer",
|
||||||
|
"bearerFormat": "JWT",
|
||||||
|
"description": "Short-lived access token minted from a device grant. Not yet applied to any operation."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {
|
||||||
|
"ServerInfo": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Public server description. Everything an add-server screen needs before login.",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"serverVersion",
|
||||||
|
"specVersion",
|
||||||
|
"setupRequired",
|
||||||
|
"loginMethods"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable server product name."
|
||||||
|
},
|
||||||
|
"serverVersion": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Version of the running server build."
|
||||||
|
},
|
||||||
|
"specVersion": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Version of the OpenAPI document this server implements."
|
||||||
|
},
|
||||||
|
"setupRequired": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "True until the first admin user has been created."
|
||||||
|
},
|
||||||
|
"loginMethods": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"password"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Problem": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "RFC 9457 problem details, returned for every 4xx and 5xx response.",
|
||||||
|
"required": [
|
||||||
|
"title",
|
||||||
|
"status",
|
||||||
|
"code"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "URI reference identifying the problem type. Omitted while the problem carries no semantics\nbeyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their\nown semantics get their own URI; switch on `code` instead.\n"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Short human-readable summary, the same for all occurrences of this problem type."
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "HTTP status code of this response."
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable explanation specific to this occurrence. Omitted for internal errors."
|
||||||
|
},
|
||||||
|
"code": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Machine-readable error code, and the value clients switch on. New codes may be added.",
|
||||||
|
"enum": [
|
||||||
|
"validation",
|
||||||
|
"unauthorized",
|
||||||
|
"forbidden",
|
||||||
|
"not_found",
|
||||||
|
"method_not_allowed",
|
||||||
|
"unavailable",
|
||||||
|
"internal"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Per-field failures. Present only when `code` is `validation`.",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ValidationError": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "One field-level validation failure.",
|
||||||
|
"required": [
|
||||||
|
"field",
|
||||||
|
"message"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"field": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the offending query parameter, path parameter, or body field (dotted for nested)."
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Why the value was rejected."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"InternalError": {
|
||||||
|
"description": "Unexpected server failure. Details are in the server log.",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Problem"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"NotModified": {
|
||||||
|
"description": "Not modified.",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"$ref": "#/components/headers/ETag"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"description": "Entity tag for `If-None-Match` revalidation.",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
194
api/bundled/openapi.yaml
Normal file
194
api/bundled/openapi.yaml
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Navidrome API
|
||||||
|
version: 1.0.0
|
||||||
|
description: |
|
||||||
|
Navidrome API v1. Spec-first, additive within v1. Clients discover implemented
|
||||||
|
capability modules through `GET /server` and never sniff versions.
|
||||||
|
|
||||||
|
Enums are open: new values may be added to any enum within v1. Clients must
|
||||||
|
accept values they do not recognise instead of failing.
|
||||||
|
|
||||||
|
Every operation declares `x-stability-level`: `alpha` operations may change or
|
||||||
|
disappear without notice, `beta` and `stable` operations only change additively.
|
||||||
|
A level is only ever raised, never lowered.
|
||||||
|
|
||||||
|
`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods
|
||||||
|
in its `Allow` header.
|
||||||
|
license:
|
||||||
|
name: GPL-3.0
|
||||||
|
url: https://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
servers:
|
||||||
|
- url: /api/v1
|
||||||
|
tags:
|
||||||
|
- name: server
|
||||||
|
description: Server discovery and the published OpenAPI document.
|
||||||
|
paths:
|
||||||
|
/server:
|
||||||
|
get:
|
||||||
|
operationId: getServerInfo
|
||||||
|
x-module: core
|
||||||
|
x-stability-level: alpha
|
||||||
|
tags: [server]
|
||||||
|
summary: Describe the server
|
||||||
|
description: |
|
||||||
|
Returns the public server description. No authentication required.
|
||||||
|
Authenticated requests will additionally receive the implemented capability modules
|
||||||
|
once authentication is available.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Server description.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ServerInfo'
|
||||||
|
'500':
|
||||||
|
$ref: '#/components/responses/InternalError'
|
||||||
|
/openapi.json:
|
||||||
|
get:
|
||||||
|
operationId: getOpenAPISpecJSON
|
||||||
|
x-module: core
|
||||||
|
x-stability-level: alpha
|
||||||
|
tags: [server]
|
||||||
|
summary: Get the OpenAPI document (JSON)
|
||||||
|
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: The OpenAPI document.
|
||||||
|
headers:
|
||||||
|
ETag:
|
||||||
|
$ref: '#/components/headers/ETag'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
description: OpenAPI 3.0 document.
|
||||||
|
'304':
|
||||||
|
$ref: '#/components/responses/NotModified'
|
||||||
|
/openapi.yaml:
|
||||||
|
get:
|
||||||
|
operationId: getOpenAPISpecYAML
|
||||||
|
x-module: core
|
||||||
|
x-stability-level: alpha
|
||||||
|
tags: [server]
|
||||||
|
summary: Get the OpenAPI document (YAML)
|
||||||
|
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: The OpenAPI document.
|
||||||
|
headers:
|
||||||
|
ETag:
|
||||||
|
$ref: '#/components/headers/ETag'
|
||||||
|
content:
|
||||||
|
application/yaml:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
description: OpenAPI 3.0 document.
|
||||||
|
'304':
|
||||||
|
$ref: '#/components/responses/NotModified'
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
description: Short-lived access token minted from a device grant. Not yet applied to any operation.
|
||||||
|
schemas:
|
||||||
|
ServerInfo:
|
||||||
|
type: object
|
||||||
|
description: Public server description. Everything an add-server screen needs before login.
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
- serverVersion
|
||||||
|
- specVersion
|
||||||
|
- setupRequired
|
||||||
|
- loginMethods
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Human-readable server product name.
|
||||||
|
serverVersion:
|
||||||
|
type: string
|
||||||
|
description: Version of the running server build.
|
||||||
|
specVersion:
|
||||||
|
type: string
|
||||||
|
description: Version of the OpenAPI document this server implements.
|
||||||
|
setupRequired:
|
||||||
|
type: boolean
|
||||||
|
description: True until the first admin user has been created.
|
||||||
|
loginMethods:
|
||||||
|
type: array
|
||||||
|
description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- password
|
||||||
|
Problem:
|
||||||
|
type: object
|
||||||
|
description: RFC 9457 problem details, returned for every 4xx and 5xx response.
|
||||||
|
required:
|
||||||
|
- title
|
||||||
|
- status
|
||||||
|
- code
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
URI reference identifying the problem type. Omitted while the problem carries no semantics
|
||||||
|
beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their
|
||||||
|
own semantics get their own URI; switch on `code` instead.
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
description: Short human-readable summary, the same for all occurrences of this problem type.
|
||||||
|
status:
|
||||||
|
type: integer
|
||||||
|
description: HTTP status code of this response.
|
||||||
|
detail:
|
||||||
|
type: string
|
||||||
|
description: Human-readable explanation specific to this occurrence. Omitted for internal errors.
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
description: Machine-readable error code, and the value clients switch on. New codes may be added.
|
||||||
|
enum:
|
||||||
|
- validation
|
||||||
|
- unauthorized
|
||||||
|
- forbidden
|
||||||
|
- not_found
|
||||||
|
- method_not_allowed
|
||||||
|
- unavailable
|
||||||
|
- internal
|
||||||
|
errors:
|
||||||
|
type: array
|
||||||
|
description: Per-field failures. Present only when `code` is `validation`.
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ValidationError'
|
||||||
|
ValidationError:
|
||||||
|
type: object
|
||||||
|
description: One field-level validation failure.
|
||||||
|
required:
|
||||||
|
- field
|
||||||
|
- message
|
||||||
|
properties:
|
||||||
|
field:
|
||||||
|
type: string
|
||||||
|
description: Name of the offending query parameter, path parameter, or body field (dotted for nested).
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: Why the value was rejected.
|
||||||
|
responses:
|
||||||
|
InternalError:
|
||||||
|
description: Unexpected server failure. Details are in the server log.
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Problem'
|
||||||
|
NotModified:
|
||||||
|
description: Not modified.
|
||||||
|
headers:
|
||||||
|
ETag:
|
||||||
|
$ref: '#/components/headers/ETag'
|
||||||
|
headers:
|
||||||
|
ETag:
|
||||||
|
description: Entity tag for `If-None-Match` revalidation.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
35
api/embed.go
Normal file
35
api/embed.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed bundled/openapi.json
|
||||||
|
var specJSON []byte
|
||||||
|
|
||||||
|
//go:embed bundled/openapi.yaml
|
||||||
|
var specYAML []byte
|
||||||
|
|
||||||
|
func SpecJSON() []byte {
|
||||||
|
return specJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
func SpecYAML() []byte {
|
||||||
|
return specYAML
|
||||||
|
}
|
||||||
|
|
||||||
|
var specVersion = sync.OnceValue(func() string {
|
||||||
|
var doc struct {
|
||||||
|
Info struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
} `json:"info"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(SpecJSON(), &doc)
|
||||||
|
return doc.Info.Version
|
||||||
|
})
|
||||||
|
|
||||||
|
func SpecVersion() string {
|
||||||
|
return specVersion()
|
||||||
|
}
|
||||||
37
api/embed_test.go
Normal file
37
api/embed_test.go
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/getkin/kin-openapi/openapi3"
|
||||||
|
"github.com/navidrome/navidrome/api"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Bundled spec", func() {
|
||||||
|
It("embeds a valid OpenAPI 3 document", func() {
|
||||||
|
doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON())
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(doc.Validate(GinkgoT().Context())).To(Succeed())
|
||||||
|
Expect(doc.Paths.Find("/server")).ToNot(BeNil())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("embeds the YAML variant", func() {
|
||||||
|
var doc map[string]any
|
||||||
|
Expect(yaml.Unmarshal(api.SpecYAML(), &doc)).To(Succeed())
|
||||||
|
Expect(doc).To(HaveKey("paths"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("reports the version from the bundle, matching the source root document", func() {
|
||||||
|
src, err := os.ReadFile("api/openapi/openapi.yaml")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
var root struct {
|
||||||
|
Info struct{ Version string } `yaml:"info"`
|
||||||
|
}
|
||||||
|
Expect(yaml.Unmarshal(src, &root)).To(Succeed())
|
||||||
|
Expect(api.SpecVersion()).To(Equal(root.Info.Version))
|
||||||
|
Expect(api.SpecVersion()).ToNot(BeEmpty())
|
||||||
|
})
|
||||||
|
})
|
||||||
3
api/openapi/components/headers/ETag.yaml
Normal file
3
api/openapi/components/headers/ETag.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
description: Entity tag for `If-None-Match` revalidation.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
9
api/openapi/components/parameters/limit.yaml
Normal file
9
api/openapi/components/parameters/limit.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
name: limit
|
||||||
|
in: query
|
||||||
|
description: Maximum number of items to return.
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 2000
|
||||||
|
default: 100
|
||||||
8
api/openapi/components/parameters/offset.yaml
Normal file
8
api/openapi/components/parameters/offset.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
name: offset
|
||||||
|
in: query
|
||||||
|
description: Zero-based index of the first item to return.
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
default: 0
|
||||||
5
api/openapi/components/responses/BadRequest.yaml
Normal file
5
api/openapi/components/responses/BadRequest.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
description: The request is malformed or fails validation.
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: ../schemas/Problem.yaml
|
||||||
5
api/openapi/components/responses/Forbidden.yaml
Normal file
5
api/openapi/components/responses/Forbidden.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
description: The caller is authenticated but not allowed to do this.
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: ../schemas/Problem.yaml
|
||||||
5
api/openapi/components/responses/InternalError.yaml
Normal file
5
api/openapi/components/responses/InternalError.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
description: Unexpected server failure. Details are in the server log.
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: ../schemas/Problem.yaml
|
||||||
5
api/openapi/components/responses/NotFound.yaml
Normal file
5
api/openapi/components/responses/NotFound.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
description: No such resource or endpoint.
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: ../schemas/Problem.yaml
|
||||||
4
api/openapi/components/responses/NotModified.yaml
Normal file
4
api/openapi/components/responses/NotModified.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
description: Not modified.
|
||||||
|
headers:
|
||||||
|
ETag:
|
||||||
|
$ref: ../headers/ETag.yaml
|
||||||
5
api/openapi/components/responses/Unauthorized.yaml
Normal file
5
api/openapi/components/responses/Unauthorized.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
description: Missing, invalid, or expired credentials.
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: ../schemas/Problem.yaml
|
||||||
13
api/openapi/components/schemas/ListMeta.yaml
Normal file
13
api/openapi/components/schemas/ListMeta.yaml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
type: object
|
||||||
|
description: Pagination metadata carried by every list response.
|
||||||
|
required: [total, offset, limit]
|
||||||
|
properties:
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
description: Total number of items matching the request, ignoring pagination.
|
||||||
|
offset:
|
||||||
|
type: integer
|
||||||
|
description: Zero-based index of the first returned item.
|
||||||
|
limit:
|
||||||
|
type: integer
|
||||||
|
description: Maximum number of items in this page.
|
||||||
35
api/openapi/components/schemas/Problem.yaml
Normal file
35
api/openapi/components/schemas/Problem.yaml
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
type: object
|
||||||
|
description: RFC 9457 problem details, returned for every 4xx and 5xx response.
|
||||||
|
required: [title, status, code]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
URI reference identifying the problem type. Omitted while the problem carries no semantics
|
||||||
|
beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their
|
||||||
|
own semantics get their own URI; switch on `code` instead.
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
description: Short human-readable summary, the same for all occurrences of this problem type.
|
||||||
|
status:
|
||||||
|
type: integer
|
||||||
|
description: HTTP status code of this response.
|
||||||
|
detail:
|
||||||
|
type: string
|
||||||
|
description: Human-readable explanation specific to this occurrence. Omitted for internal errors.
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
description: Machine-readable error code, and the value clients switch on. New codes may be added.
|
||||||
|
enum:
|
||||||
|
- validation
|
||||||
|
- unauthorized
|
||||||
|
- forbidden
|
||||||
|
- not_found
|
||||||
|
- method_not_allowed
|
||||||
|
- unavailable
|
||||||
|
- internal
|
||||||
|
errors:
|
||||||
|
type: array
|
||||||
|
description: Per-field failures. Present only when `code` is `validation`.
|
||||||
|
items:
|
||||||
|
$ref: ./ValidationError.yaml
|
||||||
22
api/openapi/components/schemas/ServerInfo.yaml
Normal file
22
api/openapi/components/schemas/ServerInfo.yaml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
type: object
|
||||||
|
description: Public server description. Everything an add-server screen needs before login.
|
||||||
|
required: [name, serverVersion, specVersion, setupRequired, loginMethods]
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Human-readable server product name.
|
||||||
|
serverVersion:
|
||||||
|
type: string
|
||||||
|
description: Version of the running server build.
|
||||||
|
specVersion:
|
||||||
|
type: string
|
||||||
|
description: Version of the OpenAPI document this server implements.
|
||||||
|
setupRequired:
|
||||||
|
type: boolean
|
||||||
|
description: True until the first admin user has been created.
|
||||||
|
loginMethods:
|
||||||
|
type: array
|
||||||
|
description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
enum: [password]
|
||||||
10
api/openapi/components/schemas/ValidationError.yaml
Normal file
10
api/openapi/components/schemas/ValidationError.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
type: object
|
||||||
|
description: One field-level validation failure.
|
||||||
|
required: [field, message]
|
||||||
|
properties:
|
||||||
|
field:
|
||||||
|
type: string
|
||||||
|
description: Name of the offending query parameter, path parameter, or body field (dotted for nested).
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: Why the value was rejected.
|
||||||
39
api/openapi/openapi.yaml
Normal file
39
api/openapi/openapi.yaml
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Navidrome API
|
||||||
|
version: 1.0.0
|
||||||
|
description: |
|
||||||
|
Navidrome API v1. Spec-first, additive within v1. Clients discover implemented
|
||||||
|
capability modules through `GET /server` and never sniff versions.
|
||||||
|
|
||||||
|
Enums are open: new values may be added to any enum within v1. Clients must
|
||||||
|
accept values they do not recognise instead of failing.
|
||||||
|
|
||||||
|
Every operation declares `x-stability-level`: `alpha` operations may change or
|
||||||
|
disappear without notice, `beta` and `stable` operations only change additively.
|
||||||
|
A level is only ever raised, never lowered.
|
||||||
|
|
||||||
|
`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods
|
||||||
|
in its `Allow` header.
|
||||||
|
license:
|
||||||
|
name: GPL-3.0
|
||||||
|
url: https://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
servers:
|
||||||
|
- url: /api/v1
|
||||||
|
tags:
|
||||||
|
- name: server
|
||||||
|
description: Server discovery and the published OpenAPI document.
|
||||||
|
paths:
|
||||||
|
/server:
|
||||||
|
$ref: ./paths/server.yaml
|
||||||
|
/openapi.json:
|
||||||
|
$ref: ./paths/openapi.yaml#/json
|
||||||
|
/openapi.yaml:
|
||||||
|
$ref: ./paths/openapi.yaml#/yaml
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
description: Short-lived access token minted from a device grant. Not yet applied to any operation.
|
||||||
42
api/openapi/paths/openapi.yaml
Normal file
42
api/openapi/paths/openapi.yaml
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
json:
|
||||||
|
get:
|
||||||
|
operationId: getOpenAPISpecJSON
|
||||||
|
x-module: core
|
||||||
|
x-stability-level: alpha
|
||||||
|
tags: [server]
|
||||||
|
summary: Get the OpenAPI document (JSON)
|
||||||
|
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: The OpenAPI document.
|
||||||
|
headers:
|
||||||
|
ETag:
|
||||||
|
$ref: ../components/headers/ETag.yaml
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
description: OpenAPI 3.0 document.
|
||||||
|
'304':
|
||||||
|
$ref: ../components/responses/NotModified.yaml
|
||||||
|
yaml:
|
||||||
|
get:
|
||||||
|
operationId: getOpenAPISpecYAML
|
||||||
|
x-module: core
|
||||||
|
x-stability-level: alpha
|
||||||
|
tags: [server]
|
||||||
|
summary: Get the OpenAPI document (YAML)
|
||||||
|
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: The OpenAPI document.
|
||||||
|
headers:
|
||||||
|
ETag:
|
||||||
|
$ref: ../components/headers/ETag.yaml
|
||||||
|
content:
|
||||||
|
application/yaml:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
description: OpenAPI 3.0 document.
|
||||||
|
'304':
|
||||||
|
$ref: ../components/responses/NotModified.yaml
|
||||||
19
api/openapi/paths/server.yaml
Normal file
19
api/openapi/paths/server.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
get:
|
||||||
|
operationId: getServerInfo
|
||||||
|
x-module: core
|
||||||
|
x-stability-level: alpha
|
||||||
|
tags: [server]
|
||||||
|
summary: Describe the server
|
||||||
|
description: |
|
||||||
|
Returns the public server description. No authentication required.
|
||||||
|
Authenticated requests will additionally receive the implemented capability modules
|
||||||
|
once authentication is available.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Server description.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: ../components/schemas/ServerInfo.yaml
|
||||||
|
'500':
|
||||||
|
$ref: ../components/responses/InternalError.yaml
|
||||||
|
|
@ -174,28 +174,28 @@ func queueTotal(stats []model.ArtworkQueueStat) int64 {
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error) {
|
func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error) {
|
||||||
q := ds.ArtworkQueue(ctx)
|
q := ds.ArtworkQueue()
|
||||||
var rep statusReport
|
var rep statusReport
|
||||||
var err error
|
var err error
|
||||||
if rep.queue, err = q.CountQueued(nil, nil); err != nil {
|
if rep.queue, err = q.CountQueued(ctx, nil, nil); err != nil {
|
||||||
return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err)
|
return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, k := range artwork.ReprocessKinds {
|
for _, k := range artwork.ReprocessKinds {
|
||||||
sources, err := q.SourcesInUse(k)
|
sources, err := q.SourcesInUse(ctx, k)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return rep, fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
|
return rep, fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
|
||||||
}
|
}
|
||||||
slices.Sort(sources)
|
slices.Sort(sources)
|
||||||
for _, s := range sources {
|
for _, s := range sources {
|
||||||
n, err := q.CountBySource(k, []string{s})
|
n, err := q.CountBySource(ctx, k, []string{s})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return rep, fmt.Errorf("counting %s artwork resolved from %s: %w", k, displaySource(s), err)
|
return rep, fmt.Errorf("counting %s artwork resolved from %s: %w", k, displaySource(s), err)
|
||||||
}
|
}
|
||||||
rep.sources = append(rep.sources, sourceCount{kind: k, source: s, count: n})
|
rep.sources = append(rep.sources, sourceCount{kind: k, source: s, count: n})
|
||||||
// An absent state is exactly a row with no source, so it needs no second query.
|
// An absent state is exactly a row with no source, so it needs no second query.
|
||||||
if s == "" {
|
if s == "" {
|
||||||
failed, err := q.CountBySource(k, []string{model.ArtworkSourceFailed})
|
failed, err := q.CountBySource(ctx, k, []string{model.ArtworkSourceFailed})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return rep, fmt.Errorf("counting failed %s artwork: %w", k, err)
|
return rep, fmt.Errorf("counting failed %s artwork: %w", k, err)
|
||||||
}
|
}
|
||||||
|
|
@ -205,7 +205,7 @@ func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error
|
||||||
}
|
}
|
||||||
|
|
||||||
rep.current, rep.inputs = artwork.ConfigFingerprint(), artwork.FingerprintInputs()
|
rep.current, rep.inputs = artwork.ConfigFingerprint(), artwork.FingerprintInputs()
|
||||||
if rep.stored, err = ds.Property(ctx).DefaultGet(consts.ArtConfFingerprintPropertyKey, ""); err != nil {
|
if rep.stored, err = ds.Property().DefaultGet(ctx, consts.ArtConfFingerprintPropertyKey, ""); err != nil {
|
||||||
return rep, fmt.Errorf("reading the stored artwork fingerprint: %w", err)
|
return rep, fmt.Errorf("reading the stored artwork fingerprint: %w", err)
|
||||||
}
|
}
|
||||||
return rep, nil
|
return rep, nil
|
||||||
|
|
@ -442,13 +442,13 @@ func promptConfirm(in io.Reader, verb string) confirmFunc {
|
||||||
|
|
||||||
// validateSources rejects a typo'd source: matching nothing silently reads as "nothing to do" when
|
// validateSources rejects a typo'd source: matching nothing silently reads as "nothing to do" when
|
||||||
// it means the filter was wrong. Checked table-wide, so a filter is never a typo for one --kind only.
|
// it means the filter was wrong. Checked table-wide, so a filter is never a typo for one --kind only.
|
||||||
func validateSources(q model.ArtworkQueueRepository, sources []string) error {
|
func validateSources(ctx context.Context, q model.ArtworkQueueRepository, sources []string) error {
|
||||||
if len(sources) == 0 {
|
if len(sources) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var inUse []string
|
var inUse []string
|
||||||
for _, k := range artwork.ReprocessKinds {
|
for _, k := range artwork.ReprocessKinds {
|
||||||
found, err := q.SourcesInUse(k)
|
found, err := q.SourcesInUse(ctx, k)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
|
return fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
|
||||||
}
|
}
|
||||||
|
|
@ -475,8 +475,8 @@ func validateSources(q model.ArtworkQueueRepository, sources []string) error {
|
||||||
// actually inserted; the two differ because an already-queued row is left untouched.
|
// actually inserted; the two differ because an already-queued row is left untouched.
|
||||||
func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, sources []string,
|
func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, sources []string,
|
||||||
imageAgents artwork.ImageAgentCount, dryRun bool, confirm confirmFunc, out io.Writer) error {
|
imageAgents artwork.ImageAgentCount, dryRun bool, confirm confirmFunc, out io.Writer) error {
|
||||||
q := ds.ArtworkQueue(ctx)
|
q := ds.ArtworkQueue()
|
||||||
if err := validateSources(q, sources); err != nil {
|
if err := validateSources(ctx, q, sources); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -495,7 +495,7 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin
|
||||||
matched := make([]int64, len(kinds))
|
matched := make([]int64, len(kinds))
|
||||||
var total, external int64
|
var total, external int64
|
||||||
for i, k := range kinds {
|
for i, k := range kinds {
|
||||||
n, err := q.CountBySource(k, sources)
|
n, err := q.CountBySource(ctx, k, sources)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("counting %s artwork: %w", k, err)
|
return fmt.Errorf("counting %s artwork: %w", k, err)
|
||||||
}
|
}
|
||||||
|
|
@ -523,7 +523,7 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin
|
||||||
if matched[i] == 0 {
|
if matched[i] == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
n, err := q.EnqueueBySource(k, sources, model.ArtworkPriorityRecheck)
|
n, err := q.EnqueueBySource(ctx, k, sources, model.ArtworkPriorityRecheck)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("queueing %s artwork: %w", k, err)
|
return fmt.Errorf("queueing %s artwork: %w", k, err)
|
||||||
}
|
}
|
||||||
|
|
@ -590,8 +590,8 @@ func parseAll[T comparable](values []string, parse func(string) (T, error)) ([]T
|
||||||
|
|
||||||
func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, priorities []int,
|
func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, priorities []int,
|
||||||
dryRun bool, confirm confirmFunc, out io.Writer) error {
|
dryRun bool, confirm confirmFunc, out io.Writer) error {
|
||||||
q := ds.ArtworkQueue(ctx)
|
q := ds.ArtworkQueue()
|
||||||
matched, err := q.CountQueued(kinds, priorities)
|
matched, err := q.CountQueued(ctx, kinds, priorities)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("counting queued artwork: %w", err)
|
return fmt.Errorf("counting queued artwork: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -612,7 +612,7 @@ func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind,
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelled, err := q.PurgeQueued(kinds, priorities)
|
cancelled, err := q.PurgeQueued(ctx, kinds, priorities)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cancelling queued artwork: %w", err)
|
return fmt.Errorf("cancelling queued artwork: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -984,11 +984,11 @@ func runExplain(ctx context.Context, args []string) {
|
||||||
}
|
}
|
||||||
rep := explainReport{kind: kind, id: id, name: name}
|
rep := explainReport{kind: kind, id: id, name: name}
|
||||||
if artwork.KeepsState(kind) {
|
if artwork.KeepsState(kind) {
|
||||||
rep.stored, err = ds.Artwork(ctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
|
rep.stored, err = ds.Artwork().GetItemArtwork(ctx, kind, id, model.ImageTypePrimary)
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
log.Fatal(ctx, "Failed to read artwork state", "kind", kind, "id", id, err)
|
log.Fatal(ctx, "Failed to read artwork state", "kind", kind, "id", id, err)
|
||||||
}
|
}
|
||||||
rep.queued, err = ds.ArtworkQueue(ctx).Get(kind, id, model.ImageTypePrimary)
|
rep.queued, err = ds.ArtworkQueue().Get(ctx, kind, id, model.ImageTypePrimary)
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
log.Fatal(ctx, "Failed to read the artwork queue", "kind", kind, "id", id, err)
|
log.Fatal(ctx, "Failed to read the artwork queue", "kind", kind, "id", id, err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -491,17 +491,17 @@ var _ = Describe("explain/reprocess source round trip", func() {
|
||||||
|
|
||||||
It("names the absent state as reprocess --source accepts it", func() {
|
It("names the absent state as reprocess --source accepts it", func() {
|
||||||
ds := &tests.MockDataStore{}
|
ds := &tests.MockDataStore{}
|
||||||
art := ds.Artwork(ctx).(*tests.MockArtworkRepo)
|
art := ds.Artwork().(*tests.MockArtworkRepo)
|
||||||
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(),
|
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(),
|
||||||
ItemID: "ar-1", ImageType: model.ImageTypePrimary})).To(Succeed())
|
ItemID: "ar-1", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||||
|
|
||||||
shown := storedSource(formatExplain(explainReport{kind: model.KindArtistArtwork, id: "ar-1",
|
shown := storedSource(formatExplain(explainReport{kind: model.KindArtistArtwork, id: "ar-1",
|
||||||
stored: &model.ItemArtwork{AttemptedAt: time.Now()}}))
|
stored: &model.ItemArtwork{AttemptedAt: time.Now()}}))
|
||||||
|
|
||||||
q := ds.ArtworkQueue(ctx)
|
q := ds.ArtworkQueue()
|
||||||
Expect(validateSources(q, repositorySources([]string{shown}))).To(Succeed(),
|
Expect(validateSources(ctx, q, repositorySources([]string{shown}))).To(Succeed(),
|
||||||
"explain's spelling of a source must be pasteable into --source")
|
"explain's spelling of a source must be pasteable into --source")
|
||||||
Expect(validateSources(q, repositorySources([]string{"(" + shown + ")"}))).ToNot(Succeed(),
|
Expect(validateSources(ctx, q, repositorySources([]string{"(" + shown + ")"}))).ToNot(Succeed(),
|
||||||
"a parenthesised name would be rejected, so explain must not print one")
|
"a parenthesised name would be rejected, so explain must not print one")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -573,7 +573,7 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
decline := func(io.Writer, int64, int64) bool { return false }
|
decline := func(io.Writer, int64, int64) bool { return false }
|
||||||
|
|
||||||
put := func(kind model.Kind, id, source string) {
|
put := func(kind model.Kind, id, source string) {
|
||||||
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
|
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
|
||||||
ImageType: model.ImageTypePrimary, Hash: "h" + id, Source: source})).To(Succeed())
|
ImageType: model.ImageTypePrimary, Hash: "h" + id, Source: source})).To(Succeed())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -584,8 +584,8 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
conf.Server.EnableM3UExternalAlbumArt = false
|
conf.Server.EnableM3UExternalAlbumArt = false
|
||||||
imageAgents = artwork.ImageAgentCount{Artist: 1, Album: 1}
|
imageAgents = artwork.ImageAgentCount{Artist: 1, Album: 1}
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
|
art = ds.Artwork().(*tests.MockArtworkRepo)
|
||||||
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
|
||||||
out.Reset()
|
out.Reset()
|
||||||
put(model.KindArtistArtwork, "ar-1", "external:deezer")
|
put(model.KindArtistArtwork, "ar-1", "external:deezer")
|
||||||
put(model.KindArtistArtwork, "ar-2", "")
|
put(model.KindArtistArtwork, "ar-2", "")
|
||||||
|
|
@ -601,19 +601,19 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
Expect(out.String()).To(ContainSubstring("album"))
|
Expect(out.String()).To(ContainSubstring("album"))
|
||||||
Expect(out.String()).To(ContainSubstring("TOTAL"))
|
Expect(out.String()).To(ContainSubstring("TOTAL"))
|
||||||
Expect(out.String()).To(ContainSubstring("Dry run"))
|
Expect(out.String()).To(ContainSubstring("Dry run"))
|
||||||
Expect(queue.Count()).To(BeZero())
|
Expect(queue.Count(ctx)).To(BeZero())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("queues nothing when the operator declines", func() {
|
It("queues nothing when the operator declines", func() {
|
||||||
Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, decline, &out)).To(Succeed())
|
Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, decline, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(out.String()).To(ContainSubstring("Aborted"))
|
Expect(out.String()).To(ContainSubstring("Aborted"))
|
||||||
Expect(queue.Count()).To(BeZero())
|
Expect(queue.Count(ctx)).To(BeZero())
|
||||||
})
|
})
|
||||||
|
|
||||||
DescribeTable("records the applied config only for a run that leaves nothing on the old one",
|
DescribeTable("records the applied config only for a run that leaves nothing on the old one",
|
||||||
func(selected []model.Kind, sources []string, dryRun, applied bool) {
|
func(selected []model.Kind, sources []string, dryRun, applied bool) {
|
||||||
Expect(ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
Expect(ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
||||||
|
|
||||||
Expect(reprocessArtwork(ctx, ds, selected, sources, imageAgents, dryRun, accept, &out)).To(Succeed())
|
Expect(reprocessArtwork(ctx, ds, selected, sources, imageAgents, dryRun, accept, &out)).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -621,7 +621,7 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
if applied {
|
if applied {
|
||||||
want = artwork.ConfigFingerprint()
|
want = artwork.ConfigFingerprint()
|
||||||
}
|
}
|
||||||
Expect(ds.Property(ctx).Get(consts.ArtConfFingerprintPropertyKey)).To(Equal(want))
|
Expect(ds.Property().Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal(want))
|
||||||
},
|
},
|
||||||
Entry("every kind, unfiltered", artwork.ReprocessKinds, nil, false, true),
|
Entry("every kind, unfiltered", artwork.ReprocessKinds, nil, false, true),
|
||||||
Entry("every kind, but nothing matched", artwork.ReprocessKinds, []string{}, false, true),
|
Entry("every kind, but nothing matched", artwork.ReprocessKinds, []string{}, false, true),
|
||||||
|
|
@ -633,14 +633,14 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
It("queues the matching items at recheck priority, leaving their artwork state alone", func() {
|
It("queues the matching items at recheck priority, leaving their artwork state alone", func() {
|
||||||
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
|
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(queue.Count()).To(Equal(int64(2)))
|
Expect(queue.Count(ctx)).To(Equal(int64(2)))
|
||||||
queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
queued, err := queue.Get(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(queued.Priority).To(Equal(model.ArtworkPriorityRecheck))
|
Expect(queued.Priority).To(Equal(model.ArtworkPriorityRecheck))
|
||||||
_, err = queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
|
_, err = queue.Get(ctx, model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound), "a non-matching source must not be queued")
|
Expect(err).To(MatchError(model.ErrNotFound), "a non-matching source must not be queued")
|
||||||
|
|
||||||
stored, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
stored, err := art.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(stored.Hash).To(Equal("hal-1"), "bulk reprocessing must not blank the current artwork")
|
Expect(stored.Hash).To(Equal("hal-1"), "bulk reprocessing must not blank the current artwork")
|
||||||
})
|
})
|
||||||
|
|
@ -648,20 +648,20 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
It("targets the absent state", func() {
|
It("targets the absent state", func() {
|
||||||
Expect(reprocessArtwork(ctx, ds, kinds, []string{""}, imageAgents, false, accept, &out)).To(Succeed())
|
Expect(reprocessArtwork(ctx, ds, kinds, []string{""}, imageAgents, false, accept, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(queue.Count()).To(Equal(int64(1)))
|
Expect(queue.Count(ctx)).To(Equal(int64(1)))
|
||||||
_, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
_, err := queue.Get(ctx, model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("reports matched and queued separately when part of the set is already queued", func() {
|
It("reports matched and queued separately when part of the set is already queued", func() {
|
||||||
Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1",
|
Expect(queue.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1",
|
||||||
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump})).To(Succeed())
|
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump})).To(Succeed())
|
||||||
|
|
||||||
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
|
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(out.String()).To(ContainSubstring("Queued 1 of 2 matched items"))
|
Expect(out.String()).To(ContainSubstring("Queued 1 of 2 matched items"))
|
||||||
Expect(out.String()).To(ContainSubstring("Already queued, left unchanged: 1"))
|
Expect(out.String()).To(ContainSubstring("Already queued, left unchanged: 1"))
|
||||||
queued, err := queue.Get(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
queued, err := queue.Get(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump),
|
Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump),
|
||||||
"an already-queued row keeps its priority and backoff")
|
"an already-queued row keeps its priority and backoff")
|
||||||
|
|
@ -675,7 +675,7 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
}, &out)).To(Succeed())
|
}, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(out.String()).To(ContainSubstring("Nothing"))
|
Expect(out.String()).To(ContainSubstring("Nothing"))
|
||||||
Expect(queue.Count()).To(BeZero())
|
Expect(queue.Count(ctx)).To(BeZero())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("reports an empty selection as a dry run when one was asked for", func() {
|
It("reports an empty selection as a dry run when one was asked for", func() {
|
||||||
|
|
@ -767,7 +767,7 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
Expect(err.Error()).To(ContainSubstring("external:deezer"))
|
Expect(err.Error()).To(ContainSubstring("external:deezer"))
|
||||||
Expect(err.Error()).To(ContainSubstring("folder"))
|
Expect(err.Error()).To(ContainSubstring("folder"))
|
||||||
Expect(err.Error()).To(ContainSubstring("absent"), "the empty source prints under its user-facing name")
|
Expect(err.Error()).To(ContainSubstring("absent"), "the empty source prints under its user-facing name")
|
||||||
Expect(queue.Count()).To(BeZero())
|
Expect(queue.Count(ctx)).To(BeZero())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("accepts the absent filter with nothing absent, still rejecting a typo", func() {
|
It("accepts the absent filter with nothing absent, still rejecting a typo", func() {
|
||||||
|
|
@ -777,7 +777,7 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
imageAgents, false, accept, &out)).To(Succeed(),
|
imageAgents, false, accept, &out)).To(Succeed(),
|
||||||
"a reserved source must stay valid once the library has none of it")
|
"a reserved source must stay valid once the library has none of it")
|
||||||
Expect(out.String()).To(ContainSubstring("Nothing matches"))
|
Expect(out.String()).To(ContainSubstring("Nothing matches"))
|
||||||
Expect(queue.Count()).To(BeZero())
|
Expect(queue.Count(ctx)).To(BeZero())
|
||||||
|
|
||||||
Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{"absnt"}),
|
Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{"absnt"}),
|
||||||
imageAgents, true, accept, &out)).ToNot(Succeed(), "a typo must still be rejected")
|
imageAgents, true, accept, &out)).ToNot(Succeed(), "a typo must still be rejected")
|
||||||
|
|
@ -797,7 +797,7 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
|
|
||||||
Expect(out.String()).To(ContainSubstring("Nothing matches"),
|
Expect(out.String()).To(ContainSubstring("Nothing matches"),
|
||||||
"a well-formed filter must not be reported as a typo because of the kinds selected")
|
"a well-formed filter must not be reported as a typo because of the kinds selected")
|
||||||
Expect(queue.Count()).To(BeZero())
|
Expect(queue.Count(ctx)).To(BeZero())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -816,19 +816,19 @@ var _ = Describe("collectStatus", func() {
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
|
art = ds.Artwork().(*tests.MockArtworkRepo)
|
||||||
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
|
||||||
put := func(kind model.Kind, id, source, hash string, attempted time.Time) {
|
put := func(kind model.Kind, id, source, hash string, attempted time.Time) {
|
||||||
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
|
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
|
||||||
ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed())
|
ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed())
|
||||||
}
|
}
|
||||||
put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now())
|
put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now())
|
||||||
put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-24*time.Hour))
|
put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-24*time.Hour))
|
||||||
// ar-3 is absent because it gave up, so the two absent artists split across the columns.
|
// ar-3 is absent because it gave up, so the two absent artists split across the columns.
|
||||||
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "ar-3",
|
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "ar-3",
|
||||||
ImageType: model.ImageTypePrimary, LastFailure: "[]", AttemptedAt: time.Now()})).To(Succeed())
|
ImageType: model.ImageTypePrimary, LastFailure: "[]", AttemptedAt: time.Now()})).To(Succeed())
|
||||||
put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now())
|
put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now())
|
||||||
Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9",
|
Expect(queue.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9",
|
||||||
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill})).To(Succeed())
|
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill})).To(Succeed())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -848,7 +848,7 @@ var _ = Describe("collectStatus", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("compares the stored fingerprint against the current one", func() {
|
It("compares the stored fingerprint against the current one", func() {
|
||||||
Expect(ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, "old-fingerprint")).To(Succeed())
|
Expect(ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, "old-fingerprint")).To(Succeed())
|
||||||
|
|
||||||
rep, err := collectStatus(ctx, ds)
|
rep, err := collectStatus(ctx, ds)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -860,7 +860,7 @@ var _ = Describe("collectStatus", func() {
|
||||||
It("queues nothing", func() {
|
It("queues nothing", func() {
|
||||||
_, err := collectStatus(ctx, ds)
|
_, err := collectStatus(ctx, ds)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(queue.Count()).To(Equal(int64(1)), "status must not enqueue anything")
|
Expect(queue.Count(ctx)).To(Equal(int64(1)), "status must not enqueue anything")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -973,21 +973,21 @@ var _ = Describe("refreshItems", func() {
|
||||||
albums := tests.CreateMockAlbumRepo()
|
albums := tests.CreateMockAlbumRepo()
|
||||||
albums.SetData(model.Albums{{ID: "al-1"}, {ID: "al-3"}})
|
albums.SetData(model.Albums{{ID: "al-1"}, {ID: "al-3"}})
|
||||||
ds = &tests.MockDataStore{MockedAlbum: albums}
|
ds = &tests.MockDataStore{MockedAlbum: albums}
|
||||||
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
|
art = ds.Artwork().(*tests.MockArtworkRepo)
|
||||||
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
|
||||||
out.Reset()
|
out.Reset()
|
||||||
})
|
})
|
||||||
|
|
||||||
It("clears the stored state and queues each id at Bump priority", func() {
|
It("clears the stored state and queues each id at Bump priority", func() {
|
||||||
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindAlbumArtwork.Prefix(),
|
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: model.KindAlbumArtwork.Prefix(),
|
||||||
ItemID: "al-1", ImageType: model.ImageTypePrimary, Hash: "abc123"})).To(Succeed())
|
ItemID: "al-1", ImageType: model.ImageTypePrimary, Hash: "abc123"})).To(Succeed())
|
||||||
|
|
||||||
Expect(refreshItems(ctx, ds, []model.ArtworkID{
|
Expect(refreshItems(ctx, ds, []model.ArtworkID{
|
||||||
{Kind: model.KindAlbumArtwork, ID: "al-1"}, {Kind: model.KindAlbumArtwork, ID: "al-3"}}, &out)).To(BeZero())
|
{Kind: model.KindAlbumArtwork, ID: "al-1"}, {Kind: model.KindAlbumArtwork, ID: "al-3"}}, &out)).To(BeZero())
|
||||||
|
|
||||||
_, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
_, err := art.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
queued, err := queue.Get(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump))
|
Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump))
|
||||||
Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n"))
|
Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n"))
|
||||||
|
|
@ -996,7 +996,7 @@ var _ = Describe("refreshItems", func() {
|
||||||
It("skips an id that does not exist instead of queuing it", func() {
|
It("skips an id that does not exist instead of queuing it", func() {
|
||||||
Expect(refreshItems(ctx, ds, []model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "al-2"}}, &out)).To(Equal(1))
|
Expect(refreshItems(ctx, ds, []model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "al-2"}}, &out)).To(Equal(1))
|
||||||
|
|
||||||
_, err := queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
|
_, err := queue.Get(ctx, model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound), "a typo must not leave an orphan queue row")
|
Expect(err).To(MatchError(model.ErrNotFound), "a typo must not leave an orphan queue row")
|
||||||
Expect(out.String()).To(BeEmpty())
|
Expect(out.String()).To(BeEmpty())
|
||||||
})
|
})
|
||||||
|
|
@ -1145,9 +1145,9 @@ var _ = Describe("cancelArtwork", func() {
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
|
||||||
out.Reset()
|
out.Reset()
|
||||||
Expect(queue.Enqueue(
|
Expect(queue.Enqueue(ctx,
|
||||||
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", ImageType: model.ImageTypePrimary,
|
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", ImageType: model.ImageTypePrimary,
|
||||||
Priority: model.ArtworkPriorityBackfill},
|
Priority: model.ArtworkPriorityBackfill},
|
||||||
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-2", ImageType: model.ImageTypePrimary,
|
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-2", ImageType: model.ImageTypePrimary,
|
||||||
|
|
@ -1164,28 +1164,28 @@ var _ = Describe("cancelArtwork", func() {
|
||||||
Expect(out.String()).To(ContainSubstring("backfill"))
|
Expect(out.String()).To(ContainSubstring("backfill"))
|
||||||
Expect(out.String()).To(ContainSubstring("TOTAL"))
|
Expect(out.String()).To(ContainSubstring("TOTAL"))
|
||||||
Expect(out.String()).To(ContainSubstring("Dry run"))
|
Expect(out.String()).To(ContainSubstring("Dry run"))
|
||||||
Expect(queue.Count()).To(BeNumerically("==", 3))
|
Expect(queue.Count(ctx)).To(BeNumerically("==", 3))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("cancels nothing when the operator declines", func() {
|
It("cancels nothing when the operator declines", func() {
|
||||||
Expect(cancelArtwork(ctx, ds, nil, nil, false, decline, &out)).To(Succeed())
|
Expect(cancelArtwork(ctx, ds, nil, nil, false, decline, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(out.String()).To(ContainSubstring("Aborted"))
|
Expect(out.String()).To(ContainSubstring("Aborted"))
|
||||||
Expect(queue.Count()).To(BeNumerically("==", 3))
|
Expect(queue.Count(ctx)).To(BeNumerically("==", 3))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("deletes the selected rows and leaves the rest queued", func() {
|
It("deletes the selected rows and leaves the rest queued", func() {
|
||||||
Expect(cancelArtwork(ctx, ds, nil, []int{model.ArtworkPriorityBackfill}, false, accept, &out)).To(Succeed())
|
Expect(cancelArtwork(ctx, ds, nil, []int{model.ArtworkPriorityBackfill}, false, accept, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(queue.Count()).To(BeNumerically("==", 1))
|
Expect(queue.Count(ctx)).To(BeNumerically("==", 1))
|
||||||
_, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
_, err := queue.Get(ctx, model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred(), "a non-matching priority must stay queued")
|
Expect(err).ToNot(HaveOccurred(), "a non-matching priority must stay queued")
|
||||||
Expect(out.String()).To(ContainSubstring("Cancelled 2 of 2 matched items."))
|
Expect(out.String()).To(ContainSubstring("Cancelled 2 of 2 matched items."))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("cancels every kind and priority when neither filter is given", func() {
|
It("cancels every kind and priority when neither filter is given", func() {
|
||||||
Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(Succeed())
|
Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(Succeed())
|
||||||
Expect(queue.Count()).To(BeZero())
|
Expect(queue.Count(ctx)).To(BeZero())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("stops at a selection that matches nothing instead of prompting", func() {
|
It("stops at a selection that matches nothing instead of prompting", func() {
|
||||||
|
|
@ -1196,7 +1196,7 @@ var _ = Describe("cancelArtwork", func() {
|
||||||
Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, false, refuse, &out)).To(Succeed())
|
Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, false, refuse, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(out.String()).To(ContainSubstring("Nothing matches this selection."))
|
Expect(out.String()).To(ContainSubstring("Nothing matches this selection."))
|
||||||
Expect(queue.Count()).To(BeNumerically("==", 3))
|
Expect(queue.Count(ctx)).To(BeNumerically("==", 3))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("reports a queue read failure instead of reporting nothing to cancel", func() {
|
It("reports a queue read failure instead of reporting nothing to cancel", func() {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/persistence"
|
||||||
"github.com/pelletier/go-toml/v2"
|
"github.com/pelletier/go-toml/v2"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
@ -28,7 +32,7 @@ var inspectCmd = &cobra.Command{
|
||||||
Long: "Show file tags as seen by Navidrome",
|
Long: "Show file tags as seen by Navidrome",
|
||||||
Args: cobra.MinimumNArgs(1),
|
Args: cobra.MinimumNArgs(1),
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
runInspector(args)
|
runInspector(cmd.Context(), args)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,18 +59,24 @@ func prettyMarshal(v any) ([]byte, error) {
|
||||||
return []byte(res.String()), nil
|
return []byte(res.String()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runInspector(args []string) {
|
func runInspector(ctx context.Context, args []string) {
|
||||||
marshal := marshalers[format]
|
marshal := marshalers[format]
|
||||||
if marshal == nil {
|
if marshal == nil {
|
||||||
log.Fatal("Invalid format", "format", format)
|
log.Fatal("Invalid format", "format", format)
|
||||||
}
|
}
|
||||||
|
libs := loadLibraries(ctx)
|
||||||
|
matcher := model.NewLibraryMatcher(libs)
|
||||||
var out []core.InspectOutput
|
var out []core.InspectOutput
|
||||||
for _, filePath := range args {
|
for _, filePath := range args {
|
||||||
if !model.IsAudioFile(filePath) {
|
if !model.IsAudioFile(filePath) {
|
||||||
log.Warn("Not an audio file", "file", filePath)
|
log.Warn("Not an audio file", "file", filePath)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
output, err := core.Inspect(filePath, 1, "")
|
lib, ok := libraryForFile(matcher, filePath)
|
||||||
|
if !ok && len(libs) > 0 {
|
||||||
|
log.Warn("File is not in any library, using the global PID config", "file", filePath)
|
||||||
|
}
|
||||||
|
output, err := core.Inspect(filePath, lib, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("Unable to process file", "file", filePath, "error", err)
|
log.Warn("Unable to process file", "file", filePath, "error", err)
|
||||||
continue
|
continue
|
||||||
|
|
@ -77,3 +87,33 @@ func runInspector(args []string) {
|
||||||
data, _ := marshal(out)
|
data, _ := marshal(out)
|
||||||
fmt.Println(string(data))
|
fmt.Println(string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadLibraries reads the libraries, so each file gets its library's PID config. It never creates a DB.
|
||||||
|
func loadLibraries(ctx context.Context) model.Libraries {
|
||||||
|
if dbFile, ok := existingDBFile(); !ok {
|
||||||
|
log.Warn(ctx, "No database found, using the global PID config", "path", dbFile)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer db.Init(ctx)()
|
||||||
|
libs, err := persistence.New(db.Db()).Library().GetAll(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn(ctx, "Could not load libraries, using the global PID config", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i := range libs {
|
||||||
|
if absPath, err := filepath.Abs(libs[i].Path); err == nil {
|
||||||
|
libs[i].Path = absPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return libs
|
||||||
|
}
|
||||||
|
|
||||||
|
// libraryForFile falls back to the default library with no overrides, which uses the global PID config.
|
||||||
|
func libraryForFile(matcher *model.LibraryMatcher, filePath string) (model.Library, bool) {
|
||||||
|
if absPath, err := filepath.Abs(filePath); err == nil {
|
||||||
|
if lib, ok := matcher.FindLibrary(absPath); ok {
|
||||||
|
return lib, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return model.Library{ID: model.DefaultLibraryID}, false
|
||||||
|
}
|
||||||
|
|
|
||||||
61
cmd/inspect_test.go
Normal file
61
cmd/inspect_test.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("inspect", func() {
|
||||||
|
Describe("libraryForFile", func() {
|
||||||
|
var matcher *model.LibraryMatcher
|
||||||
|
var root string
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
root = GinkgoT().TempDir()
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
matcher = model.NewLibraryMatcher(model.Libraries{
|
||||||
|
{ID: 1, Path: filepath.Join(root, "music")},
|
||||||
|
{ID: 2, Path: filepath.Join(cwd, "loose"), PIDAlbum: "folder"},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns the library that contains an absolute path", func() {
|
||||||
|
lib, ok := libraryForFile(matcher, filepath.Join(root, "music", "album", "track.mp3"))
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
Expect(lib.ID).To(Equal(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("resolves a relative path against the working directory", func() {
|
||||||
|
lib, ok := libraryForFile(matcher, filepath.Join("loose", "track.mp3"))
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
Expect(lib.PIDAlbum).To(Equal("folder"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("falls back to the default library without overrides", func() {
|
||||||
|
lib, ok := libraryForFile(matcher, filepath.Join(root, "elsewhere", "track.mp3"))
|
||||||
|
Expect(ok).To(BeFalse())
|
||||||
|
Expect(lib).To(Equal(model.Library{ID: model.DefaultLibraryID}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("loadLibraries", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not create a database when there is none", func() {
|
||||||
|
dbFile := filepath.Join(GinkgoT().TempDir(), "navidrome.db")
|
||||||
|
conf.Server.DbPath = dbFile + "?_journal_mode=WAL"
|
||||||
|
|
||||||
|
Expect(loadLibraries(GinkgoT().Context())).To(BeNil())
|
||||||
|
Expect(dbFile).ToNot(BeAnExistingFile())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -72,7 +72,7 @@ func runMissingList(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
mfs, err := ds.MediaFile(ctx).GetCursor(model.QueryOptions{
|
mfs, err := ds.MediaFile().GetCursor(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.Eq{"missing": true},
|
Filters: squirrel.Eq{"missing": true},
|
||||||
Sort: "path",
|
Sort: "path",
|
||||||
})
|
})
|
||||||
|
|
@ -128,7 +128,7 @@ func runMissingFix(ctx context.Context, missingRef, targetRef string) {
|
||||||
|
|
||||||
// resolveMediaFile looks up a media file by ID first, then by path (optionally libraryID:path).
|
// resolveMediaFile looks up a media file by ID first, then by path (optionally libraryID:path).
|
||||||
func resolveMediaFile(ctx context.Context, ds model.DataStore, ref string) *model.MediaFile {
|
func resolveMediaFile(ctx context.Context, ds model.DataStore, ref string) *model.MediaFile {
|
||||||
mf, err := ds.MediaFile(ctx).Get(ref)
|
mf, err := ds.MediaFile().Get(ctx, ref)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return mf
|
return mf
|
||||||
}
|
}
|
||||||
|
|
@ -136,7 +136,7 @@ func resolveMediaFile(ctx context.Context, ds model.DataStore, ref string) *mode
|
||||||
log.Fatal(ctx, "Error looking up media file", "ref", ref, err)
|
log.Fatal(ctx, "Error looking up media file", "ref", ref, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mfs, err := ds.MediaFile(ctx).FindByPaths([]string{ref})
|
mfs, err := ds.MediaFile().FindByPaths(ctx, []string{ref})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Error looking up media file by path", "ref", ref, err)
|
log.Fatal(ctx, "Error looking up media file by path", "ref", ref, err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
cmd/pls.go
10
cmd/pls.go
|
|
@ -109,7 +109,7 @@ func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.
|
||||||
}
|
}
|
||||||
options.Filters = squirrel.Eq{"owner_id": user.ID}
|
options.Filters = squirrel.Eq{"owner_id": user.ID}
|
||||||
}
|
}
|
||||||
pls, err := ds.Playlist(ctx).GetAll(options)
|
pls, err := ds.Playlist().GetAll(ctx, options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Failed to retrieve playlists", err)
|
log.Fatal(ctx, "Failed to retrieve playlists", err)
|
||||||
}
|
}
|
||||||
|
|
@ -117,17 +117,17 @@ func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.
|
||||||
}
|
}
|
||||||
|
|
||||||
func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist {
|
func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist {
|
||||||
playlist, err := ds.Playlist(ctx).GetWithTracks(nameOrID, true, false)
|
playlist, err := ds.Playlist().GetWithTracks(ctx, nameOrID, true, false)
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||||
}
|
}
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
|
playlists, err := ds.Playlist().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||||
}
|
}
|
||||||
if len(playlists) > 0 {
|
if len(playlists) > 0 {
|
||||||
playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false)
|
playlist, err = ds.Playlist().GetWithTracks(ctx, playlists[0].ID, true, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||||
}
|
}
|
||||||
|
|
@ -194,7 +194,7 @@ func runExport(ctx context.Context) {
|
||||||
|
|
||||||
exported := 0
|
exported := 0
|
||||||
for _, pls := range allPls {
|
for _, pls := range allPls {
|
||||||
plsWithTracks, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false)
|
plsWithTracks, err := ds.Playlist().GetWithTracks(ctx, pls.ID, true, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Error loading playlist tracks", "playlist", pls.Name, err)
|
log.Error("Error loading playlist tracks", "playlist", pls.Name, err)
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -243,7 +243,7 @@ func runPluginInfo(ctx context.Context, arg string) {
|
||||||
}
|
}
|
||||||
requirePluginsEnabled(ctx)
|
requirePluginsEnabled(ctx)
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
p, err := ds.Plugin(ctx).Get(arg)
|
p, err := ds.Plugin().Get(ctx, arg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
||||||
}
|
}
|
||||||
|
|
@ -264,7 +264,7 @@ func runPluginValidate(ctx context.Context, arg string) {
|
||||||
}
|
}
|
||||||
requirePluginsEnabled(ctx)
|
requirePluginsEnabled(ctx)
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
p, err := ds.Plugin(ctx).Get(arg)
|
p, err := ds.Plugin().Get(ctx, arg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
||||||
}
|
}
|
||||||
|
|
@ -329,7 +329,7 @@ func formatPluginList(list model.Plugins, format string) (string, error) {
|
||||||
func runPluginList(ctx context.Context) {
|
func runPluginList(ctx context.Context) {
|
||||||
requirePluginsEnabled(ctx)
|
requirePluginsEnabled(ctx)
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
list, err := ds.Plugin(ctx).GetAll()
|
list, err := ds.Plugin().GetAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Failed to list plugins", err)
|
log.Fatal(ctx, "Failed to list plugins", err)
|
||||||
}
|
}
|
||||||
|
|
@ -372,7 +372,7 @@ var pluginEditCmd = &cobra.Command{
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
requirePluginsEnabled(cmd.Context())
|
requirePluginsEnabled(cmd.Context())
|
||||||
ds, ctx := getAdminContext(cmd.Context())
|
ds, ctx := getAdminContext(cmd.Context())
|
||||||
cur, err := ds.Plugin(ctx).Get(args[0])
|
cur, err := ds.Plugin().Get(ctx, args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Plugin not found", "id", args[0], err)
|
log.Fatal(ctx, "Plugin not found", "id", args[0], err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
57
cmd/root.go
57
cmd/root.go
|
|
@ -44,7 +44,9 @@ Complete documentation is available at https://www.navidrome.org/docs`,
|
||||||
preRun()
|
preRun()
|
||||||
},
|
},
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
runNavidrome(cmd.Context())
|
if err := runNavidrome(cmd.Context()); err != nil {
|
||||||
|
log.Fatal("Fatal error in Navidrome. Aborting", err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
PostRun: func(cmd *cobra.Command, args []string) {
|
PostRun: func(cmd *cobra.Command, args []string) {
|
||||||
postRun()
|
postRun()
|
||||||
|
|
@ -76,12 +78,12 @@ func postRun() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks.
|
// runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks.
|
||||||
// If any of the services returns an error, it will log it and exit. If the process receives a signal to exit,
|
// If any of the services returns an error, it stops the others and returns that error, so the caller can
|
||||||
// it will cancel the context and exit gracefully.
|
// exit with a non-zero code. If the context is cancelled (a signal or a service stop), it returns nil.
|
||||||
func runNavidrome(ctx context.Context) {
|
func runNavidrome(parentCtx context.Context) error {
|
||||||
defer db.Init(ctx)()
|
defer db.Init(parentCtx)()
|
||||||
|
|
||||||
g, ctx := errgroup.WithContext(ctx)
|
g, ctx := errgroup.WithContext(parentCtx)
|
||||||
g.Go(startServer(ctx))
|
g.Go(startServer(ctx))
|
||||||
g.Go(startSignaller(ctx))
|
g.Go(startSignaller(ctx))
|
||||||
g.Go(startScheduler(ctx))
|
g.Go(startScheduler(ctx))
|
||||||
|
|
@ -102,9 +104,11 @@ func runNavidrome(ctx context.Context) {
|
||||||
log.Warn(ctx, "Automatic Scanning is DISABLED")
|
log.Warn(ctx, "Automatic Scanning is DISABLED")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := g.Wait(); err != nil {
|
// Errors caused by a normal shutdown are not failures
|
||||||
log.Error("Fatal error in Navidrome. Aborting", err)
|
if err := g.Wait(); err != nil && parentCtx.Err() == nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// mainContext returns a context that is cancelled when the process receives a signal to exit.
|
// mainContext returns a context that is cancelled when the process receives a signal to exit.
|
||||||
|
|
@ -133,6 +137,9 @@ func startServer(ctx context.Context) func() error {
|
||||||
if conf.Server.Jellyfin.Enabled {
|
if conf.Server.Jellyfin.Enabled {
|
||||||
a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx))
|
a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx))
|
||||||
}
|
}
|
||||||
|
if conf.Server.DevAPIv1 {
|
||||||
|
a.MountRouter("API v1", consts.URLPathAPIv1, CreateAPIv1Router(ctx))
|
||||||
|
}
|
||||||
if conf.Server.Prometheus.Enabled {
|
if conf.Server.Prometheus.Enabled {
|
||||||
p := CreatePrometheus()
|
p := CreatePrometheus()
|
||||||
// blocking call because takes <100ms but useful if fails
|
// blocking call because takes <100ms but useful if fails
|
||||||
|
|
@ -183,46 +190,50 @@ func schedulePeriodicScan(ctx context.Context) func() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func pidHashChanged(ds model.DataStore) (bool, error) {
|
// librariesWithChangedPID returns the names of the libraries whose effective PID config differs from
|
||||||
pidAlbum, err := ds.Property(context.Background()).DefaultGet(consts.PIDAlbumKey, "")
|
// the one used by their last finished scan
|
||||||
|
func librariesWithChangedPID(ctx context.Context, ds model.DataStore) ([]string, error) {
|
||||||
|
libs, err := ds.Library().GetAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return nil, err
|
||||||
}
|
}
|
||||||
pidTrack, err := ds.Property(context.Background()).DefaultGet(consts.PIDTrackKey, "")
|
var names []string
|
||||||
if err != nil {
|
for _, lib := range libs {
|
||||||
return false, err
|
if lib.PIDChanged() {
|
||||||
|
names = append(names, lib.Name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return !strings.EqualFold(pidAlbum, conf.Server.PID.Album) || !strings.EqualFold(pidTrack, conf.Server.PID.Track), nil
|
return names, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// runInitialScan runs an initial scan of the music library if needed.
|
// runInitialScan runs an initial scan of the music library if needed.
|
||||||
func runInitialScan(ctx context.Context) func() error {
|
func runInitialScan(ctx context.Context) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
ds := CreateDataStore()
|
ds := CreateDataStore()
|
||||||
fullScanRequired, err := ds.Property(ctx).DefaultGet(consts.FullScanAfterMigrationFlagKey, "0")
|
fullScanRequired, err := ds.Property().DefaultGet(ctx, consts.FullScanAfterMigrationFlagKey, "0")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
inProgress, err := ds.Library(ctx).ScanInProgress()
|
inProgress, err := ds.Library().ScanInProgress(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
pidHasChanged, err := pidHashChanged(ds)
|
pidChangedLibs, err := librariesWithChangedPID(ctx, ds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup
|
scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup
|
||||||
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
|
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || len(pidChangedLibs) > 0
|
||||||
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
||||||
if scanNeeded {
|
if scanNeeded {
|
||||||
s := CreateScanner(ctx)
|
s := CreateScanner(ctx)
|
||||||
switch {
|
switch {
|
||||||
case fullScanRequired == "1":
|
case fullScanRequired == "1":
|
||||||
log.Warn(ctx, "Full scan required after migration")
|
log.Warn(ctx, "Full scan required after migration")
|
||||||
_ = ds.Property(ctx).Delete(consts.FullScanAfterMigrationFlagKey)
|
_ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey)
|
||||||
case pidHasChanged:
|
case len(pidChangedLibs) > 0:
|
||||||
log.Warn(ctx, "PID config changed, performing full scan")
|
// Includes never-scanned libraries. The scanner rescans in full only the ones that need it
|
||||||
fullScanRequired = "1"
|
log.Warn(ctx, "Libraries with a new or changed PID config, scanning", "libraries", pidChangedLibs)
|
||||||
case inProgress:
|
case inProgress:
|
||||||
log.Warn(ctx, "Resuming interrupted scan")
|
log.Warn(ctx, "Resuming interrupted scan")
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path"
|
"path"
|
||||||
|
|
@ -9,6 +10,8 @@ import (
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
@ -44,3 +47,30 @@ var _ = Describe("profilerHandler", func() {
|
||||||
Entry("with a trailing-slash BasePath", "/music/"),
|
Entry("with a trailing-slash BasePath", "/music/"),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
var _ = Describe("librariesWithChangedPID", func() {
|
||||||
|
var ds *tests.MockDataStore
|
||||||
|
var libs *tests.MockLibraryRepo
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
libs = &tests.MockLibraryRepo{}
|
||||||
|
ds = &tests.MockDataStore{MockedLibrary: libs}
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns only the libraries whose PID config changed", func() {
|
||||||
|
pid := model.Library{}.EffectivePID()
|
||||||
|
libs.SetData(model.Libraries{
|
||||||
|
{ID: 1, Name: "Same", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track},
|
||||||
|
{ID: 2, Name: "Changed", PIDAlbum: "folder", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track},
|
||||||
|
{ID: 3, Name: "Never scanned"},
|
||||||
|
})
|
||||||
|
Expect(librariesWithChangedPID(GinkgoT().Context(), ds)).To(ConsistOf("Changed", "Never scanned"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns the error from the repository", func() {
|
||||||
|
libs.Err = errors.New("db down")
|
||||||
|
_, err := librariesWithChangedPID(GinkgoT().Context(), ds)
|
||||||
|
Expect(err).To(MatchError("db down"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
11
cmd/svc.go
11
cmd/svc.go
|
|
@ -44,7 +44,7 @@ var svcCmd = &cobra.Command{
|
||||||
}
|
}
|
||||||
|
|
||||||
type svcControl struct {
|
type svcControl struct {
|
||||||
ctx context.Context
|
ctx context.Context //nolint:containedctx // service lifecycle ctx, cancelled by Stop
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
}
|
}
|
||||||
|
|
@ -53,8 +53,13 @@ func (p *svcControl) Start(service.Service) error {
|
||||||
p.done = make(chan struct{})
|
p.done = make(chan struct{})
|
||||||
p.ctx, p.cancel = context.WithCancel(context.Background())
|
p.ctx, p.cancel = context.WithCancel(context.Background())
|
||||||
go func() {
|
go func() {
|
||||||
runNavidrome(p.ctx)
|
err := runNavidrome(p.ctx)
|
||||||
close(p.done)
|
close(p.done)
|
||||||
|
// service.Run() only returns when it gets a stop request, so exit here to let the
|
||||||
|
// service manager see the failure and restart the service
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Fatal error in Navidrome. Aborting", err)
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -74,7 +79,7 @@ func (p *svcControl) Stop(service.Service) error {
|
||||||
var svcInstance = sync.OnceValue(func() service.Service {
|
var svcInstance = sync.OnceValue(func() service.Service {
|
||||||
options := make(service.KeyValue)
|
options := make(service.KeyValue)
|
||||||
options["Restart"] = "on-failure"
|
options["Restart"] = "on-failure"
|
||||||
options["SuccessExitStatus"] = "1 2 8 SIGKILL"
|
options["SuccessExitStatus"] = "SIGKILL"
|
||||||
options["UserService"] = false
|
options["UserService"] = false
|
||||||
options["LogDirectory"] = conf.Server.DataFolder.String()
|
options["LogDirectory"] = conf.Server.DataFolder.String()
|
||||||
options["SystemdScript"] = systemdScript
|
options["SystemdScript"] = systemdScript
|
||||||
|
|
|
||||||
26
cmd/user.go
26
cmd/user.go
|
|
@ -183,7 +183,7 @@ func runCreateUser(ctx context.Context) {
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
|
|
||||||
err := ds.WithTx(func(tx model.DataStore) error {
|
err := ds.WithTx(func(tx model.DataStore) error {
|
||||||
existingUser, err := tx.User(ctx).FindByUsername(userID)
|
existingUser, err := tx.User().FindByUsername(ctx, userID)
|
||||||
if existingUser != nil {
|
if existingUser != nil {
|
||||||
return fmt.Errorf("existing user '%s'", userID)
|
return fmt.Errorf("existing user '%s'", userID)
|
||||||
}
|
}
|
||||||
|
|
@ -193,7 +193,7 @@ func runCreateUser(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(libraryIds) > 0 && !setAdmin {
|
if len(libraryIds) > 0 && !setAdmin {
|
||||||
user.Libraries, err = tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
user.Libraries, err = tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -202,13 +202,13 @@ func runCreateUser(ctx context.Context) {
|
||||||
return libraryError(user.Libraries)
|
return libraryError(user.Libraries)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
user.Libraries, err = tx.Library(ctx).GetAll()
|
user.Libraries, err = tx.Library().GetAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.User(ctx).Put(&user)
|
err = tx.User().Put(ctx, &user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -218,7 +218,7 @@ func runCreateUser(ctx context.Context) {
|
||||||
updatedIds[idx] = lib.ID
|
updatedIds[idx] = lib.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
|
err = tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -236,7 +236,7 @@ func runDeleteUser(ctx context.Context) {
|
||||||
var user *model.User
|
var user *model.User
|
||||||
|
|
||||||
err = ds.WithTx(func(tx model.DataStore) error {
|
err = ds.WithTx(func(tx model.DataStore) error {
|
||||||
count, err := tx.User(ctx).CountAll()
|
count, err := tx.User().CountAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -250,7 +250,7 @@ func runDeleteUser(ctx context.Context) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.User(ctx).Delete(user.ID)
|
return tx.User().Delete(ctx, user.ID)
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -276,7 +276,7 @@ func runUserEdit(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(libraryIds) > 0 && !setAdmin {
|
if len(libraryIds) > 0 && !setAdmin {
|
||||||
libraries, err := tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
libraries, err := tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -291,7 +291,7 @@ func runUserEdit(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if setAdmin && !user.IsAdmin {
|
if setAdmin && !user.IsAdmin {
|
||||||
libraries, err := tx.Library(ctx).GetAll()
|
libraries, err := tx.Library().GetAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -337,7 +337,7 @@ func runUserEdit(ctx context.Context) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err := tx.User(ctx).Put(user)
|
err := tx.User().Put(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -348,7 +348,7 @@ func runUserEdit(ctx context.Context) {
|
||||||
updatedIds[idx] = lib.ID
|
updatedIds[idx] = lib.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
err := tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
|
err := tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -393,13 +393,11 @@ func runUserList(ctx context.Context) {
|
||||||
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
|
|
||||||
users, err := ds.User(ctx).ReadAll()
|
userList, err := ds.User().ReadAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Failed to retrieve users", err)
|
log.Fatal(ctx, "Failed to retrieve users", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
userList := users.(model.Users)
|
|
||||||
|
|
||||||
if outputFormat == "csv" {
|
if outputFormat == "csv" {
|
||||||
w := csv.NewWriter(os.Stdout)
|
w := csv.NewWriter(os.Stdout)
|
||||||
_ = w.Write([]string{
|
_ = w.Write([]string{
|
||||||
|
|
|
||||||
17
cmd/utils.go
17
cmd/utils.go
|
|
@ -18,11 +18,16 @@ import (
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
)
|
)
|
||||||
|
|
||||||
// requireExistingDB aborts the command when the database file (DbPath minus DSN
|
// existingDBFile returns the database file (DbPath minus DSN params), and whether it exists.
|
||||||
// params) does not exist.
|
func existingDBFile() (string, bool) {
|
||||||
func requireExistingDB() {
|
|
||||||
path, _, _ := strings.Cut(conf.Server.DbPath, "?")
|
path, _, _ := strings.Cut(conf.Server.DbPath, "?")
|
||||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
_, err := os.Stat(path)
|
||||||
|
return path, err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireExistingDB aborts the command when the database file does not exist.
|
||||||
|
func requireExistingDB() {
|
||||||
|
if path, ok := existingDBFile(); !ok {
|
||||||
log.Fatal("No existing database", "path", path)
|
log.Fatal("No existing database", "path", path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -52,14 +57,14 @@ func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) {
|
func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) {
|
||||||
user, err := ds.User(ctx).FindByUsername(id)
|
user, err := ds.User().FindByUsername(ctx, id)
|
||||||
|
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
return nil, fmt.Errorf("finding user by name: %w", err)
|
return nil, fmt.Errorf("finding user by name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
user, err = ds.User(ctx).Get(id)
|
user, err = ds.User().Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("finding user by id: %w", err)
|
return nil, fmt.Errorf("finding user by id: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import (
|
||||||
"github.com/navidrome/navidrome/plugins"
|
"github.com/navidrome/navidrome/plugins"
|
||||||
"github.com/navidrome/navidrome/scanner"
|
"github.com/navidrome/navidrome/scanner"
|
||||||
"github.com/navidrome/navidrome/server"
|
"github.com/navidrome/navidrome/server"
|
||||||
|
"github.com/navidrome/navidrome/server/apiv1"
|
||||||
"github.com/navidrome/navidrome/server/events"
|
"github.com/navidrome/navidrome/server/events"
|
||||||
"github.com/navidrome/navidrome/server/jellyfin"
|
"github.com/navidrome/navidrome/server/jellyfin"
|
||||||
"github.com/navidrome/navidrome/server/nativeapi"
|
"github.com/navidrome/navidrome/server/nativeapi"
|
||||||
|
|
@ -94,8 +95,9 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
||||||
transcodingCache := stream.GetTranscodingCache()
|
transcodingCache := stream.GetTranscodingCache()
|
||||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||||
|
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
||||||
share := core.NewShare(dataStore)
|
share := core.NewShare(dataStore)
|
||||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork)
|
||||||
players := core.NewPlayers(dataStore)
|
players := core.NewPlayers(dataStore)
|
||||||
broker := events.GetBroker()
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
|
|
@ -109,7 +111,6 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||||
playbackServer := playback.GetInstance(dataStore)
|
playbackServer := playback.GetInstance(dataStore)
|
||||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
||||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
|
||||||
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
||||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
|
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
|
||||||
return router
|
return router
|
||||||
|
|
@ -142,6 +143,13 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CreateAPIv1Router(ctx context.Context) *apiv1.Router {
|
||||||
|
sqlDB := db.Db()
|
||||||
|
dataStore := persistence.New(sqlDB)
|
||||||
|
router := apiv1.New(dataStore)
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
func CreatePublicRouter() *public.Router {
|
func CreatePublicRouter() *public.Router {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
|
|
@ -151,9 +159,10 @@ func CreatePublicRouter() *public.Router {
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
||||||
transcodingCache := stream.GetTranscodingCache()
|
transcodingCache := stream.GetTranscodingCache()
|
||||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||||
|
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
||||||
share := core.NewShare(dataStore)
|
share := core.NewShare(dataStore)
|
||||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork)
|
||||||
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
|
router := public.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, share, archiver)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -259,7 +268,7 @@ func getPluginManager() *plugins.Manager {
|
||||||
|
|
||||||
// wire_injectors.go:
|
// wire_injectors.go:
|
||||||
|
|
||||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, jellyfin.NewDiscovery, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, 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, jellyfin.New, jellyfin.NewDiscovery, apiv1.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, 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)))
|
||||||
|
|
||||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||||
manager := getPluginManager()
|
manager := getPluginManager()
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import (
|
||||||
"github.com/navidrome/navidrome/plugins"
|
"github.com/navidrome/navidrome/plugins"
|
||||||
"github.com/navidrome/navidrome/scanner"
|
"github.com/navidrome/navidrome/scanner"
|
||||||
"github.com/navidrome/navidrome/server"
|
"github.com/navidrome/navidrome/server"
|
||||||
|
"github.com/navidrome/navidrome/server/apiv1"
|
||||||
"github.com/navidrome/navidrome/server/events"
|
"github.com/navidrome/navidrome/server/events"
|
||||||
"github.com/navidrome/navidrome/server/jellyfin"
|
"github.com/navidrome/navidrome/server/jellyfin"
|
||||||
"github.com/navidrome/navidrome/server/nativeapi"
|
"github.com/navidrome/navidrome/server/nativeapi"
|
||||||
|
|
@ -37,6 +38,7 @@ var allProviders = wire.NewSet(
|
||||||
subsonic.New,
|
subsonic.New,
|
||||||
jellyfin.New,
|
jellyfin.New,
|
||||||
jellyfin.NewDiscovery,
|
jellyfin.NewDiscovery,
|
||||||
|
apiv1.New,
|
||||||
nativeapi.New,
|
nativeapi.New,
|
||||||
public.New,
|
public.New,
|
||||||
persistence.New,
|
persistence.New,
|
||||||
|
|
@ -91,6 +93,12 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CreateAPIv1Router(ctx context.Context) *apiv1.Router {
|
||||||
|
panic(wire.Build(
|
||||||
|
allProviders,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
func CreatePublicRouter() *public.Router {
|
func CreatePublicRouter() *public.Router {
|
||||||
panic(wire.Build(
|
panic(wire.Build(
|
||||||
allProviders,
|
allProviders,
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,7 @@ type configOptions struct {
|
||||||
DevExternalArtistFetchMultiplier float64
|
DevExternalArtistFetchMultiplier float64
|
||||||
DevPreserveUnicodeInExternalCalls bool
|
DevPreserveUnicodeInExternalCalls bool
|
||||||
DevEnableMediaFileProbe bool
|
DevEnableMediaFileProbe bool
|
||||||
|
DevAPIv1 bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type scannerOptions struct {
|
type scannerOptions struct {
|
||||||
|
|
@ -514,6 +515,11 @@ func Load(noConfigDump bool) {
|
||||||
Server.UICoverArtSize = newValue
|
Server.UICoverArtSize = newValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if Server.Scanner.Extractor != consts.DefaultScannerExtractor {
|
||||||
|
log.Warn("Invalid Scanner.Extractor, using default", "value", Server.Scanner.Extractor, "default", consts.DefaultScannerExtractor)
|
||||||
|
Server.Scanner.Extractor = consts.DefaultScannerExtractor
|
||||||
|
}
|
||||||
|
|
||||||
// Floor MaxImageSize at MaxImageUploadSize so accepted uploads can always be read back.
|
// Floor MaxImageSize at MaxImageUploadSize so accepted uploads can always be read back.
|
||||||
imgSize, _ := humanize.ParseBytes(Server.MaxImageSize)
|
imgSize, _ := humanize.ParseBytes(Server.MaxImageSize)
|
||||||
uploadSize, _ := humanize.ParseBytes(Server.MaxImageUploadSize)
|
uploadSize, _ := humanize.ParseBytes(Server.MaxImageUploadSize)
|
||||||
|
|
@ -1119,6 +1125,7 @@ func setViperDefaults() {
|
||||||
viper.SetDefault("devshowartistpage", true)
|
viper.SetDefault("devshowartistpage", true)
|
||||||
viper.SetDefault("devuishowconfig", true)
|
viper.SetDefault("devuishowconfig", true)
|
||||||
viper.SetDefault("devneweventstream", true)
|
viper.SetDefault("devneweventstream", true)
|
||||||
|
viper.SetDefault("devapiv1", false)
|
||||||
viper.SetDefault("devoffsetoptimize", 50000)
|
viper.SetDefault("devoffsetoptimize", 50000)
|
||||||
// Half the pool: streams may take up to this many connections, leaving the rest for the scanner,
|
// Half the pool: streams may take up to this many connections, leaving the rest for the scanner,
|
||||||
// scrobbles and the UI. See MaxOpenConns.
|
// scrobbles and the UI. See MaxOpenConns.
|
||||||
|
|
|
||||||
|
|
@ -402,6 +402,27 @@ var _ = Describe("Configuration", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("Scanner.Extractor", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
viper.Reset()
|
||||||
|
conf.SetViperDefaults()
|
||||||
|
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||||
|
viper.SetDefault("loglevel", "error")
|
||||||
|
conf.ResetConf()
|
||||||
|
})
|
||||||
|
|
||||||
|
It("falls back to taglib for an unknown extractor", func() {
|
||||||
|
viper.SetDefault("scanner.extractor", "ffmpeg")
|
||||||
|
conf.Load(true)
|
||||||
|
Expect(conf.Server.Scanner.Extractor).To(Equal("taglib"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("keeps taglib", func() {
|
||||||
|
conf.Load(true)
|
||||||
|
Expect(conf.Server.Scanner.Extractor).To(Equal("taglib"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Describe("EnforceNonRootUser", func() {
|
Describe("EnforceNonRootUser", func() {
|
||||||
It("defaults to false", func() {
|
It("defaults to false", func() {
|
||||||
conf.Load(true)
|
conf.Load(true)
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ const (
|
||||||
DefaultEncryptionKey = "just for obfuscation"
|
DefaultEncryptionKey = "just for obfuscation"
|
||||||
PasswordsEncryptedKey = "PasswordsEncryptedKey"
|
PasswordsEncryptedKey = "PasswordsEncryptedKey"
|
||||||
PasswordAutogenPrefix = "__NAVIDROME_AUTOGEN__" //nolint:gosec
|
PasswordAutogenPrefix = "__NAVIDROME_AUTOGEN__" //nolint:gosec
|
||||||
|
APIKeyPrefix = "nds_"
|
||||||
|
|
||||||
DevInitialUserName = "admin"
|
DevInitialUserName = "admin"
|
||||||
DevInitialName = "Dev Admin"
|
DevInitialName = "Dev Admin"
|
||||||
|
|
@ -59,6 +60,7 @@ const (
|
||||||
URLPathPublic = "/share"
|
URLPathPublic = "/share"
|
||||||
URLPathPublicImages = URLPathPublic + "/img"
|
URLPathPublicImages = URLPathPublic + "/img"
|
||||||
URLPathJellyfinAPI = "/jellyfin"
|
URLPathJellyfinAPI = "/jellyfin"
|
||||||
|
URLPathAPIv1 = "/api/v1"
|
||||||
|
|
||||||
// JellyfinServerIDKey is the Property key for the stable, persisted server Id reported by the
|
// 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.
|
// Jellyfin API. Jellyfin clients cache this value, so it must survive process restarts.
|
||||||
|
|
@ -154,8 +156,6 @@ const (
|
||||||
//DefaultAlbumPID = "album_legacy"
|
//DefaultAlbumPID = "album_legacy"
|
||||||
DefaultAlbumPID = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
|
DefaultAlbumPID = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
|
||||||
DefaultTrackPID = "musicbrainz_trackid|albumid,discnumber,tracknumber,title"
|
DefaultTrackPID = "musicbrainz_trackid|albumid,discnumber,tracknumber,title"
|
||||||
PIDAlbumKey = "PIDAlbum"
|
|
||||||
PIDTrackKey = "PIDTrack"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ func (p *localAgent) AgentName() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
|
func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
|
||||||
top, err := p.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
top, err := p.ds.MediaFile().GetAll(ctx, model.QueryOptions{
|
||||||
Sort: "playCount",
|
Sort: "playCount",
|
||||||
Order: "desc",
|
Order: "desc",
|
||||||
Max: count,
|
Max: count,
|
||||||
|
|
@ -43,7 +43,7 @@ func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
|
func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
|
||||||
seed, err := p.ds.MediaFile(ctx).Get(id)
|
seed, err := p.ds.MediaFile().Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -53,7 +53,7 @@ func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artis
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
// Ask for extra so we can drop the seed itself and still fill the count.
|
// Ask for extra so we can drop the seed itself and still fill the count.
|
||||||
candidates, err := p.ds.MediaFile(ctx).GetRandom(model.QueryOptions{
|
candidates, err := p.ds.MediaFile().GetRandom(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.And{
|
Filters: squirrel.And{
|
||||||
persistence.SongGenres.ByID(genreIDs),
|
persistence.SongGenres.ByID(genreIDs),
|
||||||
squirrel.Eq{"missing": false},
|
squirrel.Eq{"missing": false},
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,13 @@ type SessionKeys struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error {
|
func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error {
|
||||||
return sk.DataStore.UserProps(ctx).Put(userId, sk.KeyName, sessionKey)
|
return sk.DataStore.UserProps().Put(ctx, userId, sk.KeyName, sessionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sk *SessionKeys) Get(ctx context.Context, userId string) (string, error) {
|
func (sk *SessionKeys) Get(ctx context.Context, userId string) (string, error) {
|
||||||
return sk.DataStore.UserProps(ctx).Get(userId, sk.KeyName)
|
return sk.DataStore.UserProps().Get(ctx, userId, sk.KeyName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sk *SessionKeys) Delete(ctx context.Context, userId string) error {
|
func (sk *SessionKeys) Delete(ctx context.Context, userId string) error {
|
||||||
return sk.DataStore.UserProps(ctx).Delete(userId, sk.KeyName)
|
return sk.DataStore.UserProps().Delete(ctx, userId, sk.KeyName)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
194
core/archiver.go
194
core/archiver.go
|
|
@ -2,23 +2,32 @@ package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"github.com/navidrome/navidrome/core/stream"
|
"github.com/navidrome/navidrome/core/stream"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/request"
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
"github.com/navidrome/navidrome/utils/slice"
|
||||||
"github.com/navidrome/navidrome/utils/str"
|
"github.com/navidrome/navidrome/utils/str"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const archiveCoverArtSize = 500
|
||||||
|
|
||||||
type Archiver interface {
|
type Archiver interface {
|
||||||
ZipAlbum(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
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
|
ZipArtist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||||
|
|
@ -26,18 +35,20 @@ type Archiver interface {
|
||||||
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share) Archiver {
|
func NewArchiver(ms stream.MediaStreamer, decider stream.TranscodeDecider, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver {
|
||||||
return &archiver{ds: ds, ms: ms, shares: shares}
|
return &archiver{ds: ds, ms: ms, decider: decider, shares: shares, artwork: artwork}
|
||||||
}
|
}
|
||||||
|
|
||||||
type archiver struct {
|
type archiver struct {
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
ms stream.MediaStreamer
|
ms stream.MediaStreamer
|
||||||
shares Share
|
decider stream.TranscodeDecider
|
||||||
|
shares Share
|
||||||
|
artwork artwork.Artwork
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||||
return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_id": id})
|
return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_id": id}, model.ArtworkID{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||||
|
|
@ -47,28 +58,30 @@ func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitr
|
||||||
persistence.ParticipantIDFilter("media_file", id, model.RoleAlbumArtist),
|
persistence.ParticipantIDFilter("media_file", id, model.RoleAlbumArtist),
|
||||||
squirrel.Eq{"missing": false},
|
squirrel.Eq{"missing": false},
|
||||||
}
|
}
|
||||||
return a.zipAlbums(ctx, id, format, bitrate, out, filter)
|
return a.zipAlbums(ctx, id, format, bitrate, out, filter, model.Artist{ID: id}.CoverArtID())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer) error {
|
// rootArt, when set, is added to the archive root.
|
||||||
mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: filters, Sort: "album"})
|
func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer, rootArt model.ArtworkID) error {
|
||||||
|
mfs, err := a.ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: filters, Sort: "album"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error loading mediafiles from artist", "id", id, err)
|
log.Error(ctx, "Error loading mediafiles from artist", "id", id, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
z := createZipWriter(out, format, bitrate)
|
z := createZipWriter(out, format, bitrate)
|
||||||
albums := slice.Group(mfs, func(mf model.MediaFile) string {
|
albums := slice.GroupOrdered(mfs, func(mf model.MediaFile) string { return mf.AlbumID })
|
||||||
return mf.AlbumID
|
folders := albumFolders(albums)
|
||||||
})
|
|
||||||
for _, album := range albums {
|
for _, album := range albums {
|
||||||
discs := slice.Group(album, func(mf model.MediaFile) int { return mf.DiscNumber })
|
discs := slice.Group(album, func(mf model.MediaFile) int { return mf.DiscNumber })
|
||||||
isMultiDisc := len(discs) > 1
|
isMultiDisc := len(discs) > 1
|
||||||
log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist,
|
folder := folders[album[0].AlbumID]
|
||||||
|
log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist, "folder", folder,
|
||||||
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
|
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
|
||||||
for _, mf := range album {
|
for _, mf := range album {
|
||||||
file := a.albumFilename(mf, format, isMultiDisc)
|
req := a.resolveRequest(ctx, &mf, format, bitrate)
|
||||||
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
file := a.albumFilename(mf, req.Format, isMultiDisc, folder)
|
||||||
|
if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||||
// Stop iterating: continuing would just rack up more
|
// Stop iterating: continuing would just rack up more
|
||||||
// rejections from the limiter. Close finalises whatever
|
// rejections from the limiter. Close finalises whatever
|
||||||
// tracks were already written; the rejected one is not
|
// tracks were already written; the rejected one is not
|
||||||
|
|
@ -78,7 +91,10 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr
|
||||||
return addErr
|
return addErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// After the tracks, so a slow artwork lookup doesn't delay the first bytes.
|
||||||
|
a.addCoverArtToZip(ctx, z, album[0].AlbumCoverArtID(), folder)
|
||||||
}
|
}
|
||||||
|
a.addCoverArtToZip(ctx, z, rootArt, "")
|
||||||
err = z.Close()
|
err = z.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error closing zip file", "id", id, err)
|
log.Error(ctx, "Error closing zip file", "id", id, err)
|
||||||
|
|
@ -96,7 +112,61 @@ func createZipWriter(out io.Writer, format string, bitrate int) *zip.Writer {
|
||||||
return z
|
return z
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc bool) string {
|
// Tried in order; the first one whose values are distinct across the clashing albums wins.
|
||||||
|
// One album may have an empty value: it keeps the plain name, which the others can't clash with.
|
||||||
|
var albumDisambiguators = []func(model.MediaFile) string{
|
||||||
|
func(mf model.MediaFile) string { return mf.Tags.First(model.TagAlbumVersion) },
|
||||||
|
func(mf model.MediaFile) string {
|
||||||
|
// Reissues share Year (often the original's) but not ReleaseYear.
|
||||||
|
if y := cmp.Or(mf.ReleaseYear, mf.Year); y != 0 {
|
||||||
|
return strconv.Itoa(y)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
func(mf model.MediaFile) string { return mf.MbzAlbumType },
|
||||||
|
func(mf model.MediaFile) string { return mf.Tags.First(model.TagRecordLabel) },
|
||||||
|
func(mf model.MediaFile) string { return mf.CatalogNum },
|
||||||
|
func(mf model.MediaFile) string { return mf.AlbumID[:min(6, len(mf.AlbumID))] },
|
||||||
|
func(mf model.MediaFile) string { return mf.AlbumID },
|
||||||
|
}
|
||||||
|
|
||||||
|
// albumFolders maps each album id to its zip folder. Albums whose names sanitize to the
|
||||||
|
// same folder get a " [suffix]" from the first disambiguator that tells them all apart.
|
||||||
|
func albumFolders(albums [][]model.MediaFile) map[string]string {
|
||||||
|
byName := map[string][]model.MediaFile{}
|
||||||
|
for _, album := range albums {
|
||||||
|
name := str.SanitizeFilename(album[0].FullAlbumName())
|
||||||
|
byName[name] = append(byName[name], album[0])
|
||||||
|
}
|
||||||
|
folders := make(map[string]string, len(albums))
|
||||||
|
for name, group := range byName {
|
||||||
|
if len(group) == 1 {
|
||||||
|
folders[group[0].AlbumID] = name
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields:
|
||||||
|
for _, field := range albumDisambiguators {
|
||||||
|
ids := make(map[string]string, len(group)) // suffix -> album id
|
||||||
|
for _, mf := range group {
|
||||||
|
s := str.SanitizeFilename(field(mf))
|
||||||
|
if _, dup := ids[s]; dup {
|
||||||
|
continue fields
|
||||||
|
}
|
||||||
|
ids[s] = mf.AlbumID
|
||||||
|
}
|
||||||
|
for s, id := range ids {
|
||||||
|
folders[id] = name
|
||||||
|
if s != "" {
|
||||||
|
folders[id] = fmt.Sprintf("%s [%s]", name, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return folders
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc bool, folder string) string {
|
||||||
_, file := filepath.Split(mf.Path)
|
_, file := filepath.Split(mf.Path)
|
||||||
if format != "raw" {
|
if format != "raw" {
|
||||||
file = strings.TrimSuffix(file, mf.Suffix) + format
|
file = strings.TrimSuffix(file, mf.Suffix) + format
|
||||||
|
|
@ -104,7 +174,7 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc
|
||||||
if isMultiDisc {
|
if isMultiDisc {
|
||||||
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
|
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file)
|
return fmt.Sprintf("%s/%s", folder, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ZipShare takes an already-loaded share: Share.Load records a visit, so
|
// ZipShare takes an already-loaded share: Share.Load records a visit, so
|
||||||
|
|
@ -114,27 +184,31 @@ func (a *archiver) ZipShare(ctx context.Context, s *model.Share, out io.Writer)
|
||||||
return model.ErrNotAuthorized
|
return model.ErrNotAuthorized
|
||||||
}
|
}
|
||||||
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
|
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)
|
// The share is the authorization (as in the public image handler): an anonymous lookup would
|
||||||
|
// hide a private playlist. Only the cover read is elevated.
|
||||||
|
coverCtx := request.WithUser(ctx, model.User{IsAdmin: true})
|
||||||
|
return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, coverCtx, s.CoverArtID(), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||||
pls, err := a.ds.Playlist(ctx).GetWithTracks(id, true, false)
|
pls, err := a.ds.Playlist().GetWithTracks(ctx, id, true, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err)
|
log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
mfs := pls.MediaFiles()
|
mfs := pls.MediaFiles()
|
||||||
log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs))
|
log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs))
|
||||||
return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, true)
|
return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, ctx, pls.CoverArtID(), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, addM3U bool) error {
|
func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, coverCtx context.Context, coverArt model.ArtworkID, addM3U bool) error {
|
||||||
z := createZipWriter(out, format, bitrate)
|
z := createZipWriter(out, format, bitrate)
|
||||||
|
|
||||||
zippedMfs := make(model.MediaFiles, len(mfs))
|
zippedMfs := make(model.MediaFiles, len(mfs))
|
||||||
for idx, mf := range mfs {
|
for idx, mf := range mfs {
|
||||||
file := a.playlistFilename(mf, format, idx)
|
req := a.resolveRequest(ctx, &mf, format, bitrate)
|
||||||
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
file := a.playlistFilename(mf, req.Format, idx)
|
||||||
|
if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||||
// Abort the whole archive: continuing would silently emit
|
// Abort the whole archive: continuing would silently emit
|
||||||
// empty zip entries since the headers are already written.
|
// empty zip entries since the headers are already written.
|
||||||
_ = z.Close()
|
_ = z.Close()
|
||||||
|
|
@ -143,6 +217,7 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st
|
||||||
mf.Path = file
|
mf.Path = file
|
||||||
zippedMfs[idx] = mf
|
zippedMfs[idx] = mf
|
||||||
}
|
}
|
||||||
|
a.addCoverArtToZip(coverCtx, z, coverArt, "")
|
||||||
|
|
||||||
// Add M3U file if requested
|
// Add M3U file if requested
|
||||||
if addM3U && len(zippedMfs) > 0 {
|
if addM3U && len(zippedMfs) > 0 {
|
||||||
|
|
@ -179,7 +254,14 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int)
|
||||||
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext)
|
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {
|
func (a *archiver) resolveRequest(ctx context.Context, mf *model.MediaFile, format string, bitrate int) stream.Request {
|
||||||
|
if format == "" || format == "raw" {
|
||||||
|
return stream.Request{Format: "raw"}
|
||||||
|
}
|
||||||
|
return a.decider.ResolveRequest(ctx, mf, format, bitrate, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, req stream.Request, filename string) error {
|
||||||
path := mf.AbsolutePath()
|
path := mf.AbsolutePath()
|
||||||
|
|
||||||
// Open the source before writing the zip entry header so a rejection
|
// Open the source before writing the zip entry header so a rejection
|
||||||
|
|
@ -187,13 +269,13 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
||||||
// archive.
|
// archive.
|
||||||
var r io.ReadCloser
|
var r io.ReadCloser
|
||||||
var err error
|
var err error
|
||||||
if format != "raw" && format != "" {
|
if req.Format != "raw" {
|
||||||
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
|
r, err = a.ms.NewStream(ctx, &mf, req)
|
||||||
} else {
|
} else {
|
||||||
r, err = os.Open(path)
|
r, err = os.Open(path)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
|
log.Error(ctx, "Error opening file for zipping", "file", path, "format", req.Format, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|
@ -220,3 +302,61 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addCoverArtToZip adds the cover as dir/folder.<ext>. Errors are logged, never returned.
|
||||||
|
func (a *archiver) addCoverArtToZip(ctx context.Context, z *zip.Writer, artID model.ArtworkID, dir string) {
|
||||||
|
if artID.ID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Buffered so a failed read leaves no empty entry.
|
||||||
|
data, err := a.readCoverArt(ctx, artID)
|
||||||
|
if errors.Is(err, artwork.ErrUnavailable) || errors.Is(err, model.ErrNotFound) {
|
||||||
|
log.Debug(ctx, "No cover art to add to zip", "artID", artID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Warn(ctx, "Error reading cover art for zipping", "artID", artID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ext := coverArtExtension(data)
|
||||||
|
if ext == "" {
|
||||||
|
log.Warn(ctx, "Unknown cover art image type, not adding it to zip", "artID", artID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w, err := z.CreateHeader(&zip.FileHeader{
|
||||||
|
Name: path.Join(dir, "folder."+ext),
|
||||||
|
Modified: time.Now(),
|
||||||
|
Method: zip.Store,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Warn(ctx, "Error creating cover art zip entry", "artID", artID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err = w.Write(data); err != nil {
|
||||||
|
log.Warn(ctx, "Error zipping cover art", "artID", artID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *archiver) readCoverArt(ctx context.Context, artID model.ArtworkID) ([]byte, error) {
|
||||||
|
img, err := a.artwork.Get(ctx, artID, archiveCoverArtSize, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer img.Close()
|
||||||
|
return io.ReadAll(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resizing may re-encode the image, so the type comes from its bytes.
|
||||||
|
func coverArtExtension(data []byte) string {
|
||||||
|
switch http.DetectContentType(data) {
|
||||||
|
case "image/jpeg":
|
||||||
|
return "jpg"
|
||||||
|
case "image/png":
|
||||||
|
return "png"
|
||||||
|
case "image/webp":
|
||||||
|
return "webp"
|
||||||
|
case "image/gif":
|
||||||
|
return "gif"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,18 @@ import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"github.com/navidrome/navidrome/core/stream"
|
"github.com/navidrome/navidrome/core/stream"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/request"
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
|
|
@ -21,15 +26,19 @@ var _ = Describe("Archiver", func() {
|
||||||
var (
|
var (
|
||||||
arch core.Archiver
|
arch core.Archiver
|
||||||
ms *mockMediaStreamer
|
ms *mockMediaStreamer
|
||||||
|
dc *fakeDecider
|
||||||
ds *mockDataStore
|
ds *mockDataStore
|
||||||
sh *mockShare
|
sh *mockShare
|
||||||
|
ca *mockCoverArt
|
||||||
)
|
)
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ms = &mockMediaStreamer{}
|
ms = &mockMediaStreamer{}
|
||||||
|
dc = &fakeDecider{}
|
||||||
sh = &mockShare{}
|
sh = &mockShare{}
|
||||||
ds = &mockDataStore{}
|
ds = &mockDataStore{}
|
||||||
arch = core.NewArchiver(ms, ds, sh)
|
ca = &mockCoverArt{images: map[string][]byte{}}
|
||||||
|
arch = core.NewArchiver(ms, dc, ds, sh, ca)
|
||||||
})
|
})
|
||||||
|
|
||||||
Context("ZipAlbum", func() {
|
Context("ZipAlbum", func() {
|
||||||
|
|
@ -45,7 +54,7 @@ var _ = Describe("Archiver", func() {
|
||||||
Sort: "album",
|
Sort: "album",
|
||||||
}}).Return(mfs, nil)
|
}}).Return(mfs, nil)
|
||||||
|
|
||||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
ds.On("MediaFile").Return(mfRepo)
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
|
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
out := new(bytes.Buffer)
|
||||||
|
|
@ -59,6 +68,23 @@ var _ = Describe("Archiver", func() {
|
||||||
Expect(zr.File[0].Name).To(Equal("Album_Promo/01 - track1.mp3"))
|
Expect(zr.File[0].Name).To(Equal("Album_Promo/01 - track1.mp3"))
|
||||||
Expect(zr.File[1].Name).To(Equal("Album_Promo/02 - track2.mp3"))
|
Expect(zr.File[1].Name).To(Equal("Album_Promo/02 - track2.mp3"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("streams the request resolved by the transcode decider and names the entry after its format", func() {
|
||||||
|
mfRepo := &mockMediaFileRepository{}
|
||||||
|
mfRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{Path: "test_data/01 - track1.flac", Suffix: "flac", AlbumID: "1"}}, nil)
|
||||||
|
ds.On("MediaFile").Return(mfRepo)
|
||||||
|
resolved := stream.Request{Format: "opus", BitRate: 128, SampleRate: 48000, Channels: 2}
|
||||||
|
dc.resolved = &resolved
|
||||||
|
ms.On("NewStream", mock.Anything, mock.Anything, resolved).Return(io.NopCloser(strings.NewReader("test")), nil).Once()
|
||||||
|
|
||||||
|
out := new(bytes.Buffer)
|
||||||
|
Expect(arch.ZipAlbum(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed())
|
||||||
|
ms.AssertExpectations(GinkgoT())
|
||||||
|
|
||||||
|
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(zr.File[0].Name).To(HaveSuffix("01 - track1.opus"))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Context("ZipArtist", func() {
|
Context("ZipArtist", func() {
|
||||||
|
|
@ -77,7 +103,7 @@ var _ = Describe("Archiver", func() {
|
||||||
Sort: "album",
|
Sort: "album",
|
||||||
}}).Return(mfs, nil)
|
}}).Return(mfs, nil)
|
||||||
|
|
||||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
ds.On("MediaFile").Return(mfRepo)
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
out := new(bytes.Buffer)
|
||||||
|
|
@ -91,6 +117,140 @@ var _ = Describe("Archiver", func() {
|
||||||
Expect(zr.File[0].Name).To(Equal("Album 1/01 - track1.mp3"))
|
Expect(zr.File[0].Name).To(Equal("Album 1/01 - track1.mp3"))
|
||||||
Expect(zr.File[1].Name).To(Equal("Album 1/02 - track2.mp3"))
|
Expect(zr.File[1].Name).To(Equal("Album 1/02 - track2.mp3"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
When("albums that share a name", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
})
|
||||||
|
|
||||||
|
// zipArtistEntries zips the given tracks as artist "1" and returns the entry names in zip order.
|
||||||
|
zipArtistEntries := func(mfs model.MediaFiles) []string {
|
||||||
|
mfRepo := &mockMediaFileRepository{}
|
||||||
|
mfRepo.On("GetAll", mock.Anything).Return(mfs, nil)
|
||||||
|
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||||
|
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
|
||||||
|
|
||||||
|
out := new(bytes.Buffer)
|
||||||
|
Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
||||||
|
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
names := make([]string, len(zr.File))
|
||||||
|
for i, f := range zr.File {
|
||||||
|
names[i] = f.Name
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
It("keeps the albums in query order", func() {
|
||||||
|
names := zipArtistEntries(model.MediaFiles{
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Album C"},
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album A"},
|
||||||
|
{Path: "a/02.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album A"},
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Album B"},
|
||||||
|
})
|
||||||
|
Expect(names).To(Equal([]string{"Album C/01.mp3", "Album A/01.mp3", "Album A/02.mp3", "Album B/01.mp3"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("suffixes the year when it tells the albums apart", func() {
|
||||||
|
names := zipArtistEntries(model.MediaFiles{
|
||||||
|
{Path: "a/01 - Intro.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
|
||||||
|
{Path: "b/01 - Intro.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005},
|
||||||
|
})
|
||||||
|
Expect(names).To(Equal([]string{"Greatest Hits [2001]/01 - Intro.mp3", "Greatest Hits [2005]/01 - Intro.mp3"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("prefers the release year, so reissues of the same original are told apart", func() {
|
||||||
|
names := zipArtistEntries(model.MediaFiles{
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 1996, ReleaseYear: 2001},
|
||||||
|
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 1996, ReleaseYear: 2011},
|
||||||
|
{Path: "c/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Greatest Hits", Year: 1996},
|
||||||
|
})
|
||||||
|
Expect(names).To(Equal([]string{"Greatest Hits [2001]/01.mp3", "Greatest Hits [2011]/01.mp3", "Greatest Hits [1996]/01.mp3"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("names the folder after the full album name", func() {
|
||||||
|
names := zipArtistEntries(model.MediaFiles{
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001,
|
||||||
|
Tags: model.Tags{model.TagAlbumVersion: {"Original"}}},
|
||||||
|
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
|
||||||
|
Tags: model.Tags{model.TagAlbumVersion: {"CD/Digital"}}},
|
||||||
|
})
|
||||||
|
Expect(names).To(Equal([]string{"Greatest Hits (Original)/01.mp3", "Greatest Hits (CD_Digital)/01.mp3"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("prefers the album version over the year when it is not part of the name", func() {
|
||||||
|
conf.Server.Subsonic.AppendAlbumVersion = false
|
||||||
|
names := zipArtistEntries(model.MediaFiles{
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001,
|
||||||
|
Tags: model.Tags{model.TagAlbumVersion: {"Original"}}},
|
||||||
|
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
|
||||||
|
Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}},
|
||||||
|
})
|
||||||
|
Expect(names).To(Equal([]string{"Greatest Hits [Original]/01.mp3", "Greatest Hits [Deluxe Edition]/01.mp3"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("leaves the one album without the field unsuffixed", func() {
|
||||||
|
conf.Server.Subsonic.AppendAlbumVersion = false
|
||||||
|
names := zipArtistEntries(model.MediaFiles{
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
|
||||||
|
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
|
||||||
|
Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}},
|
||||||
|
})
|
||||||
|
Expect(names).To(Equal([]string{"Greatest Hits/01.mp3", "Greatest Hits [Deluxe Edition]/01.mp3"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("skips a field that is empty on more than one album", func() {
|
||||||
|
conf.Server.Subsonic.AppendAlbumVersion = false
|
||||||
|
names := zipArtistEntries(model.MediaFiles{
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
|
||||||
|
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005},
|
||||||
|
{Path: "c/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Greatest Hits", Year: 2010,
|
||||||
|
Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}},
|
||||||
|
})
|
||||||
|
Expect(names).To(Equal([]string{"Greatest Hits [2001]/01.mp3", "Greatest Hits [2005]/01.mp3", "Greatest Hits [2010]/01.mp3"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("skips a field that is the same on every album", func() {
|
||||||
|
names := zipArtistEntries(model.MediaFiles{
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Live", Year: 2001, MbzAlbumType: "album", CatalogNum: "CAT-1"},
|
||||||
|
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Live", Year: 2001, MbzAlbumType: "album", CatalogNum: "CAT-2"},
|
||||||
|
})
|
||||||
|
Expect(names).To(Equal([]string{"Live [CAT-1]/01.mp3", "Live [CAT-2]/01.mp3"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("falls back to the album id when nothing differs", func() {
|
||||||
|
names := zipArtistEntries(model.MediaFiles{
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "0123456789abcdef", Album: "Greatest Hits", Year: 2001},
|
||||||
|
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "fedcba9876543210", Album: "Greatest Hits", Year: 2001},
|
||||||
|
})
|
||||||
|
Expect(names).To(Equal([]string{"Greatest Hits [012345]/01.mp3", "Greatest Hits [fedcba]/01.mp3"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("treats names that sanitize to the same folder as a clash", func() {
|
||||||
|
names := zipArtistEntries(model.MediaFiles{
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "A/B", Year: 2001},
|
||||||
|
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: `A\B`, Year: 2005},
|
||||||
|
})
|
||||||
|
Expect(names).To(Equal([]string{"A_B [2001]/01.mp3", "A_B [2005]/01.mp3"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("sanitizes the suffix", func() {
|
||||||
|
conf.Server.Subsonic.AppendAlbumVersion = false
|
||||||
|
names := zipArtistEntries(model.MediaFiles{
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Hits", Tags: model.Tags{model.TagAlbumVersion: {"Vinyl"}}},
|
||||||
|
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Hits", Tags: model.Tags{model.TagAlbumVersion: {"CD/Digital"}}},
|
||||||
|
})
|
||||||
|
Expect(names).To(Equal([]string{"Hits [Vinyl]/01.mp3", "Hits [CD_Digital]/01.mp3"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("leaves the folder name alone when only one album has it", func() {
|
||||||
|
names := zipArtistEntries(model.MediaFiles{
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
|
||||||
|
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Other", Year: 2005},
|
||||||
|
})
|
||||||
|
Expect(names).To(Equal([]string{"Greatest Hits/01.mp3", "Other/01.mp3"}))
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Context("when the transcode limiter rejects a file", func() {
|
Context("when the transcode limiter rejects a file", func() {
|
||||||
|
|
@ -105,7 +265,7 @@ var _ = Describe("Archiver", func() {
|
||||||
Filters: squirrel.Eq{"album_id": "1"},
|
Filters: squirrel.Eq{"album_id": "1"},
|
||||||
Sort: "album",
|
Sort: "album",
|
||||||
}}).Return(mfs, nil)
|
}}).Return(mfs, nil)
|
||||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
ds.On("MediaFile").Return(mfRepo)
|
||||||
|
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).
|
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).
|
||||||
Return(nil, stream.ErrTooManyTranscodes).Once()
|
Return(nil, stream.ErrTooManyTranscodes).Once()
|
||||||
|
|
@ -155,6 +315,30 @@ var _ = Describe("Archiver", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
Context("ZipPlaylist", func() {
|
Context("ZipPlaylist", func() {
|
||||||
|
It("names the entries and the M3U lines after the resolved format", func() {
|
||||||
|
pls := &model.Playlist{ID: "1", Name: "Test Playlist", Tracks: []model.PlaylistTrack{
|
||||||
|
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.flac", Suffix: "flac", Artist: "Artist 1", Title: "track1"}},
|
||||||
|
}}
|
||||||
|
plRepo := &mockPlaylistRepository{}
|
||||||
|
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
|
||||||
|
ds.On("Playlist").Return(plRepo)
|
||||||
|
dc.resolved = &stream.Request{Format: "opus", BitRate: 128}
|
||||||
|
ms.On("NewStream", mock.Anything, mock.Anything, *dc.resolved).Return(io.NopCloser(strings.NewReader("test")), nil)
|
||||||
|
|
||||||
|
out := new(bytes.Buffer)
|
||||||
|
Expect(arch.ZipPlaylist(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed())
|
||||||
|
|
||||||
|
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(zr.File[0].Name).To(Equal("01 - Artist 1 - track1.opus"))
|
||||||
|
m3u, err := zr.File[1].Open()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
defer m3u.Close()
|
||||||
|
content, err := io.ReadAll(m3u)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(string(content)).To(ContainSubstring("01 - Artist 1 - track1.opus"))
|
||||||
|
})
|
||||||
|
|
||||||
It("zips a playlist correctly", func() {
|
It("zips a playlist correctly", func() {
|
||||||
tracks := []model.PlaylistTrack{
|
tracks := []model.PlaylistTrack{
|
||||||
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "AC/DC", Title: "track1"}},
|
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "AC/DC", Title: "track1"}},
|
||||||
|
|
@ -169,7 +353,7 @@ var _ = Describe("Archiver", func() {
|
||||||
|
|
||||||
plRepo := &mockPlaylistRepository{}
|
plRepo := &mockPlaylistRepository{}
|
||||||
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
|
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
|
||||||
ds.On("Playlist", mock.Anything).Return(plRepo)
|
ds.On("Playlist").Return(plRepo)
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
out := new(bytes.Buffer)
|
||||||
|
|
@ -196,24 +380,195 @@ var _ = Describe("Archiver", func() {
|
||||||
Expect(string(m3uContent)).To(Equal(expectedM3U))
|
Expect(string(m3uContent)).To(Equal(expectedM3U))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
Context("cover art", func() {
|
||||||
|
var (
|
||||||
|
jpegData = []byte("\xff\xd8\xff\xe0 fake jpeg")
|
||||||
|
pngData = []byte("\x89PNG\x0d\x0a\x1a\x0a fake png")
|
||||||
|
)
|
||||||
|
|
||||||
|
mockAlbumTracks := func(filter squirrel.Sqlizer, mfs model.MediaFiles) {
|
||||||
|
mfRepo := &mockMediaFileRepository{}
|
||||||
|
mfRepo.On("GetAll", []model.QueryOptions{{Filters: filter, Sort: "album"}}).Return(mfs, nil)
|
||||||
|
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||||
|
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
It("adds the album cover to the album folder", func() {
|
||||||
|
ca.images["al-1"] = jpegData
|
||||||
|
mockAlbumTracks(squirrel.Eq{"album_id": "1"}, model.MediaFiles{
|
||||||
|
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1},
|
||||||
|
})
|
||||||
|
|
||||||
|
out := new(bytes.Buffer)
|
||||||
|
Expect(arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
||||||
|
|
||||||
|
files := readZip(out)
|
||||||
|
Expect(files).To(HaveLen(2))
|
||||||
|
Expect(files).To(HaveKeyWithValue("Album_Promo/folder.jpg", jpegData))
|
||||||
|
Expect(ca.requests).To(ConsistOf(coverRequest{id: "al-1", size: 500, square: false}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("adds the artist image to the root and each album cover to its folder", func() {
|
||||||
|
ca.images["ar-1"] = pngData
|
||||||
|
ca.images["al-1"] = jpegData
|
||||||
|
ca.images["al-2"] = jpegData
|
||||||
|
mockAlbumTracks(squirrel.And{
|
||||||
|
persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist),
|
||||||
|
squirrel.Eq{"missing": false},
|
||||||
|
}, model.MediaFiles{
|
||||||
|
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1},
|
||||||
|
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "2", Album: "Album 2", DiscNumber: 1},
|
||||||
|
})
|
||||||
|
|
||||||
|
out := new(bytes.Buffer)
|
||||||
|
Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
||||||
|
|
||||||
|
files := readZip(out)
|
||||||
|
Expect(files).To(HaveLen(5))
|
||||||
|
Expect(files).To(HaveKeyWithValue("folder.png", pngData))
|
||||||
|
Expect(files).To(HaveKeyWithValue("Album 1/folder.jpg", jpegData))
|
||||||
|
Expect(files).To(HaveKeyWithValue("Album 2/folder.jpg", jpegData))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("puts each same-named album's cover in that album's own folder", func() {
|
||||||
|
ca.images["al-1"] = jpegData
|
||||||
|
ca.images["al-2"] = pngData
|
||||||
|
mockAlbumTracks(squirrel.And{
|
||||||
|
persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist),
|
||||||
|
squirrel.Eq{"missing": false},
|
||||||
|
}, model.MediaFiles{
|
||||||
|
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001, DiscNumber: 1},
|
||||||
|
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005, DiscNumber: 1},
|
||||||
|
})
|
||||||
|
|
||||||
|
out := new(bytes.Buffer)
|
||||||
|
Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
||||||
|
|
||||||
|
files := readZip(out)
|
||||||
|
Expect(files).To(HaveKeyWithValue("Greatest Hits [2001]/folder.jpg", jpegData))
|
||||||
|
Expect(files).To(HaveKeyWithValue("Greatest Hits [2005]/folder.png", pngData))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("adds the playlist cover to the root", func() {
|
||||||
|
ca.images["pl-1"] = jpegData
|
||||||
|
plRepo := &mockPlaylistRepository{}
|
||||||
|
plRepo.On("GetWithTracks", "1", true, false).Return(&model.Playlist{
|
||||||
|
ID: "1",
|
||||||
|
Name: "Test Playlist",
|
||||||
|
Tracks: []model.PlaylistTrack{
|
||||||
|
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Artist: "Artist 1", Title: "track1"}},
|
||||||
|
},
|
||||||
|
}, nil)
|
||||||
|
ds.On("Playlist", mock.Anything).Return(plRepo)
|
||||||
|
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
|
||||||
|
|
||||||
|
out := new(bytes.Buffer)
|
||||||
|
Expect(arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
||||||
|
|
||||||
|
files := readZip(out)
|
||||||
|
Expect(files).To(HaveLen(3))
|
||||||
|
Expect(files).To(HaveKeyWithValue("folder.jpg", jpegData))
|
||||||
|
Expect(files).To(HaveKey("Test Playlist.m3u"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("adds the shared item's cover to the root, even for a private playlist", func() {
|
||||||
|
ca.images["pl-10"] = jpegData
|
||||||
|
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
|
||||||
|
share := &model.Share{
|
||||||
|
ID: "1",
|
||||||
|
Downloadable: true,
|
||||||
|
Format: "mp3",
|
||||||
|
MaxBitRate: 128,
|
||||||
|
ResourceType: "playlist",
|
||||||
|
ResourceIDs: "10",
|
||||||
|
Tracks: model.MediaFiles{
|
||||||
|
{ID: "1", Path: "test_data/01 - track1.mp3", Suffix: "mp3", Artist: "Artist 1", Title: "track1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out := new(bytes.Buffer)
|
||||||
|
Expect(arch.ZipShare(context.Background(), share, out)).To(Succeed())
|
||||||
|
|
||||||
|
files := readZip(out)
|
||||||
|
Expect(files).To(HaveLen(2))
|
||||||
|
Expect(files).To(HaveKeyWithValue("folder.jpg", jpegData))
|
||||||
|
Expect(ca.requests).To(ConsistOf(coverRequest{id: "pl-10", size: 500, square: false, admin: true}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("still builds the archive when the cover cannot be read", func() {
|
||||||
|
ca.err = errors.New("boom")
|
||||||
|
mockAlbumTracks(squirrel.Eq{"album_id": "1"}, model.MediaFiles{
|
||||||
|
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
|
||||||
|
})
|
||||||
|
|
||||||
|
out := new(bytes.Buffer)
|
||||||
|
Expect(arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
||||||
|
|
||||||
|
files := readZip(out)
|
||||||
|
Expect(files).To(HaveLen(1))
|
||||||
|
Expect(files).To(HaveKey("Album/01 - track1.mp3"))
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
func readZip(out *bytes.Buffer) map[string][]byte {
|
||||||
|
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
files := make(map[string][]byte, len(zr.File))
|
||||||
|
for _, f := range zr.File {
|
||||||
|
r, err := f.Open()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
_ = r.Close()
|
||||||
|
files[f.Name] = data
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
type coverRequest struct {
|
||||||
|
id string
|
||||||
|
size int
|
||||||
|
square bool
|
||||||
|
admin bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockCoverArt struct {
|
||||||
|
artwork.Artwork
|
||||||
|
images map[string][]byte
|
||||||
|
err error
|
||||||
|
requests []coverRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockCoverArt) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*artwork.Image, error) {
|
||||||
|
user, _ := request.UserFrom(ctx)
|
||||||
|
m.requests = append(m.requests, coverRequest{id: artID.String(), size: size, square: square, admin: user.IsAdmin})
|
||||||
|
if m.err != nil {
|
||||||
|
return nil, m.err
|
||||||
|
}
|
||||||
|
data, ok := m.images[artID.String()]
|
||||||
|
if !ok {
|
||||||
|
return nil, artwork.ErrUnavailable
|
||||||
|
}
|
||||||
|
return &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader(data))}, nil
|
||||||
|
}
|
||||||
|
|
||||||
type mockDataStore struct {
|
type mockDataStore struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
model.DataStore
|
model.DataStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository {
|
func (m *mockDataStore) MediaFile() model.MediaFileRepository {
|
||||||
args := m.Called(ctx)
|
args := m.Called()
|
||||||
return args.Get(0).(model.MediaFileRepository)
|
return args.Get(0).(model.MediaFileRepository)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDataStore) Playlist(ctx context.Context) model.PlaylistRepository {
|
func (m *mockDataStore) Playlist() model.PlaylistRepository {
|
||||||
args := m.Called(ctx)
|
args := m.Called()
|
||||||
return args.Get(0).(model.PlaylistRepository)
|
return args.Get(0).(model.PlaylistRepository)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDataStore) Library(context.Context) model.LibraryRepository {
|
func (m *mockDataStore) Library() model.LibraryRepository {
|
||||||
return &mockLibraryRepository{}
|
return &mockLibraryRepository{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,7 +577,7 @@ type mockLibraryRepository struct {
|
||||||
model.LibraryRepository
|
model.LibraryRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockLibraryRepository) GetPath(id int) (string, error) {
|
func (m *mockLibraryRepository) GetPath(_ context.Context, id int) (string, error) {
|
||||||
return "/music", nil
|
return "/music", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -231,7 +586,7 @@ type mockMediaFileRepository struct {
|
||||||
model.MediaFileRepository
|
model.MediaFileRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockMediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
func (m *mockMediaFileRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||||
args := m.Called(options)
|
args := m.Called(options)
|
||||||
return args.Get(0).(model.MediaFiles), args.Error(1)
|
return args.Get(0).(model.MediaFiles), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
@ -241,7 +596,7 @@ type mockPlaylistRepository struct {
|
||||||
model.PlaylistRepository
|
model.PlaylistRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockPlaylistRepository) GetWithTracks(id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) {
|
func (m *mockPlaylistRepository) GetWithTracks(_ context.Context, id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) {
|
||||||
args := m.Called(id, refreshSmartPlaylists, includeMissing)
|
args := m.Called(id, refreshSmartPlaylists, includeMissing)
|
||||||
return args.Get(0).(*model.Playlist), args.Error(1)
|
return args.Get(0).(*model.Playlist), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
@ -259,6 +614,19 @@ func (m *mockMediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile,
|
||||||
return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
|
return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fakeDecider echoes the legacy format/bitrate unless a resolved request is set.
|
||||||
|
type fakeDecider struct {
|
||||||
|
stream.TranscodeDecider
|
||||||
|
resolved *stream.Request
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDecider) ResolveRequest(_ context.Context, _ *model.MediaFile, format string, bitRate int, offset int) stream.Request {
|
||||||
|
if f.resolved != nil {
|
||||||
|
return *f.resolved
|
||||||
|
}
|
||||||
|
return stream.Request{Format: format, BitRate: bitRate, Offset: offset}
|
||||||
|
}
|
||||||
|
|
||||||
type mockShare struct {
|
type mockShare struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
core.Share
|
core.Share
|
||||||
|
|
|
||||||
|
|
@ -59,21 +59,21 @@ func entityExists(ctx context.Context, ds model.DataStore, artID model.ArtworkID
|
||||||
var err error
|
var err error
|
||||||
switch artID.Kind {
|
switch artID.Kind {
|
||||||
case model.KindArtistArtwork:
|
case model.KindArtistArtwork:
|
||||||
found, err = ds.Artist(ctx).Exists(artID.ID)
|
found, err = ds.Artist().Exists(ctx, artID.ID)
|
||||||
case model.KindAlbumArtwork:
|
case model.KindAlbumArtwork:
|
||||||
found, err = ds.Album(ctx).Exists(artID.ID)
|
found, err = ds.Album().Exists(ctx, artID.ID)
|
||||||
case model.KindMediaFileArtwork:
|
case model.KindMediaFileArtwork:
|
||||||
found, err = ds.MediaFile(ctx).Exists(artID.ID)
|
found, err = ds.MediaFile().Exists(ctx, artID.ID)
|
||||||
case model.KindPlaylistArtwork:
|
case model.KindPlaylistArtwork:
|
||||||
found, err = ds.Playlist(ctx).Exists(artID.ID)
|
found, err = ds.Playlist().Exists(ctx, artID.ID)
|
||||||
case model.KindRadioArtwork:
|
case model.KindRadioArtwork:
|
||||||
found, err = ds.Radio(ctx).Exists(artID.ID)
|
found, err = ds.Radio().Exists(ctx, artID.ID)
|
||||||
case model.KindDiscArtwork:
|
case model.KindDiscArtwork:
|
||||||
albumID, _, perr := model.ParseDiscArtworkID(artID.ID)
|
albumID, _, perr := model.ParseDiscArtworkID(artID.ID)
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
found, err = ds.Album(ctx).Exists(albumID)
|
found, err = ds.Album().Exists(ctx, albumID)
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -119,7 +119,7 @@ func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, squa
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
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)
|
ia, err := s.ds.Artwork().GetItemArtwork(ctx, artID.Kind, artID.ID, model.ImageTypePrimary)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, model.ErrNotFound):
|
case errors.Is(err, model.ErrNotFound):
|
||||||
return s.provisional(ctx, artID, size, square)
|
return s.provisional(ctx, artID, size, square)
|
||||||
|
|
@ -173,7 +173,7 @@ func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *mode
|
||||||
log.Warn(ctx, "Artwork: Stored source is not an image file, re-resolving", "artID", artID, "path", ia.SourcePath)
|
log.Warn(ctx, "Artwork: Stored source is not an image file, re-resolving", "artID", artID, "path", ia.SourcePath)
|
||||||
return s.dangling(ctx, artID)
|
return s.dangling(ctx, artID)
|
||||||
}
|
}
|
||||||
art, err := s.ds.Artwork(ctx).GetImage(ia.Hash)
|
art, err := s.ds.Artwork().GetImage(ctx, ia.Hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
return s.dangling(ctx, artID)
|
return s.dangling(ctx, artID)
|
||||||
|
|
@ -264,13 +264,13 @@ func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, siz
|
||||||
// The setting is not in the config fingerprint, so honor it at serve time: a direct mf- URL
|
// 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.
|
// must fall back to disc/album instead of serving stale persisted embedded art.
|
||||||
if !conf.Server.EnableMediaFileCoverArt {
|
if !conf.Server.EnableMediaFileCoverArt {
|
||||||
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
|
mf, err := s.ds.MediaFile().Get(ctx, artID.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
|
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
|
||||||
}
|
}
|
||||||
ia, err := s.ds.Artwork(ctx).GetItemArtwork(model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary)
|
ia, err := s.ds.Artwork().GetItemArtwork(ctx, model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary)
|
||||||
switch {
|
switch {
|
||||||
case err == nil && ia.Hash != "":
|
case err == nil && ia.Hash != "":
|
||||||
return s.serveHash(ctx, artID, ia, size, square)
|
return s.serveHash(ctx, artID, ia, size, square)
|
||||||
|
|
@ -283,7 +283,7 @@ func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, siz
|
||||||
}
|
}
|
||||||
noRow := errors.Is(err, model.ErrNotFound)
|
noRow := errors.Is(err, model.ErrNotFound)
|
||||||
|
|
||||||
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
|
mf, err := s.ds.MediaFile().Get(ctx, artID.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -342,7 +342,7 @@ func (s *service) dangling(ctx context.Context, artID model.ArtworkID) (*Image,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) {
|
func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) {
|
||||||
err := s.ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
err := s.ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{
|
||||||
ItemKind: artID.Kind.Prefix(),
|
ItemKind: artID.Kind.Prefix(),
|
||||||
ItemID: artID.ID,
|
ItemID: artID.ID,
|
||||||
ImageType: model.ImageTypePrimary,
|
ImageType: model.ImageTypePrimary,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package artwork
|
package artwork
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
@ -108,15 +109,15 @@ type fakeFolderRepo struct {
|
||||||
otherAudioErr error
|
otherAudioErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) {
|
func (f *fakeFolderRepo) GetAll(context.Context, ...model.QueryOptions) ([]model.Folder, error) {
|
||||||
return f.result, f.err
|
return f.result, f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
|
func (f *fakeFolderRepo) HasAudioOutsideFolders(context.Context, model.Folder, []string) (bool, error) {
|
||||||
return f.hasOtherAudio, f.otherAudioErr
|
return f.hasOtherAudio, f.otherAudioErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFolderRepo) Get(string) (*model.Folder, error) {
|
func (f *fakeFolderRepo) Get(context.Context, string) (*model.Folder, error) {
|
||||||
f.getCallCount++
|
f.getCallCount++
|
||||||
if f.getErr != nil {
|
if f.getErr != nil {
|
||||||
return nil, f.getErr
|
return nil, f.getErr
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,8 @@ var _ = Describe("Artwork", func() {
|
||||||
hash, err := hashImage(bytes.NewReader(imgBytes))
|
hash, err := hashImage(bytes.NewReader(imgBytes))
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(store.Write(hash, "image/jpeg", bytes.NewReader(imgBytes))).To(Succeed())
|
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.PutImage(ctx, &model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed())
|
||||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed())
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed())
|
||||||
seedEntity(kind, id)
|
seedEntity(kind, id)
|
||||||
return hash
|
return hash
|
||||||
}
|
}
|
||||||
|
|
@ -56,9 +56,9 @@ var _ = Describe("Artwork", func() {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
switch kind {
|
switch kind {
|
||||||
case "al":
|
case "al":
|
||||||
Expect(albumRepo.Put(&model.Album{ID: id, Name: "Album"})).To(Succeed())
|
Expect(albumRepo.Put(ctx, &model.Album{ID: id, Name: "Album"})).To(Succeed())
|
||||||
case "mf":
|
case "mf":
|
||||||
Expect(mfRepo.Put(&model.MediaFile{ID: id})).To(Succeed())
|
Expect(mfRepo.Put(ctx, &model.MediaFile{ID: id})).To(Succeed())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,9 +147,9 @@ var _ = Describe("Artwork", func() {
|
||||||
imgPath := filepath.Join(dir, "cover.jpg")
|
imgPath := filepath.Join(dir, "cover.jpg")
|
||||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||||
mtime := fileMtime(imgPath)
|
mtime := fileMtime(imgPath)
|
||||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed())
|
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed())
|
||||||
seedEntity("al", "al2")
|
seedEntity("al", "al2")
|
||||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "al2", Hash: "aaaaaaaaaaaaaaaa",
|
ItemKind: "al", ItemID: "al2", Hash: "aaaaaaaaaaaaaaaa",
|
||||||
Source: "folder", SourcePath: imgPath, RefMtime: mtime,
|
Source: "folder", SourcePath: imgPath, RefMtime: mtime,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
@ -163,9 +163,9 @@ var _ = Describe("Artwork", func() {
|
||||||
dir := GinkgoT().TempDir()
|
dir := GinkgoT().TempDir()
|
||||||
secretPath := filepath.Join(dir, "config.ini")
|
secretPath := filepath.Join(dir, "config.ini")
|
||||||
Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed())
|
Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed())
|
||||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "dddddddddddddddd", Mime: "image/jpeg"})).To(Succeed())
|
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "dddddddddddddddd", Mime: "image/jpeg"})).To(Succeed())
|
||||||
seedEntity("al", "alni")
|
seedEntity("al", "alni")
|
||||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "alni", Hash: "dddddddddddddddd",
|
ItemKind: "al", ItemID: "alni", Hash: "dddddddddddddddd",
|
||||||
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
|
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
@ -180,9 +180,9 @@ var _ = Describe("Artwork", func() {
|
||||||
dir := GinkgoT().TempDir()
|
dir := GinkgoT().TempDir()
|
||||||
secretPath := filepath.Join(dir, "config.ini")
|
secretPath := filepath.Join(dir, "config.ini")
|
||||||
Expect(os.WriteFile(secretPath, secret, 0600)).To(Succeed())
|
Expect(os.WriteFile(secretPath, secret, 0600)).To(Succeed())
|
||||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "eeeeeeeeeeeeeeee", Mime: "image/jpeg"})).To(Succeed())
|
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "eeeeeeeeeeeeeeee", Mime: "image/jpeg"})).To(Succeed())
|
||||||
seedEntity("al", "alnic")
|
seedEntity("al", "alnic")
|
||||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "alnic", Hash: "eeeeeeeeeeeeeeee",
|
ItemKind: "al", ItemID: "alnic", Hash: "eeeeeeeeeeeeeeee",
|
||||||
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
|
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
@ -210,9 +210,9 @@ var _ = Describe("Artwork", func() {
|
||||||
dir := GinkgoT().TempDir()
|
dir := GinkgoT().TempDir()
|
||||||
imgPath := filepath.Join(dir, "cover.jpg")
|
imgPath := filepath.Join(dir, "cover.jpg")
|
||||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed())
|
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed())
|
||||||
seedEntity("al", "al3")
|
seedEntity("al", "al3")
|
||||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "al3", Hash: "bbbbbbbbbbbbbbbb",
|
ItemKind: "al", ItemID: "al3", Hash: "bbbbbbbbbbbbbbbb",
|
||||||
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
@ -220,7 +220,7 @@ var _ = Describe("Artwork", func() {
|
||||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al3"), 0, false)
|
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al3"), 0, false)
|
||||||
Expect(err).To(MatchError(ErrUnavailable))
|
Expect(err).To(MatchError(ErrUnavailable))
|
||||||
Expect(queueRepo.Data[primaryKey("al", "al3")].Priority).To(Equal(model.ArtworkPriorityScan))
|
Expect(queueRepo.Data[primaryKey("al", "al3")].Priority).To(Equal(model.ArtworkPriorityScan))
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb"))
|
Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb"))
|
||||||
})
|
})
|
||||||
|
|
@ -229,9 +229,9 @@ var _ = Describe("Artwork", func() {
|
||||||
dir := GinkgoT().TempDir()
|
dir := GinkgoT().TempDir()
|
||||||
imgPath := filepath.Join(dir, "cover.jpg")
|
imgPath := filepath.Join(dir, "cover.jpg")
|
||||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed())
|
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed())
|
||||||
seedEntity("al", "al3b")
|
seedEntity("al", "al3b")
|
||||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "al3b", Hash: "cccccccccccccccc",
|
ItemKind: "al", ItemID: "al3b", Hash: "cccccccccccccccc",
|
||||||
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
@ -252,7 +252,7 @@ var _ = Describe("Artwork", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("never re-enqueues an absent state on view, however old", func() {
|
It("never re-enqueues an absent state on view, however old", func() {
|
||||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now().Add(-365 * 24 * time.Hour),
|
ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now().Add(-365 * 24 * time.Hour),
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -272,7 +272,7 @@ var _ = Describe("Artwork", func() {
|
||||||
Expect(readAll(img)).To(Equal(coverBytes))
|
Expect(readAll(img)).To(Equal(coverBytes))
|
||||||
|
|
||||||
Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump))
|
Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||||
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -283,7 +283,7 @@ var _ = Describe("Artwork", func() {
|
||||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al6"), 0, false)
|
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al6"), 0, false)
|
||||||
Expect(err).To(MatchError(ErrUnavailable))
|
Expect(err).To(MatchError(ErrUnavailable))
|
||||||
Expect(queueRepo.Data[primaryKey("al", "al6")].Priority).To(Equal(model.ArtworkPriorityBump))
|
Expect(queueRepo.Data[primaryKey("al", "al6")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||||
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -310,7 +310,7 @@ var _ = Describe("Artwork", func() {
|
||||||
|
|
||||||
It("delegates to the album when the track's state is absent", func() {
|
It("delegates to the album when the track's state is absent", func() {
|
||||||
seedFoundStore("al", "albm", coverBytes)
|
seedFoundStore("al", "albm", coverBytes)
|
||||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed())
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed())
|
||||||
mfRepo.SetData(model.MediaFiles{{ID: "mf2", AlbumID: "albm"}})
|
mfRepo.SetData(model.MediaFiles{{ID: "mf2", AlbumID: "albm"}})
|
||||||
|
|
||||||
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf2"), 0, false)
|
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf2"), 0, false)
|
||||||
|
|
@ -343,7 +343,7 @@ var _ = Describe("Artwork", func() {
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
||||||
Expect(queueRepo.Data[primaryKey("mf", "mf4")].Priority).To(Equal(model.ArtworkPriorityBump))
|
Expect(queueRepo.Data[primaryKey("mf", "mf4")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||||
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -494,7 +494,7 @@ var _ = Describe("Artwork", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("falls back to the artist placeholder for an absent artist", func() {
|
It("falls back to the artist placeholder for an absent artist", func() {
|
||||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed())
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed())
|
||||||
|
|
||||||
img, err := svc.GetOrPlaceholder(ctx, "ar-arph", 300, false)
|
img, err := svc.GetOrPlaceholder(ctx, "ar-arph", 300, false)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -535,7 +535,7 @@ var _ = Describe("EntityExists", func() {
|
||||||
artistRepo := tests.CreateMockArtistRepo()
|
artistRepo := tests.CreateMockArtistRepo()
|
||||||
artistRepo.SetData(model.Artists{{ID: "ar1"}})
|
artistRepo.SetData(model.Artists{{ID: "ar1"}})
|
||||||
radioRepo := tests.CreateMockedRadioRepo()
|
radioRepo := tests.CreateMockedRadioRepo()
|
||||||
Expect(radioRepo.Put(&model.Radio{ID: "ra1", Name: "R"})).To(Succeed())
|
Expect(radioRepo.Put(ctx, &model.Radio{ID: "ra1", Name: "R"})).To(Succeed())
|
||||||
ds = &tests.MockDataStore{MockedAlbum: albumRepo, MockedArtist: artistRepo, MockedRadio: radioRepo}
|
ds = &tests.MockDataStore{MockedAlbum: albumRepo, MockedArtist: artistRepo, MockedRadio: radioRepo}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
|
||||||
return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err)
|
return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
al, err := ds.Album(ctx).Get(albumID)
|
al, err := ds.Album().Get(ctx, albumID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +61,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query mediafiles for this album + disc to find folder associations and first track
|
// Query mediafiles for this album + disc to find folder associations and first track
|
||||||
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{
|
||||||
Sort: "track_number",
|
Sort: "track_number",
|
||||||
Order: "ASC",
|
Order: "ASC",
|
||||||
Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber},
|
Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber},
|
||||||
|
|
@ -88,7 +88,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
|
||||||
// Resolve folder IDs to library-relative paths
|
// Resolve folder IDs to library-relative paths
|
||||||
discFoldersRel := make(map[string]bool)
|
discFoldersRel := make(map[string]bool)
|
||||||
if len(folderIDs) > 0 {
|
if len(folderIDs) > 0 {
|
||||||
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{
|
folders, err := ds.Folder().GetAll(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.Eq{"folder.id": folderIDs},
|
Filters: squirrel.Eq{"folder.id": folderIDs},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -44,20 +44,20 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
|
|
||||||
itemFound := func(kind model.Kind, id string) func() bool {
|
itemFound := func(kind model.Kind, id string) func() bool {
|
||||||
return func() bool {
|
return func() bool {
|
||||||
ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, kind, id, model.ImageTypePrimary)
|
||||||
return err == nil && ia.Hash != ""
|
return err == nil && ia.Hash != ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
itemAbsent := func(kind model.Kind, id string) func() bool {
|
itemAbsent := func(kind model.Kind, id string) func() bool {
|
||||||
return func() bool {
|
return func() bool {
|
||||||
ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, kind, id, model.ImageTypePrimary)
|
||||||
return err == nil && ia.Hash == ""
|
return err == nil && ia.Hash == ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
|
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
|
||||||
bump := func(kind, id string) {
|
bump := func(kind, id string) {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
Expect(ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
Expect(ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{
|
||||||
ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary,
|
ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary,
|
||||||
Priority: model.ArtworkPriorityBump,
|
Priority: model.ArtworkPriorityBump,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
@ -141,7 +141,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("al", "al1")
|
bump("al", "al1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("folder"))
|
Expect(ia.Source).To(Equal("folder"))
|
||||||
|
|
||||||
|
|
@ -158,7 +158,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("ar", "ar1")
|
bump("ar", "ar1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindArtistArtwork, "ar1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindArtistArtwork, "ar1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("upload"))
|
Expect(ia.Source).To(Equal("upload"))
|
||||||
|
|
||||||
|
|
@ -175,14 +175,14 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("pl", "pl1")
|
bump("pl", "pl1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindPlaylistArtwork, "pl1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindPlaylistArtwork, "pl1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("generated"))
|
Expect(ia.Source).To(Equal("generated"))
|
||||||
|
|
||||||
img, err := svc.Get(ctx, model.MustParseArtworkID("pl-pl1"), 0, false)
|
img, err := svc.Get(ctx, model.MustParseArtworkID("pl-pl1"), 0, false)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(img.Hash).To(Equal(ia.Hash))
|
Expect(img.Hash).To(Equal(ia.Hash))
|
||||||
art, err := artRepo.GetImage(ia.Hash)
|
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(art.Mime).To(Equal("image/png"))
|
Expect(art.Mime).To(Equal("image/png"))
|
||||||
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
||||||
|
|
@ -194,7 +194,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("ra", "ra1")
|
bump("ra", "ra1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("upload"))
|
Expect(ia.Source).To(Equal("upload"))
|
||||||
|
|
||||||
|
|
@ -216,12 +216,12 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
provisionalBytes := readAll(provisional)
|
provisionalBytes := readAll(provisional)
|
||||||
Expect(len(provisionalBytes)).To(BeNumerically(">", 0))
|
Expect(len(provisionalBytes)).To(BeNumerically(">", 0))
|
||||||
|
|
||||||
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound), "provisional serving must not write a state row")
|
Expect(err).To(MatchError(model.ErrNotFound), "provisional serving must not write a state row")
|
||||||
|
|
||||||
// The provisional read enqueued a Bump; drain it.
|
// The provisional read enqueued a Bump; drain it.
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindMediaFileArtwork, "mf1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindMediaFileArtwork, "mf1"))
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("embedded"))
|
Expect(ia.Source).To(Equal("embedded"))
|
||||||
Expect(ia.Hash).To(Equal(provisional.Hash))
|
Expect(ia.Hash).To(Equal(provisional.Hash))
|
||||||
|
|
@ -237,9 +237,9 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("al", "al1")
|
bump("al", "al1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
art, err := artRepo.GetImage(ia.Hash)
|
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(art.Mime).To(Equal("image/jpeg"))
|
Expect(art.Mime).To(Equal("image/jpeg"))
|
||||||
Expect(art.Width).To(BeNumerically(">", 0))
|
Expect(art.Width).To(BeNumerically(">", 0))
|
||||||
|
|
@ -259,9 +259,9 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("ra", "ra1")
|
bump("ra", "ra1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
art, err := artRepo.GetImage(ia.Hash)
|
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(art.Mime).To(Equal("image/gif"))
|
Expect(art.Mime).To(Equal("image/gif"))
|
||||||
Expect(art.Width).To(BeNumerically("==", 4))
|
Expect(art.Width).To(BeNumerically("==", 4))
|
||||||
|
|
@ -279,9 +279,9 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
return itemFound(model.KindAlbumArtwork, "al1")() && itemFound(model.KindAlbumArtwork, "al2")()
|
return itemFound(model.KindAlbumArtwork, "al1")() && itemFound(model.KindAlbumArtwork, "al2")()
|
||||||
})
|
})
|
||||||
|
|
||||||
ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
ia1, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
|
ia2, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia1.Hash).To(Equal(ia2.Hash), "identical bytes must share one content hash")
|
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))
|
Expect(readAll(mustGet(svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false)))).To(Equal(coverBytes))
|
||||||
|
|
@ -293,7 +293,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("ra", "ra1")
|
bump("ra", "ra1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
staleHash := ia.Hash
|
staleHash := ia.Hash
|
||||||
|
|
||||||
|
|
@ -308,7 +308,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
|
|
||||||
// That failed read enqueued a re-resolution.
|
// That failed read enqueued a re-resolution.
|
||||||
runWorkerUntil(ctx, worker, func() bool {
|
runWorkerUntil(ctx, worker, func() bool {
|
||||||
cur, gerr := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
cur, gerr := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||||
return gerr == nil && cur.Hash != "" && cur.Hash != staleHash
|
return gerr == nil && cur.Hash != "" && cur.Hash != staleHash
|
||||||
})
|
})
|
||||||
img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false)
|
img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false)
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
uploaded := ar.ID + "_upload.jpg"
|
uploaded := ar.ID + "_upload.jpg"
|
||||||
writeUploadedImage(consts.EntityArtist, uploaded, pngBytes("artist-uploaded"))
|
writeUploadedImage(consts.EntityArtist, uploaded, pngBytes("artist-uploaded"))
|
||||||
ar.UploadedImage = uploaded
|
ar.UploadedImage = uploaded
|
||||||
Expect(rds.Artist(rctx).Put(&ar)).To(Succeed())
|
Expect(rds.Artist().Put(rctx, &ar)).To(Succeed())
|
||||||
|
|
||||||
ia := acquire(model.KindArtistArtwork, ar.ID)
|
ia := acquire(model.KindArtistArtwork, ar.ID)
|
||||||
Expect(ia.Source).To(Equal("upload"))
|
Expect(ia.Source).To(Equal("upload"))
|
||||||
|
|
@ -279,7 +279,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
|
|
||||||
func soleArtist() model.Artist {
|
func soleArtist() model.Artist {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
artists, err := rds.Artist(rctx).GetAll(model.QueryOptions{
|
artists, err := rds.Artist().GetAll(rctx, model.QueryOptions{
|
||||||
Filters: squirrel.Eq{"artist.name": "Artist"},
|
Filters: squirrel.Eq{"artist.name": "Artist"},
|
||||||
})
|
})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
|
||||||
|
|
@ -67,13 +67,17 @@ type fakeFolderRepo struct {
|
||||||
result []model.Folder
|
result []model.Folder
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { return f.result, nil }
|
func (f *fakeFolderRepo) GetAll(context.Context, ...model.QueryOptions) ([]model.Folder, error) {
|
||||||
|
return f.result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
|
func (f *fakeFolderRepo) HasAudioOutsideFolders(context.Context, model.Folder, []string) (bool, error) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFolderRepo) Get(string) (*model.Folder, error) { return nil, model.ErrNotFound }
|
func (f *fakeFolderRepo) Get(context.Context, string) (*model.Folder, error) {
|
||||||
|
return nil, model.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
func writeUpload(entityType, name, srcFixture string) string {
|
func writeUpload(entityType, name, srcFixture string) string {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,7 @@ var _ = Describe("MediaFile artwork resolution", func() {
|
||||||
|
|
||||||
func mediafileOn(relPath string) model.MediaFile {
|
func mediafileOn(relPath string) model.MediaFile {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{
|
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{
|
||||||
Filters: squirrel.Like{"media_file.path": relPath},
|
Filters: squirrel.Like{"media_file.path": relPath},
|
||||||
})
|
})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
|
||||||
|
|
@ -142,13 +142,13 @@ var _ = Describe("Playlist artwork resolution", func() {
|
||||||
})
|
})
|
||||||
scan()
|
scan()
|
||||||
|
|
||||||
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{})
|
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(mfs).To(HaveLen(2))
|
Expect(mfs).To(HaveLen(2))
|
||||||
|
|
||||||
pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"}
|
pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"}
|
||||||
pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID})
|
pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID})
|
||||||
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
|
||||||
|
|
||||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||||
Expect(ia.Source).To(Equal("generated"))
|
Expect(ia.Source).To(Equal("generated"))
|
||||||
|
|
@ -180,14 +180,14 @@ var _ = Describe("Playlist artwork resolution", func() {
|
||||||
setLayout(layout)
|
setLayout(layout)
|
||||||
scan()
|
scan()
|
||||||
|
|
||||||
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{})
|
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(mfs).To(HaveLen(4))
|
Expect(mfs).To(HaveLen(4))
|
||||||
ids := slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID })
|
ids := slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID })
|
||||||
|
|
||||||
pl := model.Playlist{ID: "pl-8", Name: "Four", OwnerID: "admin-1"}
|
pl := model.Playlist{ID: "pl-8", Name: "Four", OwnerID: "admin-1"}
|
||||||
pl.AddMediaFilesByID(ids)
|
pl.AddMediaFilesByID(ids)
|
||||||
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
|
||||||
|
|
||||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||||
Expect(ia.Source).To(Equal("generated"))
|
Expect(ia.Source).To(Equal("generated"))
|
||||||
|
|
@ -208,6 +208,6 @@ func putPlaylist(pl model.Playlist) model.Playlist {
|
||||||
if pl.OwnerID == "" {
|
if pl.OwnerID == "" {
|
||||||
pl.OwnerID = "admin-1"
|
pl.OwnerID = "admin-1"
|
||||||
}
|
}
|
||||||
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
|
||||||
return pl
|
return pl
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ var _ = Describe("Radio artwork resolution", func() {
|
||||||
It("returns the uploaded image bytes", func() {
|
It("returns the uploaded image bytes", func() {
|
||||||
writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", pngBytes("radio-logo"))
|
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"}
|
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())
|
Expect(rds.Radio().Put(rctx, &rd)).To(Succeed())
|
||||||
|
|
||||||
ia := acquire(model.KindRadioArtwork, rd.ID)
|
ia := acquire(model.KindRadioArtwork, rd.ID)
|
||||||
Expect(ia.Source).To(Equal("upload"))
|
Expect(ia.Source).To(Equal("upload"))
|
||||||
|
|
@ -35,7 +35,7 @@ var _ = Describe("Radio artwork resolution", func() {
|
||||||
// (no files on disk — the resolver has no sources to fall back to)
|
// (no files on disk — the resolver has no sources to fall back to)
|
||||||
It("settles absent", func() {
|
It("settles absent", func() {
|
||||||
rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"}
|
rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"}
|
||||||
Expect(rds.Radio(rctx).Put(&rd)).To(Succeed())
|
Expect(rds.Radio().Put(rctx, &rd)).To(Succeed())
|
||||||
|
|
||||||
ia := acquire(model.KindRadioArtwork, rd.ID)
|
ia := acquire(model.KindRadioArtwork, rd.ID)
|
||||||
Expect(ia.Hash).To(BeEmpty())
|
Expect(ia.Hash).To(BeEmpty())
|
||||||
|
|
|
||||||
|
|
@ -99,11 +99,11 @@ func setupResolutionHarness() {
|
||||||
rds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
rds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||||
|
|
||||||
adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"}
|
adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"}
|
||||||
Expect(rds.User(rctx).Put(&adminUser)).To(Succeed())
|
Expect(rds.User().Put(rctx, &adminUser)).To(Succeed())
|
||||||
|
|
||||||
lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath}
|
lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath}
|
||||||
Expect(rds.Library(rctx).Put(&lib)).To(Succeed())
|
Expect(rds.Library().Put(rctx, &lib)).To(Succeed())
|
||||||
Expect(rds.User(rctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())
|
Expect(rds.User().SetUserLibraries(rctx, adminUser.ID, []int{lib.ID})).To(Succeed())
|
||||||
|
|
||||||
loadEmbeddedFixture()
|
loadEmbeddedFixture()
|
||||||
|
|
||||||
|
|
@ -140,13 +140,13 @@ func scan() {
|
||||||
func acquire(kind model.Kind, id string) model.ItemArtwork {
|
func acquire(kind model.Kind, id string) model.ItemArtwork {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
|
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
|
||||||
Expect(rds.ArtworkQueue(rctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
Expect(rds.ArtworkQueue().EnqueuePreservingBackoff(rctx, model.ArtworkQueueItem{
|
||||||
ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary,
|
ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary,
|
||||||
Priority: model.ArtworkPriorityBump,
|
Priority: model.ArtworkPriorityBump,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
var ia *model.ItemArtwork
|
var ia *model.ItemArtwork
|
||||||
runResolutionWorkerUntil(func() bool {
|
runResolutionWorkerUntil(func() bool {
|
||||||
got, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
|
got, err := rds.Artwork().GetItemArtwork(rctx, kind, id, model.ImageTypePrimary)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -211,7 +211,7 @@ func expectAlbumFolderCover(al model.Album, suffix string) {
|
||||||
// A drain settles every ready item, so byte-level folder assertions must precede any acquire.
|
// A drain settles every ready item, so byte-level folder assertions must precede any acquire.
|
||||||
func requireNoStateRow(kind model.Kind, id string) {
|
func requireNoStateRow(kind model.Kind, id string) {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
_, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
|
_, err := rds.Artwork().GetItemArtwork(rctx, kind, id, model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound),
|
Expect(err).To(MatchError(model.ErrNotFound),
|
||||||
"assert %s %q before acquiring any other entity in this spec", kind, id)
|
"assert %s %q before acquiring any other entity in this spec", kind, id)
|
||||||
}
|
}
|
||||||
|
|
@ -266,7 +266,7 @@ func gridQuadrants(data []byte) [4]color.RGBA {
|
||||||
// Store-backed sources only (embedded/generated); file-backed ones assert on ia.SourcePath.
|
// Store-backed sources only (embedded/generated); file-backed ones assert on ia.SourcePath.
|
||||||
func storedBytes(ia model.ItemArtwork) []byte {
|
func storedBytes(ia model.ItemArtwork) []byte {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
art, err := rds.Artwork(rctx).GetImage(ia.Hash)
|
art, err := rds.Artwork().GetImage(rctx, ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
r, err := rstore.Open(ia.Hash, art.Mime)
|
r, err := rstore.Open(ia.Hash, art.Mime)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -345,7 +345,7 @@ func replaceWithRealMP3(relPath string) {
|
||||||
|
|
||||||
func firstAlbum() model.Album {
|
func firstAlbum() model.Album {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
albums, err := rds.Album(rctx).GetAll(model.QueryOptions{})
|
albums, err := rds.Album().GetAll(rctx, model.QueryOptions{})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums))
|
Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums))
|
||||||
return albums[0]
|
return albums[0]
|
||||||
|
|
@ -353,7 +353,7 @@ func firstAlbum() model.Album {
|
||||||
|
|
||||||
func albumByName(name string) model.Album {
|
func albumByName(name string) model.Album {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
albums, err := rds.Album(rctx).GetAll(model.QueryOptions{})
|
albums, err := rds.Album().GetAll(rctx, model.QueryOptions{})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
for _, al := range albums {
|
for _, al := range albums {
|
||||||
if al.Name == name {
|
if al.Name == name {
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, album model.
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadFolders(ctx context.Context, ds model.DataStore, folderIDs []string) ([]model.Folder, error) {
|
func loadFolders(ctx context.Context, ds model.DataStore, folderIDs []string) ([]model.Folder, error) {
|
||||||
return ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}})
|
return ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}})
|
||||||
}
|
}
|
||||||
|
|
||||||
// folderImages collects the folders' image files, sorted so files without
|
// folderImages collects the folders' image files, sorted so files without
|
||||||
|
|
@ -79,7 +79,7 @@ func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Fo
|
||||||
if len(folders) < 2 && anyFolderHasImages(folders) {
|
if len(folders) < 2 && anyFolderHasImages(folders) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
parent, err := ds.Folder(ctx).Get(commonParentID)
|
parent, err := ds.Folder().Get(ctx, commonParentID)
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
log.Warn(ctx, "Artwork: Parent folder not found for album cover art lookup", "parentID", commonParentID)
|
log.Warn(ctx, "Artwork: Parent folder not found for album cover art lookup", "parentID", commonParentID)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|
@ -91,7 +91,7 @@ func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Fo
|
||||||
// The library root can never be an album root
|
// The library root can never be an album root
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
hasOtherAudio, err := ds.Folder(ctx).HasAudioOutsideFolders(*parent, folderIDs)
|
hasOtherAudio, err := ds.Folder().HasAudioOutsideFolders(ctx, *parent, folderIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
"github.com/navidrome/navidrome/core"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/utils"
|
"github.com/navidrome/navidrome/utils"
|
||||||
|
|
@ -169,14 +168,15 @@ func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albu
|
||||||
folderPath = filepath.Dir(folderPath)
|
folderPath = filepath.Dir(folderPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Hacky, but the easiest way to get the folder ID ATM
|
// Cleaned like the album paths; Join keeps an empty path empty, Clean would return ".".
|
||||||
libPath := core.AbsolutePath(ctx, ds, libID, "")
|
libPath, _ := ds.Library().GetPath(ctx, libID)
|
||||||
|
libPath = filepath.Join(libPath)
|
||||||
folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath)
|
folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath)
|
||||||
|
|
||||||
log.Trace(ctx, "Artwork: Calculating artist folder details", "folderPath", folderPath, "folderID", folderID,
|
log.Trace(ctx, "Artwork: Calculating artist folder details", "folderPath", folderPath, "folderID", folderID,
|
||||||
"libPath", libPath, "libID", libID, "albumPaths", paths)
|
"libPath", libPath, "libID", libID, "albumPaths", paths)
|
||||||
|
|
||||||
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}})
|
folders, err := ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}})
|
||||||
if err != nil || len(folders) == 0 {
|
if err != nil || len(folders) == 0 {
|
||||||
log.Warn(ctx, "Artwork: Could not find folder for artist", "folderPath", folderPath, "id", folderID,
|
log.Warn(ctx, "Artwork: Could not find folder for artist", "folderPath", folderPath, "id", folderID,
|
||||||
"libPath", libPath, "libID", libID, err)
|
"libPath", libPath, "libID", libID, err)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/core"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
|
@ -47,11 +46,11 @@ var _ = Describe("loadArtistFolder", func() {
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
DeferCleanup(stubCoreAbsolutePath())
|
|
||||||
|
|
||||||
updatedAt = time.Now().Truncate(time.Second).Add(5 * time.Minute)
|
updatedAt = time.Now().Truncate(time.Second).Add(5 * time.Minute)
|
||||||
repo = &fakeFolderRepo{result: []model.Folder{{ImagesUpdatedAt: updatedAt}}}
|
repo = &fakeFolderRepo{result: []model.Folder{{ImagesUpdatedAt: updatedAt}}}
|
||||||
ds = &tests.MockDataStore{MockedFolder: repo}
|
libRepo := &tests.MockLibraryRepo{}
|
||||||
|
libRepo.SetData(model.Libraries{{ID: 1, Path: filepath.FromSlash("/music")}})
|
||||||
|
ds = &tests.MockDataStore{MockedFolder: repo, MockedLibrary: libRepo}
|
||||||
albums = model.Albums{{LibraryID: 1, ID: "album1", Name: "Album 1"}}
|
albums = model.Albums{{LibraryID: 1, ID: "album1", Name: "Album 1"}}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -107,11 +106,3 @@ var _ = Describe("loadArtistFolder", func() {
|
||||||
Expect(upd).To(BeZero())
|
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 }
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ func ConfigFingerprint() string {
|
||||||
// resolved under it. Nothing re-resolves on its own; applying a change is an explicit reprocess.
|
// resolved under it. Nothing re-resolves on its own; applying a change is an explicit reprocess.
|
||||||
func ReconcileConfigFingerprint(ctx context.Context, ds model.DataStore) error {
|
func ReconcileConfigFingerprint(ctx context.Context, ds model.DataStore) error {
|
||||||
current := ConfigFingerprint()
|
current := ConfigFingerprint()
|
||||||
stored, err := ds.Property(ctx).DefaultGet(consts.ArtConfFingerprintPropertyKey, "")
|
stored, err := ds.Property().DefaultGet(ctx, consts.ArtConfFingerprintPropertyKey, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -89,14 +89,14 @@ func ReconcileConfigFingerprint(ctx context.Context, ds model.DataStore) error {
|
||||||
|
|
||||||
// MarkConfigApplied records the current fingerprint as the one the library is resolved under.
|
// MarkConfigApplied records the current fingerprint as the one the library is resolved under.
|
||||||
func MarkConfigApplied(ctx context.Context, ds model.DataStore) error {
|
func MarkConfigApplied(ctx context.Context, ds model.DataStore) error {
|
||||||
return ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())
|
return ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())
|
||||||
}
|
}
|
||||||
|
|
||||||
// enqueueMissingAll is the safety net for entities a scan never enqueued (added between scans, or scanner off).
|
// 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 {
|
func enqueueMissingAll(ctx context.Context, ds model.DataStore) error {
|
||||||
queue := ds.ArtworkQueue(ctx)
|
queue := ds.ArtworkQueue()
|
||||||
for _, kind := range ReprocessKinds {
|
for _, kind := range ReprocessKinds {
|
||||||
if _, err := queue.EnqueueAllMissing(kind, model.ArtworkPriorityRecheck); err != nil {
|
if _, err := queue.EnqueueAllMissing(ctx, kind, model.ArtworkPriorityRecheck); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -108,31 +108,31 @@ func enqueueMissingAll(ctx context.Context, ds model.DataStore) error {
|
||||||
func ItemName(ctx context.Context, ds model.DataStore, kind model.Kind, id string) (string, error) {
|
func ItemName(ctx context.Context, ds model.DataStore, kind model.Kind, id string) (string, error) {
|
||||||
switch kind {
|
switch kind {
|
||||||
case model.KindArtistArtwork:
|
case model.KindArtistArtwork:
|
||||||
ar, err := ds.Artist(ctx).Get(id)
|
ar, err := ds.Artist().Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return ar.Name, nil
|
return ar.Name, nil
|
||||||
case model.KindAlbumArtwork:
|
case model.KindAlbumArtwork:
|
||||||
al, err := ds.Album(ctx).Get(id)
|
al, err := ds.Album().Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return al.Name, nil
|
return al.Name, nil
|
||||||
case model.KindPlaylistArtwork:
|
case model.KindPlaylistArtwork:
|
||||||
pls, err := ds.Playlist(ctx).Get(id)
|
pls, err := ds.Playlist().Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return pls.Name, nil
|
return pls.Name, nil
|
||||||
case model.KindRadioArtwork:
|
case model.KindRadioArtwork:
|
||||||
rd, err := ds.Radio(ctx).Get(id)
|
rd, err := ds.Radio().Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return rd.Name, nil
|
return rd.Name, nil
|
||||||
case model.KindMediaFileArtwork:
|
case model.KindMediaFileArtwork:
|
||||||
mf, err := ds.MediaFile(ctx).Get(id)
|
mf, err := ds.MediaFile().Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -148,7 +148,7 @@ func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
al, err := ds.Album(ctx).Get(albumID)
|
al, err := ds.Album().Get(ctx, albumID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -162,11 +162,11 @@ func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string
|
||||||
|
|
||||||
// Refresh drops an item's resolved artwork state and re-queues it at Bump priority.
|
// 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 {
|
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 {
|
if err := ds.Artwork().DeleteForItems(ctx, kind, []string{id}); err != nil {
|
||||||
return fmt.Errorf("clearing artwork state: %w", err)
|
return fmt.Errorf("clearing artwork state: %w", err)
|
||||||
}
|
}
|
||||||
item := model.ArtworkQueueItem{ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump}
|
item := model.ArtworkQueueItem{ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump}
|
||||||
if err := ds.ArtworkQueue(ctx).Enqueue(item); err != nil {
|
if err := ds.ArtworkQueue().Enqueue(ctx, item); err != nil {
|
||||||
return fmt.Errorf("enqueuing artwork refresh: %w", err)
|
return fmt.Errorf("enqueuing artwork refresh: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -95,15 +95,15 @@ var _ = Describe("Housekeeping", func() {
|
||||||
It("records the current fingerprint when none was ever stored", func() {
|
It("records the current fingerprint when none was ever stored", func() {
|
||||||
Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed())
|
Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed())
|
||||||
|
|
||||||
Expect(propRepo.Get(consts.ArtConfFingerprintPropertyKey)).To(Equal(ConfigFingerprint()))
|
Expect(propRepo.Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal(ConfigFingerprint()))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("leaves a stale fingerprint stored, so the warning survives a restart", func() {
|
It("leaves a stale fingerprint stored, so the warning survives a restart", func() {
|
||||||
Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
Expect(propRepo.Put(ctx, consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
||||||
|
|
||||||
Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed())
|
Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed())
|
||||||
|
|
||||||
Expect(propRepo.Get(consts.ArtConfFingerprintPropertyKey)).To(Equal("stale-fingerprint"))
|
Expect(propRepo.Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal("stale-fingerprint"))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -153,7 +153,7 @@ var _ = Describe("ItemName", func() {
|
||||||
{ID: "al-2", Name: "Sandinista!", Discs: model.Discs{2: "Side Three"}},
|
{ID: "al-2", Name: "Sandinista!", Discs: model.Discs{2: "Side Three"}},
|
||||||
})
|
})
|
||||||
ds = &tests.MockDataStore{MockedAlbum: albumRepo}
|
ds = &tests.MockDataStore{MockedAlbum: albumRepo}
|
||||||
Expect(ds.Artist(ctx).(*tests.MockArtistRepo).Put(&model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed())
|
Expect(ds.Artist().(*tests.MockArtistRepo).Put(ctx, &model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns the album name", func() {
|
It("returns the album name", func() {
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ func (v libraryView) Abs(rel string) string {
|
||||||
// loadLibraryView resolves the MusicFS and absolute root path in a single
|
// loadLibraryView resolves the MusicFS and absolute root path in a single
|
||||||
// library lookup.
|
// library lookup.
|
||||||
func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (libraryView, error) {
|
func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (libraryView, error) {
|
||||||
lib, err := ds.Library(ctx).Get(libID)
|
lib, err := ds.Library().Get(ctx, libID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return libraryView{}, err
|
return libraryView{}, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ var _ = Describe("loadLibraryView", Ordered, func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns a view for a library backed by registered storage", func() {
|
It("returns a view for a library backed by registered storage", func() {
|
||||||
Expect(ds.Library(ctx).Put(&model.Library{ID: 1, Path: "fake:///music"})).To(Succeed())
|
Expect(ds.Library().Put(ctx, &model.Library{ID: 1, Path: "fake:///music"})).To(Succeed())
|
||||||
|
|
||||||
lib, err := loadLibraryView(ctx, ds, 1)
|
lib, err := loadLibraryView(ctx, ds, 1)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -45,7 +45,7 @@ var _ = Describe("loadLibraryView", Ordered, func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns an error when the library path uses an unregistered scheme", func() {
|
It("returns an error when the library path uses an unregistered scheme", func() {
|
||||||
Expect(ds.Library(ctx).Put(&model.Library{ID: 2, Path: "unsupported:///music"})).To(Succeed())
|
Expect(ds.Library().Put(ctx, &model.Library{ID: 2, Path: "unsupported:///music"})).To(Succeed())
|
||||||
_, err := loadLibraryView(ctx, ds, 2)
|
_, err := loadLibraryView(ctx, ds, 2)
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ type processor struct {
|
||||||
// acquire resolves one queue item end to end: find an image, hash/decode/
|
// acquire resolves one queue item end to end: find an image, hash/decode/
|
||||||
// blurhash it, place its bytes, and persist the resulting state.
|
// blurhash it, place its bytes, and persist the resulting state.
|
||||||
func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) {
|
func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) {
|
||||||
repo := p.ds.Artwork(ctx)
|
repo := p.ds.Artwork()
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() {
|
defer func() {
|
||||||
log.Debug(ctx, "Artwork: Acquisition finished", "kind", item.ItemKind, "id", item.ItemID,
|
log.Debug(ctx, "Artwork: Acquisition finished", "kind", item.ItemKind, "id", item.ItemID,
|
||||||
|
|
@ -137,7 +137,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
|
||||||
log.Trace(ctx, "Artwork: Hashed image", "kind", item.ItemKind, "id", item.ItemID,
|
log.Trace(ctx, "Artwork: Hashed image", "kind", item.ItemKind, "id", item.ItemID,
|
||||||
"hash", hash, "bytes", len(data), "elapsed", time.Since(hashStart))
|
"hash", hash, "bytes", len(data), "elapsed", time.Since(hashStart))
|
||||||
|
|
||||||
art, err := repo.GetImage(hash)
|
art, err := repo.GetImage(ctx, hash)
|
||||||
switch {
|
switch {
|
||||||
case err == nil && art.Width > 0:
|
case err == nil && art.Width > 0:
|
||||||
log.Debug(ctx, "Artwork: Reusing a known image, skipping decode", "kind", item.ItemKind,
|
log.Debug(ctx, "Artwork: Reusing a known image, skipping decode", "kind", item.ItemKind,
|
||||||
|
|
@ -195,7 +195,7 @@ func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, i
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("writing image store: %w", err)
|
return nil, fmt.Errorf("writing image store: %w", err)
|
||||||
}
|
}
|
||||||
if err := repo.PutImage(art); err != nil {
|
if err := repo.PutImage(ctx, art); err != nil {
|
||||||
return nil, fmt.Errorf("persisting artwork image: %w", err)
|
return nil, fmt.Errorf("persisting artwork image: %w", err)
|
||||||
}
|
}
|
||||||
ia := &model.ItemArtwork{
|
ia := &model.ItemArtwork{
|
||||||
|
|
@ -210,7 +210,7 @@ func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, i
|
||||||
Trace: traceFrom(ctx).encode(sourcePath),
|
Trace: traceFrom(ctx).encode(sourcePath),
|
||||||
}
|
}
|
||||||
// PutItemArtwork stamps UpdatedAt on ia, so the returned struct matches the persisted row.
|
// PutItemArtwork stamps UpdatedAt on ia, so the returned struct matches the persisted row.
|
||||||
if err := repo.PutItemArtwork(ia); err != nil {
|
if err := repo.PutItemArtwork(ctx, ia); err != nil {
|
||||||
return nil, fmt.Errorf("persisting item artwork state: %w", err)
|
return nil, fmt.Errorf("persisting item artwork state: %w", err)
|
||||||
}
|
}
|
||||||
return ia, nil
|
return ia, nil
|
||||||
|
|
@ -218,7 +218,7 @@ func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, i
|
||||||
|
|
||||||
// writeAbsent records a known-absent state: every source answered definitively "no".
|
// writeAbsent records a known-absent state: every source answered definitively "no".
|
||||||
func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem) outcome {
|
func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem) outcome {
|
||||||
err := repo.PutItemArtwork(&model.ItemArtwork{
|
err := repo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||||
ItemKind: item.ItemKind,
|
ItemKind: item.ItemKind,
|
||||||
ItemID: item.ItemID,
|
ItemID: item.ItemID,
|
||||||
ImageType: item.ImageType,
|
ImageType: item.ImageType,
|
||||||
|
|
|
||||||
|
|
@ -93,14 +93,14 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"})
|
||||||
Expect(out).To(Equal(outcomeFound))
|
Expect(out).To(Equal(outcomeFound))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).ToNot(BeEmpty())
|
Expect(ia.Hash).ToNot(BeEmpty())
|
||||||
Expect(ia.Source).To(Equal("folder"))
|
Expect(ia.Source).To(Equal("folder"))
|
||||||
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg"))
|
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg"))
|
||||||
Expect(ia.RefMtime).To(BeNumerically(">", 0))
|
Expect(ia.RefMtime).To(BeNumerically(">", 0))
|
||||||
|
|
||||||
art, err := artRepo.GetImage(ia.Hash)
|
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
// Every placeholder is derived from the one shared thumbnail, so all three land together.
|
// Every placeholder is derived from the one shared thumbnail, so all three land together.
|
||||||
Expect(art.BlurHash).ToNot(BeEmpty())
|
Expect(art.BlurHash).ToNot(BeEmpty())
|
||||||
|
|
@ -156,12 +156,12 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"})
|
||||||
Expect(out).To(Equal(outcomeFound))
|
Expect(out).To(Equal(outcomeFound))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("embedded"))
|
Expect(ia.Source).To(Equal("embedded"))
|
||||||
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3"))
|
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3"))
|
||||||
|
|
||||||
art, err := artRepo.GetImage(ia.Hash)
|
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(art.BlurHash).ToNot(BeEmpty())
|
Expect(art.BlurHash).ToNot(BeEmpty())
|
||||||
|
|
||||||
|
|
@ -179,7 +179,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"})
|
||||||
Expect(out).To(Equal(outcomeAbsent))
|
Expect(out).To(Equal(outcomeAbsent))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).To(BeEmpty())
|
Expect(ia.Hash).To(BeEmpty())
|
||||||
Expect(ia.Source).To(BeEmpty())
|
Expect(ia.Source).To(BeEmpty())
|
||||||
|
|
@ -200,7 +200,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-io"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-io"})
|
||||||
Expect(out).To(Equal(outcomeFailed))
|
Expect(out).To(Equal(outcomeFailed))
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al-io", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-io", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound), "an I/O fault must not be recorded as absent")
|
Expect(err).To(MatchError(model.ErrNotFound), "an I/O fault must not be recorded as absent")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -225,7 +225,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-io"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-io"})
|
||||||
Expect(out).To(Equal(outcomeFailed))
|
Expect(out).To(Equal(outcomeFailed))
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra-io", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra-io", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound), "an unreadable upload must not be recorded as absent")
|
Expect(err).To(MatchError(model.ErrNotFound), "an unreadable upload must not be recorded as absent")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -269,7 +269,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
Expect(out).To(Equal(outcomeFailed))
|
Expect(out).To(Equal(outcomeFailed))
|
||||||
Expect(retryIn).To(BeZero(), "a plain failure asks for no particular delay")
|
Expect(retryIn).To(BeZero(), "a plain failure asks for no particular delay")
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -299,7 +299,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})
|
||||||
Expect(out).To(Equal(outcomeFoundStale))
|
Expect(out).To(Equal(outcomeFoundStale))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).ToNot(BeEmpty())
|
Expect(ia.Hash).ToNot(BeEmpty())
|
||||||
Expect(ia.Source).To(Equal("folder"))
|
Expect(ia.Source).To(Equal("folder"))
|
||||||
|
|
@ -316,10 +316,10 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alU"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alU"})
|
||||||
Expect(out).To(Equal(outcomeFound))
|
Expect(out).To(Equal(outcomeFound))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alU", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alU", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("folder"))
|
Expect(ia.Source).To(Equal("folder"))
|
||||||
art, err := artRepo.GetImage(ia.Hash)
|
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(art.Width).To(BeZero())
|
Expect(art.Width).To(BeZero())
|
||||||
Expect(art.BlurHash).To(BeEmpty())
|
Expect(art.BlurHash).To(BeEmpty())
|
||||||
|
|
@ -336,7 +336,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"})
|
||||||
Expect(out).To(Equal(outcomeFailed))
|
Expect(out).To(Equal(outcomeFailed))
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alE", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alE", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -354,7 +354,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"})
|
||||||
Expect(out).To(Equal(outcomeFailed))
|
Expect(out).To(Equal(outcomeFailed))
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alX", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alX", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -373,12 +373,12 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"})
|
||||||
Expect(out).To(Equal(outcomeFound))
|
Expect(out).To(Equal(outcomeFound))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alext", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alext", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("external:deezerFake"))
|
Expect(ia.Source).To(Equal("external:deezerFake"))
|
||||||
Expect(ia.Hash).ToNot(BeEmpty())
|
Expect(ia.Hash).ToNot(BeEmpty())
|
||||||
|
|
||||||
art, err := artRepo.GetImage(ia.Hash)
|
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
rc, err := store.Open(ia.Hash, art.Mime)
|
rc, err := store.Open(ia.Hash, art.Mime)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -397,7 +397,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
|
|
||||||
out1, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"})
|
out1, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"})
|
||||||
Expect(out1).To(Equal(outcomeFound))
|
Expect(out1).To(Equal(outcomeFound))
|
||||||
ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
ia1, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
// A re-decode instead of a hash dedup would overwrite this sentinel.
|
// A re-decode instead of a hash dedup would overwrite this sentinel.
|
||||||
|
|
@ -407,11 +407,11 @@ var _ = Describe("processor.acquire", func() {
|
||||||
|
|
||||||
out2, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"})
|
out2, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"})
|
||||||
Expect(out2).To(Equal(outcomeFound))
|
Expect(out2).To(Equal(outcomeFound))
|
||||||
ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
ia2, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia2.Hash).To(Equal(ia1.Hash))
|
Expect(ia2.Hash).To(Equal(ia1.Hash))
|
||||||
|
|
||||||
reused, err := artRepo.GetImage(ia1.Hash)
|
reused, err := artRepo.GetImage(ctx, ia1.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(reused.BlurHash).To(Equal("SENTINEL"))
|
Expect(reused.BlurHash).To(Equal("SENTINEL"))
|
||||||
})
|
})
|
||||||
|
|
@ -437,7 +437,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
folderRepo.result = []model.Folder{{Path: "album-a", ImageFiles: []string{"cover.jpg"}}}
|
folderRepo.result = []model.Folder{{Path: "album-a", ImageFiles: []string{"cover.jpg"}}}
|
||||||
outN, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"})
|
outN, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"})
|
||||||
Expect(outN).To(Equal(outcomeFound))
|
Expect(outN).To(Equal(outcomeFound))
|
||||||
iaA, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
|
iaA, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(iaA.Source).To(Equal("folder"))
|
Expect(iaA.Source).To(Equal("folder"))
|
||||||
Expect(filepath.ToSlash(iaA.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
|
Expect(filepath.ToSlash(iaA.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
|
||||||
|
|
@ -451,19 +451,19 @@ var _ = Describe("processor.acquire", func() {
|
||||||
folderRepo.result = []model.Folder{{Path: "album-b", ImageFiles: []string{"cover.jpg"}}}
|
folderRepo.result = []model.Folder{{Path: "album-b", ImageFiles: []string{"cover.jpg"}}}
|
||||||
outN, _, _ = proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"})
|
outN, _, _ = proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"})
|
||||||
Expect(outN).To(Equal(outcomeFound))
|
Expect(outN).To(Equal(outcomeFound))
|
||||||
iaB, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alB", model.ImageTypePrimary)
|
iaB, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alB", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(iaB.Hash).To(Equal(iaA.Hash))
|
Expect(iaB.Hash).To(Equal(iaA.Hash))
|
||||||
Expect(filepath.ToSlash(iaB.SourcePath)).To(HaveSuffix("album-b/cover.jpg"))
|
Expect(filepath.ToSlash(iaB.SourcePath)).To(HaveSuffix("album-b/cover.jpg"))
|
||||||
Expect(iaB.RefMtime).To(Equal(time.Unix(2000, 0).UnixNano()))
|
Expect(iaB.RefMtime).To(Equal(time.Unix(2000, 0).UnixNano()))
|
||||||
|
|
||||||
iaAafter, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
|
iaAafter, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(filepath.ToSlash(iaAafter.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
|
Expect(filepath.ToSlash(iaAafter.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
|
||||||
Expect(iaAafter.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano()))
|
Expect(iaAafter.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano()))
|
||||||
|
|
||||||
Expect(artRepo.Data).To(HaveLen(1))
|
Expect(artRepo.Data).To(HaveLen(1))
|
||||||
reused, err := artRepo.GetImage(iaA.Hash)
|
reused, err := artRepo.GetImage(ctx, iaA.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(reused.BlurHash).To(Equal("SENTINEL"))
|
Expect(reused.BlurHash).To(Equal("SENTINEL"))
|
||||||
})
|
})
|
||||||
|
|
@ -483,7 +483,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"})
|
||||||
Expect(out).To(Equal(outcomeFailed))
|
Expect(out).To(Equal(outcomeFailed))
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -504,7 +504,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"})
|
||||||
Expect(out).To(Equal(outcomeFailed))
|
Expect(out).To(Equal(outcomeFailed))
|
||||||
|
|
||||||
_, err = artRepo.GetItemArtwork(model.KindRadioArtwork, "big", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "big", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -565,12 +565,12 @@ var _ = Describe("processor.acquire", func() {
|
||||||
|
|
||||||
hash, err := hashImage(bytes.NewReader(imgBytes))
|
hash, err := hashImage(bytes.NewReader(imgBytes))
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "application/octet-stream"})).To(Succeed())
|
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: hash, Mime: "application/octet-stream"})).To(Succeed())
|
||||||
|
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alM"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alM"})
|
||||||
Expect(out).To(Equal(outcomeFound))
|
Expect(out).To(Equal(outcomeFound))
|
||||||
|
|
||||||
upgraded, err := artRepo.GetImage(hash)
|
upgraded, err := artRepo.GetImage(ctx, hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(upgraded.Width).To(BeNumerically(">", 0))
|
Expect(upgraded.Width).To(BeNumerically(">", 0))
|
||||||
Expect(upgraded.BlurHash).ToNot(BeEmpty())
|
Expect(upgraded.BlurHash).ToNot(BeEmpty())
|
||||||
|
|
@ -590,7 +590,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"})
|
||||||
Expect(out).To(Equal(outcomeFailed))
|
Expect(out).To(Equal(outcomeFailed))
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al7", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,9 @@ const pruneMinAge = time.Hour
|
||||||
func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
|
func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() { log.Debug(ctx, "Artwork: Prune finished", "elapsed", time.Since(start)) }()
|
defer func() { log.Debug(ctx, "Artwork: Prune finished", "elapsed", time.Since(start)) }()
|
||||||
repo := ds.Artwork(ctx)
|
repo := ds.Artwork()
|
||||||
|
|
||||||
purged, err := repo.PurgeDanglingItems()
|
purged, err := repo.PurgeDanglingItems(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -25,7 +25,7 @@ func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue rows for deleted entities would otherwise retry forever (Get -> not found -> failed).
|
// Queue rows for deleted entities would otherwise retry forever (Get -> not found -> failed).
|
||||||
queuePurged, err := ds.ArtworkQueue(ctx).PurgeDangling()
|
queuePurged, err := ds.ArtworkQueue().PurgeDangling(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -35,7 +35,7 @@ func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
|
||||||
|
|
||||||
// Files younger than the grace window may belong to acquisitions whose rows aren't committed yet.
|
// Files younger than the grace window may belong to acquisitions whose rows aren't committed yet.
|
||||||
cutoff := time.Now().Add(-pruneMinAge)
|
cutoff := time.Now().Add(-pruneMinAge)
|
||||||
orphans, err := repo.PurgeOrphans(cutoff)
|
orphans, err := repo.PurgeOrphans(ctx, cutoff)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -44,7 +44,7 @@ func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read after the delete, so the sweep below reclaims the files of the rows just removed.
|
// Read after the delete, so the sweep below reclaims the files of the rows just removed.
|
||||||
mimes, err := repo.GetMimeByHash()
|
mimes, err := repo.GetMimeByHash(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,18 +18,20 @@ type flakyGetArtworkRepo struct {
|
||||||
*tests.MockArtworkRepo
|
*tests.MockArtworkRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *flakyGetArtworkRepo) GetMimeByHash() (map[string]string, error) {
|
func (f *flakyGetArtworkRepo) GetMimeByHash(context.Context) (map[string]string, error) {
|
||||||
return nil, errors.New("db locked")
|
return nil, errors.New("db locked")
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = Describe("Prune", func() {
|
var _ = Describe("Prune", func() {
|
||||||
|
var ctx context.Context
|
||||||
var ds *tests.MockDataStore
|
var ds *tests.MockDataStore
|
||||||
var store *ImageStore
|
var store *ImageStore
|
||||||
var awRepo *tests.MockArtworkRepo
|
var awRepo *tests.MockArtworkRepo
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
ctx = GinkgoT().Context()
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
awRepo = ds.Artwork(context.Background()).(*tests.MockArtworkRepo)
|
awRepo = ds.Artwork().(*tests.MockArtworkRepo)
|
||||||
store = NewImageStore(GinkgoT().TempDir())
|
store = NewImageStore(GinkgoT().TempDir())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -41,9 +43,9 @@ var _ = Describe("Prune", func() {
|
||||||
}
|
}
|
||||||
|
|
||||||
It("purges dangling item_artwork state for gone entities, summed across kinds", func() {
|
It("purges dangling item_artwork state for gone entities, summed across kinds", func() {
|
||||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary})).To(Succeed())
|
Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "gone-artist", ImageType: model.ImageTypePrimary})).To(Succeed())
|
Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "gone-artist", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "live-artist", ImageType: model.ImageTypePrimary})).To(Succeed())
|
Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "live-artist", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||||
awRepo.ExistingIDs = map[string]map[string]bool{
|
awRepo.ExistingIDs = map[string]map[string]bool{
|
||||||
"al": {},
|
"al": {},
|
||||||
"ar": {"live-artist": true},
|
"ar": {"live-artist": true},
|
||||||
|
|
@ -51,17 +53,17 @@ var _ = Describe("Prune", func() {
|
||||||
|
|
||||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||||
|
|
||||||
_, err := awRepo.GetItemArtwork(model.KindAlbumArtwork, "gone-album", model.ImageTypePrimary)
|
_, err := awRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "gone-album", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
_, err = awRepo.GetItemArtwork(model.KindArtistArtwork, "gone-artist", model.ImageTypePrimary)
|
_, err = awRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "gone-artist", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
_, err = awRepo.GetItemArtwork(model.KindArtistArtwork, "live-artist", model.ImageTypePrimary)
|
_, err = awRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "live-artist", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("purges dangling artwork_queue rows for gone entities", func() {
|
It("purges dangling artwork_queue rows for gone entities", func() {
|
||||||
queueRepo := tests.CreateMockArtworkQueueRepo()
|
queueRepo := tests.CreateMockArtworkQueueRepo()
|
||||||
Expect(queueRepo.Enqueue(
|
Expect(queueRepo.Enqueue(ctx,
|
||||||
model.ArtworkQueueItem{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary},
|
model.ArtworkQueueItem{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary},
|
||||||
model.ArtworkQueueItem{ItemKind: "al", ItemID: "live-album", ImageType: model.ImageTypePrimary},
|
model.ArtworkQueueItem{ItemKind: "al", ItemID: "live-album", ImageType: model.ImageTypePrimary},
|
||||||
)).To(Succeed())
|
)).To(Succeed())
|
||||||
|
|
@ -80,17 +82,17 @@ var _ = Describe("Prune", func() {
|
||||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||||
old := time.Now().Add(-2 * time.Hour)
|
old := time.Now().Add(-2 * time.Hour)
|
||||||
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
|
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
|
||||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||||
ageArtwork(h, old)
|
ageArtwork(h, old)
|
||||||
|
|
||||||
kept := []byte("kept-bytes")
|
kept := []byte("kept-bytes")
|
||||||
hk, _ := hashImage(bytes.NewReader(kept))
|
hk, _ := hashImage(bytes.NewReader(kept))
|
||||||
Expect(store.Write(hk, "image/jpeg", bytes.NewReader(kept))).To(Succeed())
|
Expect(store.Write(hk, "image/jpeg", bytes.NewReader(kept))).To(Succeed())
|
||||||
Expect(awRepo.PutImage(&model.Artwork{Hash: hk, Mime: "image/jpeg"})).To(Succeed())
|
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: hk, Mime: "image/jpeg"})).To(Succeed())
|
||||||
|
|
||||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||||
|
|
||||||
_, err := awRepo.GetImage(h)
|
_, err := awRepo.GetImage(ctx, h)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
_, err = store.Open(h, "image/jpeg")
|
_, err = store.Open(h, "image/jpeg")
|
||||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||||
|
|
@ -103,14 +105,14 @@ var _ = Describe("Prune", func() {
|
||||||
data := []byte("reacquired-bytes")
|
data := []byte("reacquired-bytes")
|
||||||
h, _ := hashImage(bytes.NewReader(data))
|
h, _ := hashImage(bytes.NewReader(data))
|
||||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||||
ageArtwork(h, time.Now().Add(-2*time.Hour))
|
ageArtwork(h, time.Now().Add(-2*time.Hour))
|
||||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "a1",
|
Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "a1",
|
||||||
ImageType: model.ImageTypePrimary, Hash: h, Source: "folder"})).To(Succeed())
|
ImageType: model.ImageTypePrimary, Hash: h, Source: "folder"})).To(Succeed())
|
||||||
|
|
||||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||||
|
|
||||||
_, err := awRepo.GetImage(h)
|
_, err := awRepo.GetImage(ctx, h)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
rc, err := store.Open(h, "image/jpeg")
|
rc, err := store.Open(h, "image/jpeg")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -122,11 +124,11 @@ var _ = Describe("Prune", func() {
|
||||||
h, _ := hashImage(bytes.NewReader(data))
|
h, _ := hashImage(bytes.NewReader(data))
|
||||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||||
// Reacquisition refreshed created_at, so the row is unreferenced but too young to drop.
|
// Reacquisition refreshed created_at, so the row is unreferenced but too young to drop.
|
||||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||||
|
|
||||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||||
|
|
||||||
_, err := awRepo.GetImage(h)
|
_, err := awRepo.GetImage(ctx, h)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
rc, err := store.Open(h, "image/jpeg")
|
rc, err := store.Open(h, "image/jpeg")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -137,7 +139,7 @@ var _ = Describe("Prune", func() {
|
||||||
data := []byte("racing-bytes")
|
data := []byte("racing-bytes")
|
||||||
h, _ := hashImage(bytes.NewReader(data))
|
h, _ := hashImage(bytes.NewReader(data))
|
||||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||||
ageArtwork(h, time.Now().Add(-2*time.Hour))
|
ageArtwork(h, time.Now().Add(-2*time.Hour))
|
||||||
// The row is orphaned, but a concurrent acquisition just touched the file's mtime.
|
// The row is orphaned, but a concurrent acquisition just touched the file's mtime.
|
||||||
|
|
||||||
|
|
@ -170,7 +172,7 @@ var _ = Describe("Prune", func() {
|
||||||
Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed())
|
Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed())
|
||||||
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
|
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
|
||||||
// The row records the current mime; the .png file is a superseded variant.
|
// The row records the current mime; the .png file is a superseded variant.
|
||||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||||
|
|
||||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -192,14 +194,14 @@ var _ = Describe("Prune", func() {
|
||||||
hb, _ := hashImage(bytes.NewReader(blocked))
|
hb, _ := hashImage(bytes.NewReader(blocked))
|
||||||
Expect(store.Write(hb, "image/jpeg", bytes.NewReader(blocked))).To(Succeed())
|
Expect(store.Write(hb, "image/jpeg", bytes.NewReader(blocked))).To(Succeed())
|
||||||
Expect(os.Chtimes(store.path(hb, "image/jpeg"), old, old)).To(Succeed())
|
Expect(os.Chtimes(store.path(hb, "image/jpeg"), old, old)).To(Succeed())
|
||||||
Expect(awRepo.PutImage(&model.Artwork{Hash: hb, Mime: "image/jpeg"})).To(Succeed())
|
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: hb, Mime: "image/jpeg"})).To(Succeed())
|
||||||
ageArtwork(hb, old)
|
ageArtwork(hb, old)
|
||||||
|
|
||||||
good := []byte("good-bytes")
|
good := []byte("good-bytes")
|
||||||
hg, _ := hashImage(bytes.NewReader(good))
|
hg, _ := hashImage(bytes.NewReader(good))
|
||||||
Expect(store.Write(hg, "image/jpeg", bytes.NewReader(good))).To(Succeed())
|
Expect(store.Write(hg, "image/jpeg", bytes.NewReader(good))).To(Succeed())
|
||||||
Expect(os.Chtimes(store.path(hg, "image/jpeg"), old, old)).To(Succeed())
|
Expect(os.Chtimes(store.path(hg, "image/jpeg"), old, old)).To(Succeed())
|
||||||
Expect(awRepo.PutImage(&model.Artwork{Hash: hg, Mime: "image/jpeg"})).To(Succeed())
|
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: hg, Mime: "image/jpeg"})).To(Succeed())
|
||||||
ageArtwork(hg, old)
|
ageArtwork(hg, old)
|
||||||
|
|
||||||
// A read-only shard directory makes os.Remove fail (EACCES) for hb's file only.
|
// A read-only shard directory makes os.Remove fail (EACCES) for hb's file only.
|
||||||
|
|
@ -210,13 +212,13 @@ var _ = Describe("Prune", func() {
|
||||||
|
|
||||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||||
|
|
||||||
_, err := awRepo.GetImage(hg)
|
_, err := awRepo.GetImage(ctx, hg)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
_, err = store.Open(hg, "image/jpeg")
|
_, err = store.Open(hg, "image/jpeg")
|
||||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||||
|
|
||||||
// The row purge does not depend on file removal, so only the file survives.
|
// The row purge does not depend on file removal, so only the file survives.
|
||||||
_, err = awRepo.GetImage(hb)
|
_, err = awRepo.GetImage(ctx, hb)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
rc, err := store.Open(hb, "image/jpeg")
|
rc, err := store.Open(hb, "image/jpeg")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,7 @@ func (r *resolver) fetchExternalArtist(ctx context.Context, ar model.Artist) (io
|
||||||
|
|
||||||
// resolveAlbum walks conf.Server.CoverArtPriority over the folder, embedded and external sources.
|
// resolveAlbum walks conf.Server.CoverArtPriority over the folder, embedded and external sources.
|
||||||
func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution, error) {
|
func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution, error) {
|
||||||
al, err := r.ds.Album(ctx).Get(albumID)
|
al, err := r.ds.Album().Get(ctx, albumID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolution{}, err
|
return resolution{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -243,7 +243,7 @@ func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution
|
||||||
|
|
||||||
// resolveArtist tries the uploaded image first, then walks conf.Server.ArtistArtPriority.
|
// resolveArtist tries the uploaded image first, then walks conf.Server.ArtistArtPriority.
|
||||||
func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resolution, error) {
|
func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resolution, error) {
|
||||||
ar, err := r.ds.Artist(ctx).Get(artistID)
|
ar, err := r.ds.Artist().Get(ctx, artistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolution{}, err
|
return resolution{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -259,7 +259,7 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only consider albums where the artist is the sole album artist.
|
// Only consider albums where the artist is the sole album artist.
|
||||||
als, err := r.ds.Album(ctx).GetAll(model.QueryOptions{Filters: persistence.SoleAlbumArtistFilter(artistID)})
|
als, err := r.ds.Album().GetAll(ctx, model.QueryOptions{Filters: persistence.SoleAlbumArtistFilter(artistID)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolution{}, err
|
return resolution{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -328,7 +328,7 @@ const PlaylistGridSamples = 4
|
||||||
|
|
||||||
// resolvePlaylist tries the uploaded image, the sidecar and ExternalImageURL, then a generated grid.
|
// resolvePlaylist tries the uploaded image, the sidecar and ExternalImageURL, then a generated grid.
|
||||||
func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (resolution, error) {
|
func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (resolution, error) {
|
||||||
pl, err := r.ds.Playlist(ctx).Get(playlistID)
|
pl, err := r.ds.Playlist().Get(ctx, playlistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolution{}, err
|
return resolution{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -374,8 +374,11 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
albumIDs, err := r.ds.Playlist(ctx).Tracks(pl.ID, false).
|
tracks := r.ds.Playlist().Tracks(ctx, pl.ID, false)
|
||||||
GetAlbumIDs(model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
|
if tracks == nil {
|
||||||
|
return resolution{}, fmt.Errorf("resolvePlaylist: could not load tracks for playlist %s", pl.ID)
|
||||||
|
}
|
||||||
|
albumIDs, err := tracks.GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolution{}, err
|
return resolution{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -428,7 +431,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
|
||||||
|
|
||||||
// resolveRadio serves only an uploaded image; there is no fallback.
|
// resolveRadio serves only an uploaded image; there is no fallback.
|
||||||
func (r *resolver) resolveRadio(ctx context.Context, radioID string) (resolution, error) {
|
func (r *resolver) resolveRadio(ctx context.Context, radioID string) (resolution, error) {
|
||||||
radio, err := r.ds.Radio(ctx).Get(radioID)
|
radio, err := r.ds.Radio().Get(ctx, radioID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolution{}, err
|
return resolution{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -439,7 +442,7 @@ func (r *resolver) resolveRadio(ctx context.Context, radioID string) (resolution
|
||||||
// resolveMediaFile resolves a track's own embedded art only, so disabled or missing cover art
|
// resolveMediaFile resolves a track's own embedded art only, so disabled or missing cover art
|
||||||
// is a definitive absent.
|
// is a definitive absent.
|
||||||
func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution, error) {
|
func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution, error) {
|
||||||
mf, err := r.ds.MediaFile(ctx).Get(id)
|
mf, err := r.ds.MediaFile().Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolution{}, err
|
return resolution{}, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -707,6 +707,16 @@ var _ = Describe("resolveItem", func() {
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
Expect(res).To(Equal(resolution{}))
|
Expect(res).To(Equal(resolution{}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("returns an error when the playlist tracks cannot be loaded", func() {
|
||||||
|
plRepo := tests.CreateMockPlaylistRepo()
|
||||||
|
plRepo.SetData(model.Playlists{{ID: "pl4", Name: "Playlist"}})
|
||||||
|
ds.MockedPlaylist = plRepo
|
||||||
|
|
||||||
|
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl4"})
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(res).To(Equal(resolution{}))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,14 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ = Describe("Uploader", func() {
|
var _ = Describe("Uploader", func() {
|
||||||
|
var ctx context.Context
|
||||||
var svc Uploader
|
var svc Uploader
|
||||||
var tmpDir string
|
var tmpDir string
|
||||||
var artRepo *tests.MockArtworkRepo
|
var artRepo *tests.MockArtworkRepo
|
||||||
var queueRepo *tests.MockArtworkQueueRepo
|
var queueRepo *tests.MockArtworkQueueRepo
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
ctx = GinkgoT().Context()
|
||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
tmpDir = GinkgoT().TempDir()
|
tmpDir = GinkgoT().TempDir()
|
||||||
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
||||||
|
|
@ -33,7 +35,6 @@ var _ = Describe("Uploader", func() {
|
||||||
|
|
||||||
Describe("SetImage", func() {
|
Describe("SetImage", func() {
|
||||||
It("creates directory and saves image file", func() {
|
It("creates directory and saves image file", func() {
|
||||||
ctx := context.Background()
|
|
||||||
reader := strings.NewReader("fake image data")
|
reader := strings.NewReader("fake image data")
|
||||||
filename, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", reader, ".jpg")
|
filename, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", reader, ".jpg")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -46,7 +47,6 @@ var _ = Describe("Uploader", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("falls back to ID-only filename when name cleans to empty", func() {
|
It("falls back to ID-only filename when name cleans to empty", func() {
|
||||||
ctx := context.Background()
|
|
||||||
reader := strings.NewReader("data")
|
reader := strings.NewReader("data")
|
||||||
filename, err := svc.SetImage(ctx, consts.EntityPlaylist, "pl-1", "!!!", "", reader, ".png")
|
filename, err := svc.SetImage(ctx, consts.EntityPlaylist, "pl-1", "!!!", "", reader, ".png")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -54,7 +54,6 @@ var _ = Describe("Uploader", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("removes old image when replacing", func() {
|
It("removes old image when replacing", func() {
|
||||||
ctx := context.Background()
|
|
||||||
oldDir := filepath.Join(tmpDir, "artwork", "artist")
|
oldDir := filepath.Join(tmpDir, "artwork", "artist")
|
||||||
Expect(os.MkdirAll(oldDir, 0755)).To(Succeed())
|
Expect(os.MkdirAll(oldDir, 0755)).To(Succeed())
|
||||||
oldFile := filepath.Join(oldDir, "ar-1_old.png")
|
oldFile := filepath.Join(oldDir, "ar-1_old.png")
|
||||||
|
|
@ -70,15 +69,13 @@ var _ = Describe("Uploader", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("ignores missing old file without error", func() {
|
It("ignores missing old file without error", func() {
|
||||||
ctx := context.Background()
|
|
||||||
reader := strings.NewReader("data")
|
reader := strings.NewReader("data")
|
||||||
_, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Name", "/nonexistent/path.jpg", reader, ".jpg")
|
_, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Name", "/nonexistent/path.jpg", reader, ".jpg")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("does not touch artwork state or the queue (that is EnqueueArtwork's job, post-Put)", func() {
|
It("does not touch artwork state or the queue (that is EnqueueArtwork's job, post-Put)", func() {
|
||||||
ctx := context.Background()
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
|
||||||
ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external",
|
ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external",
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -87,25 +84,24 @@ var _ = Describe("Uploader", func() {
|
||||||
|
|
||||||
// SetImage only writes the file; the state row survives and nothing is queued until
|
// SetImage only writes the file; the state row survives and nothing is queued until
|
||||||
// the caller has persisted the new filename and called EnqueueArtwork.
|
// the caller has persisted the new filename and called EnqueueArtwork.
|
||||||
_, err = artRepo.GetItemArtwork(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(queueRepo.DequeueBatch(1000)).To(BeEmpty())
|
Expect(queueRepo.DequeueBatch(ctx, 1000)).To(BeEmpty())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("EnqueueArtwork", func() {
|
Describe("EnqueueArtwork", func() {
|
||||||
It("clears artwork state and enqueues a Bump", func() {
|
It("clears artwork state and enqueues a Bump", func() {
|
||||||
ctx := context.Background()
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
|
||||||
ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external",
|
ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external",
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
||||||
svc.EnqueueArtwork(ctx, consts.EntityArtist, "ar-1")
|
svc.EnqueueArtwork(ctx, consts.EntityArtist, "ar-1")
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
|
|
||||||
queued, err := queueRepo.DequeueBatch(1000)
|
queued, err := queueRepo.DequeueBatch(ctx, 1000)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(queued).To(ContainElement(SatisfyAll(
|
Expect(queued).To(ContainElement(SatisfyAll(
|
||||||
HaveField("ItemKind", "ar"),
|
HaveField("ItemKind", "ar"),
|
||||||
|
|
@ -116,13 +112,12 @@ var _ = Describe("Uploader", func() {
|
||||||
|
|
||||||
It("is a no-op for an unknown entity type", func() {
|
It("is a no-op for an unknown entity type", func() {
|
||||||
svc.EnqueueArtwork(context.Background(), "unknown", "x-1")
|
svc.EnqueueArtwork(context.Background(), "unknown", "x-1")
|
||||||
Expect(queueRepo.DequeueBatch(1000)).To(BeEmpty())
|
Expect(queueRepo.DequeueBatch(ctx, 1000)).To(BeEmpty())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("RemoveImage", func() {
|
Describe("RemoveImage", func() {
|
||||||
It("removes the file at the given path", func() {
|
It("removes the file at the given path", func() {
|
||||||
ctx := context.Background()
|
|
||||||
dir := filepath.Join(tmpDir, "artwork", "artist")
|
dir := filepath.Join(tmpDir, "artwork", "artist")
|
||||||
Expect(os.MkdirAll(dir, 0755)).To(Succeed())
|
Expect(os.MkdirAll(dir, 0755)).To(Succeed())
|
||||||
path := filepath.Join(dir, "ar-1_test.jpg")
|
path := filepath.Join(dir, "ar-1_test.jpg")
|
||||||
|
|
@ -134,13 +129,11 @@ var _ = Describe("Uploader", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("succeeds when file does not exist", func() {
|
It("succeeds when file does not exist", func() {
|
||||||
ctx := context.Background()
|
|
||||||
err := svc.RemoveImage(ctx, "/nonexistent/file.jpg")
|
err := svc.RemoveImage(ctx, "/nonexistent/file.jpg")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("succeeds with empty path", func() {
|
It("succeeds with empty path", func() {
|
||||||
ctx := context.Background()
|
|
||||||
err := svc.RemoveImage(ctx, "")
|
err := svc.RemoveImage(ctx, "")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"cmp"
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
|
"runtime/debug"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -45,7 +47,7 @@ type Worker struct {
|
||||||
broker events.Broker
|
broker events.Broker
|
||||||
pruneMu sync.RWMutex
|
pruneMu sync.RWMutex
|
||||||
pools []*drainPool
|
pools []*drainPool
|
||||||
runCtx context.Context
|
runCtx context.Context //nolint:containedctx // worker lifecycle ctx, set at Run
|
||||||
paused func() bool
|
paused func() bool
|
||||||
|
|
||||||
gatesMu sync.Mutex
|
gatesMu sync.Mutex
|
||||||
|
|
@ -155,7 +157,7 @@ func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (i
|
||||||
}
|
}
|
||||||
// Dequeue well past the pool size so a slow external lookup never idles the other slots.
|
// Dequeue well past the pool size so a slow external lookup never idles the other slots.
|
||||||
// DequeueBatch does not mark rows taken, so this is one query per pass, not per slot.
|
// DequeueBatch does not mark rows taken, so this is one query per pass, not per slot.
|
||||||
items, err := w.proc.ds.ArtworkQueue(ctx).DequeueBatch(max(16, 4*concurrency), kinds...)
|
items, err := w.proc.ds.ArtworkQueue().DequeueBatch(ctx, max(16, 4*concurrency), kinds...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
@ -244,14 +246,14 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
||||||
item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary)
|
item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary)
|
||||||
trace := &ChainTrace{}
|
trace := &ChainTrace{}
|
||||||
ctx = withTrace(ctx, trace)
|
ctx = withTrace(ctx, trace)
|
||||||
out, got, retryIn := w.proc.acquire(ctx, item)
|
out, got, retryIn := w.safeAcquire(ctx, item)
|
||||||
|
|
||||||
queue := w.proc.ds.ArtworkQueue(ctx)
|
queue := w.proc.ds.ArtworkQueue()
|
||||||
switch out {
|
switch out {
|
||||||
case outcomeFound, outcomeAbsent:
|
case outcomeFound, outcomeAbsent:
|
||||||
// A scan that re-enqueued this row mid-flight reset its retry_at, so the row survives
|
// A scan that re-enqueued this row mid-flight reset its retry_at, so the row survives
|
||||||
// here and the next drain re-resolves it.
|
// here and the next drain re-resolves it.
|
||||||
if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
|
if err := queue.DeleteIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
|
||||||
log.Warn(ctx, "Artwork: Could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
log.Warn(ctx, "Artwork: Could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||||
}
|
}
|
||||||
case outcomeFoundStale, outcomeFailed:
|
case outcomeFoundStale, outcomeFailed:
|
||||||
|
|
@ -260,7 +262,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
||||||
if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) {
|
if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) {
|
||||||
// A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its
|
// A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its
|
||||||
// fresh, immediate eligibility.
|
// fresh, immediate eligibility.
|
||||||
if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt, encoded); err != nil {
|
if err := queue.MarkFailedIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt, encoded); err != nil {
|
||||||
log.Warn(ctx, "Artwork: Could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
log.Warn(ctx, "Artwork: Could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||||
}
|
}
|
||||||
log.Debug(ctx, "Artwork: Rescheduled item", "kind", item.ItemKind, "id", item.ItemID,
|
log.Debug(ctx, "Artwork: Rescheduled item", "kind", item.ItemKind, "id", item.ItemID,
|
||||||
|
|
@ -271,7 +273,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
||||||
// Art already being served is kept: exhaustion means unreachable, not removed.
|
// Art already being served is kept: exhaustion means unreachable, not removed.
|
||||||
settled := "kept previous state"
|
settled := "kept previous state"
|
||||||
if out == outcomeFailed && settlesAbsentOnGiveUp(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) {
|
if out == outcomeFailed && settlesAbsentOnGiveUp(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) {
|
||||||
writeAbsent(ctx, w.proc.ds.Artwork(ctx), item)
|
writeAbsent(ctx, w.proc.ds.Artwork(), item)
|
||||||
settled = "recorded absent"
|
settled = "recorded absent"
|
||||||
}
|
}
|
||||||
// The queue row is about to go, taking the only record of the failure with it. This write is
|
// The queue row is about to go, taking the only record of the failure with it. This write is
|
||||||
|
|
@ -279,13 +281,27 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
||||||
w.recordGiveUp(ctx, item, encoded)
|
w.recordGiveUp(ctx, item, encoded)
|
||||||
log.Info(ctx, "Artwork: Retry budget exhausted, giving up", "kind", item.ItemKind, "id", item.ItemID,
|
log.Info(ctx, "Artwork: Retry budget exhausted, giving up", "kind", item.ItemKind, "id", item.ItemID,
|
||||||
"outcome", out, "attempts", item.Attempts+1, "budget", giveUpAfter, "settled", settled)
|
"outcome", out, "attempts", item.Attempts+1, "budget", giveUpAfter, "settled", settled)
|
||||||
if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
|
if err := queue.DeleteIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
|
||||||
log.Warn(ctx, "Artwork: Could not remove exhausted queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
log.Warn(ctx, "Artwork: Could not remove exhausted queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, got
|
return out, got
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// safeAcquire turns a panic into a failed attempt: the drain runs on a bare goroutine, so an
|
||||||
|
// unrecovered panic would crash the server, and the still-queued row would crash it again on restart.
|
||||||
|
func (w *Worker) safeAcquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Error(ctx, "Artwork: Panic while processing item", "kind", item.ItemKind, "id", item.ItemID,
|
||||||
|
"imageType", item.ImageType, "attempts", item.Attempts, "panic", r, "stack", string(debug.Stack()))
|
||||||
|
traceStage(ctx, "panic", fmt.Errorf("%v", r))
|
||||||
|
out, got, retryIn = outcomeFailed, nil, 0
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return w.proc.acquire(ctx, item)
|
||||||
|
}
|
||||||
|
|
||||||
// recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item
|
// recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item
|
||||||
// that never resolved has no row to update, and creating one would settle it absent.
|
// that never resolved has no row to update, and creating one would settle it absent.
|
||||||
func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) {
|
func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) {
|
||||||
|
|
@ -293,7 +309,7 @@ func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem,
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := w.proc.ds.Artwork(ctx).PutLastFailure(kind, item.ItemID, item.ImageType, trace); err != nil {
|
if err := w.proc.ds.Artwork().PutLastFailure(ctx, kind, item.ItemID, item.ImageType, trace); err != nil {
|
||||||
log.Warn(ctx, "Artwork: Could not record the last failure", "kind", item.ItemKind, "id", item.ItemID, err)
|
log.Warn(ctx, "Artwork: Could not record the last failure", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -303,7 +319,7 @@ func (w *Worker) hasResolvedArtwork(ctx context.Context, item model.ArtworkQueue
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
ia, err := w.proc.ds.Artwork(ctx).GetItemArtwork(kind, item.ItemID, item.ImageType)
|
ia, err := w.proc.ds.Artwork().GetItemArtwork(ctx, kind, item.ItemID, item.ImageType)
|
||||||
return err == nil && ia.Hash != ""
|
return err == nil && ia.Hash != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,12 @@ import (
|
||||||
const soakCycles = 2200
|
const soakCycles = 2200
|
||||||
|
|
||||||
var _ = Describe("Worker soak", func() {
|
var _ = Describe("Worker soak", func() {
|
||||||
|
var ctx context.Context
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
ctx = GinkgoT().Context()
|
||||||
|
})
|
||||||
|
|
||||||
It("does not leak goroutines, heap, or fds over many acquisition cycles", func() {
|
It("does not leak goroutines, heap, or fds over many acquisition cycles", func() {
|
||||||
if testing.Short() {
|
if testing.Short() {
|
||||||
Skip("skipping soak test in short mode")
|
Skip("skipping soak test in short mode")
|
||||||
|
|
@ -100,9 +106,9 @@ var _ = Describe("Worker soak", func() {
|
||||||
// Read-back exercises the surfaces a caller would use after acquisition.
|
// Read-back exercises the surfaces a caller would use after acquisition.
|
||||||
if out == outcomeFound {
|
if out == outcomeFound {
|
||||||
kind, _ := model.ParseKind(it.ItemKind)
|
kind, _ := model.ParseKind(it.ItemKind)
|
||||||
ia, err := artRepo.GetItemArtwork(kind, it.ItemID, model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, kind, it.ItemID, model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred(), "cycle %d: GetItemArtwork", i)
|
Expect(err).ToNot(HaveOccurred(), "cycle %d: GetItemArtwork", i)
|
||||||
art, err := artRepo.GetImage(ia.Hash)
|
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred(), "cycle %d: GetImage", i)
|
Expect(err).ToNot(HaveOccurred(), "cycle %d: GetImage", i)
|
||||||
rc, err := store.Open(ia.Hash, art.Mime)
|
rc, err := store.Open(ia.Hash, art.Mime)
|
||||||
switch {
|
switch {
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,8 @@ type reenqueueOnDequeue struct {
|
||||||
done bool
|
done bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *reenqueueOnDequeue) DequeueBatch(n int, kinds ...string) ([]model.ArtworkQueueItem, error) {
|
func (r *reenqueueOnDequeue) DequeueBatch(ctx context.Context, n int, kinds ...string) ([]model.ArtworkQueueItem, error) {
|
||||||
items, err := r.MockArtworkQueueRepo.DequeueBatch(n, kinds...)
|
items, err := r.MockArtworkQueueRepo.DequeueBatch(ctx, n, kinds...)
|
||||||
if !r.done && len(items) > 0 {
|
if !r.done && len(items) > 0 {
|
||||||
r.done = true
|
r.done = true
|
||||||
for k, it := range r.Data {
|
for k, it := range r.Data {
|
||||||
|
|
@ -124,18 +124,39 @@ type visibilityPlaylistDS struct {
|
||||||
tracks model.PlaylistTrackRepository
|
tracks model.PlaylistTrackRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visibilityPlaylistDS) Playlist(ctx context.Context) model.PlaylistRepository {
|
func (v *visibilityPlaylistDS) Playlist() model.PlaylistRepository {
|
||||||
repo := tests.CreateMockPlaylistRepo()
|
repo := tests.CreateMockPlaylistRepo()
|
||||||
repo.TracksRepo = v.tracks
|
repo.TracksRepo = v.tracks
|
||||||
if u, ok := request.UserFrom(ctx); ok && u.IsAdmin {
|
repo.SetData(model.Playlists{v.private})
|
||||||
repo.SetData(model.Playlists{v.private})
|
return &visibilityPlaylistRepo{MockPlaylistRepo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
type visibilityPlaylistRepo struct {
|
||||||
|
*tests.MockPlaylistRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *visibilityPlaylistRepo) Get(ctx context.Context, id string) (*model.Playlist, error) {
|
||||||
|
if u, ok := request.UserFrom(ctx); !ok || !u.IsAdmin {
|
||||||
|
return nil, model.ErrNotFound
|
||||||
}
|
}
|
||||||
return repo
|
return v.MockPlaylistRepo.Get(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
type panickingAlbumRepo struct {
|
||||||
|
*tests.MockAlbumRepo
|
||||||
|
panicID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *panickingAlbumRepo) Get(ctx context.Context, id string) (*model.Album, error) {
|
||||||
|
if id == r.panicID {
|
||||||
|
panic("boom")
|
||||||
|
}
|
||||||
|
return r.MockAlbumRepo.Get(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func adminUserRepo() *tests.MockedUserRepo {
|
func adminUserRepo() *tests.MockedUserRepo {
|
||||||
repo := tests.CreateMockUserRepo()
|
repo := tests.CreateMockUserRepo()
|
||||||
Expect(repo.Put(&model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed())
|
Expect(repo.Put(GinkgoT().Context(), &model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed())
|
||||||
return repo
|
return repo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,8 +178,8 @@ var _ = Describe("Worker", func() {
|
||||||
)
|
)
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
ctx = GinkgoT().Context()
|
||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
ctx = context.Background()
|
|
||||||
var err error
|
var err error
|
||||||
repoRoot, err = os.Getwd()
|
repoRoot, err = os.Getwd()
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -200,7 +221,7 @@ var _ = Describe("Worker", func() {
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||||
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}},
|
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}},
|
||||||
})
|
})
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||||
ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan,
|
ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -208,11 +229,11 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(n).To(Equal(1))
|
Expect(n).To(Equal(1))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("folder"))
|
Expect(ia.Source).To(Equal("folder"))
|
||||||
|
|
||||||
count, err := queueRepo.Count()
|
count, err := queueRepo.Count(ctx)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(count).To(BeZero(), "a found item must be deleted from the queue")
|
Expect(count).To(BeZero(), "a found item must be deleted from the queue")
|
||||||
})
|
})
|
||||||
|
|
@ -223,7 +244,7 @@ var _ = Describe("Worker", func() {
|
||||||
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||||
{ID: "mf1", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true},
|
{ID: "mf1", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true},
|
||||||
})
|
})
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||||
ItemKind: "mf", ItemID: "mf1", Priority: model.ArtworkPriorityBump,
|
ItemKind: "mf", ItemID: "mf1", Priority: model.ArtworkPriorityBump,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -231,12 +252,12 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(n).To(Equal(1))
|
Expect(n).To(Equal(1))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("embedded"))
|
Expect(ia.Source).To(Equal("embedded"))
|
||||||
Expect(ia.Hash).ToNot(BeEmpty())
|
Expect(ia.Hash).ToNot(BeEmpty())
|
||||||
|
|
||||||
art, err := artRepo.GetImage(ia.Hash)
|
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
r, err := store.Open(ia.Hash, art.Mime)
|
r, err := store.Open(ia.Hash, art.Mime)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -245,7 +266,7 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(data).ToNot(BeEmpty(), "embedded bytes must be written to the store")
|
Expect(data).ToNot(BeEmpty(), "embedded bytes must be written to the store")
|
||||||
|
|
||||||
count, err := queueRepo.Count()
|
count, err := queueRepo.Count(ctx)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(count).To(BeZero())
|
Expect(count).To(BeZero())
|
||||||
})
|
})
|
||||||
|
|
@ -254,7 +275,7 @@ var _ = Describe("Worker", func() {
|
||||||
conf.Server.CoverArtPriority = "external"
|
conf.Server.CoverArtPriority = "external"
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al4", Name: "Album"}})
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al4", Name: "Album"}})
|
||||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})).To(Succeed())
|
||||||
|
|
||||||
n, err := w.drain(ctx, 2)
|
n, err := w.drain(ctx, 2)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -265,17 +286,47 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(it.Attempts).To(Equal(1))
|
Expect(it.Attempts).To(Equal(1))
|
||||||
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
|
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
|
||||||
|
|
||||||
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent")
|
Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("fails an item that panics, without stopping the rest of the batch", func() {
|
||||||
|
folderRepo.result = []model.Folder{{
|
||||||
|
Path: "tests/fixtures/artist/an-album",
|
||||||
|
ImageFiles: []string{"cover.jpg"},
|
||||||
|
}}
|
||||||
|
albums := tests.CreateMockAlbumRepo()
|
||||||
|
albums.SetData(model.Albums{
|
||||||
|
{ID: "alboom", Name: "Album", FolderIDs: []string{"f1"}},
|
||||||
|
{ID: "alok", Name: "Album", FolderIDs: []string{"f1"}},
|
||||||
|
})
|
||||||
|
ds.MockedAlbum = &panickingAlbumRepo{MockAlbumRepo: albums, panicID: "alboom"}
|
||||||
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alboom"})).To(Succeed())
|
||||||
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alok"})).To(Succeed())
|
||||||
|
|
||||||
|
n, err := w.drain(ctx, 1)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(n).To(Equal(2))
|
||||||
|
|
||||||
|
it := findQueued(queueRepo, "al", "alboom")
|
||||||
|
Expect(it).ToNot(BeNil(), "a panicking item must be rescheduled, not dropped")
|
||||||
|
Expect(it.Attempts).To(Equal(1))
|
||||||
|
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
|
||||||
|
Expect(it.Trace).To(ContainSubstring("boom"))
|
||||||
|
|
||||||
|
Expect(findQueued(queueRepo, "al", "alok")).To(BeNil())
|
||||||
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alok", model.ImageTypePrimary)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(ia.Source).To(Equal("folder"))
|
||||||
|
})
|
||||||
|
|
||||||
It("reschedules past the provider's requested delay when it exceeds the backoff", func() {
|
It("reschedules past the provider's requested delay when it exceeds the backoff", func() {
|
||||||
conf.Server.CoverArtPriority = "external"
|
conf.Server.CoverArtPriority = "external"
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})
|
||||||
// Well above backoff(0)'s jittered ceiling, so only the hint can produce this retry_at.
|
// Well above backoff(0)'s jittered ceiling, so only the hint can produce this retry_at.
|
||||||
const askedFor = 90 * time.Minute
|
const askedFor = 90 * time.Minute
|
||||||
imageAgents(&fakeImageAgent{name: "throttledAgent", err: &agents.RetryLaterError{RetryIn: askedFor}})
|
imageAgents(&fakeImageAgent{name: "throttledAgent", err: &agents.RetryLaterError{RetryIn: askedFor}})
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
|
||||||
|
|
||||||
n, err := w.drain(ctx, 2)
|
n, err := w.drain(ctx, 2)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -296,7 +347,7 @@ var _ = Describe("Worker", func() {
|
||||||
{ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}},
|
{ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}},
|
||||||
})
|
})
|
||||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})).To(Succeed())
|
||||||
|
|
||||||
n, err := w.drain(ctx, 2)
|
n, err := w.drain(ctx, 2)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -307,7 +358,7 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(it.Attempts).To(Equal(1))
|
Expect(it.Attempts).To(Equal(1))
|
||||||
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
|
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("folder"), "the fallback art is served meanwhile")
|
Expect(ia.Source).To(Equal("folder"), "the fallback art is served meanwhile")
|
||||||
|
|
||||||
|
|
@ -327,7 +378,7 @@ var _ = Describe("Worker", func() {
|
||||||
racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo}
|
racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo}
|
||||||
ds.MockedArtworkQueue = racing
|
ds.MockedArtworkQueue = racing
|
||||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||||
ItemKind: "al", ItemID: "al7", Priority: model.ArtworkPriorityScan,
|
ItemKind: "al", ItemID: "al7", Priority: model.ArtworkPriorityScan,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -337,7 +388,7 @@ var _ = Describe("Worker", func() {
|
||||||
|
|
||||||
// The concurrent re-enqueue changed retry_at, so the found-path delete was a no-op.
|
// The concurrent re-enqueue changed retry_at, so the found-path delete was a no-op.
|
||||||
Expect(findQueued(queueRepo, "al", "al7")).ToNot(BeNil())
|
Expect(findQueued(queueRepo, "al", "al7")).ToNot(BeNil())
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al7", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("folder"))
|
Expect(ia.Source).To(Equal("folder"))
|
||||||
})
|
})
|
||||||
|
|
@ -349,7 +400,7 @@ var _ = Describe("Worker", func() {
|
||||||
racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo}
|
racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo}
|
||||||
ds.MockedArtworkQueue = racing
|
ds.MockedArtworkQueue = racing
|
||||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al8"})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al8"})).To(Succeed())
|
||||||
dequeued := findQueued(queueRepo, "al", "al8").RetryAt
|
dequeued := findQueued(queueRepo, "al", "al8").RetryAt
|
||||||
|
|
||||||
n, err := w.drain(ctx, 1)
|
n, err := w.drain(ctx, 1)
|
||||||
|
|
@ -368,7 +419,7 @@ var _ = Describe("Worker", func() {
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})
|
||||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
|
||||||
// Age the row past the retry budget.
|
// Age the row past the retry budget.
|
||||||
expireQueued(queueRepo, "al9")
|
expireQueued(queueRepo, "al9")
|
||||||
|
|
||||||
|
|
@ -377,7 +428,7 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(n).To(Equal(1))
|
Expect(n).To(Equal(1))
|
||||||
|
|
||||||
Expect(findQueued(queueRepo, "al", "al9")).To(BeNil())
|
Expect(findQueued(queueRepo, "al", "al9")).To(BeNil())
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al9", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al9", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).To(BeEmpty())
|
Expect(ia.Hash).To(BeEmpty())
|
||||||
})
|
})
|
||||||
|
|
@ -385,13 +436,13 @@ var _ = Describe("Worker", func() {
|
||||||
It("keeps already-served art when the retry budget is exhausted", func() {
|
It("keeps already-served art when the retry budget is exhausted", func() {
|
||||||
conf.Server.CoverArtPriority = "external"
|
conf.Server.CoverArtPriority = "external"
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al10", Name: "Album"}})
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al10", Name: "Album"}})
|
||||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "al10", ImageType: model.ImageTypePrimary,
|
ItemKind: "al", ItemID: "al10", ImageType: model.ImageTypePrimary,
|
||||||
Hash: "cafebabe", Source: "external:lastfm",
|
Hash: "cafebabe", Source: "external:lastfm",
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al10"})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al10"})).To(Succeed())
|
||||||
expireQueued(queueRepo, "al10")
|
expireQueued(queueRepo, "al10")
|
||||||
|
|
||||||
n, err := w.drain(ctx, 1)
|
n, err := w.drain(ctx, 1)
|
||||||
|
|
@ -399,7 +450,7 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(n).To(Equal(1))
|
Expect(n).To(Equal(1))
|
||||||
|
|
||||||
Expect(findQueued(queueRepo, "al", "al10")).To(BeNil())
|
Expect(findQueued(queueRepo, "al", "al10")).To(BeNil())
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al10", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al10", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art")
|
Expect(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art")
|
||||||
})
|
})
|
||||||
|
|
@ -409,7 +460,7 @@ var _ = Describe("Worker", func() {
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al11", Name: "Album"}})
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al11", Name: "Album"}})
|
||||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al11"})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al11"})).To(Succeed())
|
||||||
|
|
||||||
_, err := w.drain(ctx, 1)
|
_, err := w.drain(ctx, 1)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -430,13 +481,13 @@ var _ = Describe("Worker", func() {
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al13", Name: "Album"}})
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al13", Name: "Album"}})
|
||||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al13"})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al13"})).To(Succeed())
|
||||||
expireQueued(queueRepo, "al13")
|
expireQueued(queueRepo, "al13")
|
||||||
|
|
||||||
_, err := w.drain(ctx, 1)
|
_, err := w.drain(ctx, 1)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al13", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al13", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred(), "settling absent must create the row the failure is written to")
|
Expect(err).ToNot(HaveOccurred(), "settling absent must create the row the failure is written to")
|
||||||
Expect(ia.Hash).To(BeEmpty())
|
Expect(ia.Hash).To(BeEmpty())
|
||||||
Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty())
|
Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty())
|
||||||
|
|
@ -445,20 +496,20 @@ var _ = Describe("Worker", func() {
|
||||||
It("keeps the failure on the state row after the queue row is deleted", func() {
|
It("keeps the failure on the state row after the queue row is deleted", func() {
|
||||||
conf.Server.CoverArtPriority = "external"
|
conf.Server.CoverArtPriority = "external"
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al12", Name: "Album"}})
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al12", Name: "Album"}})
|
||||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "al12", ImageType: model.ImageTypePrimary,
|
ItemKind: "al", ItemID: "al12", ImageType: model.ImageTypePrimary,
|
||||||
Hash: "cafebabe", Source: "external:lastfm",
|
Hash: "cafebabe", Source: "external:lastfm",
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al12"})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al12"})).To(Succeed())
|
||||||
expireQueued(queueRepo, "al12")
|
expireQueued(queueRepo, "al12")
|
||||||
|
|
||||||
_, err := w.drain(ctx, 1)
|
_, err := w.drain(ctx, 1)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
Expect(findQueued(queueRepo, "al", "al12")).To(BeNil())
|
Expect(findQueued(queueRepo, "al", "al12")).To(BeNil())
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al12", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al12", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty(),
|
Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty(),
|
||||||
"the queue row is gone, so this is the only remaining record of the failure")
|
"the queue row is gone, so this is the only remaining record of the failure")
|
||||||
|
|
@ -473,7 +524,7 @@ var _ = Describe("Worker", func() {
|
||||||
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||||
{ID: "mfX", LibraryID: 0, Path: "tests/fixtures/artist/an-album/gone.mp3", HasCoverArt: true},
|
{ID: "mfX", LibraryID: 0, Path: "tests/fixtures/artist/an-album/gone.mp3", HasCoverArt: true},
|
||||||
})
|
})
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mfX"})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mfX"})).To(Succeed())
|
||||||
expireQueued(queueRepo, "mfX")
|
expireQueued(queueRepo, "mfX")
|
||||||
|
|
||||||
n, err := w.drain(ctx, 1)
|
n, err := w.drain(ctx, 1)
|
||||||
|
|
@ -481,7 +532,7 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(n).To(Equal(1))
|
Expect(n).To(Equal(1))
|
||||||
|
|
||||||
Expect(findQueued(queueRepo, "mf", "mfX")).To(BeNil(), "the row must stop retrying")
|
Expect(findQueued(queueRepo, "mf", "mfX")).To(BeNil(), "the row must stop retrying")
|
||||||
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mfX", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mfX", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound),
|
Expect(err).To(MatchError(model.ErrNotFound),
|
||||||
"no row leaves the track unresolved, so a later view can still recover it")
|
"no row leaves the track unresolved, so a later view can still recover it")
|
||||||
// Known gap: with no row and no absent settle, there is nowhere to keep the failure.
|
// Known gap: with no row and no absent settle, there is nowhere to keep the failure.
|
||||||
|
|
@ -496,14 +547,14 @@ var _ = Describe("Worker", func() {
|
||||||
tracks: &tests.MockPlaylistTrackRepo{},
|
tracks: &tests.MockPlaylistTrackRepo{},
|
||||||
}
|
}
|
||||||
w = NewWorker(vds, store, ag, ffm, broker, imgCache)
|
w = NewWorker(vds, store, ag, ffm, broker, imgCache)
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plPriv"})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plPriv"})).To(Succeed())
|
||||||
|
|
||||||
n, err := w.drain(ctx, 1)
|
n, err := w.drain(ctx, 1)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(n).To(Equal(1))
|
Expect(n).To(Equal(1))
|
||||||
|
|
||||||
Expect(findQueued(queueRepo, "pl", "plPriv")).To(BeNil())
|
Expect(findQueued(queueRepo, "pl", "plPriv")).To(BeNil())
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "plPriv", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindPlaylistArtwork, "plPriv", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).To(BeEmpty())
|
Expect(ia.Hash).To(BeEmpty())
|
||||||
})
|
})
|
||||||
|
|
@ -523,9 +574,9 @@ var _ = Describe("Worker", func() {
|
||||||
{ID: "al1", Name: "Album 1", FolderIDs: []string{"f1"}},
|
{ID: "al1", Name: "Album 1", FolderIDs: []string{"f1"}},
|
||||||
{ID: "al2", Name: "Album 2", FolderIDs: []string{"f1"}},
|
{ID: "al2", Name: "Album 2", FolderIDs: []string{"f1"}},
|
||||||
})
|
})
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
||||||
|
|
||||||
n, err := w.drain(ctx, 3)
|
n, err := w.drain(ctx, 3)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -572,7 +623,7 @@ var _ = Describe("Worker", func() {
|
||||||
conf.Server.CoverArtPriority = "cover.*" // local-only; no folder image → absent
|
conf.Server.CoverArtPriority = "cover.*" // local-only; no folder image → absent
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al3", Name: "Artless"}})
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al3", Name: "Artless"}})
|
||||||
folderRepo.result = nil
|
folderRepo.result = nil
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
||||||
|
|
||||||
n, err := w.drain(ctx, 2)
|
n, err := w.drain(ctx, 2)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -582,7 +633,7 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(evts).To(HaveLen(1), "a removed cover must live-refresh clients so they drop it")
|
Expect(evts).To(HaveLen(1), "a removed cover must live-refresh clients so they drop it")
|
||||||
Expect(evts[0].(*events.RefreshResource).Data(evts[0])).To(ContainSubstring("al3"))
|
Expect(evts[0].(*events.RefreshResource).Data(evts[0])).To(ContainSubstring("al3"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).To(BeEmpty(), "the outcome was absent, not found")
|
Expect(ia.Hash).To(BeEmpty(), "the outcome was absent, not found")
|
||||||
})
|
})
|
||||||
|
|
@ -591,7 +642,7 @@ var _ = Describe("Worker", func() {
|
||||||
conf.Server.CoverArtPriority = "external"
|
conf.Server.CoverArtPriority = "external"
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}})
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}})
|
||||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"})).To(Succeed())
|
||||||
|
|
||||||
n, err := w.drain(ctx, 2)
|
n, err := w.drain(ctx, 2)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -725,7 +776,7 @@ var _ = Describe("Worker", func() {
|
||||||
{ID: "alpc", Name: "Album", FolderIDs: []string{"f1"}},
|
{ID: "alpc", Name: "Album", FolderIDs: []string{"f1"}},
|
||||||
})
|
})
|
||||||
conf.Server.UICoverArtSize = 300
|
conf.Server.UICoverArtSize = 300
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||||
ItemKind: "al", ItemID: "alpc", Priority: model.ArtworkPriorityScan,
|
ItemKind: "al", ItemID: "alpc", Priority: model.ArtworkPriorityScan,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
})
|
})
|
||||||
|
|
@ -813,11 +864,11 @@ var _ = Describe("Worker", func() {
|
||||||
|
|
||||||
// Artists first, exactly as Backfill orders them.
|
// Artists first, exactly as Backfill orders them.
|
||||||
for _, a := range artists {
|
for _, a := range artists {
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||||
ItemKind: "ar", ItemID: a.ID, Priority: model.ArtworkPriorityBackfill,
|
ItemKind: "ar", ItemID: a.ID, Priority: model.ArtworkPriorityBackfill,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
}
|
}
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||||
ItemKind: "al", ItemID: "alx", Priority: model.ArtworkPriorityBackfill,
|
ItemKind: "al", ItemID: "alx", Priority: model.ArtworkPriorityBackfill,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -832,12 +883,12 @@ var _ = Describe("Worker", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
Eventually(func() bool {
|
Eventually(func() bool {
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alx", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alx", model.ImageTypePrimary)
|
||||||
return err == nil && ia.Hash != ""
|
return err == nil && ia.Hash != ""
|
||||||
}, 5*time.Second, 50*time.Millisecond).Should(BeTrue(),
|
}, 5*time.Second, 50*time.Millisecond).Should(BeTrue(),
|
||||||
"a blocked external pool must not hold up local artwork")
|
"a blocked external pool must not hold up local artwork")
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "arx0", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "arx0", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound), "artists are still blocked, as intended")
|
Expect(err).To(MatchError(model.ErrNotFound), "artists are still blocked, as intended")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -849,7 +900,7 @@ var _ = Describe("Worker", func() {
|
||||||
for i := range 8 {
|
for i := range 8 {
|
||||||
id := fmt.Sprintf("alc%d", i)
|
id := fmt.Sprintf("alc%d", i)
|
||||||
albums = append(albums, model.Album{ID: id, Name: "Album"})
|
albums = append(albums, model.Album{ID: id, Name: "Album"})
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||||
ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan,
|
ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
}
|
}
|
||||||
|
|
@ -875,21 +926,21 @@ var _ = Describe("Worker", func() {
|
||||||
for i := range 8 {
|
for i := range 8 {
|
||||||
id := fmt.Sprintf("alp%d", i)
|
id := fmt.Sprintf("alp%d", i)
|
||||||
albums = append(albums, model.Album{ID: id, Name: "Album", FolderIDs: []string{"f1"}})
|
albums = append(albums, model.Album{ID: id, Name: "Album", FolderIDs: []string{"f1"}})
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||||
ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan,
|
ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
}
|
}
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(albums)
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(albums)
|
||||||
// Pauses as soon as the first item has left the queue.
|
// Pauses as soon as the first item has left the queue.
|
||||||
w.PauseWhile(func() bool {
|
w.PauseWhile(func() bool {
|
||||||
n, _ := queueRepo.Count()
|
n, _ := queueRepo.Count(ctx)
|
||||||
return n < 8
|
return n < 8
|
||||||
})
|
})
|
||||||
|
|
||||||
_, err := w.drain(ctx, 1)
|
_, err := w.drain(ctx, 1)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
count, err := queueRepo.Count()
|
count, err := queueRepo.Count(ctx)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(count).To(Equal(int64(7)), "only the item dispatched before the pause may leave the queue")
|
Expect(count).To(Equal(int64(7)), "only the item dispatched before the pause may leave the queue")
|
||||||
})
|
})
|
||||||
|
|
@ -897,7 +948,7 @@ var _ = Describe("Worker", func() {
|
||||||
It("dequeues past the worker pool so one drain covers many items", func() {
|
It("dequeues past the worker pool so one drain covers many items", func() {
|
||||||
for i := range 16 {
|
for i := range 16 {
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: fmt.Sprintf("alb%d", i), Name: "Album"}})
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: fmt.Sprintf("alb%d", i), Name: "Album"}})
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||||
ItemKind: "al", ItemID: fmt.Sprintf("alb%d", i), Priority: model.ArtworkPriorityScan,
|
ItemKind: "al", ItemID: fmt.Sprintf("alb%d", i), Priority: model.ArtworkPriorityScan,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
}
|
}
|
||||||
|
|
@ -932,7 +983,7 @@ var _ = Describe("Worker", func() {
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||||
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}},
|
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}},
|
||||||
})
|
})
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||||
ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan,
|
ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
w.PauseWhile(func() bool { return true })
|
w.PauseWhile(func() bool { return true })
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ func Init(ds model.DataStore) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadOrCreateSecret(ctx context.Context, ds model.DataStore, key string) string {
|
func loadOrCreateSecret(ctx context.Context, ds model.DataStore, key string) string {
|
||||||
secret, err := ds.Property(ctx).Get(key)
|
secret, err := ds.Property().Get(ctx, key)
|
||||||
if err != nil || secret == "" {
|
if err != nil || secret == "" {
|
||||||
log.Info(ctx, "Creating new JWT secret", "key", key)
|
log.Info(ctx, "Creating new JWT secret", "key", key)
|
||||||
return createNewSecret(ctx, ds, key)
|
return createNewSecret(ctx, ds, key)
|
||||||
|
|
@ -154,9 +154,9 @@ func CheckClaims(c Claims, usr model.User, audience string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
|
func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
|
||||||
u, err := ds.User(ctx).FindFirstAdmin()
|
u, err := ds.User().FindFirstAdmin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c, err := ds.User(ctx).CountAll()
|
c, err := ds.User().CountAll(ctx)
|
||||||
if c == 0 && err == nil {
|
if c == 0 && err == nil {
|
||||||
log.Debug(ctx, "No admin user yet!", err)
|
log.Debug(ctx, "No admin user yet!", err)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -176,7 +176,7 @@ func createNewSecret(ctx context.Context, ds model.DataStore, key string) string
|
||||||
log.Error(ctx, "Could not encrypt JWT secret", err)
|
log.Error(ctx, "Could not encrypt JWT secret", err)
|
||||||
return secret
|
return secret
|
||||||
}
|
}
|
||||||
if err := ds.Property(ctx).Put(key, encSecret); err != nil {
|
if err := ds.Property().Put(ctx, key, encSecret); err != nil {
|
||||||
log.Error(ctx, "Could not save JWT secret in DB", err)
|
log.Error(ctx, "Could not save JWT secret in DB", err)
|
||||||
}
|
}
|
||||||
return secret
|
return secret
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ func userName(ctx context.Context) string {
|
||||||
// BFR We should only access files through the `storage.Storage` interface. This will require changing how
|
// BFR We should only access files through the `storage.Storage` interface. This will require changing how
|
||||||
// TagLib and ffmpeg access files
|
// TagLib and ffmpeg access files
|
||||||
var AbsolutePath = func(ctx context.Context, ds model.DataStore, libId int, path string) string {
|
var AbsolutePath = func(ctx context.Context, ds model.DataStore, libId int, path string) string {
|
||||||
libPath, err := ds.Library(ctx).GetPath(libId)
|
libPath, err := ds.Library().GetPath(ctx, libId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
core/external/extdata_helper_test.go
vendored
18
core/external/extdata_helper_test.go
vendored
|
|
@ -31,7 +31,7 @@ func (m *mockArtistRepo) SetData(artists model.Artists) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get implements model.ArtistRepository.
|
// Get implements model.ArtistRepository.
|
||||||
func (m *mockArtistRepo) Get(id string) (*model.Artist, error) {
|
func (m *mockArtistRepo) Get(_ context.Context, id string) (*model.Artist, error) {
|
||||||
args := m.Called(id)
|
args := m.Called(id)
|
||||||
if args.Get(0) == nil {
|
if args.Get(0) == nil {
|
||||||
return nil, args.Error(1)
|
return nil, args.Error(1)
|
||||||
|
|
@ -40,7 +40,7 @@ func (m *mockArtistRepo) Get(id string) (*model.Artist, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAll implements model.ArtistRepository.
|
// GetAll implements model.ArtistRepository.
|
||||||
func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) {
|
func (m *mockArtistRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Artists, error) {
|
||||||
argsSlice := make([]any, len(options))
|
argsSlice := make([]any, len(options))
|
||||||
for i, v := range options {
|
for i, v := range options {
|
||||||
argsSlice[i] = v
|
argsSlice[i] = v
|
||||||
|
|
@ -85,7 +85,7 @@ func (m *mockMediaFileRepo) SetData(mediaFiles model.MediaFiles) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get implements model.MediaFileRepository.
|
// Get implements model.MediaFileRepository.
|
||||||
func (m *mockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
|
func (m *mockMediaFileRepo) Get(ctx context.Context, id string) (*model.MediaFile, error) {
|
||||||
args := m.Called(id)
|
args := m.Called(id)
|
||||||
if args.Get(0) == nil {
|
if args.Get(0) == nil {
|
||||||
return nil, args.Error(1)
|
return nil, args.Error(1)
|
||||||
|
|
@ -94,12 +94,12 @@ func (m *mockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllByTags implements model.MediaFileRepository.
|
// GetAllByTags implements model.MediaFileRepository.
|
||||||
func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
func (m *mockMediaFileRepo) GetAllByTags(ctx context.Context, _ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||||
return m.GetAll(options...)
|
return m.GetAll(ctx, options...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAll implements model.MediaFileRepository.
|
// GetAll implements model.MediaFileRepository.
|
||||||
func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
func (m *mockMediaFileRepo) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||||
argsSlice := make([]any, len(options))
|
argsSlice := make([]any, len(options))
|
||||||
for i, v := range options {
|
for i, v := range options {
|
||||||
argsSlice[i] = v
|
argsSlice[i] = v
|
||||||
|
|
@ -112,7 +112,7 @@ func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFi
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRandom implements model.MediaFileRepository.
|
// GetRandom implements model.MediaFileRepository.
|
||||||
func (m *mockMediaFileRepo) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) {
|
func (m *mockMediaFileRepo) GetRandom(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||||
argsSlice := make([]any, len(options))
|
argsSlice := make([]any, len(options))
|
||||||
for i, v := range options {
|
for i, v := range options {
|
||||||
argsSlice[i] = v
|
argsSlice[i] = v
|
||||||
|
|
@ -156,7 +156,7 @@ func newMockAlbumRepo() *mockAlbumRepo {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get implements model.AlbumRepository.
|
// Get implements model.AlbumRepository.
|
||||||
func (m *mockAlbumRepo) Get(id string) (*model.Album, error) {
|
func (m *mockAlbumRepo) Get(_ context.Context, id string) (*model.Album, error) {
|
||||||
args := m.Called(id)
|
args := m.Called(id)
|
||||||
if args.Get(0) == nil {
|
if args.Get(0) == nil {
|
||||||
return nil, args.Error(1)
|
return nil, args.Error(1)
|
||||||
|
|
@ -165,7 +165,7 @@ func (m *mockAlbumRepo) Get(id string) (*model.Album, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAll implements model.AlbumRepository.
|
// GetAll implements model.AlbumRepository.
|
||||||
func (m *mockAlbumRepo) GetAll(options ...model.QueryOptions) (model.Albums, error) {
|
func (m *mockAlbumRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Albums, error) {
|
||||||
argsSlice := make([]any, len(options))
|
argsSlice := make([]any, len(options))
|
||||||
for i, v := range options {
|
for i, v := range options {
|
||||||
argsSlice[i] = v
|
argsSlice[i] = v
|
||||||
|
|
|
||||||
16
core/external/provider.go
vendored
16
core/external/provider.go
vendored
|
|
@ -182,7 +182,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = e.ds.Album(ctx).UpdateExternalInfo(&album.Album)
|
err = e.ds.Album().UpdateExternalInfo(ctx, &album.Album)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", albumName,
|
log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", albumName,
|
||||||
"elapsed", time.Since(start), err)
|
"elapsed", time.Since(start), err)
|
||||||
|
|
@ -285,7 +285,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
|
||||||
if !throttled {
|
if !throttled {
|
||||||
artist.ExternalInfoUpdatedAt = new(time.Now())
|
artist.ExternalInfoUpdatedAt = new(time.Now())
|
||||||
}
|
}
|
||||||
err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist)
|
err := e.ds.Artist().UpdateExternalInfo(ctx, &artist.Artist)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName,
|
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName,
|
||||||
"elapsed", time.Since(start), err)
|
"elapsed", time.Since(start), err)
|
||||||
|
|
@ -548,7 +548,7 @@ func (e *provider) loadArtistsByID(ctx context.Context, similar []agents.Artist)
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return matches, nil
|
return matches, nil
|
||||||
}
|
}
|
||||||
res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.Eq{"artist.id": ids},
|
Filters: squirrel.Eq{"artist.id": ids},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -577,7 +577,7 @@ func (e *provider) loadArtistsByMBID(ctx context.Context, similar []agents.Artis
|
||||||
if len(mbids) == 0 {
|
if len(mbids) == 0 {
|
||||||
return matches, nil
|
return matches, nil
|
||||||
}
|
}
|
||||||
res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.Eq{"mbz_artist_id": mbids},
|
Filters: squirrel.Eq{"mbz_artist_id": mbids},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -612,7 +612,7 @@ func (e *provider) loadArtistsByName(ctx context.Context, similar []agents.Artis
|
||||||
clauses := slice.Map(names, func(name string) squirrel.Sqlizer {
|
clauses := slice.Map(names, func(name string) squirrel.Sqlizer {
|
||||||
return squirrel.Like{"artist.name": name}
|
return squirrel.Like{"artist.name": name}
|
||||||
})
|
})
|
||||||
res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.Or(clauses),
|
Filters: squirrel.Or(clauses),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -628,7 +628,7 @@ func (e *provider) loadArtistsByName(ctx context.Context, similar []agents.Artis
|
||||||
|
|
||||||
func (e *provider) findArtist(ctx context.Context, artistName, id string) (*auxArtist, error) {
|
func (e *provider) findArtist(ctx context.Context, artistName, id string) (*auxArtist, error) {
|
||||||
if id != "" {
|
if id != "" {
|
||||||
artist, err := e.ds.Artist(ctx).Get(id)
|
artist, err := e.ds.Artist().Get(ctx, id)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return &auxArtist{Artist: *artist}, nil
|
return &auxArtist{Artist: *artist}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -644,7 +644,7 @@ func (e *provider) findArtist(ctx context.Context, artistName, id string) (*auxA
|
||||||
return nil, model.ErrNotFound
|
return nil, model.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
artists, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.Like{"artist.name": artistName},
|
Filters: squirrel.Like{"artist.name": artistName},
|
||||||
Max: 1,
|
Max: 1,
|
||||||
})
|
})
|
||||||
|
|
@ -666,7 +666,7 @@ func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int
|
||||||
ids = append(ids, sa.ID)
|
ids = append(ids, sa.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
similar, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
similar, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.Eq{"artist.id": ids},
|
Filters: squirrel.Eq{"artist.id": ids},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
8
core/external/provider_refreshinfo_test.go
vendored
8
core/external/provider_refreshinfo_test.go
vendored
|
|
@ -71,8 +71,8 @@ var _ = Describe("Provider - RefreshInfo", func() {
|
||||||
ag = new(mockAgents)
|
ag = new(mockAgents)
|
||||||
broker = &fakeBroker{}
|
broker = &fakeBroker{}
|
||||||
p = external.NewProvider(ds, ag, matcher.New(ds), broker)
|
p = external.NewProvider(ds, ag, matcher.New(ds), broker)
|
||||||
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
|
mockArtistRepo = ds.Artist().(*tests.MockArtistRepo)
|
||||||
mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo)
|
||||||
})
|
})
|
||||||
|
|
||||||
It("repopulates an artist even when its info is fresh", func() {
|
It("repopulates an artist even when its info is fresh", func() {
|
||||||
|
|
@ -84,7 +84,7 @@ var _ = Describe("Provider - RefreshInfo", func() {
|
||||||
|
|
||||||
Expect(p.RefreshInfo(ctx, model.KindArtistArtwork, "ar-1")).To(Succeed())
|
Expect(p.RefreshInfo(ctx, model.KindArtistArtwork, "ar-1")).To(Succeed())
|
||||||
|
|
||||||
saved, err := mockArtistRepo.Get("ar-1")
|
saved, err := mockArtistRepo.Get(ctx, "ar-1")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(saved.Biography).To(Equal("Fresh Bio"))
|
Expect(saved.Biography).To(Equal("Fresh Bio"))
|
||||||
})
|
})
|
||||||
|
|
@ -99,7 +99,7 @@ var _ = Describe("Provider - RefreshInfo", func() {
|
||||||
|
|
||||||
Expect(p.RefreshInfo(ctx, model.KindAlbumArtwork, "al-1")).To(Succeed())
|
Expect(p.RefreshInfo(ctx, model.KindAlbumArtwork, "al-1")).To(Succeed())
|
||||||
|
|
||||||
saved, err := mockAlbumRepo.Get("al-1")
|
saved, err := mockAlbumRepo.Get(ctx, "al-1")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(saved.Description).To(Equal("Fresh Notes"))
|
Expect(saved.Description).To(Equal("Fresh Notes"))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
8
core/external/provider_similarsongs.go
vendored
8
core/external/provider_similarsongs.go
vendored
|
|
@ -37,7 +37,7 @@ func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (mode
|
||||||
if !errors.Is(err, model.ErrNotFound) {
|
if !errors.Is(err, model.ErrNotFound) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
genre, err := e.ds.Genre(ctx).Get(id)
|
genre, err := e.ds.Genre().Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -178,13 +178,13 @@ func (e *provider) seedMix(ctx context.Context, count int, sample func() (model.
|
||||||
func (e *provider) samplePlaylistTracks(ctx context.Context, playlistID string, n int) (model.MediaFiles, error) {
|
func (e *provider) samplePlaylistTracks(ctx context.Context, playlistID string, n int) (model.MediaFiles, error) {
|
||||||
// Refresh: a smart playlist materializes no tracks until it is evaluated, so skipping it would
|
// Refresh: a smart playlist materializes no tracks until it is evaluated, so skipping it would
|
||||||
// mix an empty seed set. It is a no-op for regular playlists and inside the refresh delay.
|
// mix an empty seed set. It is a no-op for regular playlists and inside the refresh delay.
|
||||||
repo := e.ds.Playlist(ctx).Tracks(playlistID, true)
|
repo := e.ds.Playlist().Tracks(ctx, playlistID, true)
|
||||||
if repo == nil {
|
if repo == nil {
|
||||||
return nil, model.ErrNotFound
|
return nil, model.ErrNotFound
|
||||||
}
|
}
|
||||||
// A playlist can hold the same file at several positions, so over-fetch and dedup: a repeated
|
// A playlist can hold the same file at several positions, so over-fetch and dedup: a repeated
|
||||||
// seed wastes an agent call and can reach the mix twice through the seed fallback.
|
// seed wastes an agent call and can reach the mix twice through the seed fallback.
|
||||||
tracks, err := repo.GetAll(model.QueryOptions{
|
tracks, err := repo.GetAll(ctx, model.QueryOptions{
|
||||||
Sort: "random",
|
Sort: "random",
|
||||||
Max: n * 4,
|
Max: n * 4,
|
||||||
Filters: squirrel.Eq{"missing": false},
|
Filters: squirrel.Eq{"missing": false},
|
||||||
|
|
@ -225,7 +225,7 @@ func (e *provider) sampleGenreTracks(ctx context.Context, genre *model.Genre, n
|
||||||
// sampleTracks returns up to n random present tracks. Seeds can end up in the mix verbatim, so
|
// sampleTracks returns up to n random present tracks. Seeds can end up in the mix verbatim, so
|
||||||
// missing files would surface as unplayable entries.
|
// missing files would surface as unplayable entries.
|
||||||
func (e *provider) sampleTracks(ctx context.Context, filter squirrel.Sqlizer, n int) (model.MediaFiles, error) {
|
func (e *provider) sampleTracks(ctx context.Context, filter squirrel.Sqlizer, n int) (model.MediaFiles, error) {
|
||||||
return e.ds.MediaFile(ctx).GetRandom(model.QueryOptions{
|
return e.ds.MediaFile().GetRandom(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.And{filter, squirrel.Eq{"missing": false}},
|
Filters: squirrel.And{filter, squirrel.Eq{"missing": false}},
|
||||||
Max: n,
|
Max: n,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
|
||||||
ds = new(tests.MockDataStore)
|
ds = new(tests.MockDataStore)
|
||||||
ag = new(mockAgents)
|
ag = new(mockAgents)
|
||||||
p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{})
|
p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{})
|
||||||
mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo)
|
||||||
conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour
|
conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
|
||||||
ds = new(tests.MockDataStore)
|
ds = new(tests.MockDataStore)
|
||||||
ag = new(mockAgents)
|
ag = new(mockAgents)
|
||||||
p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{})
|
p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{})
|
||||||
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
|
mockArtistRepo = ds.Artist().(*tests.MockArtistRepo)
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns error when artist is not found", func() {
|
It("returns error when artist is not found", func() {
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@ type FFmpeg interface {
|
||||||
Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error)
|
Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error)
|
||||||
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
|
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
|
||||||
ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error)
|
ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error)
|
||||||
Probe(ctx context.Context, files []string) (string, error)
|
|
||||||
ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error)
|
ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error)
|
||||||
CmdPath() (string, error)
|
CmdPath() (string, error)
|
||||||
IsAvailable() bool
|
IsAvailable() bool
|
||||||
|
|
@ -68,7 +67,6 @@ var ErrAnimatedWebPUnsupported = errors.New("ffmpeg lacks libwebp_anim encoder
|
||||||
|
|
||||||
const (
|
const (
|
||||||
extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
|
extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
|
||||||
probeCmd = "ffmpeg %s -f ffmetadata"
|
|
||||||
probeAudioStreamCmd = "ffprobe -v error -select_streams a:0 -print_format json -show_streams -show_format %s"
|
probeAudioStreamCmd = "ffprobe -v error -select_streams a:0 -print_format json -show_streams -show_format %s"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -149,17 +147,6 @@ func fileExists(path string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *ffmpeg) Probe(ctx context.Context, files []string) (string, error) {
|
|
||||||
if _, err := ffmpegCmd(); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
args := createProbeCommand(probeCmd, files)
|
|
||||||
log.Trace(ctx, "Executing ffmpeg command", "args", args)
|
|
||||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec
|
|
||||||
output, _ := cmd.CombinedOutput()
|
|
||||||
return string(output), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ffmpeg) ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) {
|
func (e *ffmpeg) ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) {
|
||||||
if _, err := ffmpegCmd(); err != nil {
|
if _, err := ffmpegCmd(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -593,20 +580,6 @@ func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
func createProbeCommand(cmd string, inputs []string) []string {
|
|
||||||
var args []string
|
|
||||||
for _, s := range fixCmd(cmd) {
|
|
||||||
if s == "%s" {
|
|
||||||
for _, inp := range inputs {
|
|
||||||
args = append(args, "-i", inp)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
args = append(args, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return args
|
|
||||||
}
|
|
||||||
|
|
||||||
func fixCmd(cmd string) []string {
|
func fixCmd(cmd string) []string {
|
||||||
split := strings.Fields(cmd)
|
split := strings.Fields(cmd)
|
||||||
cmdPath, _ := ffmpegCmd()
|
cmdPath, _ := ffmpegCmd()
|
||||||
|
|
|
||||||
|
|
@ -62,23 +62,16 @@ var _ = Describe("ffmpeg", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("createProbeCommand", func() {
|
|
||||||
It("creates a valid command line", func() {
|
|
||||||
args := createProbeCommand(probeCmd, []string{"/music library/one.mp3", "/music library/two.mp3"})
|
|
||||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/one.mp3", "-i", "/music library/two.mp3", "-f", "ffmetadata"}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
When("ffmpegPath is set", func() {
|
When("ffmpegPath is set", func() {
|
||||||
It("returns the correct ffmpeg path", func() {
|
It("returns the correct ffmpeg path", func() {
|
||||||
ffmpegPath = "/usr/bin/ffmpeg"
|
ffmpegPath = "/usr/bin/ffmpeg"
|
||||||
args := createProbeCommand(probeCmd, []string{"one.mp3"})
|
args := createFFmpegCommand("ffmpeg -i %s -f mp3 -", "one.mp3", 0, 0)
|
||||||
Expect(args).To(Equal([]string{"/usr/bin/ffmpeg", "-i", "one.mp3", "-f", "ffmetadata"}))
|
Expect(args).To(Equal([]string{"/usr/bin/ffmpeg", "-i", "one.mp3", "-f", "mp3", "-"}))
|
||||||
})
|
})
|
||||||
It("returns the correct ffmpeg path with spaces", func() {
|
It("returns the correct ffmpeg path with spaces", func() {
|
||||||
ffmpegPath = "/usr/bin/with spaces/ffmpeg.exe"
|
ffmpegPath = "/usr/bin/with spaces/ffmpeg.exe"
|
||||||
args := createProbeCommand(probeCmd, []string{"one.mp3"})
|
args := createFFmpegCommand("ffmpeg -i %s -f mp3 -", "one.mp3", 0, 0)
|
||||||
Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "ffmetadata"}))
|
Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "mp3", "-"}))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ type InspectOutput struct {
|
||||||
MappedTags *model.MediaFile `json:"mappedTags,omitempty"`
|
MappedTags *model.MediaFile `json:"mappedTags,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, error) {
|
func Inspect(filePath string, lib model.Library, folderId string) (*InspectOutput, error) {
|
||||||
path, file := filepath.Split(filePath)
|
path, file := filepath.Split(filePath)
|
||||||
|
|
||||||
s, err := storage.For(path)
|
s, err := storage.For(path)
|
||||||
|
|
@ -39,12 +39,22 @@ func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, e
|
||||||
return nil, model.ErrNotFound
|
return nil, model.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
md := metadata.New(path, tag)
|
md := metadata.New(scannerPath(lib, filePath), tag)
|
||||||
result := &InspectOutput{
|
result := &InspectOutput{
|
||||||
File: filePath,
|
File: filePath,
|
||||||
RawTags: tags[file].Tags,
|
RawTags: tags[file].Tags,
|
||||||
MappedTags: new(md.ToMediaFile(libraryId, folderId)),
|
MappedTags: new(md.ToMediaFile(lib, folderId)),
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scannerPath returns the path the scanner uses for the file (relative to its library), so
|
||||||
|
// folder-based PIDs match the DB. Files outside the library keep their absolute path.
|
||||||
|
func scannerPath(lib model.Library, filePath string) string {
|
||||||
|
absPath, err := filepath.Abs(filePath)
|
||||||
|
if err != nil || lib.Path == "" {
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
return model.LibraryRelativePath(lib.Path, absPath)
|
||||||
|
}
|
||||||
|
|
|
||||||
45
core/inspect_test.go
Normal file
45
core/inspect_test.go
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/core"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Inspect", func() {
|
||||||
|
var fixtures string
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
var err error
|
||||||
|
fixtures, err = filepath.Abs(filepath.Join("tests", "fixtures"))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("maps the file with the library-relative path the scanner uses", func() {
|
||||||
|
lib := model.Library{ID: 2, Path: filepath.Dir(fixtures), PIDAlbum: "folder"}
|
||||||
|
out, err := core.Inspect(filepath.Join(fixtures, "test.mp3"), lib, "")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(out.MappedTags.Path).To(Equal("fixtures/test.mp3"))
|
||||||
|
Expect(out.MappedTags.LibraryID).To(Equal(2))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("gives the same IDs for relative and absolute paths", func() {
|
||||||
|
lib := model.Library{ID: 2, Path: filepath.Dir(fixtures), PIDAlbum: "folder"}
|
||||||
|
abs, err := core.Inspect(filepath.Join(fixtures, "test.mp3"), lib, "")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
rel, err := core.Inspect(filepath.Join("tests", "fixtures", "test.mp3"), lib, "")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(rel.MappedTags.AlbumID).To(Equal(abs.MappedTags.AlbumID))
|
||||||
|
Expect(rel.MappedTags.PID).To(Equal(abs.MappedTags.PID))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("keeps the given path for a file outside the library", func() {
|
||||||
|
filePath := filepath.Join(fixtures, "test.mp3")
|
||||||
|
out, err := core.Inspect(filePath, model.Library{ID: model.DefaultLibraryID}, "")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(out.MappedTags.Path).To(Equal(filePath))
|
||||||
|
})
|
||||||
|
})
|
||||||
186
core/library.go
186
core/library.go
|
|
@ -2,10 +2,12 @@ package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -15,6 +17,7 @@ import (
|
||||||
"github.com/navidrome/navidrome/core/storage"
|
"github.com/navidrome/navidrome/core/storage"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/metadata"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
"github.com/navidrome/navidrome/server/events"
|
"github.com/navidrome/navidrome/server/events"
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
"github.com/navidrome/navidrome/utils/slice"
|
||||||
|
|
@ -32,25 +35,28 @@ type Library interface {
|
||||||
SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error
|
SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error
|
||||||
ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error
|
ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error
|
||||||
|
|
||||||
NewRepository(ctx context.Context) rest.Repository
|
Repository() rest.Repository[model.Library]
|
||||||
}
|
}
|
||||||
|
|
||||||
type libraryService struct {
|
type libraryService struct {
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
scanner model.Scanner
|
broker events.Broker
|
||||||
watcher Watcher
|
repo *libraryRepositoryWrapper
|
||||||
broker events.Broker
|
|
||||||
pluginManager PluginUnloader
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLibrary creates a new Library service
|
// NewLibrary creates a new Library service
|
||||||
func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, broker events.Broker, pluginManager PluginUnloader) Library {
|
func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, broker events.Broker, pluginManager PluginUnloader) Library {
|
||||||
return &libraryService{
|
return &libraryService{
|
||||||
ds: ds,
|
ds: ds,
|
||||||
scanner: scanner,
|
broker: broker,
|
||||||
watcher: watcher,
|
repo: &libraryRepositoryWrapper{
|
||||||
broker: broker,
|
LibraryRepository: ds.Library(),
|
||||||
pluginManager: pluginManager,
|
ds: ds,
|
||||||
|
scanner: scanner,
|
||||||
|
watcher: watcher,
|
||||||
|
broker: broker,
|
||||||
|
pluginManager: pluginManager,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,16 +64,16 @@ func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, brok
|
||||||
|
|
||||||
func (s *libraryService) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) {
|
func (s *libraryService) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) {
|
||||||
// Verify user exists
|
// Verify user exists
|
||||||
if _, err := s.ds.User(ctx).Get(userID); err != nil {
|
if _, err := s.ds.User().Get(ctx, userID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.ds.User(ctx).GetUserLibraries(userID)
|
return s.ds.User().GetUserLibraries(ctx, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *libraryService) SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error {
|
func (s *libraryService) SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error {
|
||||||
// Verify user exists
|
// Verify user exists
|
||||||
user, err := s.ds.User(ctx).Get(userID)
|
user, err := s.ds.User().Get(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -90,7 +96,7 @@ func (s *libraryService) SetUserLibraries(ctx context.Context, userID string, li
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set user libraries
|
// Set user libraries
|
||||||
err = s.ds.User(ctx).SetUserLibraries(userID, libraryIDs)
|
err = s.ds.User().SetUserLibraries(ctx, userID, libraryIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error setting user libraries: %w", err)
|
return fmt.Errorf("error setting user libraries: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -115,7 +121,7 @@ func (s *libraryService) ValidateLibraryAccess(ctx context.Context, userID strin
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has explicit access to this library
|
// Check if user has explicit access to this library
|
||||||
libraries, err := s.ds.User(ctx).GetUserLibraries(userID)
|
libraries, err := s.ds.User().GetUserLibraries(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error checking library access", "userID", userID, "libraryID", libraryID, err)
|
log.Error(ctx, "Error checking library access", "userID", userID, "libraryID", libraryID, err)
|
||||||
return fmt.Errorf("error checking library access: %w", err)
|
return fmt.Errorf("error checking library access: %w", err)
|
||||||
|
|
@ -132,25 +138,14 @@ func (s *libraryService) ValidateLibraryAccess(ctx context.Context, userID strin
|
||||||
|
|
||||||
// REST repository wrapper
|
// REST repository wrapper
|
||||||
|
|
||||||
func (s *libraryService) NewRepository(ctx context.Context) rest.Repository {
|
func (s *libraryService) Repository() rest.Repository[model.Library] {
|
||||||
repo := s.ds.Library(ctx)
|
return s.repo
|
||||||
wrapper := &libraryRepositoryWrapper{
|
|
||||||
ctx: ctx,
|
|
||||||
LibraryRepository: repo,
|
|
||||||
Repository: repo.(rest.Repository),
|
|
||||||
ds: s.ds,
|
|
||||||
scanner: s.scanner,
|
|
||||||
watcher: s.watcher,
|
|
||||||
broker: s.broker,
|
|
||||||
pluginManager: s.pluginManager,
|
|
||||||
}
|
|
||||||
return wrapper
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _ rest.Persistable[model.Library] = (*libraryRepositoryWrapper)(nil)
|
||||||
|
|
||||||
type libraryRepositoryWrapper struct {
|
type libraryRepositoryWrapper struct {
|
||||||
rest.Repository
|
|
||||||
model.LibraryRepository
|
model.LibraryRepository
|
||||||
ctx context.Context
|
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
scanner model.Scanner
|
scanner model.Scanner
|
||||||
watcher Watcher
|
watcher Watcher
|
||||||
|
|
@ -158,87 +153,94 @@ type libraryRepositoryWrapper struct {
|
||||||
pluginManager PluginUnloader
|
pluginManager PluginUnloader
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *libraryRepositoryWrapper) Save(entity any) (string, error) {
|
func (r *libraryRepositoryWrapper) Save(ctx context.Context, lib *model.Library) (string, error) {
|
||||||
lib := entity.(*model.Library)
|
if err := r.validateLibrary(ctx, lib); err != nil {
|
||||||
if err := r.validateLibrary(lib); err != nil {
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
err := r.LibraryRepository.Put(lib)
|
err := r.LibraryRepository.Put(ctx, lib)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", r.mapError(err)
|
return "", r.mapError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start watcher and trigger scan after successful library creation
|
// Start watcher and trigger scan after successful library creation
|
||||||
if r.watcher != nil {
|
if r.watcher != nil {
|
||||||
if err := r.watcher.Watch(r.ctx, lib); err != nil {
|
if err := r.watcher.Watch(ctx, lib); err != nil {
|
||||||
log.Warn(r.ctx, "Failed to start watcher for new library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
|
log.Warn(ctx, "Failed to start watcher for new library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.scanner != nil {
|
if r.scanner != nil {
|
||||||
go r.triggerScan(lib, "new")
|
go r.triggerScan(ctx, lib, "new")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send library refresh event to all clients
|
// Send library refresh event to all clients
|
||||||
if r.broker != nil {
|
if r.broker != nil {
|
||||||
event := &events.RefreshResource{}
|
event := &events.RefreshResource{}
|
||||||
r.broker.SendBroadcastMessage(r.ctx, event.With("library", strconv.Itoa(lib.ID)))
|
r.broker.SendBroadcastMessage(ctx, event.With("library", strconv.Itoa(lib.ID)))
|
||||||
log.Debug(r.ctx, "Library created - sent refresh event", "libraryID", lib.ID, "name", lib.Name)
|
log.Debug(ctx, "Library created - sent refresh event", "libraryID", lib.ID, "name", lib.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
return strconv.Itoa(lib.ID), nil
|
return strconv.Itoa(lib.ID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *libraryRepositoryWrapper) Update(id string, entity any, cols ...string) error {
|
func (r *libraryRepositoryWrapper) Update(ctx context.Context, id string, entity model.Library, cols ...string) error {
|
||||||
lib := entity.(*model.Library)
|
lib := &entity
|
||||||
libID, err := strconv.Atoi(id)
|
libID, err := strconv.Atoi(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid library ID: %s", id)
|
return fmt.Errorf("invalid library ID: %s", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
lib.ID = libID
|
lib.ID = libID
|
||||||
if err := r.validateLibrary(lib); err != nil {
|
if err := r.validateLibrary(ctx, lib); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the original library to check if path changed
|
// Get the original library to check if path changed
|
||||||
originalLib, err := r.Get(libID)
|
originalLib, err := r.Get(ctx, libID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.mapError(err)
|
return r.mapError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pathChanged := originalLib.Path != lib.Path
|
pathChanged := originalLib.Path != lib.Path
|
||||||
|
pidChanged := (updatesColumn(cols, "pidAlbum") && originalLib.PIDAlbum != lib.PIDAlbum) ||
|
||||||
|
(updatesColumn(cols, "pidTrack") && originalLib.PIDTrack != lib.PIDTrack)
|
||||||
|
|
||||||
err = r.LibraryRepository.Put(lib, cols...)
|
err = r.LibraryRepository.Put(ctx, lib, cols...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.mapError(err)
|
return r.mapError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restart watcher and trigger scan if path was updated
|
if pathChanged && r.watcher != nil {
|
||||||
if pathChanged {
|
if err := r.watcher.Watch(ctx, lib); err != nil {
|
||||||
if r.watcher != nil {
|
log.Warn(ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
|
||||||
if err := r.watcher.Watch(r.ctx, lib); err != nil {
|
|
||||||
log.Warn(r.ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if r.scanner != nil {
|
if (pathChanged || pidChanged) && r.scanner != nil {
|
||||||
go r.triggerScan(lib, "updated")
|
go r.triggerScan(ctx, lib, "updated")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send library refresh event to all clients
|
// Send library refresh event to all clients
|
||||||
if r.broker != nil {
|
if r.broker != nil {
|
||||||
event := &events.RefreshResource{}
|
event := &events.RefreshResource{}
|
||||||
r.broker.SendBroadcastMessage(r.ctx, event.With("library", id))
|
r.broker.SendBroadcastMessage(ctx, event.With("library", id))
|
||||||
log.Debug(r.ctx, "Library updated - sent refresh event", "libraryID", libID, "name", lib.Name)
|
log.Debug(ctx, "Library updated - sent refresh event", "libraryID", libID, "name", lib.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *libraryRepositoryWrapper) Delete(id string) error {
|
func (r *libraryRepositoryWrapper) Delete(ctx context.Context, ids ...string) error {
|
||||||
|
for _, id := range ids {
|
||||||
|
if err := r.deleteOne(ctx, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *libraryRepositoryWrapper) deleteOne(ctx context.Context, id string) error {
|
||||||
libID, err := strconv.Atoi(id)
|
libID, err := strconv.Atoi(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &rest.ValidationError{Errors: map[string]string{
|
return &rest.ValidationError{Errors: map[string]string{
|
||||||
|
|
@ -247,7 +249,7 @@ func (r *libraryRepositoryWrapper) Delete(id string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get library info before deletion for logging
|
// Get library info before deletion for logging
|
||||||
lib, err := r.Get(libID)
|
lib, err := r.Get(ctx, libID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.mapError(err)
|
return r.mapError(err)
|
||||||
}
|
}
|
||||||
|
|
@ -255,7 +257,7 @@ func (r *libraryRepositoryWrapper) Delete(id string) error {
|
||||||
// Run the deletion in a transaction so the cascade delete and the orphaned-artist
|
// Run the deletion in a transaction so the cascade delete and the orphaned-artist
|
||||||
// reconciliation it triggers (see libraryRepository.Delete) commit atomically.
|
// reconciliation it triggers (see libraryRepository.Delete) commit atomically.
|
||||||
err = r.ds.WithTx(func(tx model.DataStore) error {
|
err = r.ds.WithTx(func(tx model.DataStore) error {
|
||||||
return tx.Library(r.ctx).Delete(libID)
|
return tx.Library().Delete(ctx, libID)
|
||||||
}, "delete library")
|
}, "delete library")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.mapError(err)
|
return r.mapError(err)
|
||||||
|
|
@ -263,25 +265,25 @@ func (r *libraryRepositoryWrapper) Delete(id string) error {
|
||||||
|
|
||||||
// Stop watcher and trigger scan after successful library deletion to clean up orphaned data
|
// Stop watcher and trigger scan after successful library deletion to clean up orphaned data
|
||||||
if r.watcher != nil {
|
if r.watcher != nil {
|
||||||
if err := r.watcher.StopWatching(r.ctx, libID); err != nil {
|
if err := r.watcher.StopWatching(ctx, libID); err != nil {
|
||||||
log.Warn(r.ctx, "Failed to stop watcher for deleted library", "libraryID", libID, "name", lib.Name, "path", lib.Path, err)
|
log.Warn(ctx, "Failed to stop watcher for deleted library", "libraryID", libID, "name", lib.Name, "path", lib.Path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.scanner != nil {
|
if r.scanner != nil {
|
||||||
go r.triggerScan(lib, "deleted")
|
go r.triggerScan(ctx, lib, "deleted")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send library refresh event to all clients
|
// Send library refresh event to all clients
|
||||||
if r.broker != nil {
|
if r.broker != nil {
|
||||||
event := &events.RefreshResource{}
|
event := &events.RefreshResource{}
|
||||||
r.broker.SendBroadcastMessage(r.ctx, event.With("library", id))
|
r.broker.SendBroadcastMessage(ctx, event.With("library", id))
|
||||||
log.Debug(r.ctx, "Library deleted - sent refresh event", "libraryID", libID, "name", lib.Name)
|
log.Debug(ctx, "Library deleted - sent refresh event", "libraryID", libID, "name", lib.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// After successful deletion, check if any plugins were auto-disabled
|
// After successful deletion, check if any plugins were auto-disabled
|
||||||
// and need to be unloaded from memory
|
// and need to be unloaded from memory
|
||||||
r.pluginManager.UnloadDisabledPlugins(r.ctx)
|
r.pluginManager.UnloadDisabledPlugins(ctx)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -309,7 +311,7 @@ func (r *libraryRepositoryWrapper) mapError(err error) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error {
|
func (r *libraryRepositoryWrapper) validateLibrary(ctx context.Context, library *model.Library) error {
|
||||||
validationErrors := make(map[string]string)
|
validationErrors := make(map[string]string)
|
||||||
|
|
||||||
if library.Name == "" {
|
if library.Name == "" {
|
||||||
|
|
@ -320,11 +322,20 @@ func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error
|
||||||
validationErrors["path"] = "ra.validation.required"
|
validationErrors["path"] = "ra.validation.required"
|
||||||
} else {
|
} else {
|
||||||
// Validate path format and accessibility
|
// Validate path format and accessibility
|
||||||
if err := r.validateLibraryPath(library); err != nil {
|
if err := r.validateLibraryPath(ctx, library); err != nil {
|
||||||
validationErrors["path"] = err.Error()
|
validationErrors["path"] = err.Error()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
library.PIDAlbum = strings.TrimSpace(library.PIDAlbum)
|
||||||
|
library.PIDTrack = strings.TrimSpace(library.PIDTrack)
|
||||||
|
if err := metadata.ValidatePIDSpec(library.PIDAlbum, true); err != nil {
|
||||||
|
validationErrors["pidAlbum"] = err.Error()
|
||||||
|
}
|
||||||
|
if err := metadata.ValidatePIDSpec(library.PIDTrack, false); err != nil {
|
||||||
|
validationErrors["pidTrack"] = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
if len(validationErrors) > 0 {
|
if len(validationErrors) > 0 {
|
||||||
return &rest.ValidationError{Errors: validationErrors}
|
return &rest.ValidationError{Errors: validationErrors}
|
||||||
}
|
}
|
||||||
|
|
@ -332,7 +343,12 @@ func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *libraryRepositoryWrapper) validateLibraryPath(library *model.Library) error {
|
// updatesColumn reports whether an update with these columns writes col. No columns means all of them.
|
||||||
|
func updatesColumn(cols []string, col string) bool {
|
||||||
|
return len(cols) == 0 || slices.Contains(cols, col)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *libraryRepositoryWrapper) validateLibraryPath(ctx context.Context, library *model.Library) error {
|
||||||
// Validate path format
|
// Validate path format
|
||||||
if !filepath.IsAbs(library.Path) {
|
if !filepath.IsAbs(library.Path) {
|
||||||
return fmt.Errorf("library path must be absolute")
|
return fmt.Errorf("library path must be absolute")
|
||||||
|
|
@ -350,7 +366,7 @@ func (r *libraryRepositoryWrapper) validateLibraryPath(library *model.Library) e
|
||||||
|
|
||||||
fsys, err := fileStore.FS()
|
fsys, err := fileStore.FS()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn(r.ctx, "Error validating library.path", "path", library.Path, err)
|
log.Warn(ctx, "Error validating library.path", "path", library.Path, err)
|
||||||
return fmt.Errorf("resources.library.validation.pathInvalid")
|
return fmt.Errorf("resources.library.validation.pathInvalid")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -358,7 +374,7 @@ func (r *libraryRepositoryWrapper) validateLibraryPath(library *model.Library) e
|
||||||
info, err := fs.Stat(fsys, ".")
|
info, err := fs.Stat(fsys, ".")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Parse the error message to check for "not a directory"
|
// Parse the error message to check for "not a directory"
|
||||||
log.Warn(r.ctx, "Error stating library.path", "path", library.Path, err)
|
log.Warn(ctx, "Error stating library.path", "path", library.Path, err)
|
||||||
errStr := err.Error()
|
errStr := err.Error()
|
||||||
if strings.Contains(errStr, "not a directory") ||
|
if strings.Contains(errStr, "not a directory") ||
|
||||||
strings.Contains(errStr, "The directory name is invalid.") {
|
strings.Contains(errStr, "The directory name is invalid.") {
|
||||||
|
|
@ -385,7 +401,7 @@ func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []in
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use CountAll to efficiently validate library IDs exist
|
// Use CountAll to efficiently validate library IDs exist
|
||||||
count, err := s.ds.Library(ctx).CountAll(model.QueryOptions{
|
count, err := s.ds.Library().CountAll(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.Eq{"id": libraryIDs},
|
Filters: squirrel.Eq{"id": libraryIDs},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -399,13 +415,29 @@ func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []in
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *libraryRepositoryWrapper) triggerScan(lib *model.Library, action string) {
|
var scanWaitInterval = time.Second
|
||||||
log.Info(r.ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path)
|
|
||||||
|
func (r *libraryRepositoryWrapper) triggerScan(ctx context.Context, lib *model.Library, action string) {
|
||||||
|
// Runs in its own goroutine and outlives the HTTP request
|
||||||
|
ctx = context.WithoutCancel(ctx)
|
||||||
|
|
||||||
|
// A running scan loaded the libraries before this change, and would reject a new request
|
||||||
|
for {
|
||||||
|
status, err := r.scanner.Status(ctx)
|
||||||
|
if err != nil || !status.Scanning {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(scanWaitInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info(ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path)
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
warnings, err := r.scanner.ScanAll(r.ctx, false) // Quick scan for new library
|
warnings, err := r.scanner.ScanAll(ctx, false) // Quick scan: the scanner rescans libraries with a changed PID config in full
|
||||||
if err != nil {
|
if errors.Is(err, model.ErrAlreadyScanning) {
|
||||||
log.Error(r.ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err)
|
log.Debug(ctx, "Scan already running, it covers this change", "libraryID", lib.ID, "name", lib.Name)
|
||||||
|
} else if err != nil {
|
||||||
|
log.Error(ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err)
|
||||||
} else {
|
} else {
|
||||||
log.Info(r.ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start))
|
log.Info(ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,18 +66,18 @@ var _ = Describe("Library Service", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Library CRUD Operations", func() {
|
Describe("Library CRUD Operations", func() {
|
||||||
var repo rest.Persistable
|
var repo rest.Persistable[model.Library]
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
r := service.NewRepository(ctx)
|
r := service.Repository()
|
||||||
repo = r.(rest.Persistable)
|
repo = r.(rest.Persistable[model.Library])
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Create", func() {
|
Describe("Create", func() {
|
||||||
It("creates a new library successfully", func() {
|
It("creates a new library successfully", func() {
|
||||||
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
|
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
|
||||||
|
|
||||||
_, err := repo.Save(library)
|
_, err := repo.Save(ctx, library)
|
||||||
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
Expect(libraryRepo.Data[1].Name).To(Equal("New Library"))
|
Expect(libraryRepo.Data[1].Name).To(Equal("New Library"))
|
||||||
|
|
@ -87,7 +87,7 @@ var _ = Describe("Library Service", func() {
|
||||||
It("fails when library name is empty", func() {
|
It("fails when library name is empty", func() {
|
||||||
library := &model.Library{Path: tempDir}
|
library := &model.Library{Path: tempDir}
|
||||||
|
|
||||||
_, err := repo.Save(library)
|
_, err := repo.Save(ctx, library)
|
||||||
|
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
Expect(err.Error()).To(ContainSubstring("ra.validation.required"))
|
Expect(err.Error()).To(ContainSubstring("ra.validation.required"))
|
||||||
|
|
@ -96,7 +96,7 @@ var _ = Describe("Library Service", func() {
|
||||||
It("fails when library path is empty", func() {
|
It("fails when library path is empty", func() {
|
||||||
library := &model.Library{Name: "Test"}
|
library := &model.Library{Name: "Test"}
|
||||||
|
|
||||||
_, err := repo.Save(library)
|
_, err := repo.Save(ctx, library)
|
||||||
|
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
Expect(err.Error()).To(ContainSubstring("ra.validation.required"))
|
Expect(err.Error()).To(ContainSubstring("ra.validation.required"))
|
||||||
|
|
@ -105,7 +105,7 @@ var _ = Describe("Library Service", func() {
|
||||||
It("fails when library path is not absolute", func() {
|
It("fails when library path is not absolute", func() {
|
||||||
library := &model.Library{Name: "Test", Path: "relative/path"}
|
library := &model.Library{Name: "Test", Path: "relative/path"}
|
||||||
|
|
||||||
_, err := repo.Save(library)
|
_, err := repo.Save(ctx, library)
|
||||||
|
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
var validationErr *rest.ValidationError
|
var validationErr *rest.ValidationError
|
||||||
|
|
@ -140,7 +140,7 @@ var _ = Describe("Library Service", func() {
|
||||||
return errors.New("UNIQUE constraint failed: library.name")
|
return errors.New("UNIQUE constraint failed: library.name")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = repo.Save(library)
|
_, err = repo.Save(ctx, library)
|
||||||
|
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
var validationErr *rest.ValidationError
|
var validationErr *rest.ValidationError
|
||||||
|
|
@ -157,7 +157,7 @@ var _ = Describe("Library Service", func() {
|
||||||
return errors.New("UNIQUE constraint failed: library.path")
|
return errors.New("UNIQUE constraint failed: library.path")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := repo.Save(library)
|
_, err := repo.Save(ctx, library)
|
||||||
|
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
var validationErr *rest.ValidationError
|
var validationErr *rest.ValidationError
|
||||||
|
|
@ -181,7 +181,7 @@ var _ = Describe("Library Service", func() {
|
||||||
|
|
||||||
library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir}
|
library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir}
|
||||||
|
|
||||||
err = repo.Update("1", library)
|
err = repo.Update(ctx, "1", *library)
|
||||||
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
Expect(libraryRepo.Data[1].Name).To(Equal("Updated Library"))
|
Expect(libraryRepo.Data[1].Name).To(Equal("Updated Library"))
|
||||||
|
|
@ -191,7 +191,7 @@ var _ = Describe("Library Service", func() {
|
||||||
It("forwards the columns sent by the client to the repository", func() {
|
It("forwards the columns sent by the client to the repository", func() {
|
||||||
library := &model.Library{ID: 1, Name: "Updated Library", Path: tempDir}
|
library := &model.Library{ID: 1, Name: "Updated Library", Path: tempDir}
|
||||||
|
|
||||||
err := repo.Update("1", library, "name", "path")
|
err := repo.Update(ctx, "1", *library, "name", "path")
|
||||||
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
Expect(libraryRepo.PutCols).To(Equal([]string{"name", "path"}))
|
Expect(libraryRepo.PutCols).To(Equal([]string{"name", "path"}))
|
||||||
|
|
@ -205,7 +205,7 @@ var _ = Describe("Library Service", func() {
|
||||||
|
|
||||||
library := &model.Library{ID: 999, Name: "Non-existent", Path: uniqueTempDir}
|
library := &model.Library{ID: 999, Name: "Non-existent", Path: uniqueTempDir}
|
||||||
|
|
||||||
err = repo.Update("999", library)
|
err = repo.Update(ctx, "999", *library)
|
||||||
|
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
Expect(err).To(Equal(model.ErrNotFound))
|
Expect(err).To(Equal(model.ErrNotFound))
|
||||||
|
|
@ -214,7 +214,7 @@ var _ = Describe("Library Service", func() {
|
||||||
It("fails when library name is empty", func() {
|
It("fails when library name is empty", func() {
|
||||||
library := &model.Library{ID: 1, Path: tempDir}
|
library := &model.Library{ID: 1, Path: tempDir}
|
||||||
|
|
||||||
err := repo.Update("1", library)
|
err := repo.Update(ctx, "1", *library)
|
||||||
|
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
Expect(err.Error()).To(ContainSubstring("ra.validation.required"))
|
Expect(err.Error()).To(ContainSubstring("ra.validation.required"))
|
||||||
|
|
@ -224,7 +224,7 @@ var _ = Describe("Library Service", func() {
|
||||||
unnormalizedPath := tempDir + "//../" + filepath.Base(tempDir)
|
unnormalizedPath := tempDir + "//../" + filepath.Base(tempDir)
|
||||||
library := &model.Library{ID: 1, Name: "Updated Library", Path: unnormalizedPath}
|
library := &model.Library{ID: 1, Name: "Updated Library", Path: unnormalizedPath}
|
||||||
|
|
||||||
err := repo.Update("1", library)
|
err := repo.Update(ctx, "1", *library)
|
||||||
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
Expect(libraryRepo.Data[1].Path).To(Equal(filepath.Clean(unnormalizedPath)))
|
Expect(libraryRepo.Data[1].Path).To(Equal(filepath.Clean(unnormalizedPath)))
|
||||||
|
|
@ -239,7 +239,7 @@ var _ = Describe("Library Service", func() {
|
||||||
// Update the library keeping the same name (should be allowed)
|
// Update the library keeping the same name (should be allowed)
|
||||||
library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir}
|
library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir}
|
||||||
|
|
||||||
err := repo.Update("1", library)
|
err := repo.Update(ctx, "1", *library)
|
||||||
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
})
|
})
|
||||||
|
|
@ -253,7 +253,7 @@ var _ = Describe("Library Service", func() {
|
||||||
// Update the library keeping the same path (should be allowed)
|
// Update the library keeping the same path (should be allowed)
|
||||||
library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir}
|
library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir}
|
||||||
|
|
||||||
err := repo.Update("1", library)
|
err := repo.Update(ctx, "1", *library)
|
||||||
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
})
|
})
|
||||||
|
|
@ -284,7 +284,7 @@ var _ = Describe("Library Service", func() {
|
||||||
// Try to update library 2 to have the same name as library 1
|
// Try to update library 2 to have the same name as library 1
|
||||||
library := &model.Library{ID: 2, Name: "Library One", Path: otherTempDir}
|
library := &model.Library{ID: 2, Name: "Library One", Path: otherTempDir}
|
||||||
|
|
||||||
err = repo.Update("2", library)
|
err = repo.Update(ctx, "2", *library)
|
||||||
|
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
var validationErr *rest.ValidationError
|
var validationErr *rest.ValidationError
|
||||||
|
|
@ -312,7 +312,7 @@ var _ = Describe("Library Service", func() {
|
||||||
// Try to update library 2 to have the same path as library 1
|
// Try to update library 2 to have the same path as library 1
|
||||||
library := &model.Library{ID: 2, Name: "Library Two", Path: tempDir}
|
library := &model.Library{ID: 2, Name: "Library Two", Path: tempDir}
|
||||||
|
|
||||||
err = repo.Update("2", library)
|
err = repo.Update(ctx, "2", *library)
|
||||||
|
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
var validationErr *rest.ValidationError
|
var validationErr *rest.ValidationError
|
||||||
|
|
@ -322,12 +322,43 @@ var _ = Describe("Library Service", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("PID validation", func() {
|
||||||
|
pidError := func(err error, field string) string {
|
||||||
|
var validationErr *rest.ValidationError
|
||||||
|
Expect(errors.As(err, &validationErr)).To(BeTrue())
|
||||||
|
return validationErr.Errors[field]
|
||||||
|
}
|
||||||
|
|
||||||
|
It("rejects an unknown attribute in the album PID", func() {
|
||||||
|
_, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: "albmversion"})
|
||||||
|
Expect(pidError(err, "pidAlbum")).To(ContainSubstring(`unknown attribute "albmversion"`))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects albumid in the album PID", func() {
|
||||||
|
_, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: "albumid"})
|
||||||
|
Expect(pidError(err, "pidAlbum")).To(ContainSubstring("albumid"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects an unknown attribute in the track PID", func() {
|
||||||
|
_, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDTrack: "nosuchtag"})
|
||||||
|
Expect(pidError(err, "pidTrack")).To(ContainSubstring(`unknown attribute "nosuchtag"`))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("trims spaces", func() {
|
||||||
|
library := &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: " folder ", PIDTrack: " "}
|
||||||
|
_, err := repo.Save(ctx, library)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(library.PIDAlbum).To(Equal("folder"))
|
||||||
|
Expect(library.PIDTrack).To(BeEmpty())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Describe("Path Validation", func() {
|
Describe("Path Validation", func() {
|
||||||
Context("Create operation", func() {
|
Context("Create operation", func() {
|
||||||
It("fails when path is not absolute", func() {
|
It("fails when path is not absolute", func() {
|
||||||
library := &model.Library{Name: "Test", Path: "relative/path"}
|
library := &model.Library{Name: "Test", Path: "relative/path"}
|
||||||
|
|
||||||
_, err := repo.Save(library)
|
_, err := repo.Save(ctx, library)
|
||||||
|
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
var validationErr *rest.ValidationError
|
var validationErr *rest.ValidationError
|
||||||
|
|
@ -339,7 +370,7 @@ var _ = Describe("Library Service", func() {
|
||||||
nonExistentPath := filepath.Join(tempDir, "nonexistent")
|
nonExistentPath := filepath.Join(tempDir, "nonexistent")
|
||||||
library := &model.Library{Name: "Test", Path: nonExistentPath}
|
library := &model.Library{Name: "Test", Path: nonExistentPath}
|
||||||
|
|
||||||
_, err := repo.Save(library)
|
_, err := repo.Save(ctx, library)
|
||||||
|
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
var validationErr *rest.ValidationError
|
var validationErr *rest.ValidationError
|
||||||
|
|
@ -354,7 +385,7 @@ var _ = Describe("Library Service", func() {
|
||||||
|
|
||||||
library := &model.Library{Name: "Test", Path: testFile}
|
library := &model.Library{Name: "Test", Path: testFile}
|
||||||
|
|
||||||
_, err = repo.Save(library)
|
_, err = repo.Save(ctx, library)
|
||||||
|
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
var validationErr *rest.ValidationError
|
var validationErr *rest.ValidationError
|
||||||
|
|
@ -371,7 +402,7 @@ var _ = Describe("Library Service", func() {
|
||||||
It("handles multiple validation errors", func() {
|
It("handles multiple validation errors", func() {
|
||||||
library := &model.Library{Name: "", Path: "relative/path"}
|
library := &model.Library{Name: "", Path: "relative/path"}
|
||||||
|
|
||||||
_, err := repo.Save(library)
|
_, err := repo.Save(ctx, library)
|
||||||
|
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
var validationErr *rest.ValidationError
|
var validationErr *rest.ValidationError
|
||||||
|
|
@ -393,7 +424,7 @@ var _ = Describe("Library Service", func() {
|
||||||
It("fails when updated path is not absolute", func() {
|
It("fails when updated path is not absolute", func() {
|
||||||
library := &model.Library{ID: 1, Name: "Test", Path: "relative/path"}
|
library := &model.Library{ID: 1, Name: "Test", Path: "relative/path"}
|
||||||
|
|
||||||
err := repo.Update("1", library)
|
err := repo.Update(ctx, "1", *library)
|
||||||
|
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
var validationErr *rest.ValidationError
|
var validationErr *rest.ValidationError
|
||||||
|
|
@ -410,7 +441,7 @@ var _ = Describe("Library Service", func() {
|
||||||
// Update the library keeping the same name (should be allowed)
|
// Update the library keeping the same name (should be allowed)
|
||||||
library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir}
|
library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir}
|
||||||
|
|
||||||
err := repo.Update("1", library)
|
err := repo.Update(ctx, "1", *library)
|
||||||
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
})
|
})
|
||||||
|
|
@ -419,7 +450,7 @@ var _ = Describe("Library Service", func() {
|
||||||
nonExistentPath := filepath.Join(tempDir, "nonexistent")
|
nonExistentPath := filepath.Join(tempDir, "nonexistent")
|
||||||
library := &model.Library{ID: 1, Name: "Test", Path: nonExistentPath}
|
library := &model.Library{ID: 1, Name: "Test", Path: nonExistentPath}
|
||||||
|
|
||||||
err := repo.Update("1", library)
|
err := repo.Update(ctx, "1", *library)
|
||||||
|
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
var validationErr *rest.ValidationError
|
var validationErr *rest.ValidationError
|
||||||
|
|
@ -434,7 +465,7 @@ var _ = Describe("Library Service", func() {
|
||||||
|
|
||||||
library := &model.Library{ID: 1, Name: "Test", Path: testFile}
|
library := &model.Library{ID: 1, Name: "Test", Path: testFile}
|
||||||
|
|
||||||
err = repo.Update("1", library)
|
err = repo.Update(ctx, "1", *library)
|
||||||
|
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
var validationErr *rest.ValidationError
|
var validationErr *rest.ValidationError
|
||||||
|
|
@ -446,7 +477,7 @@ var _ = Describe("Library Service", func() {
|
||||||
// Try to update with empty name and invalid path
|
// Try to update with empty name and invalid path
|
||||||
library := &model.Library{ID: 1, Name: "", Path: "relative/path"}
|
library := &model.Library{ID: 1, Name: "", Path: "relative/path"}
|
||||||
|
|
||||||
err := repo.Update("1", library)
|
err := repo.Update(ctx, "1", *library)
|
||||||
|
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
var validationErr *rest.ValidationError
|
var validationErr *rest.ValidationError
|
||||||
|
|
@ -467,14 +498,14 @@ var _ = Describe("Library Service", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("deletes an existing library successfully", func() {
|
It("deletes an existing library successfully", func() {
|
||||||
err := repo.Delete("1")
|
err := repo.Delete(ctx, "1")
|
||||||
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
Expect(libraryRepo.Data).To(HaveLen(0))
|
Expect(libraryRepo.Data).To(HaveLen(0))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("fails when library doesn't exist", func() {
|
It("fails when library doesn't exist", func() {
|
||||||
err := repo.Delete("999")
|
err := repo.Delete(ctx, "999")
|
||||||
|
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
Expect(err).To(Equal(model.ErrNotFound))
|
Expect(err).To(Equal(model.ErrNotFound))
|
||||||
|
|
@ -613,17 +644,17 @@ var _ = Describe("Library Service", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Scan Triggering", func() {
|
Describe("Scan Triggering", func() {
|
||||||
var repo rest.Persistable
|
var repo rest.Persistable[model.Library]
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
r := service.NewRepository(ctx)
|
r := service.Repository()
|
||||||
repo = r.(rest.Persistable)
|
repo = r.(rest.Persistable[model.Library])
|
||||||
})
|
})
|
||||||
|
|
||||||
It("triggers scan when creating a new library", func() {
|
It("triggers scan when creating a new library", func() {
|
||||||
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
|
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
|
||||||
|
|
||||||
_, err := repo.Save(library)
|
_, err := repo.Save(ctx, library)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
// Wait briefly for the goroutine to complete
|
// Wait briefly for the goroutine to complete
|
||||||
|
|
@ -649,7 +680,7 @@ var _ = Describe("Library Service", func() {
|
||||||
|
|
||||||
// Update the library with a new path
|
// Update the library with a new path
|
||||||
library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir}
|
library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir}
|
||||||
err = repo.Update("1", library)
|
err = repo.Update(ctx, "1", *library)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
// Wait briefly for the goroutine to complete
|
// Wait briefly for the goroutine to complete
|
||||||
|
|
@ -670,7 +701,7 @@ var _ = Describe("Library Service", func() {
|
||||||
|
|
||||||
// Update the library name only (same path)
|
// Update the library name only (same path)
|
||||||
library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir}
|
library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir}
|
||||||
err := repo.Update("1", library)
|
err := repo.Update(ctx, "1", *library)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
// Wait a bit to ensure no scan was triggered
|
// Wait a bit to ensure no scan was triggered
|
||||||
|
|
@ -679,11 +710,53 @@ var _ = Describe("Library Service", func() {
|
||||||
}, "100ms", "10ms").Should(Equal(0))
|
}, "100ms", "10ms").Should(Equal(0))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("triggers scan when updating the library PID config", func() {
|
||||||
|
libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir}})
|
||||||
|
|
||||||
|
library := model.Library{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}
|
||||||
|
Expect(repo.Update(ctx, "1", library)).To(Succeed())
|
||||||
|
|
||||||
|
Eventually(func() int {
|
||||||
|
return scanner.GetScanAllCallCount()
|
||||||
|
}, "1s", "10ms").Should(Equal(1))
|
||||||
|
// A quick scan: the scanner itself rescans this library in full
|
||||||
|
Expect(scanner.GetScanAllCalls()[0].FullScan).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not trigger scan when the PID fields were not sent", func() {
|
||||||
|
libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}})
|
||||||
|
|
||||||
|
// The REST layer decodes a missing pidAlbum as "". Only the sent fields count.
|
||||||
|
library := model.Library{ID: 1, Name: "Renamed", Path: tempDir}
|
||||||
|
Expect(repo.Update(ctx, "1", library, "name", "path")).To(Succeed())
|
||||||
|
|
||||||
|
Consistently(func() int {
|
||||||
|
return scanner.GetScanAllCallCount()
|
||||||
|
}, "100ms", "10ms").Should(Equal(0))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("waits for a running scan before triggering a new one", func() {
|
||||||
|
libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir}})
|
||||||
|
scanner.SetScanning(true)
|
||||||
|
|
||||||
|
library := model.Library{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}
|
||||||
|
Expect(repo.Update(ctx, "1", library)).To(Succeed())
|
||||||
|
|
||||||
|
Consistently(func() int {
|
||||||
|
return scanner.GetScanAllCallCount()
|
||||||
|
}, "200ms", "20ms").Should(Equal(0))
|
||||||
|
|
||||||
|
scanner.SetScanning(false)
|
||||||
|
Eventually(func() int {
|
||||||
|
return scanner.GetScanAllCallCount()
|
||||||
|
}, "3s", "20ms").Should(Equal(1))
|
||||||
|
})
|
||||||
|
|
||||||
It("does not trigger scan when library creation fails", func() {
|
It("does not trigger scan when library creation fails", func() {
|
||||||
// Try to create library with invalid data (empty name)
|
// Try to create library with invalid data (empty name)
|
||||||
library := &model.Library{Path: tempDir}
|
library := &model.Library{Path: tempDir}
|
||||||
|
|
||||||
_, err := repo.Save(library)
|
_, err := repo.Save(ctx, library)
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
|
|
||||||
// Ensure no scan was triggered since creation failed
|
// Ensure no scan was triggered since creation failed
|
||||||
|
|
@ -700,7 +773,7 @@ var _ = Describe("Library Service", func() {
|
||||||
|
|
||||||
// Try to update with invalid data (empty name)
|
// Try to update with invalid data (empty name)
|
||||||
library := &model.Library{ID: 1, Name: "", Path: tempDir}
|
library := &model.Library{ID: 1, Name: "", Path: tempDir}
|
||||||
err := repo.Update("1", library)
|
err := repo.Update(ctx, "1", *library)
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
|
|
||||||
// Ensure no scan was triggered since update failed
|
// Ensure no scan was triggered since update failed
|
||||||
|
|
@ -716,7 +789,7 @@ var _ = Describe("Library Service", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Delete the library
|
// Delete the library
|
||||||
err := repo.Delete("1")
|
err := repo.Delete(ctx, "1")
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
// Wait briefly for the goroutine to complete
|
// Wait briefly for the goroutine to complete
|
||||||
|
|
@ -731,7 +804,7 @@ var _ = Describe("Library Service", func() {
|
||||||
|
|
||||||
It("does not trigger scan when library deletion fails", func() {
|
It("does not trigger scan when library deletion fails", func() {
|
||||||
// Try to delete a non-existent library
|
// Try to delete a non-existent library
|
||||||
err := repo.Delete("999")
|
err := repo.Delete(ctx, "999")
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
|
|
||||||
// Ensure no scan was triggered since deletion failed
|
// Ensure no scan was triggered since deletion failed
|
||||||
|
|
@ -744,7 +817,7 @@ var _ = Describe("Library Service", func() {
|
||||||
It("starts watcher when creating a new library", func() {
|
It("starts watcher when creating a new library", func() {
|
||||||
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
|
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
|
||||||
|
|
||||||
_, err := repo.Save(library)
|
_, err := repo.Save(ctx, library)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
// Verify watcher was started
|
// Verify watcher was started
|
||||||
|
|
@ -773,7 +846,7 @@ var _ = Describe("Library Service", func() {
|
||||||
|
|
||||||
// Update library with new path
|
// Update library with new path
|
||||||
library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir}
|
library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir}
|
||||||
err = repo.Update("1", library)
|
err = repo.Update(ctx, "1", *library)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
// Verify watcher was restarted
|
// Verify watcher was restarted
|
||||||
|
|
@ -793,7 +866,7 @@ var _ = Describe("Library Service", func() {
|
||||||
|
|
||||||
// Update library with same path but different name
|
// Update library with same path but different name
|
||||||
library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir}
|
library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir}
|
||||||
err := repo.Update("1", library)
|
err := repo.Update(ctx, "1", *library)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
// Verify watcher was NOT restarted (since path didn't change)
|
// Verify watcher was NOT restarted (since path didn't change)
|
||||||
|
|
@ -808,7 +881,7 @@ var _ = Describe("Library Service", func() {
|
||||||
{ID: 1, Name: "Test Library", Path: tempDir},
|
{ID: 1, Name: "Test Library", Path: tempDir},
|
||||||
})
|
})
|
||||||
|
|
||||||
err := repo.Delete("1")
|
err := repo.Delete(ctx, "1")
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
// Verify watcher was stopped
|
// Verify watcher was stopped
|
||||||
|
|
@ -826,7 +899,7 @@ var _ = Describe("Library Service", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mock deletion to fail by trying to delete non-existent library
|
// Mock deletion to fail by trying to delete non-existent library
|
||||||
err := repo.Delete("999")
|
err := repo.Delete(ctx, "999")
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
|
|
||||||
// Verify watcher was NOT stopped since deletion failed
|
// Verify watcher was NOT stopped since deletion failed
|
||||||
|
|
@ -838,11 +911,11 @@ var _ = Describe("Library Service", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Event Broadcasting", func() {
|
Describe("Event Broadcasting", func() {
|
||||||
var repo rest.Persistable
|
var repo rest.Persistable[model.Library]
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
r := service.NewRepository(ctx)
|
r := service.Repository()
|
||||||
repo = r.(rest.Persistable)
|
repo = r.(rest.Persistable[model.Library])
|
||||||
// Clear any events from broker
|
// Clear any events from broker
|
||||||
broker.Events = []events.Event{}
|
broker.Events = []events.Event{}
|
||||||
})
|
})
|
||||||
|
|
@ -850,7 +923,7 @@ var _ = Describe("Library Service", func() {
|
||||||
It("sends refresh event when creating a library", func() {
|
It("sends refresh event when creating a library", func() {
|
||||||
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
|
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
|
||||||
|
|
||||||
_, err := repo.Save(library)
|
_, err := repo.Save(ctx, library)
|
||||||
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
Expect(broker.Events).To(HaveLen(1))
|
Expect(broker.Events).To(HaveLen(1))
|
||||||
|
|
@ -863,7 +936,7 @@ var _ = Describe("Library Service", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
library := &model.Library{ID: 1, Name: "Updated Library", Path: tempDir}
|
library := &model.Library{ID: 1, Name: "Updated Library", Path: tempDir}
|
||||||
err := repo.Update("1", library)
|
err := repo.Update(ctx, "1", *library)
|
||||||
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
Expect(broker.Events).To(HaveLen(1))
|
Expect(broker.Events).To(HaveLen(1))
|
||||||
|
|
@ -875,7 +948,7 @@ var _ = Describe("Library Service", func() {
|
||||||
{ID: 2, Name: "Library to Delete", Path: tempDir},
|
{ID: 2, Name: "Library to Delete", Path: tempDir},
|
||||||
})
|
})
|
||||||
|
|
||||||
err := repo.Delete("2")
|
err := repo.Delete(ctx, "2")
|
||||||
|
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
Expect(broker.Events).To(HaveLen(1))
|
Expect(broker.Events).To(HaveLen(1))
|
||||||
|
|
@ -883,13 +956,13 @@ var _ = Describe("Library Service", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Plugin Manager Integration", func() {
|
Describe("Plugin Manager Integration", func() {
|
||||||
var repo rest.Persistable
|
var repo rest.Persistable[model.Library]
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
// Reset the call count for each test
|
// Reset the call count for each test
|
||||||
pluginManager.unloadCalls = 0
|
pluginManager.unloadCalls = 0
|
||||||
r := service.NewRepository(ctx)
|
r := service.Repository()
|
||||||
repo = r.(rest.Persistable)
|
repo = r.(rest.Persistable[model.Library])
|
||||||
})
|
})
|
||||||
|
|
||||||
It("calls UnloadDisabledPlugins after successful library deletion", func() {
|
It("calls UnloadDisabledPlugins after successful library deletion", func() {
|
||||||
|
|
@ -897,14 +970,14 @@ var _ = Describe("Library Service", func() {
|
||||||
{ID: 2, Name: "Library to Delete", Path: tempDir},
|
{ID: 2, Name: "Library to Delete", Path: tempDir},
|
||||||
})
|
})
|
||||||
|
|
||||||
err := repo.Delete("2")
|
err := repo.Delete(ctx, "2")
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
Expect(pluginManager.unloadCalls).To(Equal(1))
|
Expect(pluginManager.unloadCalls).To(Equal(1))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("does not call UnloadDisabledPlugins when library deletion fails", func() {
|
It("does not call UnloadDisabledPlugins when library deletion fails", func() {
|
||||||
// Try to delete non-existent library
|
// Try to delete non-existent library
|
||||||
err := repo.Delete("999")
|
err := repo.Delete(ctx, "999")
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
Expect(pluginManager.unloadCalls).To(Equal(0))
|
Expect(pluginManager.unloadCalls).To(Equal(0))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ func (l *lyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (mod
|
||||||
func (l *lyricsService) GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error) {
|
func (l *lyricsService) GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error) {
|
||||||
opts := songsByArtistTitleWithLyricsFirst(artist, title)
|
opts := songsByArtistTitleWithLyricsFirst(artist, title)
|
||||||
opts.Max = maxLegacyLyricsCandidates
|
opts.Max = maxLegacyLyricsCandidates
|
||||||
mediaFiles, err := l.ds.MediaFile(ctx).GetAll(opts)
|
mediaFiles, err := l.ds.MediaFile().GetAll(ctx, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ func (s *maintenanceService) RemapMissingFile(ctx context.Context, missingID, ta
|
||||||
return fmt.Errorf("%w: %q", ErrSameFile, missingID)
|
return fmt.Errorf("%w: %q", ErrSameFile, missingID)
|
||||||
}
|
}
|
||||||
|
|
||||||
missing, err := s.ds.MediaFile(ctx).Get(missingID)
|
missing, err := s.ds.MediaFile().Get(ctx, missingID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("loading missing file %q: %w", missingID, err)
|
return fmt.Errorf("loading missing file %q: %w", missingID, err)
|
||||||
}
|
}
|
||||||
|
|
@ -66,7 +66,7 @@ func (s *maintenanceService) RemapMissingFile(ctx context.Context, missingID, ta
|
||||||
return fmt.Errorf("%w: %q", ErrNotMissing, missingID)
|
return fmt.Errorf("%w: %q", ErrNotMissing, missingID)
|
||||||
}
|
}
|
||||||
|
|
||||||
target, err := s.ds.MediaFile(ctx).GetWithParticipants(targetID)
|
target, err := s.ds.MediaFile().GetWithParticipants(ctx, targetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("loading target file %q: %w", targetID, err)
|
return fmt.Errorf("loading target file %q: %w", targetID, err)
|
||||||
}
|
}
|
||||||
|
|
@ -82,27 +82,27 @@ func (s *maintenanceService) RemapMissingFile(ctx context.Context, missingID, ta
|
||||||
// Preserve the original created_at so the remapped track doesn't resurface in "Recently Added"
|
// Preserve the original created_at so the remapped track doesn't resurface in "Recently Added"
|
||||||
target.CreatedAt = missing.CreatedAt
|
target.CreatedAt = missing.CreatedAt
|
||||||
target.ID = missing.ID
|
target.ID = missing.ID
|
||||||
if err := tx.MediaFile(ctx).Put(target); err != nil {
|
if err := tx.MediaFile().Put(ctx, target); err != nil {
|
||||||
return fmt.Errorf("update matched track: %w", err)
|
return fmt.Errorf("update matched track: %w", err)
|
||||||
}
|
}
|
||||||
// Unlike the scanner's freshly-imported target, this one may carry history of its own
|
// Unlike the scanner's freshly-imported target, this one may carry history of its own
|
||||||
if err := tx.MediaFile(ctx).ReassignReferences(discardedID, missing.ID); err != nil {
|
if err := tx.MediaFile().ReassignReferences(ctx, discardedID, missing.ID); err != nil {
|
||||||
return fmt.Errorf("reassign target references: %w", err)
|
return fmt.Errorf("reassign target references: %w", err)
|
||||||
}
|
}
|
||||||
if err := tx.MediaFile(ctx).Delete(discardedID); err != nil {
|
if err := tx.MediaFile().Delete(ctx, discardedID); err != nil {
|
||||||
return fmt.Errorf("delete discarded track: %w", err)
|
return fmt.Errorf("delete discarded track: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if oldAlbumID != newAlbumID {
|
if oldAlbumID != newAlbumID {
|
||||||
oldAlbumTracks, err := tx.MediaFile(ctx).CountAll(model.QueryOptions{Filters: squirrel.Eq{"album_id": oldAlbumID}})
|
oldAlbumTracks, err := tx.MediaFile().CountAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album_id": oldAlbumID}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get old album tracks: %w", err)
|
return fmt.Errorf("get old album tracks: %w", err)
|
||||||
}
|
}
|
||||||
if oldAlbumTracks == 0 {
|
if oldAlbumTracks == 0 {
|
||||||
if err := tx.Album(ctx).ReassignAnnotation(oldAlbumID, newAlbumID); err != nil {
|
if err := tx.Album().ReassignAnnotation(ctx, oldAlbumID, newAlbumID); err != nil {
|
||||||
return fmt.Errorf("reassign album annotations: %w", err)
|
return fmt.Errorf("reassign album annotations: %w", err)
|
||||||
}
|
}
|
||||||
if err := tx.Album(ctx).CopyAttributes(oldAlbumID, newAlbumID, "created_at"); err != nil && !errors.Is(err, model.ErrNotFound) {
|
if err := tx.Album().CopyAttributes(ctx, oldAlbumID, newAlbumID, "created_at"); err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
return fmt.Errorf("copy album attributes: %w", err)
|
return fmt.Errorf("copy album attributes: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -121,7 +121,7 @@ func (s *maintenanceService) RemapMissingFile(ctx context.Context, missingID, ta
|
||||||
|
|
||||||
// Stats are refreshed synchronously, unlike deleteMissing, so the CLI sees them before it exits.
|
// Stats are refreshed synchronously, unlike deleteMissing, so the CLI sees them before it exits.
|
||||||
// album/artist play count aggregates are not recalculated here; they are refreshed by the next scan.
|
// album/artist play count aggregates are not recalculated here; they are refreshed by the next scan.
|
||||||
if _, err := s.ds.Artist(ctx).RefreshStats(true); err != nil {
|
if _, err := s.ds.Artist().RefreshStats(ctx, true); err != nil {
|
||||||
log.Error(ctx, "Error refreshing artist stats after remapping missing file", err)
|
log.Error(ctx, "Error refreshing artist stats after remapping missing file", err)
|
||||||
}
|
}
|
||||||
affectedAlbumIDs := []string{newAlbumID}
|
affectedAlbumIDs := []string{newAlbumID}
|
||||||
|
|
@ -146,10 +146,10 @@ func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) er
|
||||||
// Delete missing files within a transaction
|
// Delete missing files within a transaction
|
||||||
err = s.ds.WithTx(func(tx model.DataStore) error {
|
err = s.ds.WithTx(func(tx model.DataStore) error {
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
_, err := tx.MediaFile(ctx).DeleteAllMissing()
|
_, err := tx.MediaFile().DeleteAllMissing(ctx)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.MediaFile(ctx).DeleteMissing(ids)
|
return tx.MediaFile().DeleteMissing(ctx, ids)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err)
|
log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err)
|
||||||
|
|
@ -192,11 +192,11 @@ func (s *maintenanceService) refreshAlbums(ctx context.Context, albumIDs []strin
|
||||||
|
|
||||||
// refreshAlbumChunk processes a single chunk of album IDs
|
// refreshAlbumChunk processes a single chunk of album IDs
|
||||||
func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []string) error {
|
func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []string) error {
|
||||||
albumRepo := s.ds.Album(ctx)
|
albumRepo := s.ds.Album()
|
||||||
mfRepo := s.ds.MediaFile(ctx)
|
mfRepo := s.ds.MediaFile()
|
||||||
|
|
||||||
// Batch load existing albums
|
// Batch load existing albums
|
||||||
albums, err := albumRepo.GetAll(model.QueryOptions{
|
albums, err := albumRepo.GetAll(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.Eq{"album.id": albumIDs},
|
Filters: squirrel.Eq{"album.id": albumIDs},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -210,7 +210,7 @@ func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []s
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch load all media files for these albums
|
// Batch load all media files for these albums
|
||||||
mediaFiles, err := mfRepo.GetAll(model.QueryOptions{
|
mediaFiles, err := mfRepo.GetAll(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.Eq{"album_id": albumIDs},
|
Filters: squirrel.Eq{"album_id": albumIDs},
|
||||||
Sort: "album_id, path",
|
Sort: "album_id, path",
|
||||||
})
|
})
|
||||||
|
|
@ -243,7 +243,7 @@ func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []s
|
||||||
newAlbum.UpdatedAt = time.Now()
|
newAlbum.UpdatedAt = time.Now()
|
||||||
newAlbum.CreatedAt = oldAlbum.CreatedAt
|
newAlbum.CreatedAt = oldAlbum.CreatedAt
|
||||||
|
|
||||||
if err := albumRepo.Put(&newAlbum); err != nil {
|
if err := albumRepo.Put(ctx, &newAlbum); err != nil {
|
||||||
log.Error(ctx, "Error updating album during refresh", "albumID", albumID, err)
|
log.Error(ctx, "Error updating album during refresh", "albumID", albumID, err)
|
||||||
// Continue with other albums instead of failing entirely
|
// Continue with other albums instead of failing entirely
|
||||||
continue
|
continue
|
||||||
|
|
@ -265,7 +265,7 @@ func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, ids []stri
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mfs, err := s.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
mfs, err := s.ds.MediaFile().GetAll(ctx, model.QueryOptions{
|
||||||
Filters: filters,
|
Filters: filters,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -293,7 +293,7 @@ func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbu
|
||||||
// Refresh artist stats in background
|
// Refresh artist stats in background
|
||||||
s.wg.Go(func() {
|
s.wg.Go(func() {
|
||||||
bgCtx := request.AddValues(context.Background(), ctx)
|
bgCtx := request.AddValues(context.Background(), ctx)
|
||||||
if _, err := s.ds.Artist(bgCtx).RefreshStats(true); err != nil {
|
if _, err := s.ds.Artist().RefreshStats(bgCtx, true); err != nil {
|
||||||
log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err)
|
log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err)
|
||||||
} else {
|
} else {
|
||||||
log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files")
|
log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files")
|
||||||
|
|
|
||||||
|
|
@ -262,12 +262,12 @@ var _ = Describe("Maintenance", func() {
|
||||||
|
|
||||||
Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed())
|
Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed())
|
||||||
|
|
||||||
got, err := mfRepo.Get("m1")
|
got, err := mfRepo.Get(ctx, "m1")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(got.Path).To(Equal("new/song.mp3")) // moved to target's location
|
Expect(got.Path).To(Equal("new/song.mp3")) // moved to target's location
|
||||||
Expect(got.Missing).To(BeFalse())
|
Expect(got.Missing).To(BeFalse())
|
||||||
Expect(got.CreatedAt).To(BeTemporally("==", created)) // created_at preserved
|
Expect(got.CreatedAt).To(BeTemporally("==", created)) // created_at preserved
|
||||||
exists, _ := mfRepo.Exists("t1")
|
exists, _ := mfRepo.Exists(ctx, "t1")
|
||||||
Expect(exists).To(BeFalse()) // discarded row removed
|
Expect(exists).To(BeFalse()) // discarded row removed
|
||||||
Expect(ds.GCCalled).To(BeTrue())
|
Expect(ds.GCCalled).To(BeTrue())
|
||||||
})
|
})
|
||||||
|
|
@ -369,14 +369,14 @@ var _ = Describe("Maintenance", func() {
|
||||||
Expect(artistRepo.IsRefreshStatsCalled()).To(BeTrue(), "Artist stats should be refreshed")
|
Expect(artistRepo.IsRefreshStatsCalled()).To(BeTrue(), "Artist stats should be refreshed")
|
||||||
|
|
||||||
// The old album lost the remapped track, so its stats are recalculated from the remaining one
|
// The old album lost the remapped track, so its stats are recalculated from the remaining one
|
||||||
oldAlbum, err := albumRepo.Get("album1")
|
oldAlbum, err := albumRepo.Get(ctx, "album1")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(oldAlbum.SongCount).To(Equal(1))
|
Expect(oldAlbum.SongCount).To(Equal(1))
|
||||||
Expect(oldAlbum.Size).To(Equal(int64(1000)))
|
Expect(oldAlbum.Size).To(Equal(int64(1000)))
|
||||||
Expect(oldAlbum.Duration).To(BeNumerically("==", 100))
|
Expect(oldAlbum.Duration).To(BeNumerically("==", 100))
|
||||||
|
|
||||||
// The target album keeps the track, now under the missing file's ID
|
// The target album keeps the track, now under the missing file's ID
|
||||||
newAlbum, err := albumRepo.Get("album2")
|
newAlbum, err := albumRepo.Get(ctx, "album2")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(newAlbum.SongCount).To(Equal(1))
|
Expect(newAlbum.SongCount).To(Equal(1))
|
||||||
Expect(newAlbum.Size).To(Equal(int64(2000)))
|
Expect(newAlbum.Size).To(Equal(int64(2000)))
|
||||||
|
|
@ -407,7 +407,7 @@ var _ = Describe("Maintenance", func() {
|
||||||
Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed())
|
Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed())
|
||||||
|
|
||||||
// The surviving row is the missing file's ID, holding the target's data
|
// The surviving row is the missing file's ID, holding the target's data
|
||||||
got, err := mfRepo.GetWithParticipants("m1")
|
got, err := mfRepo.GetWithParticipants(ctx, "m1")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(got.Participants).To(HaveKeyWithValue(model.RoleArtist, model.ParticipantList{participant}))
|
Expect(got.Participants).To(HaveKeyWithValue(model.RoleArtist, model.ParticipantList{participant}))
|
||||||
})
|
})
|
||||||
|
|
@ -447,7 +447,7 @@ type extendedMediaFileRepo struct {
|
||||||
deleteMissingError error
|
deleteMissingError error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *extendedMediaFileRepo) DeleteMissing(ids []string) error {
|
func (m *extendedMediaFileRepo) DeleteMissing(ctx context.Context, ids []string) error {
|
||||||
m.deleteMissingCalled = true
|
m.deleteMissingCalled = true
|
||||||
m.deletedIDs = ids
|
m.deletedIDs = ids
|
||||||
if m.deleteMissingError != nil {
|
if m.deleteMissingError != nil {
|
||||||
|
|
@ -470,7 +470,7 @@ type extendedAlbumRepo struct {
|
||||||
failOnce bool
|
failOnce bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *extendedAlbumRepo) Put(album *model.Album) error {
|
func (m *extendedAlbumRepo) Put(ctx context.Context, album *model.Album) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.putCallCount++
|
m.putCallCount++
|
||||||
m.lastPutData = album
|
m.lastPutData = album
|
||||||
|
|
@ -490,7 +490,7 @@ func (m *extendedAlbumRepo) Put(album *model.Album) error {
|
||||||
}
|
}
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
return m.MockAlbumRepo.Put(album)
|
return m.MockAlbumRepo.Put(ctx, album)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *extendedAlbumRepo) GetPutCallCount() int {
|
func (m *extendedAlbumRepo) GetPutCallCount() int {
|
||||||
|
|
@ -507,7 +507,7 @@ type extendedArtistRepo struct {
|
||||||
refreshStatsError error
|
refreshStatsError error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *extendedArtistRepo) RefreshStats(allArtists bool) (int64, error) {
|
func (m *extendedArtistRepo) RefreshStats(ctx context.Context, allArtists bool) (int64, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.refreshStatsCalled = true
|
m.refreshStatsCalled = true
|
||||||
err := m.refreshStatsError
|
err := m.refreshStatsError
|
||||||
|
|
@ -516,7 +516,7 @@ func (m *extendedArtistRepo) RefreshStats(allArtists bool) (int64, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return m.MockArtistRepo.RefreshStats(allArtists)
|
return m.MockArtistRepo.RefreshStats(ctx, allArtists)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *extendedArtistRepo) IsRefreshStatsCalled() bool {
|
func (m *extendedArtistRepo) IsRefreshStatsCalled() bool {
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ func (m *Matcher) matchByID(ctx context.Context, songs []agents.Song, result map
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
res, err := m.ds.MediaFile().GetAll(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.And{
|
Filters: squirrel.And{
|
||||||
squirrel.Eq{"media_file.id": ids},
|
squirrel.Eq{"media_file.id": ids},
|
||||||
squirrel.Eq{"missing": false},
|
squirrel.Eq{"missing": false},
|
||||||
|
|
@ -134,7 +134,7 @@ func (m *Matcher) matchByMBID(ctx context.Context, songs []agents.Song, result m
|
||||||
if len(mbids) == 0 {
|
if len(mbids) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
res, err := m.ds.MediaFile().GetAll(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.And{
|
Filters: squirrel.And{
|
||||||
squirrel.Eq{"mbz_recording_id": mbids},
|
squirrel.Eq{"mbz_recording_id": mbids},
|
||||||
squirrel.Eq{"missing": false},
|
squirrel.Eq{"missing": false},
|
||||||
|
|
@ -180,7 +180,7 @@ func (m *Matcher) matchByISRC(ctx context.Context, songs []agents.Song, result m
|
||||||
if len(isrcs) == 0 {
|
if len(isrcs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
res, err := m.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{
|
res, err := m.ds.MediaFile().GetAllByTags(ctx, model.TagISRC, isrcs, model.QueryOptions{
|
||||||
Filters: squirrel.Eq{"missing": false},
|
Filters: squirrel.Eq{"missing": false},
|
||||||
Sort: "starred desc, rating desc, year asc, compilation asc",
|
Sort: "starred desc, rating desc, year asc, compilation asc",
|
||||||
})
|
})
|
||||||
|
|
@ -442,7 +442,7 @@ func (m *Matcher) resolveArtists(ctx context.Context, queries []indexedQuery) (r
|
||||||
filter = append(filter, squirrel.Eq{"id": slices.Collect(maps.Keys(allIDs))})
|
filter = append(filter, squirrel.Eq{"id": slices.Collect(maps.Keys(allIDs))})
|
||||||
}
|
}
|
||||||
if len(filter) > 0 {
|
if len(filter) > 0 {
|
||||||
artists, err := m.ds.Artist(ctx).GetAll(model.QueryOptions{Filters: filter})
|
artists, err := m.ds.Artist().GetAll(ctx, model.QueryOptions{Filters: filter})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolvedArtists{}, err
|
return resolvedArtists{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -543,7 +543,7 @@ func (m *Matcher) fetchTracksCreditedTo(ctx context.Context, artistIDs []string)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
args := slice.Map(artistIDs, func(id string) any { return id })
|
args := slice.Map(artistIDs, func(id string) any { return id })
|
||||||
return m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
return m.ds.MediaFile().GetAll(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.And{
|
Filters: squirrel.And{
|
||||||
squirrel.Expr(
|
squirrel.Expr(
|
||||||
"media_file.id IN (SELECT media_file_id FROM media_file_artists "+
|
"media_file.id IN (SELECT media_file_id FROM media_file_artists "+
|
||||||
|
|
|
||||||
|
|
@ -1267,7 +1267,7 @@ func newMockMediaFileRepo() *mockMediaFileRepo {
|
||||||
return &mockMediaFileRepo{}
|
return &mockMediaFileRepo{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
func (m *mockMediaFileRepo) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||||
argsSlice := make([]any, len(options))
|
argsSlice := make([]any, len(options))
|
||||||
for i, v := range options {
|
for i, v := range options {
|
||||||
argsSlice[i] = v
|
argsSlice[i] = v
|
||||||
|
|
@ -1279,8 +1279,8 @@ func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFi
|
||||||
return args.Get(0).(model.MediaFiles), args.Error(1)
|
return args.Get(0).(model.MediaFiles), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
func (m *mockMediaFileRepo) GetAllByTags(ctx context.Context, _ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||||
return m.GetAll(options...)
|
return m.GetAll(ctx, options...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockMediaFileRepo) SetError(hasError bool) {
|
func (m *mockMediaFileRepo) SetError(hasError bool) {
|
||||||
|
|
@ -1298,7 +1298,7 @@ func newMockArtistRepo() *mockArtistRepo {
|
||||||
return &mockArtistRepo{}
|
return &mockArtistRepo{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) {
|
func (m *mockArtistRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Artists, error) {
|
||||||
argsSlice := make([]any, len(options))
|
argsSlice := make([]any, len(options))
|
||||||
for i, v := range options {
|
for i, v := range options {
|
||||||
argsSlice[i] = v
|
argsSlice[i] = v
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
@ -47,11 +48,11 @@ type insightsCollector struct {
|
||||||
|
|
||||||
func GetInstance(ds model.DataStore) Insights {
|
func GetInstance(ds model.DataStore) Insights {
|
||||||
return singleton.GetInstance(func() *insightsCollector {
|
return singleton.GetInstance(func() *insightsCollector {
|
||||||
id, err := ds.Property(context.TODO()).Get(consts.InsightsIDKey)
|
id, err := ds.Property().Get(context.TODO(), consts.InsightsIDKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Trace("Could not get Insights ID from DB. Creating one", err)
|
log.Trace("Could not get Insights ID from DB. Creating one", err)
|
||||||
id = uuid.NewString()
|
id = uuid.NewString()
|
||||||
err = ds.Property(context.TODO()).Put(consts.InsightsIDKey, id)
|
err = ds.Property().Put(context.TODO(), consts.InsightsIDKey, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Trace("Could not save Insights ID to DB", err)
|
log.Trace("Could not save Insights ID to DB", err)
|
||||||
}
|
}
|
||||||
|
|
@ -87,7 +88,7 @@ func (c *insightsCollector) LastRun(context.Context) (timestamp time.Time, succe
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *insightsCollector) sendInsights(ctx context.Context) {
|
func (c *insightsCollector) sendInsights(ctx context.Context) {
|
||||||
count, err := c.ds.User(ctx).CountAll(model.QueryOptions{})
|
count, err := c.ds.User().CountAll(ctx, model.QueryOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Trace(ctx, "Could not check user count", err)
|
log.Trace(ctx, "Could not check user count", err)
|
||||||
return
|
return
|
||||||
|
|
@ -245,41 +246,45 @@ func (c *insightsCollector) collect(ctx context.Context) []byte {
|
||||||
|
|
||||||
// Library info
|
// Library info
|
||||||
var err error
|
var err error
|
||||||
data.Library.Tracks, err = c.ds.MediaFile(ctx).CountAll()
|
data.Library.Tracks, err = c.ds.MediaFile().CountAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Trace(ctx, "Error reading tracks count", err)
|
log.Trace(ctx, "Error reading tracks count", err)
|
||||||
}
|
}
|
||||||
data.Library.Albums, err = c.ds.Album(ctx).CountAll()
|
data.Library.Albums, err = c.ds.Album().CountAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Trace(ctx, "Error reading albums count", err)
|
log.Trace(ctx, "Error reading albums count", err)
|
||||||
}
|
}
|
||||||
data.Library.Artists, err = c.ds.Artist(ctx).CountAll()
|
data.Library.Artists, err = c.ds.Artist().CountAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Trace(ctx, "Error reading artists count", err)
|
log.Trace(ctx, "Error reading artists count", err)
|
||||||
}
|
}
|
||||||
data.Library.Playlists, err = c.ds.Playlist(ctx).CountAll()
|
data.Library.Playlists, err = c.ds.Playlist().CountAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Trace(ctx, "Error reading playlists count", err)
|
log.Trace(ctx, "Error reading playlists count", err)
|
||||||
}
|
}
|
||||||
data.Library.Shares, err = c.ds.Share(ctx).CountAll()
|
data.Library.Shares, err = c.ds.Share().CountAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Trace(ctx, "Error reading shares count", err)
|
log.Trace(ctx, "Error reading shares count", err)
|
||||||
}
|
}
|
||||||
data.Library.Radios, err = c.ds.Radio(ctx).Count()
|
data.Library.Radios, err = c.ds.Radio().CountAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Trace(ctx, "Error reading radios count", err)
|
log.Trace(ctx, "Error reading radios count", err)
|
||||||
}
|
}
|
||||||
data.Library.Libraries, err = c.ds.Library(ctx).CountAll()
|
libs, err := c.ds.Library().GetAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Trace(ctx, "Error reading libraries count", err)
|
log.Trace(ctx, "Error reading libraries", err)
|
||||||
}
|
}
|
||||||
data.Library.ActiveUsers, err = c.ds.User(ctx).CountAll(model.QueryOptions{
|
data.Library.Libraries = int64(len(libs))
|
||||||
|
if slices.ContainsFunc(libs, func(lib model.Library) bool { return lib.PIDAlbum != "" || lib.PIDTrack != "" }) {
|
||||||
|
data.Config.HasCustomPID = true
|
||||||
|
}
|
||||||
|
data.Library.ActiveUsers, err = c.ds.User().CountAll(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)},
|
Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Trace(ctx, "Error reading active users count", err)
|
log.Trace(ctx, "Error reading active users count", err)
|
||||||
}
|
}
|
||||||
data.Library.FileSuffixes, err = c.ds.MediaFile(ctx).CountBySuffix()
|
data.Library.FileSuffixes, err = c.ds.MediaFile().CountBySuffix(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Trace(ctx, "Error reading file suffixes count", err)
|
log.Trace(ctx, "Error reading file suffixes count", err)
|
||||||
}
|
}
|
||||||
|
|
@ -297,7 +302,7 @@ func (c *insightsCollector) collect(ctx context.Context) []byte {
|
||||||
|
|
||||||
// Collect active players if permitted
|
// Collect active players if permitted
|
||||||
if conf.Server.DevEnablePlayerInsights {
|
if conf.Server.DevEnablePlayerInsights {
|
||||||
data.Library.ActivePlayers, err = c.ds.Player(ctx).CountByClient(model.QueryOptions{
|
data.Library.ActivePlayers, err = c.ds.Player().CountByClient(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.Gt{"last_seen": time.Now().Add(-7 * 24 * time.Hour)},
|
Filters: squirrel.Gt{"last_seen": time.Now().Add(-7 * 24 * time.Hour)},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -324,7 +329,7 @@ func (c *insightsCollector) collect(ctx context.Context) []byte {
|
||||||
|
|
||||||
// hasSmartPlaylists checks if there are any smart playlists (playlists with rules)
|
// hasSmartPlaylists checks if there are any smart playlists (playlists with rules)
|
||||||
func (c *insightsCollector) hasSmartPlaylists(ctx context.Context) (bool, error) {
|
func (c *insightsCollector) hasSmartPlaylists(ctx context.Context) (bool, error) {
|
||||||
count, err := c.ds.Playlist(ctx).CountAll(model.QueryOptions{
|
count, err := c.ds.Playlist().CountAll(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.And{squirrel.NotEq{"rules": ""}, squirrel.NotEq{"rules": nil}},
|
Filters: squirrel.And{squirrel.NotEq{"rules": ""}, squirrel.NotEq{"rules": nil}},
|
||||||
})
|
})
|
||||||
return count > 0, err
|
return count > 0, err
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue