mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
Merge branch 'master' into fix/es-translations
This commit is contained in:
commit
be0aa9a50c
432 changed files with 10637 additions and 7672 deletions
23
.github/workflows/pipeline.yml
vendored
23
.github/workflows/pipeline.yml
vendored
|
|
@ -92,8 +92,23 @@ jobs:
|
|||
exit 1
|
||||
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
|
||||
run: go generate ./...
|
||||
run: |
|
||||
make api-gen
|
||||
go generate ./...
|
||||
- name: Verify no changes from go generate
|
||||
run: |
|
||||
git status --porcelain
|
||||
|
|
@ -102,6 +117,12 @@ jobs:
|
|||
exit 1
|
||||
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:
|
||||
name: Validate DB migrations
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ linters:
|
|||
- asciicheck
|
||||
- bidichk
|
||||
- bodyclose
|
||||
- containedctx
|
||||
- copyloopvar
|
||||
- dogsled
|
||||
- durationcheck
|
||||
|
|
@ -57,6 +58,9 @@ linters:
|
|||
- gosec
|
||||
path: _test\.go
|
||||
text: "G703"
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- containedctx
|
||||
- path-except: 'db/migrations/'
|
||||
linters:
|
||||
- forbidigo
|
||||
|
|
|
|||
45
Makefile
45
Makefile
|
|
@ -21,6 +21,10 @@ PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
|||
DOCKER_TAG ?= deluan/navidrome:develop
|
||||
|
||||
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/*")
|
||||
|
||||
|
|
@ -92,6 +96,45 @@ install-golangci-lint: ##@Development Install golangci-lint if not present
|
|||
fi
|
||||
.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
|
||||
PATH=./bin:$$PATH golangci-lint run --timeout 5m
|
||||
.PHONY: lint
|
||||
|
|
@ -111,7 +154,7 @@ wire: check_go_env ##@Development Update Dependency Injection
|
|||
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
|
||||
.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 ./...
|
||||
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
|
||||
|
|
|
|||
|
|
@ -357,7 +357,7 @@ var _ = Describe("lastfmAgent", func() {
|
|||
var httpClient *tests.FakeHttpClient
|
||||
var track *model.MediaFile
|
||||
BeforeEach(func() {
|
||||
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
||||
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
client := newClient("API_KEY", "SECRET", httpClient)
|
||||
agent = lastFMConstructor(ds)
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ var _ = Describe("auth_router", func() {
|
|||
})
|
||||
|
||||
storedSessionKey := func(userID string) string {
|
||||
key, _ := userProps.Get(userID, sessionKeyProperty)
|
||||
key, _ := userProps.Get(GinkgoT().Context(), userID, sessionKeyProperty)
|
||||
return key
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ var _ = Describe("listenBrainzAgent", func() {
|
|||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
ctx = context.Background()
|
||||
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
||||
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
agent = listenBrainzConstructor(ds)
|
||||
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 (
|
||||
"testing"
|
||||
|
|
@ -9,9 +9,9 @@ import (
|
|||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestFFMpeg(t *testing.T) {
|
||||
tests.Init(t, true)
|
||||
func TestAPI(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
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) {
|
||||
q := ds.ArtworkQueue(ctx)
|
||||
q := ds.ArtworkQueue()
|
||||
var rep statusReport
|
||||
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)
|
||||
}
|
||||
|
||||
for _, k := range artwork.ReprocessKinds {
|
||||
sources, err := q.SourcesInUse(k)
|
||||
sources, err := q.SourcesInUse(ctx, k)
|
||||
if err != nil {
|
||||
return rep, fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
|
||||
}
|
||||
slices.Sort(sources)
|
||||
for _, s := range sources {
|
||||
n, err := q.CountBySource(k, []string{s})
|
||||
n, err := q.CountBySource(ctx, k, []string{s})
|
||||
if err != nil {
|
||||
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})
|
||||
// An absent state is exactly a row with no source, so it needs no second query.
|
||||
if s == "" {
|
||||
failed, err := q.CountBySource(k, []string{model.ArtworkSourceFailed})
|
||||
failed, err := q.CountBySource(ctx, k, []string{model.ArtworkSourceFailed})
|
||||
if err != nil {
|
||||
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()
|
||||
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, 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
|
||||
// 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 {
|
||||
return nil
|
||||
}
|
||||
var inUse []string
|
||||
for _, k := range artwork.ReprocessKinds {
|
||||
found, err := q.SourcesInUse(k)
|
||||
found, err := q.SourcesInUse(ctx, k)
|
||||
if err != nil {
|
||||
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.
|
||||
func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, sources []string,
|
||||
imageAgents artwork.ImageAgentCount, dryRun bool, confirm confirmFunc, out io.Writer) error {
|
||||
q := ds.ArtworkQueue(ctx)
|
||||
if err := validateSources(q, sources); err != nil {
|
||||
q := ds.ArtworkQueue()
|
||||
if err := validateSources(ctx, q, sources); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -495,7 +495,7 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin
|
|||
matched := make([]int64, len(kinds))
|
||||
var total, external int64
|
||||
for i, k := range kinds {
|
||||
n, err := q.CountBySource(k, sources)
|
||||
n, err := q.CountBySource(ctx, k, sources)
|
||||
if err != nil {
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
n, err := q.EnqueueBySource(k, sources, model.ArtworkPriorityRecheck)
|
||||
n, err := q.EnqueueBySource(ctx, k, sources, model.ArtworkPriorityRecheck)
|
||||
if err != nil {
|
||||
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,
|
||||
dryRun bool, confirm confirmFunc, out io.Writer) error {
|
||||
q := ds.ArtworkQueue(ctx)
|
||||
matched, err := q.CountQueued(kinds, priorities)
|
||||
q := ds.ArtworkQueue()
|
||||
matched, err := q.CountQueued(ctx, kinds, priorities)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
cancelled, err := q.PurgeQueued(kinds, priorities)
|
||||
cancelled, err := q.PurgeQueued(ctx, kinds, priorities)
|
||||
if err != nil {
|
||||
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}
|
||||
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) {
|
||||
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) {
|
||||
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() {
|
||||
ds := &tests.MockDataStore{}
|
||||
art := ds.Artwork(ctx).(*tests.MockArtworkRepo)
|
||||
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(),
|
||||
art := ds.Artwork().(*tests.MockArtworkRepo)
|
||||
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(),
|
||||
ItemID: "ar-1", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||
|
||||
shown := storedSource(formatExplain(explainReport{kind: model.KindArtistArtwork, id: "ar-1",
|
||||
stored: &model.ItemArtwork{AttemptedAt: time.Now()}}))
|
||||
|
||||
q := ds.ArtworkQueue(ctx)
|
||||
Expect(validateSources(q, repositorySources([]string{shown}))).To(Succeed(),
|
||||
q := ds.ArtworkQueue()
|
||||
Expect(validateSources(ctx, q, repositorySources([]string{shown}))).To(Succeed(),
|
||||
"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")
|
||||
})
|
||||
})
|
||||
|
|
@ -573,7 +573,7 @@ var _ = Describe("reprocessArtwork", func() {
|
|||
decline := func(io.Writer, int64, int64) bool { return false }
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
|
|
@ -584,8 +584,8 @@ var _ = Describe("reprocessArtwork", func() {
|
|||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
imageAgents = artwork.ImageAgentCount{Artist: 1, Album: 1}
|
||||
ds = &tests.MockDataStore{}
|
||||
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
|
||||
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
||||
art = ds.Artwork().(*tests.MockArtworkRepo)
|
||||
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
|
||||
out.Reset()
|
||||
put(model.KindArtistArtwork, "ar-1", "external:deezer")
|
||||
put(model.KindArtistArtwork, "ar-2", "")
|
||||
|
|
@ -601,19 +601,19 @@ var _ = Describe("reprocessArtwork", func() {
|
|||
Expect(out.String()).To(ContainSubstring("album"))
|
||||
Expect(out.String()).To(ContainSubstring("TOTAL"))
|
||||
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() {
|
||||
Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, decline, &out)).To(Succeed())
|
||||
|
||||
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",
|
||||
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())
|
||||
|
||||
|
|
@ -621,7 +621,7 @@ var _ = Describe("reprocessArtwork", func() {
|
|||
if applied {
|
||||
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, 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() {
|
||||
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
|
||||
|
||||
Expect(queue.Count()).To(Equal(int64(2)))
|
||||
queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||
Expect(queue.Count(ctx)).To(Equal(int64(2)))
|
||||
queued, err := queue.Get(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
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")
|
||||
|
||||
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(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() {
|
||||
Expect(reprocessArtwork(ctx, ds, kinds, []string{""}, imageAgents, false, accept, &out)).To(Succeed())
|
||||
|
||||
Expect(queue.Count()).To(Equal(int64(1)))
|
||||
_, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
||||
Expect(queue.Count(ctx)).To(Equal(int64(1)))
|
||||
_, err := queue.Get(ctx, model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
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())
|
||||
|
||||
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("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(queued.Priority).To(Equal(model.ArtworkPriorityBump),
|
||||
"an already-queued row keeps its priority and backoff")
|
||||
|
|
@ -675,7 +675,7 @@ var _ = Describe("reprocessArtwork", func() {
|
|||
}, &out)).To(Succeed())
|
||||
|
||||
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() {
|
||||
|
|
@ -767,7 +767,7 @@ var _ = Describe("reprocessArtwork", func() {
|
|||
Expect(err.Error()).To(ContainSubstring("external:deezer"))
|
||||
Expect(err.Error()).To(ContainSubstring("folder"))
|
||||
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() {
|
||||
|
|
@ -777,7 +777,7 @@ var _ = Describe("reprocessArtwork", func() {
|
|||
imageAgents, false, accept, &out)).To(Succeed(),
|
||||
"a reserved source must stay valid once the library has none of it")
|
||||
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"}),
|
||||
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"),
|
||||
"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() {
|
||||
ds = &tests.MockDataStore{}
|
||||
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
|
||||
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
||||
art = ds.Artwork().(*tests.MockArtworkRepo)
|
||||
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
|
||||
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())
|
||||
}
|
||||
put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now())
|
||||
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.
|
||||
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())
|
||||
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())
|
||||
})
|
||||
|
||||
|
|
@ -848,7 +848,7 @@ var _ = Describe("collectStatus", 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)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -860,7 +860,7 @@ var _ = Describe("collectStatus", func() {
|
|||
It("queues nothing", func() {
|
||||
_, err := collectStatus(ctx, ds)
|
||||
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.SetData(model.Albums{{ID: "al-1"}, {ID: "al-3"}})
|
||||
ds = &tests.MockDataStore{MockedAlbum: albums}
|
||||
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
|
||||
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
||||
art = ds.Artwork().(*tests.MockArtworkRepo)
|
||||
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
|
||||
out.Reset()
|
||||
})
|
||||
|
||||
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())
|
||||
|
||||
Expect(refreshItems(ctx, ds, []model.ArtworkID{
|
||||
{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))
|
||||
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(queued.Priority).To(Equal(model.ArtworkPriorityBump))
|
||||
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() {
|
||||
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(out.String()).To(BeEmpty())
|
||||
})
|
||||
|
|
@ -1145,9 +1145,9 @@ var _ = Describe("cancelArtwork", func() {
|
|||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
||||
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
|
||||
out.Reset()
|
||||
Expect(queue.Enqueue(
|
||||
Expect(queue.Enqueue(ctx,
|
||||
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", ImageType: model.ImageTypePrimary,
|
||||
Priority: model.ArtworkPriorityBackfill},
|
||||
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("TOTAL"))
|
||||
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() {
|
||||
Expect(cancelArtwork(ctx, ds, nil, nil, false, decline, &out)).To(Succeed())
|
||||
|
||||
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() {
|
||||
Expect(cancelArtwork(ctx, ds, nil, []int{model.ArtworkPriorityBackfill}, false, accept, &out)).To(Succeed())
|
||||
|
||||
Expect(queue.Count()).To(BeNumerically("==", 1))
|
||||
_, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
||||
Expect(queue.Count(ctx)).To(BeNumerically("==", 1))
|
||||
_, err := queue.Get(ctx, model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred(), "a non-matching priority must stay queued")
|
||||
Expect(out.String()).To(ContainSubstring("Cancelled 2 of 2 matched items."))
|
||||
})
|
||||
|
||||
It("cancels every kind and priority when neither filter is given", func() {
|
||||
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() {
|
||||
|
|
@ -1196,7 +1196,7 @@ var _ = Describe("cancelArtwork", func() {
|
|||
Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, false, refuse, &out)).To(Succeed())
|
||||
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ func runMissingList(ctx context.Context) {
|
|||
}
|
||||
|
||||
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},
|
||||
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).
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
mfs, err := ds.MediaFile(ctx).FindByPaths([]string{ref})
|
||||
mfs, err := ds.MediaFile().FindByPaths(ctx, []string{ref})
|
||||
if err != nil {
|
||||
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}
|
||||
}
|
||||
pls, err := ds.Playlist(ctx).GetAll(options)
|
||||
pls, err := ds.Playlist().GetAll(ctx, options)
|
||||
if err != nil {
|
||||
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 {
|
||||
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) {
|
||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||
}
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
|
||||
playlists, err := ds.Playlist().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
|
||||
if err != nil {
|
||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||
}
|
||||
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 {
|
||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||
}
|
||||
|
|
@ -194,7 +194,7 @@ func runExport(ctx context.Context) {
|
|||
|
||||
exported := 0
|
||||
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 {
|
||||
log.Error("Error loading playlist tracks", "playlist", pls.Name, err)
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -243,7 +243,7 @@ func runPluginInfo(ctx context.Context, arg string) {
|
|||
}
|
||||
requirePluginsEnabled(ctx)
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
p, err := ds.Plugin(ctx).Get(arg)
|
||||
p, err := ds.Plugin().Get(ctx, arg)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
||||
}
|
||||
|
|
@ -264,7 +264,7 @@ func runPluginValidate(ctx context.Context, arg string) {
|
|||
}
|
||||
requirePluginsEnabled(ctx)
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
p, err := ds.Plugin(ctx).Get(arg)
|
||||
p, err := ds.Plugin().Get(ctx, arg)
|
||||
if err != nil {
|
||||
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) {
|
||||
requirePluginsEnabled(ctx)
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
list, err := ds.Plugin(ctx).GetAll()
|
||||
list, err := ds.Plugin().GetAll(ctx)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to list plugins", err)
|
||||
}
|
||||
|
|
@ -372,7 +372,7 @@ var pluginEditCmd = &cobra.Command{
|
|||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requirePluginsEnabled(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 {
|
||||
log.Fatal(ctx, "Plugin not found", "id", args[0], err)
|
||||
}
|
||||
|
|
|
|||
33
cmd/root.go
33
cmd/root.go
|
|
@ -44,7 +44,9 @@ Complete documentation is available at https://www.navidrome.org/docs`,
|
|||
preRun()
|
||||
},
|
||||
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()
|
||||
|
|
@ -76,12 +78,12 @@ func postRun() {
|
|||
}
|
||||
|
||||
// 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,
|
||||
// it will cancel the context and exit gracefully.
|
||||
func runNavidrome(ctx context.Context) {
|
||||
defer db.Init(ctx)()
|
||||
// If any of the services returns an error, it stops the others and returns that error, so the caller can
|
||||
// exit with a non-zero code. If the context is cancelled (a signal or a service stop), it returns nil.
|
||||
func runNavidrome(parentCtx context.Context) error {
|
||||
defer db.Init(parentCtx)()
|
||||
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g, ctx := errgroup.WithContext(parentCtx)
|
||||
g.Go(startServer(ctx))
|
||||
g.Go(startSignaller(ctx))
|
||||
g.Go(startScheduler(ctx))
|
||||
|
|
@ -102,9 +104,11 @@ func runNavidrome(ctx context.Context) {
|
|||
log.Warn(ctx, "Automatic Scanning is DISABLED")
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
log.Error("Fatal error in Navidrome. Aborting", err)
|
||||
// Errors caused by a normal shutdown are not failures
|
||||
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.
|
||||
|
|
@ -133,6 +137,9 @@ func startServer(ctx context.Context) func() error {
|
|||
if conf.Server.Jellyfin.Enabled {
|
||||
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 {
|
||||
p := CreatePrometheus()
|
||||
// blocking call because takes <100ms but useful if fails
|
||||
|
|
@ -184,11 +191,11 @@ func schedulePeriodicScan(ctx context.Context) func() error {
|
|||
}
|
||||
|
||||
func pidHashChanged(ds model.DataStore) (bool, error) {
|
||||
pidAlbum, err := ds.Property(context.Background()).DefaultGet(consts.PIDAlbumKey, "")
|
||||
pidAlbum, err := ds.Property().DefaultGet(context.Background(), consts.PIDAlbumKey, "")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
pidTrack, err := ds.Property(context.Background()).DefaultGet(consts.PIDTrackKey, "")
|
||||
pidTrack, err := ds.Property().DefaultGet(context.Background(), consts.PIDTrackKey, "")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
|
@ -199,11 +206,11 @@ func pidHashChanged(ds model.DataStore) (bool, error) {
|
|||
func runInitialScan(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
ds := CreateDataStore()
|
||||
fullScanRequired, err := ds.Property(ctx).DefaultGet(consts.FullScanAfterMigrationFlagKey, "0")
|
||||
fullScanRequired, err := ds.Property().DefaultGet(ctx, consts.FullScanAfterMigrationFlagKey, "0")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inProgress, err := ds.Library(ctx).ScanInProgress()
|
||||
inProgress, err := ds.Library().ScanInProgress(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -219,7 +226,7 @@ func runInitialScan(ctx context.Context) func() error {
|
|||
switch {
|
||||
case fullScanRequired == "1":
|
||||
log.Warn(ctx, "Full scan required after migration")
|
||||
_ = ds.Property(ctx).Delete(consts.FullScanAfterMigrationFlagKey)
|
||||
_ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey)
|
||||
case pidHasChanged:
|
||||
log.Warn(ctx, "PID config changed, performing full scan")
|
||||
fullScanRequired = "1"
|
||||
|
|
|
|||
11
cmd/svc.go
11
cmd/svc.go
|
|
@ -44,7 +44,7 @@ var svcCmd = &cobra.Command{
|
|||
}
|
||||
|
||||
type svcControl struct {
|
||||
ctx context.Context
|
||||
ctx context.Context //nolint:containedctx // service lifecycle ctx, cancelled by Stop
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
|
@ -53,8 +53,13 @@ func (p *svcControl) Start(service.Service) error {
|
|||
p.done = make(chan struct{})
|
||||
p.ctx, p.cancel = context.WithCancel(context.Background())
|
||||
go func() {
|
||||
runNavidrome(p.ctx)
|
||||
err := runNavidrome(p.ctx)
|
||||
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
|
||||
}
|
||||
|
|
@ -74,7 +79,7 @@ func (p *svcControl) Stop(service.Service) error {
|
|||
var svcInstance = sync.OnceValue(func() service.Service {
|
||||
options := make(service.KeyValue)
|
||||
options["Restart"] = "on-failure"
|
||||
options["SuccessExitStatus"] = "1 2 8 SIGKILL"
|
||||
options["SuccessExitStatus"] = "SIGKILL"
|
||||
options["UserService"] = false
|
||||
options["LogDirectory"] = conf.Server.DataFolder.String()
|
||||
options["SystemdScript"] = systemdScript
|
||||
|
|
|
|||
26
cmd/user.go
26
cmd/user.go
|
|
@ -183,7 +183,7 @@ func runCreateUser(ctx context.Context) {
|
|||
ds, ctx := getAdminContext(ctx)
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("existing user '%s'", userID)
|
||||
}
|
||||
|
|
@ -193,7 +193,7 @@ func runCreateUser(ctx context.Context) {
|
|||
}
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
|
@ -202,13 +202,13 @@ func runCreateUser(ctx context.Context) {
|
|||
return libraryError(user.Libraries)
|
||||
}
|
||||
} else {
|
||||
user.Libraries, err = tx.Library(ctx).GetAll()
|
||||
user.Libraries, err = tx.Library().GetAll(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.User(ctx).Put(&user)
|
||||
err = tx.User().Put(ctx, &user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -218,7 +218,7 @@ func runCreateUser(ctx context.Context) {
|
|||
updatedIds[idx] = lib.ID
|
||||
}
|
||||
|
||||
err = tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
|
||||
err = tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
|
||||
return err
|
||||
})
|
||||
|
||||
|
|
@ -236,7 +236,7 @@ func runDeleteUser(ctx context.Context) {
|
|||
var user *model.User
|
||||
|
||||
err = ds.WithTx(func(tx model.DataStore) error {
|
||||
count, err := tx.User(ctx).CountAll()
|
||||
count, err := tx.User().CountAll(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -250,7 +250,7 @@ func runDeleteUser(ctx context.Context) {
|
|||
return err
|
||||
}
|
||||
|
||||
return tx.User(ctx).Delete(user.ID)
|
||||
return tx.User().Delete(ctx, user.ID)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
|
|
@ -276,7 +276,7 @@ func runUserEdit(ctx context.Context) {
|
|||
}
|
||||
|
||||
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 {
|
||||
return err
|
||||
|
|
@ -291,7 +291,7 @@ func runUserEdit(ctx context.Context) {
|
|||
}
|
||||
|
||||
if setAdmin && !user.IsAdmin {
|
||||
libraries, err := tx.Library(ctx).GetAll()
|
||||
libraries, err := tx.Library().GetAll(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -337,7 +337,7 @@ func runUserEdit(ctx context.Context) {
|
|||
return nil
|
||||
}
|
||||
|
||||
err := tx.User(ctx).Put(user)
|
||||
err := tx.User().Put(ctx, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -348,7 +348,7 @@ func runUserEdit(ctx context.Context) {
|
|||
updatedIds[idx] = lib.ID
|
||||
}
|
||||
|
||||
err := tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
|
||||
err := tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -393,13 +393,11 @@ func runUserList(ctx context.Context) {
|
|||
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
|
||||
users, err := ds.User(ctx).ReadAll()
|
||||
userList, err := ds.User().ReadAll(ctx)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to retrieve users", err)
|
||||
}
|
||||
|
||||
userList := users.(model.Users)
|
||||
|
||||
if outputFormat == "csv" {
|
||||
w := csv.NewWriter(os.Stdout)
|
||||
_ = w.Write([]string{
|
||||
|
|
|
|||
|
|
@ -52,14 +52,14 @@ func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
|
|||
}
|
||||
|
||||
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) {
|
||||
return nil, fmt.Errorf("finding user by name: %w", err)
|
||||
}
|
||||
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
user, err = ds.User(ctx).Get(id)
|
||||
user, err = ds.User().Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("finding user by id: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import (
|
|||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/apiv1"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/jellyfin"
|
||||
"github.com/navidrome/navidrome/server/nativeapi"
|
||||
|
|
@ -95,7 +96,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
|||
transcodingCache := stream.GetTranscodingCache()
|
||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
share := core.NewShare(dataStore)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share, artworkArtwork)
|
||||
players := core.NewPlayers(dataStore)
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
|
|
@ -142,6 +143,13 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.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 {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
|
|
@ -152,7 +160,7 @@ func CreatePublicRouter() *public.Router {
|
|||
transcodingCache := stream.GetTranscodingCache()
|
||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
share := core.NewShare(dataStore)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share, artworkArtwork)
|
||||
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
|
||||
return router
|
||||
}
|
||||
|
|
@ -259,7 +267,7 @@ func getPluginManager() *plugins.Manager {
|
|||
|
||||
// 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 {
|
||||
manager := getPluginManager()
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import (
|
|||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/apiv1"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/jellyfin"
|
||||
"github.com/navidrome/navidrome/server/nativeapi"
|
||||
|
|
@ -37,6 +38,7 @@ var allProviders = wire.NewSet(
|
|||
subsonic.New,
|
||||
jellyfin.New,
|
||||
jellyfin.NewDiscovery,
|
||||
apiv1.New,
|
||||
nativeapi.New,
|
||||
public.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 {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ type configOptions struct {
|
|||
DevExternalArtistFetchMultiplier float64
|
||||
DevPreserveUnicodeInExternalCalls bool
|
||||
DevEnableMediaFileProbe bool
|
||||
DevAPIv1 bool
|
||||
}
|
||||
|
||||
type scannerOptions struct {
|
||||
|
|
@ -514,6 +515,11 @@ func Load(noConfigDump bool) {
|
|||
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.
|
||||
imgSize, _ := humanize.ParseBytes(Server.MaxImageSize)
|
||||
uploadSize, _ := humanize.ParseBytes(Server.MaxImageUploadSize)
|
||||
|
|
@ -1119,6 +1125,7 @@ func setViperDefaults() {
|
|||
viper.SetDefault("devshowartistpage", true)
|
||||
viper.SetDefault("devuishowconfig", true)
|
||||
viper.SetDefault("devneweventstream", true)
|
||||
viper.SetDefault("devapiv1", false)
|
||||
viper.SetDefault("devoffsetoptimize", 50000)
|
||||
// Half the pool: streams may take up to this many connections, leaving the rest for the scanner,
|
||||
// scrobbles and the UI. See MaxOpenConns.
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
It("defaults to false", func() {
|
||||
conf.Load(true)
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ const (
|
|||
DefaultEncryptionKey = "just for obfuscation"
|
||||
PasswordsEncryptedKey = "PasswordsEncryptedKey"
|
||||
PasswordAutogenPrefix = "__NAVIDROME_AUTOGEN__" //nolint:gosec
|
||||
APIKeyPrefix = "nds_"
|
||||
|
||||
DevInitialUserName = "admin"
|
||||
DevInitialName = "Dev Admin"
|
||||
|
|
@ -59,6 +60,7 @@ const (
|
|||
URLPathPublic = "/share"
|
||||
URLPathPublicImages = URLPathPublic + "/img"
|
||||
URLPathJellyfinAPI = "/jellyfin"
|
||||
URLPathAPIv1 = "/api/v1"
|
||||
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ func (p *localAgent) AgentName() string {
|
|||
}
|
||||
|
||||
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",
|
||||
Order: "desc",
|
||||
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) {
|
||||
seed, err := p.ds.MediaFile(ctx).Get(id)
|
||||
seed, err := p.ds.MediaFile().Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -53,7 +53,7 @@ func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artis
|
|||
return nil, nil
|
||||
}
|
||||
// Ask for extra so we can drop the seed itself and still fill the count.
|
||||
candidates, err := p.ds.MediaFile(ctx).GetRandom(model.QueryOptions{
|
||||
candidates, err := p.ds.MediaFile().GetRandom(ctx, model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
persistence.SongGenres.ByID(genreIDs),
|
||||
squirrel.Eq{"missing": false},
|
||||
|
|
|
|||
|
|
@ -13,13 +13,13 @@ type SessionKeys struct {
|
|||
}
|
||||
|
||||
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) {
|
||||
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 {
|
||||
return sk.DataStore.UserProps(ctx).Delete(userId, sk.KeyName)
|
||||
return sk.DataStore.UserProps().Delete(ctx, userId, sk.KeyName)
|
||||
}
|
||||
|
|
|
|||
170
core/archiver.go
170
core/archiver.go
|
|
@ -2,23 +2,32 @@ package core
|
|||
|
||||
import (
|
||||
"archive/zip"
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
const archiveCoverArtSize = 500
|
||||
|
||||
type Archiver interface {
|
||||
ZipAlbum(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||
ZipArtist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||
|
|
@ -26,18 +35,19 @@ type Archiver interface {
|
|||
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 {
|
||||
return &archiver{ds: ds, ms: ms, shares: shares}
|
||||
func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver {
|
||||
return &archiver{ds: ds, ms: ms, shares: shares, artwork: artwork}
|
||||
}
|
||||
|
||||
type archiver struct {
|
||||
ds model.DataStore
|
||||
ms stream.MediaStreamer
|
||||
shares Share
|
||||
ds model.DataStore
|
||||
ms stream.MediaStreamer
|
||||
shares Share
|
||||
artwork artwork.Artwork
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -47,27 +57,28 @@ func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitr
|
|||
persistence.ParticipantIDFilter("media_file", id, model.RoleAlbumArtist),
|
||||
squirrel.Eq{"missing": false},
|
||||
}
|
||||
return a.zipAlbums(ctx, id, format, bitrate, out, filter)
|
||||
return a.zipAlbums(ctx, id, format, bitrate, out, 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 {
|
||||
mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: filters, Sort: "album"})
|
||||
// rootArt, when set, is added to the archive root.
|
||||
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 {
|
||||
log.Error(ctx, "Error loading mediafiles from artist", "id", id, err)
|
||||
return err
|
||||
}
|
||||
|
||||
z := createZipWriter(out, format, bitrate)
|
||||
albums := slice.Group(mfs, func(mf model.MediaFile) string {
|
||||
return mf.AlbumID
|
||||
})
|
||||
albums := slice.GroupOrdered(mfs, func(mf model.MediaFile) string { return mf.AlbumID })
|
||||
folders := albumFolders(albums)
|
||||
for _, album := range albums {
|
||||
discs := slice.Group(album, func(mf model.MediaFile) int { return mf.DiscNumber })
|
||||
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))
|
||||
for _, mf := range album {
|
||||
file := a.albumFilename(mf, format, isMultiDisc)
|
||||
file := a.albumFilename(mf, format, isMultiDisc, folder)
|
||||
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||
// Stop iterating: continuing would just rack up more
|
||||
// rejections from the limiter. Close finalises whatever
|
||||
|
|
@ -78,7 +89,10 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr
|
|||
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()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error closing zip file", "id", id, err)
|
||||
|
|
@ -96,7 +110,61 @@ func createZipWriter(out io.Writer, format string, bitrate int) *zip.Writer {
|
|||
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)
|
||||
if format != "raw" {
|
||||
file = strings.TrimSuffix(file, mf.Suffix) + format
|
||||
|
|
@ -104,7 +172,7 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc
|
|||
if isMultiDisc {
|
||||
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
|
||||
}
|
||||
return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file)
|
||||
return fmt.Sprintf("%s/%s", folder, file)
|
||||
}
|
||||
|
||||
// ZipShare takes an already-loaded share: Share.Load records a visit, so
|
||||
|
|
@ -114,21 +182,24 @@ func (a *archiver) ZipShare(ctx context.Context, s *model.Share, out io.Writer)
|
|||
return model.ErrNotAuthorized
|
||||
}
|
||||
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
|
||||
return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false)
|
||||
// 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 {
|
||||
pls, err := a.ds.Playlist(ctx).GetWithTracks(id, true, false)
|
||||
pls, err := a.ds.Playlist().GetWithTracks(ctx, id, true, false)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err)
|
||||
return err
|
||||
}
|
||||
mfs := pls.MediaFiles()
|
||||
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)
|
||||
|
||||
zippedMfs := make(model.MediaFiles, len(mfs))
|
||||
|
|
@ -143,6 +214,7 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st
|
|||
mf.Path = file
|
||||
zippedMfs[idx] = mf
|
||||
}
|
||||
a.addCoverArtToZip(coverCtx, z, coverArt, "")
|
||||
|
||||
// Add M3U file if requested
|
||||
if addM3U && len(zippedMfs) > 0 {
|
||||
|
|
@ -220,3 +292,61 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
|||
|
||||
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"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"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/artwork"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
|
@ -23,13 +28,15 @@ var _ = Describe("Archiver", func() {
|
|||
ms *mockMediaStreamer
|
||||
ds *mockDataStore
|
||||
sh *mockShare
|
||||
ca *mockCoverArt
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ms = &mockMediaStreamer{}
|
||||
sh = &mockShare{}
|
||||
ds = &mockDataStore{}
|
||||
arch = core.NewArchiver(ms, ds, sh)
|
||||
ca = &mockCoverArt{images: map[string][]byte{}}
|
||||
arch = core.NewArchiver(ms, ds, sh, ca)
|
||||
})
|
||||
|
||||
Context("ZipAlbum", func() {
|
||||
|
|
@ -45,7 +52,7 @@ var _ = Describe("Archiver", func() {
|
|||
Sort: "album",
|
||||
}}).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)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
|
|
@ -77,7 +84,7 @@ var _ = Describe("Archiver", func() {
|
|||
Sort: "album",
|
||||
}}).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)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
|
|
@ -91,6 +98,140 @@ var _ = Describe("Archiver", func() {
|
|||
Expect(zr.File[0].Name).To(Equal("Album 1/01 - track1.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() {
|
||||
|
|
@ -105,7 +246,7 @@ var _ = Describe("Archiver", func() {
|
|||
Filters: squirrel.Eq{"album_id": "1"},
|
||||
Sort: "album",
|
||||
}}).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(nil, stream.ErrTooManyTranscodes).Once()
|
||||
|
|
@ -169,7 +310,7 @@ var _ = Describe("Archiver", func() {
|
|||
|
||||
plRepo := &mockPlaylistRepository{}
|
||||
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)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
|
|
@ -196,24 +337,195 @@ var _ = Describe("Archiver", func() {
|
|||
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 {
|
||||
mock.Mock
|
||||
model.DataStore
|
||||
}
|
||||
|
||||
func (m *mockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository {
|
||||
args := m.Called(ctx)
|
||||
func (m *mockDataStore) MediaFile() model.MediaFileRepository {
|
||||
args := m.Called()
|
||||
return args.Get(0).(model.MediaFileRepository)
|
||||
}
|
||||
|
||||
func (m *mockDataStore) Playlist(ctx context.Context) model.PlaylistRepository {
|
||||
args := m.Called(ctx)
|
||||
func (m *mockDataStore) Playlist() model.PlaylistRepository {
|
||||
args := m.Called()
|
||||
return args.Get(0).(model.PlaylistRepository)
|
||||
}
|
||||
|
||||
func (m *mockDataStore) Library(context.Context) model.LibraryRepository {
|
||||
func (m *mockDataStore) Library() model.LibraryRepository {
|
||||
return &mockLibraryRepository{}
|
||||
}
|
||||
|
||||
|
|
@ -222,7 +534,7 @@ type mockLibraryRepository struct {
|
|||
model.LibraryRepository
|
||||
}
|
||||
|
||||
func (m *mockLibraryRepository) GetPath(id int) (string, error) {
|
||||
func (m *mockLibraryRepository) GetPath(_ context.Context, id int) (string, error) {
|
||||
return "/music", nil
|
||||
}
|
||||
|
||||
|
|
@ -231,7 +543,7 @@ type mockMediaFileRepository struct {
|
|||
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)
|
||||
return args.Get(0).(model.MediaFiles), args.Error(1)
|
||||
}
|
||||
|
|
@ -241,7 +553,7 @@ type mockPlaylistRepository struct {
|
|||
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)
|
||||
return args.Get(0).(*model.Playlist), args.Error(1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,21 +59,21 @@ func entityExists(ctx context.Context, ds model.DataStore, artID model.ArtworkID
|
|||
var err error
|
||||
switch artID.Kind {
|
||||
case model.KindArtistArtwork:
|
||||
found, err = ds.Artist(ctx).Exists(artID.ID)
|
||||
found, err = ds.Artist().Exists(ctx, artID.ID)
|
||||
case model.KindAlbumArtwork:
|
||||
found, err = ds.Album(ctx).Exists(artID.ID)
|
||||
found, err = ds.Album().Exists(ctx, artID.ID)
|
||||
case model.KindMediaFileArtwork:
|
||||
found, err = ds.MediaFile(ctx).Exists(artID.ID)
|
||||
found, err = ds.MediaFile().Exists(ctx, artID.ID)
|
||||
case model.KindPlaylistArtwork:
|
||||
found, err = ds.Playlist(ctx).Exists(artID.ID)
|
||||
found, err = ds.Playlist().Exists(ctx, artID.ID)
|
||||
case model.KindRadioArtwork:
|
||||
found, err = ds.Radio(ctx).Exists(artID.ID)
|
||||
found, err = ds.Radio().Exists(ctx, artID.ID)
|
||||
case model.KindDiscArtwork:
|
||||
albumID, _, perr := model.ParseDiscArtworkID(artID.ID)
|
||||
if perr != nil {
|
||||
return false
|
||||
}
|
||||
found, err = ds.Album(ctx).Exists(albumID)
|
||||
found, err = ds.Album().Exists(ctx, albumID)
|
||||
default:
|
||||
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) {
|
||||
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 {
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
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)
|
||||
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 errors.Is(err, model.ErrNotFound) {
|
||||
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
|
||||
// must fall back to disc/album instead of serving stale persisted embedded art.
|
||||
if !conf.Server.EnableMediaFileCoverArt {
|
||||
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
|
||||
mf, err := s.ds.MediaFile().Get(ctx, artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
|
||||
}
|
||||
ia, err := s.ds.Artwork(ctx).GetItemArtwork(model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary)
|
||||
ia, err := s.ds.Artwork().GetItemArtwork(ctx, model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary)
|
||||
switch {
|
||||
case err == nil && ia.Hash != "":
|
||||
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)
|
||||
|
||||
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
|
||||
mf, err := s.ds.MediaFile().Get(ctx, artID.ID)
|
||||
if err != nil {
|
||||
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) {
|
||||
err := s.ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
||||
err := s.ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{
|
||||
ItemKind: artID.Kind.Prefix(),
|
||||
ItemID: artID.ID,
|
||||
ImageType: model.ImageTypePrimary,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
|
|
@ -108,15 +109,15 @@ type fakeFolderRepo struct {
|
|||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) Get(string) (*model.Folder, error) {
|
||||
func (f *fakeFolderRepo) Get(context.Context, string) (*model.Folder, error) {
|
||||
f.getCallCount++
|
||||
if f.getErr != nil {
|
||||
return nil, f.getErr
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ var _ = Describe("Artwork", func() {
|
|||
hash, err := hashImage(bytes.NewReader(imgBytes))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(store.Write(hash, "image/jpeg", bytes.NewReader(imgBytes))).To(Succeed())
|
||||
Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed())
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed())
|
||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed())
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed())
|
||||
seedEntity(kind, id)
|
||||
return hash
|
||||
}
|
||||
|
|
@ -56,9 +56,9 @@ var _ = Describe("Artwork", func() {
|
|||
GinkgoHelper()
|
||||
switch kind {
|
||||
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":
|
||||
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")
|
||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||
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")
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al2", Hash: "aaaaaaaaaaaaaaaa",
|
||||
Source: "folder", SourcePath: imgPath, RefMtime: mtime,
|
||||
})).To(Succeed())
|
||||
|
|
@ -163,9 +163,9 @@ var _ = Describe("Artwork", func() {
|
|||
dir := GinkgoT().TempDir()
|
||||
secretPath := filepath.Join(dir, "config.ini")
|
||||
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")
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "alni", Hash: "dddddddddddddddd",
|
||||
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
|
||||
})).To(Succeed())
|
||||
|
|
@ -180,9 +180,9 @@ var _ = Describe("Artwork", func() {
|
|||
dir := GinkgoT().TempDir()
|
||||
secretPath := filepath.Join(dir, "config.ini")
|
||||
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")
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "alnic", Hash: "eeeeeeeeeeeeeeee",
|
||||
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
|
||||
})).To(Succeed())
|
||||
|
|
@ -210,9 +210,9 @@ var _ = Describe("Artwork", func() {
|
|||
dir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(dir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed())
|
||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed())
|
||||
seedEntity("al", "al3")
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al3", Hash: "bbbbbbbbbbbbbbbb",
|
||||
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
||||
})).To(Succeed())
|
||||
|
|
@ -220,7 +220,7 @@ var _ = Describe("Artwork", func() {
|
|||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al3"), 0, false)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(queueRepo.Data[primaryKey("al", "al3")].Priority).To(Equal(model.ArtworkPriorityScan))
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb"))
|
||||
})
|
||||
|
|
@ -229,9 +229,9 @@ var _ = Describe("Artwork", func() {
|
|||
dir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(dir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed())
|
||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed())
|
||||
seedEntity("al", "al3b")
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al3b", Hash: "cccccccccccccccc",
|
||||
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
||||
})).To(Succeed())
|
||||
|
|
@ -252,7 +252,7 @@ var _ = Describe("Artwork", 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),
|
||||
})).To(Succeed())
|
||||
|
||||
|
|
@ -272,7 +272,7 @@ var _ = Describe("Artwork", func() {
|
|||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
|
||||
Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
||||
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
|
|
@ -283,7 +283,7 @@ var _ = Describe("Artwork", func() {
|
|||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al6"), 0, false)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(queueRepo.Data[primaryKey("al", "al6")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
||||
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
||||
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() {
|
||||
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"}})
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf2"), 0, false)
|
||||
|
|
@ -343,7 +343,7 @@ var _ = Describe("Artwork", func() {
|
|||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
||||
Expect(queueRepo.Data[primaryKey("mf", "mf4")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary)
|
||||
_, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary)
|
||||
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() {
|
||||
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)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -535,7 +535,7 @@ var _ = Describe("EntityExists", func() {
|
|||
artistRepo := tests.CreateMockArtistRepo()
|
||||
artistRepo.SetData(model.Artists{{ID: "ar1"}})
|
||||
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}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
al, err := ds.Album(ctx).Get(albumID)
|
||||
al, err := ds.Album().Get(ctx, albumID)
|
||||
if err != nil {
|
||||
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
|
||||
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{
|
||||
Sort: "track_number",
|
||||
Order: "ASC",
|
||||
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
|
||||
discFoldersRel := make(map[string]bool)
|
||||
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},
|
||||
})
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -44,20 +44,20 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
|
||||
itemFound := func(kind model.Kind, id string) 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 != ""
|
||||
}
|
||||
}
|
||||
itemAbsent := func(kind model.Kind, id string) 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 == ""
|
||||
}
|
||||
}
|
||||
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
|
||||
bump := func(kind, id string) {
|
||||
GinkgoHelper()
|
||||
Expect(ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
||||
Expect(ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{
|
||||
ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary,
|
||||
Priority: model.ArtworkPriorityBump,
|
||||
})).To(Succeed())
|
||||
|
|
@ -141,7 +141,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
bump("al", "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(ia.Source).To(Equal("folder"))
|
||||
|
||||
|
|
@ -158,7 +158,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
bump("ar", "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(ia.Source).To(Equal("upload"))
|
||||
|
||||
|
|
@ -175,14 +175,14 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
bump("pl", "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(ia.Source).To(Equal("generated"))
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("pl-pl1"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Hash).To(Equal(ia.Hash))
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(art.Mime).To(Equal("image/png"))
|
||||
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
||||
|
|
@ -194,7 +194,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
bump("ra", "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(ia.Source).To(Equal("upload"))
|
||||
|
||||
|
|
@ -216,12 +216,12 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
provisionalBytes := readAll(provisional)
|
||||
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")
|
||||
|
||||
// The provisional read enqueued a Bump; drain it.
|
||||
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(ia.Source).To(Equal("embedded"))
|
||||
Expect(ia.Hash).To(Equal(provisional.Hash))
|
||||
|
|
@ -237,9 +237,9 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
bump("al", "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())
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(art.Mime).To(Equal("image/jpeg"))
|
||||
Expect(art.Width).To(BeNumerically(">", 0))
|
||||
|
|
@ -259,9 +259,9 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
bump("ra", "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())
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(art.Mime).To(Equal("image/gif"))
|
||||
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")()
|
||||
})
|
||||
|
||||
ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
ia1, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
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(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))
|
||||
|
|
@ -293,7 +293,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
bump("ra", "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())
|
||||
staleHash := ia.Hash
|
||||
|
||||
|
|
@ -308,7 +308,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
|
||||
// That failed read enqueued a re-resolution.
|
||||
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
|
||||
})
|
||||
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"
|
||||
writeUploadedImage(consts.EntityArtist, uploaded, pngBytes("artist-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)
|
||||
Expect(ia.Source).To(Equal("upload"))
|
||||
|
|
@ -279,7 +279,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
|||
|
||||
func soleArtist() model.Artist {
|
||||
GinkgoHelper()
|
||||
artists, err := rds.Artist(rctx).GetAll(model.QueryOptions{
|
||||
artists, err := rds.Artist().GetAll(rctx, model.QueryOptions{
|
||||
Filters: squirrel.Eq{"artist.name": "Artist"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
|
|||
|
|
@ -67,13 +67,17 @@ type fakeFolderRepo struct {
|
|||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
GinkgoHelper()
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ var _ = Describe("MediaFile artwork resolution", func() {
|
|||
|
||||
func mediafileOn(relPath string) model.MediaFile {
|
||||
GinkgoHelper()
|
||||
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{
|
||||
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{
|
||||
Filters: squirrel.Like{"media_file.path": relPath},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
|
|||
|
|
@ -142,13 +142,13 @@ var _ = Describe("Playlist artwork resolution", func() {
|
|||
})
|
||||
scan()
|
||||
|
||||
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{})
|
||||
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfs).To(HaveLen(2))
|
||||
|
||||
pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"}
|
||||
pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID})
|
||||
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
||||
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
|
||||
|
||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||
Expect(ia.Source).To(Equal("generated"))
|
||||
|
|
@ -180,14 +180,14 @@ var _ = Describe("Playlist artwork resolution", func() {
|
|||
setLayout(layout)
|
||||
scan()
|
||||
|
||||
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{})
|
||||
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfs).To(HaveLen(4))
|
||||
ids := slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID })
|
||||
|
||||
pl := model.Playlist{ID: "pl-8", Name: "Four", OwnerID: "admin-1"}
|
||||
pl.AddMediaFilesByID(ids)
|
||||
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
||||
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
|
||||
|
||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||
Expect(ia.Source).To(Equal("generated"))
|
||||
|
|
@ -208,6 +208,6 @@ func putPlaylist(pl model.Playlist) model.Playlist {
|
|||
if pl.OwnerID == "" {
|
||||
pl.OwnerID = "admin-1"
|
||||
}
|
||||
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
||||
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
|
||||
return pl
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ var _ = Describe("Radio artwork resolution", func() {
|
|||
It("returns the uploaded image bytes", func() {
|
||||
writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", pngBytes("radio-logo"))
|
||||
rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"}
|
||||
Expect(rds.Radio(rctx).Put(&rd)).To(Succeed())
|
||||
Expect(rds.Radio().Put(rctx, &rd)).To(Succeed())
|
||||
|
||||
ia := acquire(model.KindRadioArtwork, rd.ID)
|
||||
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)
|
||||
It("settles absent", func() {
|
||||
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)
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
|
|
|
|||
|
|
@ -99,11 +99,11 @@ func setupResolutionHarness() {
|
|||
rds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||
|
||||
adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"}
|
||||
Expect(rds.User(rctx).Put(&adminUser)).To(Succeed())
|
||||
Expect(rds.User().Put(rctx, &adminUser)).To(Succeed())
|
||||
|
||||
lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath}
|
||||
Expect(rds.Library(rctx).Put(&lib)).To(Succeed())
|
||||
Expect(rds.User(rctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())
|
||||
Expect(rds.Library().Put(rctx, &lib)).To(Succeed())
|
||||
Expect(rds.User().SetUserLibraries(rctx, adminUser.ID, []int{lib.ID})).To(Succeed())
|
||||
|
||||
loadEmbeddedFixture()
|
||||
|
||||
|
|
@ -140,13 +140,13 @@ func scan() {
|
|||
func acquire(kind model.Kind, id string) model.ItemArtwork {
|
||||
GinkgoHelper()
|
||||
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
|
||||
Expect(rds.ArtworkQueue(rctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
||||
Expect(rds.ArtworkQueue().EnqueuePreservingBackoff(rctx, model.ArtworkQueueItem{
|
||||
ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary,
|
||||
Priority: model.ArtworkPriorityBump,
|
||||
})).To(Succeed())
|
||||
var ia *model.ItemArtwork
|
||||
runResolutionWorkerUntil(func() bool {
|
||||
got, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||
got, err := rds.Artwork().GetItemArtwork(rctx, kind, id, model.ImageTypePrimary)
|
||||
if err != nil {
|
||||
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.
|
||||
func requireNoStateRow(kind model.Kind, id string) {
|
||||
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),
|
||||
"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.
|
||||
func storedBytes(ia model.ItemArtwork) []byte {
|
||||
GinkgoHelper()
|
||||
art, err := rds.Artwork(rctx).GetImage(ia.Hash)
|
||||
art, err := rds.Artwork().GetImage(rctx, ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
r, err := rstore.Open(ia.Hash, art.Mime)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -345,7 +345,7 @@ func replaceWithRealMP3(relPath string) {
|
|||
|
||||
func firstAlbum() model.Album {
|
||||
GinkgoHelper()
|
||||
albums, err := rds.Album(rctx).GetAll(model.QueryOptions{})
|
||||
albums, err := rds.Album().GetAll(rctx, model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums))
|
||||
return albums[0]
|
||||
|
|
@ -353,7 +353,7 @@ func firstAlbum() model.Album {
|
|||
|
||||
func albumByName(name string) model.Album {
|
||||
GinkgoHelper()
|
||||
albums, err := rds.Album(rctx).GetAll(model.QueryOptions{})
|
||||
albums, err := rds.Album().GetAll(rctx, model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for _, al := range albums {
|
||||
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) {
|
||||
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
|
||||
|
|
@ -79,7 +79,7 @@ func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Fo
|
|||
if len(folders) < 2 && anyFolderHasImages(folders) {
|
||||
return nil, nil
|
||||
}
|
||||
parent, err := ds.Folder(ctx).Get(commonParentID)
|
||||
parent, err := ds.Folder().Get(ctx, commonParentID)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "Artwork: Parent folder not found for album cover art lookup", "parentID", commonParentID)
|
||||
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
|
||||
return nil, nil
|
||||
}
|
||||
hasOtherAudio, err := ds.Folder(ctx).HasAudioOutsideFolders(*parent, folderIDs)
|
||||
hasOtherAudio, err := ds.Folder().HasAudioOutsideFolders(ctx, *parent, folderIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
|
|
@ -169,14 +168,15 @@ func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albu
|
|||
folderPath = filepath.Dir(folderPath)
|
||||
}
|
||||
|
||||
// TODO: Hacky, but the easiest way to get the folder ID ATM
|
||||
libPath := core.AbsolutePath(ctx, ds, libID, "")
|
||||
// Cleaned like the album paths; Join keeps an empty path empty, Clean would return ".".
|
||||
libPath, _ := ds.Library().GetPath(ctx, libID)
|
||||
libPath = filepath.Join(libPath)
|
||||
folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath)
|
||||
|
||||
log.Trace(ctx, "Artwork: Calculating artist folder details", "folderPath", folderPath, "folderID", folderID,
|
||||
"libPath", libPath, "libID", libID, "albumPaths", paths)
|
||||
|
||||
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}})
|
||||
folders, err := ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}})
|
||||
if err != nil || len(folders) == 0 {
|
||||
log.Warn(ctx, "Artwork: Could not find folder for artist", "folderPath", folderPath, "id", folderID,
|
||||
"libPath", libPath, "libID", libID, err)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import (
|
|||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
|
|
@ -47,11 +46,11 @@ var _ = Describe("loadArtistFolder", func() {
|
|||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
DeferCleanup(stubCoreAbsolutePath())
|
||||
|
||||
updatedAt = time.Now().Truncate(time.Second).Add(5 * time.Minute)
|
||||
repo = &fakeFolderRepo{result: []model.Folder{{ImagesUpdatedAt: updatedAt}}}
|
||||
ds = &tests.MockDataStore{MockedFolder: repo}
|
||||
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"}}
|
||||
})
|
||||
|
||||
|
|
@ -107,11 +106,3 @@ var _ = Describe("loadArtistFolder", func() {
|
|||
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.
|
||||
func ReconcileConfigFingerprint(ctx context.Context, ds model.DataStore) error {
|
||||
current := ConfigFingerprint()
|
||||
stored, err := ds.Property(ctx).DefaultGet(consts.ArtConfFingerprintPropertyKey, "")
|
||||
stored, err := ds.Property().DefaultGet(ctx, consts.ArtConfFingerprintPropertyKey, "")
|
||||
if err != nil {
|
||||
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.
|
||||
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).
|
||||
func enqueueMissingAll(ctx context.Context, ds model.DataStore) error {
|
||||
queue := ds.ArtworkQueue(ctx)
|
||||
queue := ds.ArtworkQueue()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
switch kind {
|
||||
case model.KindArtistArtwork:
|
||||
ar, err := ds.Artist(ctx).Get(id)
|
||||
ar, err := ds.Artist().Get(ctx, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ar.Name, nil
|
||||
case model.KindAlbumArtwork:
|
||||
al, err := ds.Album(ctx).Get(id)
|
||||
al, err := ds.Album().Get(ctx, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return al.Name, nil
|
||||
case model.KindPlaylistArtwork:
|
||||
pls, err := ds.Playlist(ctx).Get(id)
|
||||
pls, err := ds.Playlist().Get(ctx, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return pls.Name, nil
|
||||
case model.KindRadioArtwork:
|
||||
rd, err := ds.Radio(ctx).Get(id)
|
||||
rd, err := ds.Radio().Get(ctx, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rd.Name, nil
|
||||
case model.KindMediaFileArtwork:
|
||||
mf, err := ds.MediaFile(ctx).Get(id)
|
||||
mf, err := ds.MediaFile().Get(ctx, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
@ -148,7 +148,7 @@ func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string
|
|||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
al, err := ds.Album(ctx).Get(albumID)
|
||||
al, err := ds.Album().Get(ctx, albumID)
|
||||
if err != nil {
|
||||
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.
|
||||
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)
|
||||
}
|
||||
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 nil
|
||||
|
|
|
|||
|
|
@ -95,15 +95,15 @@ var _ = Describe("Housekeeping", func() {
|
|||
It("records the current fingerprint when none was ever stored", func() {
|
||||
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() {
|
||||
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(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"}},
|
||||
})
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ func (v libraryView) Abs(rel string) string {
|
|||
// loadLibraryView resolves the MusicFS and absolute root path in a single
|
||||
// library lookup.
|
||||
func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (libraryView, error) {
|
||||
lib, err := ds.Library(ctx).Get(libID)
|
||||
lib, err := ds.Library().Get(ctx, libID)
|
||||
if err != nil {
|
||||
return libraryView{}, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ var _ = Describe("loadLibraryView", Ordered, 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)
|
||||
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() {
|
||||
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)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ type processor struct {
|
|||
// acquire resolves one queue item end to end: find an image, hash/decode/
|
||||
// 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) {
|
||||
repo := p.ds.Artwork(ctx)
|
||||
repo := p.ds.Artwork()
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
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,
|
||||
"hash", hash, "bytes", len(data), "elapsed", time.Since(hashStart))
|
||||
|
||||
art, err := repo.GetImage(hash)
|
||||
art, err := repo.GetImage(ctx, hash)
|
||||
switch {
|
||||
case err == nil && art.Width > 0:
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
ia := &model.ItemArtwork{
|
||||
|
|
@ -210,7 +210,7 @@ func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, i
|
|||
Trace: traceFrom(ctx).encode(sourcePath),
|
||||
}
|
||||
// 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 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".
|
||||
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,
|
||||
ItemID: item.ItemID,
|
||||
ImageType: item.ImageType,
|
||||
|
|
|
|||
|
|
@ -93,14 +93,14 @@ var _ = Describe("processor.acquire", func() {
|
|||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"})
|
||||
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(ia.Hash).ToNot(BeEmpty())
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg"))
|
||||
Expect(ia.RefMtime).To(BeNumerically(">", 0))
|
||||
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Every placeholder is derived from the one shared thumbnail, so all three land together.
|
||||
Expect(art.BlurHash).ToNot(BeEmpty())
|
||||
|
|
@ -156,12 +156,12 @@ var _ = Describe("processor.acquire", func() {
|
|||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"})
|
||||
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(ia.Source).To(Equal("embedded"))
|
||||
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(art.BlurHash).ToNot(BeEmpty())
|
||||
|
||||
|
|
@ -179,7 +179,7 @@ var _ = Describe("processor.acquire", func() {
|
|||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"})
|
||||
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(ia.Hash).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"})
|
||||
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")
|
||||
})
|
||||
|
||||
|
|
@ -225,7 +225,7 @@ var _ = Describe("processor.acquire", func() {
|
|||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-io"})
|
||||
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")
|
||||
})
|
||||
|
||||
|
|
@ -269,7 +269,7 @@ var _ = Describe("processor.acquire", func() {
|
|||
Expect(out).To(Equal(outcomeFailed))
|
||||
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))
|
||||
})
|
||||
|
||||
|
|
@ -299,7 +299,7 @@ var _ = Describe("processor.acquire", func() {
|
|||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})
|
||||
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(ia.Hash).ToNot(BeEmpty())
|
||||
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"})
|
||||
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(ia.Source).To(Equal("folder"))
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(art.Width).To(BeZero())
|
||||
Expect(art.BlurHash).To(BeEmpty())
|
||||
|
|
@ -336,7 +336,7 @@ var _ = Describe("processor.acquire", func() {
|
|||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"})
|
||||
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))
|
||||
})
|
||||
|
||||
|
|
@ -354,7 +354,7 @@ var _ = Describe("processor.acquire", func() {
|
|||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"})
|
||||
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))
|
||||
})
|
||||
|
||||
|
|
@ -373,12 +373,12 @@ var _ = Describe("processor.acquire", func() {
|
|||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"})
|
||||
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(ia.Source).To(Equal("external:deezerFake"))
|
||||
Expect(ia.Hash).ToNot(BeEmpty())
|
||||
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc, err := store.Open(ia.Hash, art.Mime)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -397,7 +397,7 @@ var _ = Describe("processor.acquire", func() {
|
|||
|
||||
out1, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"})
|
||||
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())
|
||||
|
||||
// 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"})
|
||||
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(ia2.Hash).To(Equal(ia1.Hash))
|
||||
|
||||
reused, err := artRepo.GetImage(ia1.Hash)
|
||||
reused, err := artRepo.GetImage(ctx, ia1.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
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"}}}
|
||||
outN, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"})
|
||||
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(iaA.Source).To(Equal("folder"))
|
||||
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"}}}
|
||||
outN, _, _ = proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"})
|
||||
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(iaB.Hash).To(Equal(iaA.Hash))
|
||||
Expect(filepath.ToSlash(iaB.SourcePath)).To(HaveSuffix("album-b/cover.jpg"))
|
||||
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(filepath.ToSlash(iaAafter.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
|
||||
Expect(iaAafter.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano()))
|
||||
|
||||
Expect(artRepo.Data).To(HaveLen(1))
|
||||
reused, err := artRepo.GetImage(iaA.Hash)
|
||||
reused, err := artRepo.GetImage(ctx, iaA.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
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"})
|
||||
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))
|
||||
})
|
||||
|
||||
|
|
@ -504,7 +504,7 @@ var _ = Describe("processor.acquire", func() {
|
|||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"})
|
||||
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))
|
||||
})
|
||||
|
||||
|
|
@ -565,12 +565,12 @@ var _ = Describe("processor.acquire", func() {
|
|||
|
||||
hash, err := hashImage(bytes.NewReader(imgBytes))
|
||||
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"})
|
||||
Expect(out).To(Equal(outcomeFound))
|
||||
|
||||
upgraded, err := artRepo.GetImage(hash)
|
||||
upgraded, err := artRepo.GetImage(ctx, hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(upgraded.Width).To(BeNumerically(">", 0))
|
||||
Expect(upgraded.BlurHash).ToNot(BeEmpty())
|
||||
|
|
@ -590,7 +590,7 @@ var _ = Describe("processor.acquire", func() {
|
|||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"})
|
||||
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))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ const pruneMinAge = time.Hour
|
|||
func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
|
||||
start := time.Now()
|
||||
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 {
|
||||
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).
|
||||
queuePurged, err := ds.ArtworkQueue(ctx).PurgeDangling()
|
||||
queuePurged, err := ds.ArtworkQueue().PurgeDangling(ctx)
|
||||
if err != nil {
|
||||
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.
|
||||
cutoff := time.Now().Add(-pruneMinAge)
|
||||
orphans, err := repo.PurgeOrphans(cutoff)
|
||||
orphans, err := repo.PurgeOrphans(ctx, cutoff)
|
||||
if err != nil {
|
||||
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.
|
||||
mimes, err := repo.GetMimeByHash()
|
||||
mimes, err := repo.GetMimeByHash(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,18 +18,20 @@ type flakyGetArtworkRepo struct {
|
|||
*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")
|
||||
}
|
||||
|
||||
var _ = Describe("Prune", func() {
|
||||
var ctx context.Context
|
||||
var ds *tests.MockDataStore
|
||||
var store *ImageStore
|
||||
var awRepo *tests.MockArtworkRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
ds = &tests.MockDataStore{}
|
||||
awRepo = ds.Artwork(context.Background()).(*tests.MockArtworkRepo)
|
||||
awRepo = ds.Artwork().(*tests.MockArtworkRepo)
|
||||
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() {
|
||||
Expect(awRepo.PutItemArtwork(&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(&model.ItemArtwork{ItemKind: "ar", ItemID: "live-artist", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||
Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||
Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "gone-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{
|
||||
"al": {},
|
||||
"ar": {"live-artist": true},
|
||||
|
|
@ -51,17 +53,17 @@ var _ = Describe("Prune", func() {
|
|||
|
||||
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))
|
||||
_, 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))
|
||||
_, err = awRepo.GetItemArtwork(model.KindArtistArtwork, "live-artist", model.ImageTypePrimary)
|
||||
_, err = awRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "live-artist", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("purges dangling artwork_queue rows for gone entities", func() {
|
||||
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: "live-album", ImageType: model.ImageTypePrimary},
|
||||
)).To(Succeed())
|
||||
|
|
@ -80,17 +82,17 @@ var _ = Describe("Prune", func() {
|
|||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
Expect(os.Chtimes(store.path(h, "image/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)
|
||||
|
||||
kept := []byte("kept-bytes")
|
||||
hk, _ := hashImage(bytes.NewReader(kept))
|
||||
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())
|
||||
|
||||
_, err := awRepo.GetImage(h)
|
||||
_, err := awRepo.GetImage(ctx, h)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
_, err = store.Open(h, "image/jpeg")
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
|
|
@ -103,14 +105,14 @@ var _ = Describe("Prune", func() {
|
|||
data := []byte("reacquired-bytes")
|
||||
h, _ := hashImage(bytes.NewReader(data))
|
||||
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))
|
||||
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())
|
||||
|
||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||
|
||||
_, err := awRepo.GetImage(h)
|
||||
_, err := awRepo.GetImage(ctx, h)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -122,11 +124,11 @@ var _ = Describe("Prune", func() {
|
|||
h, _ := hashImage(bytes.NewReader(data))
|
||||
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.
|
||||
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())
|
||||
|
||||
_, err := awRepo.GetImage(h)
|
||||
_, err := awRepo.GetImage(ctx, h)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -137,7 +139,7 @@ var _ = Describe("Prune", func() {
|
|||
data := []byte("racing-bytes")
|
||||
h, _ := hashImage(bytes.NewReader(data))
|
||||
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))
|
||||
// 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/jpeg"), old, old)).To(Succeed())
|
||||
// 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())
|
||||
|
||||
|
|
@ -192,14 +194,14 @@ var _ = Describe("Prune", func() {
|
|||
hb, _ := hashImage(bytes.NewReader(blocked))
|
||||
Expect(store.Write(hb, "image/jpeg", bytes.NewReader(blocked))).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)
|
||||
|
||||
good := []byte("good-bytes")
|
||||
hg, _ := hashImage(bytes.NewReader(good))
|
||||
Expect(store.Write(hg, "image/jpeg", bytes.NewReader(good))).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)
|
||||
|
||||
// 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())
|
||||
|
||||
_, err := awRepo.GetImage(hg)
|
||||
_, err := awRepo.GetImage(ctx, hg)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
_, err = store.Open(hg, "image/jpeg")
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
|
||||
// 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))
|
||||
rc, err := store.Open(hb, "image/jpeg")
|
||||
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.
|
||||
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 {
|
||||
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.
|
||||
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 {
|
||||
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.
|
||||
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 {
|
||||
return resolution{}, err
|
||||
}
|
||||
|
|
@ -328,7 +328,7 @@ const PlaylistGridSamples = 4
|
|||
|
||||
// resolvePlaylist tries the uploaded image, the sidecar and ExternalImageURL, then a generated grid.
|
||||
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 {
|
||||
return resolution{}, err
|
||||
}
|
||||
|
|
@ -374,8 +374,8 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
|
|||
}
|
||||
}
|
||||
|
||||
albumIDs, err := r.ds.Playlist(ctx).Tracks(pl.ID, false).
|
||||
GetAlbumIDs(model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
|
||||
albumIDs, err := r.ds.Playlist().Tracks(ctx, pl.ID, false).
|
||||
GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
|
|
@ -428,7 +428,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
|
|||
|
||||
// resolveRadio serves only an uploaded image; there is no fallback.
|
||||
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 {
|
||||
return resolution{}, err
|
||||
}
|
||||
|
|
@ -439,7 +439,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
|
||||
// is a definitive absent.
|
||||
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 {
|
||||
return resolution{}, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,12 +16,14 @@ import (
|
|||
)
|
||||
|
||||
var _ = Describe("Uploader", func() {
|
||||
var ctx context.Context
|
||||
var svc Uploader
|
||||
var tmpDir string
|
||||
var artRepo *tests.MockArtworkRepo
|
||||
var queueRepo *tests.MockArtworkQueueRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tmpDir = GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
||||
|
|
@ -33,7 +35,6 @@ var _ = Describe("Uploader", func() {
|
|||
|
||||
Describe("SetImage", func() {
|
||||
It("creates directory and saves image file", func() {
|
||||
ctx := context.Background()
|
||||
reader := strings.NewReader("fake image data")
|
||||
filename, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", reader, ".jpg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -46,7 +47,6 @@ var _ = Describe("Uploader", func() {
|
|||
})
|
||||
|
||||
It("falls back to ID-only filename when name cleans to empty", func() {
|
||||
ctx := context.Background()
|
||||
reader := strings.NewReader("data")
|
||||
filename, err := svc.SetImage(ctx, consts.EntityPlaylist, "pl-1", "!!!", "", reader, ".png")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -54,7 +54,6 @@ var _ = Describe("Uploader", func() {
|
|||
})
|
||||
|
||||
It("removes old image when replacing", func() {
|
||||
ctx := context.Background()
|
||||
oldDir := filepath.Join(tmpDir, "artwork", "artist")
|
||||
Expect(os.MkdirAll(oldDir, 0755)).To(Succeed())
|
||||
oldFile := filepath.Join(oldDir, "ar-1_old.png")
|
||||
|
|
@ -70,15 +69,13 @@ var _ = Describe("Uploader", func() {
|
|||
})
|
||||
|
||||
It("ignores missing old file without error", func() {
|
||||
ctx := context.Background()
|
||||
reader := strings.NewReader("data")
|
||||
_, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Name", "/nonexistent/path.jpg", reader, ".jpg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("does not touch artwork state or the queue (that is EnqueueArtwork's job, post-Put)", func() {
|
||||
ctx := context.Background()
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||
ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external",
|
||||
})).To(Succeed())
|
||||
|
||||
|
|
@ -87,25 +84,24 @@ var _ = Describe("Uploader", func() {
|
|||
|
||||
// SetImage only writes the file; the state row survives and nothing is queued until
|
||||
// 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(queueRepo.DequeueBatch(1000)).To(BeEmpty())
|
||||
Expect(queueRepo.DequeueBatch(ctx, 1000)).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("EnqueueArtwork", func() {
|
||||
It("clears artwork state and enqueues a Bump", func() {
|
||||
ctx := context.Background()
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||
ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external",
|
||||
})).To(Succeed())
|
||||
|
||||
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))
|
||||
|
||||
queued, err := queueRepo.DequeueBatch(1000)
|
||||
queued, err := queueRepo.DequeueBatch(ctx, 1000)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(queued).To(ContainElement(SatisfyAll(
|
||||
HaveField("ItemKind", "ar"),
|
||||
|
|
@ -116,13 +112,12 @@ var _ = Describe("Uploader", func() {
|
|||
|
||||
It("is a no-op for an unknown entity type", func() {
|
||||
svc.EnqueueArtwork(context.Background(), "unknown", "x-1")
|
||||
Expect(queueRepo.DequeueBatch(1000)).To(BeEmpty())
|
||||
Expect(queueRepo.DequeueBatch(ctx, 1000)).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RemoveImage", func() {
|
||||
It("removes the file at the given path", func() {
|
||||
ctx := context.Background()
|
||||
dir := filepath.Join(tmpDir, "artwork", "artist")
|
||||
Expect(os.MkdirAll(dir, 0755)).To(Succeed())
|
||||
path := filepath.Join(dir, "ar-1_test.jpg")
|
||||
|
|
@ -134,13 +129,11 @@ var _ = Describe("Uploader", func() {
|
|||
})
|
||||
|
||||
It("succeeds when file does not exist", func() {
|
||||
ctx := context.Background()
|
||||
err := svc.RemoveImage(ctx, "/nonexistent/file.jpg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("succeeds with empty path", func() {
|
||||
ctx := context.Background()
|
||||
err := svc.RemoveImage(ctx, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ type Worker struct {
|
|||
broker events.Broker
|
||||
pruneMu sync.RWMutex
|
||||
pools []*drainPool
|
||||
runCtx context.Context
|
||||
runCtx context.Context //nolint:containedctx // worker lifecycle ctx, set at Run
|
||||
paused func() bool
|
||||
|
||||
gatesMu sync.Mutex
|
||||
|
|
@ -155,7 +155,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.
|
||||
// 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 {
|
||||
return 0, err
|
||||
}
|
||||
|
|
@ -246,12 +246,12 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
|||
ctx = withTrace(ctx, trace)
|
||||
out, got, retryIn := w.proc.acquire(ctx, item)
|
||||
|
||||
queue := w.proc.ds.ArtworkQueue(ctx)
|
||||
queue := w.proc.ds.ArtworkQueue()
|
||||
switch out {
|
||||
case outcomeFound, outcomeAbsent:
|
||||
// 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.
|
||||
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)
|
||||
}
|
||||
case outcomeFoundStale, outcomeFailed:
|
||||
|
|
@ -260,7 +260,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
|||
if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) {
|
||||
// A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its
|
||||
// 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.Debug(ctx, "Artwork: Rescheduled item", "kind", item.ItemKind, "id", item.ItemID,
|
||||
|
|
@ -271,7 +271,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
|||
// Art already being served is kept: exhaustion means unreachable, not removed.
|
||||
settled := "kept previous state"
|
||||
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"
|
||||
}
|
||||
// The queue row is about to go, taking the only record of the failure with it. This write is
|
||||
|
|
@ -279,7 +279,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
|||
w.recordGiveUp(ctx, item, encoded)
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -293,7 +293,7 @@ func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem,
|
|||
if !ok {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -303,7 +303,7 @@ func (w *Worker) hasResolvedArtwork(ctx context.Context, item model.ArtworkQueue
|
|||
if !ok {
|
||||
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 != ""
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ import (
|
|||
const soakCycles = 2200
|
||||
|
||||
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() {
|
||||
if testing.Short() {
|
||||
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.
|
||||
if out == outcomeFound {
|
||||
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)
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred(), "cycle %d: GetImage", i)
|
||||
rc, err := store.Open(ia.Hash, art.Mime)
|
||||
switch {
|
||||
|
|
|
|||
|
|
@ -56,8 +56,8 @@ type reenqueueOnDequeue struct {
|
|||
done bool
|
||||
}
|
||||
|
||||
func (r *reenqueueOnDequeue) DequeueBatch(n int, kinds ...string) ([]model.ArtworkQueueItem, error) {
|
||||
items, err := r.MockArtworkQueueRepo.DequeueBatch(n, kinds...)
|
||||
func (r *reenqueueOnDequeue) DequeueBatch(ctx context.Context, n int, kinds ...string) ([]model.ArtworkQueueItem, error) {
|
||||
items, err := r.MockArtworkQueueRepo.DequeueBatch(ctx, n, kinds...)
|
||||
if !r.done && len(items) > 0 {
|
||||
r.done = true
|
||||
for k, it := range r.Data {
|
||||
|
|
@ -124,18 +124,27 @@ type visibilityPlaylistDS struct {
|
|||
tracks model.PlaylistTrackRepository
|
||||
}
|
||||
|
||||
func (v *visibilityPlaylistDS) Playlist(ctx context.Context) model.PlaylistRepository {
|
||||
func (v *visibilityPlaylistDS) Playlist() model.PlaylistRepository {
|
||||
repo := tests.CreateMockPlaylistRepo()
|
||||
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)
|
||||
}
|
||||
|
||||
func adminUserRepo() *tests.MockedUserRepo {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -157,8 +166,8 @@ var _ = Describe("Worker", func() {
|
|||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = context.Background()
|
||||
var err error
|
||||
repoRoot, err = os.Getwd()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -200,7 +209,7 @@ var _ = Describe("Worker", func() {
|
|||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{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,
|
||||
})).To(Succeed())
|
||||
|
||||
|
|
@ -208,11 +217,11 @@ var _ = Describe("Worker", func() {
|
|||
Expect(err).ToNot(HaveOccurred())
|
||||
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(ia.Source).To(Equal("folder"))
|
||||
|
||||
count, err := queueRepo.Count()
|
||||
count, err := queueRepo.Count(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeZero(), "a found item must be deleted from the queue")
|
||||
})
|
||||
|
|
@ -223,7 +232,7 @@ var _ = Describe("Worker", func() {
|
|||
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
{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,
|
||||
})).To(Succeed())
|
||||
|
||||
|
|
@ -231,12 +240,12 @@ var _ = Describe("Worker", func() {
|
|||
Expect(err).ToNot(HaveOccurred())
|
||||
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(ia.Source).To(Equal("embedded"))
|
||||
Expect(ia.Hash).ToNot(BeEmpty())
|
||||
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
r, err := store.Open(ia.Hash, art.Mime)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -245,7 +254,7 @@ var _ = Describe("Worker", func() {
|
|||
Expect(err).ToNot(HaveOccurred())
|
||||
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(count).To(BeZero())
|
||||
})
|
||||
|
|
@ -254,7 +263,7 @@ var _ = Describe("Worker", func() {
|
|||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al4", Name: "Album"}})
|
||||
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)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -265,7 +274,7 @@ var _ = Describe("Worker", func() {
|
|||
Expect(it.Attempts).To(Equal(1))
|
||||
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")
|
||||
})
|
||||
|
||||
|
|
@ -275,7 +284,7 @@ var _ = Describe("Worker", func() {
|
|||
// Well above backoff(0)'s jittered ceiling, so only the hint can produce this retry_at.
|
||||
const askedFor = 90 * time.Minute
|
||||
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)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -296,7 +305,7 @@ var _ = Describe("Worker", func() {
|
|||
{ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
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)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -307,7 +316,7 @@ var _ = Describe("Worker", func() {
|
|||
Expect(it.Attempts).To(Equal(1))
|
||||
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(ia.Source).To(Equal("folder"), "the fallback art is served meanwhile")
|
||||
|
||||
|
|
@ -327,7 +336,7 @@ var _ = Describe("Worker", func() {
|
|||
racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo}
|
||||
ds.MockedArtworkQueue = racing
|
||||
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,
|
||||
})).To(Succeed())
|
||||
|
||||
|
|
@ -337,7 +346,7 @@ var _ = Describe("Worker", func() {
|
|||
|
||||
// The concurrent re-enqueue changed retry_at, so the found-path delete was a no-op.
|
||||
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(ia.Source).To(Equal("folder"))
|
||||
})
|
||||
|
|
@ -349,7 +358,7 @@ var _ = Describe("Worker", func() {
|
|||
racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo}
|
||||
ds.MockedArtworkQueue = racing
|
||||
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
|
||||
|
||||
n, err := w.drain(ctx, 1)
|
||||
|
|
@ -368,7 +377,7 @@ var _ = Describe("Worker", func() {
|
|||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
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.
|
||||
expireQueued(queueRepo, "al9")
|
||||
|
||||
|
|
@ -377,7 +386,7 @@ var _ = Describe("Worker", func() {
|
|||
Expect(n).To(Equal(1))
|
||||
|
||||
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(ia.Hash).To(BeEmpty())
|
||||
})
|
||||
|
|
@ -385,13 +394,13 @@ var _ = Describe("Worker", func() {
|
|||
It("keeps already-served art when the retry budget is exhausted", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
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,
|
||||
Hash: "cafebabe", Source: "external:lastfm",
|
||||
})).To(Succeed())
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
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")
|
||||
|
||||
n, err := w.drain(ctx, 1)
|
||||
|
|
@ -399,7 +408,7 @@ var _ = Describe("Worker", func() {
|
|||
Expect(n).To(Equal(1))
|
||||
|
||||
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(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art")
|
||||
})
|
||||
|
|
@ -409,7 +418,7 @@ var _ = Describe("Worker", func() {
|
|||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al11", Name: "Album"}})
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
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)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -430,13 +439,13 @@ var _ = Describe("Worker", func() {
|
|||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al13", Name: "Album"}})
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
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")
|
||||
|
||||
_, err := w.drain(ctx, 1)
|
||||
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(ia.Hash).To(BeEmpty())
|
||||
Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty())
|
||||
|
|
@ -445,20 +454,20 @@ var _ = Describe("Worker", func() {
|
|||
It("keeps the failure on the state row after the queue row is deleted", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
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,
|
||||
Hash: "cafebabe", Source: "external:lastfm",
|
||||
})).To(Succeed())
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
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")
|
||||
|
||||
_, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
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(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty(),
|
||||
"the queue row is gone, so this is the only remaining record of the failure")
|
||||
|
|
@ -473,7 +482,7 @@ var _ = Describe("Worker", func() {
|
|||
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
{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")
|
||||
|
||||
n, err := w.drain(ctx, 1)
|
||||
|
|
@ -481,7 +490,7 @@ var _ = Describe("Worker", func() {
|
|||
Expect(n).To(Equal(1))
|
||||
|
||||
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),
|
||||
"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.
|
||||
|
|
@ -496,14 +505,14 @@ var _ = Describe("Worker", func() {
|
|||
tracks: &tests.MockPlaylistTrackRepo{},
|
||||
}
|
||||
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)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(1))
|
||||
|
||||
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(ia.Hash).To(BeEmpty())
|
||||
})
|
||||
|
|
@ -523,9 +532,9 @@ var _ = Describe("Worker", func() {
|
|||
{ID: "al1", Name: "Album 1", 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(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: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2", 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)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -572,7 +581,7 @@ var _ = Describe("Worker", func() {
|
|||
conf.Server.CoverArtPriority = "cover.*" // local-only; no folder image → absent
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al3", Name: "Artless"}})
|
||||
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)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -582,7 +591,7 @@ var _ = Describe("Worker", func() {
|
|||
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"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).To(BeEmpty(), "the outcome was absent, not found")
|
||||
})
|
||||
|
|
@ -591,7 +600,7 @@ var _ = Describe("Worker", func() {
|
|||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}})
|
||||
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)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -725,7 +734,7 @@ var _ = Describe("Worker", func() {
|
|||
{ID: "alpc", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
conf.Server.UICoverArtSize = 300
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||
ItemKind: "al", ItemID: "alpc", Priority: model.ArtworkPriorityScan,
|
||||
})).To(Succeed())
|
||||
})
|
||||
|
|
@ -813,11 +822,11 @@ var _ = Describe("Worker", func() {
|
|||
|
||||
// Artists first, exactly as Backfill orders them.
|
||||
for _, a := range artists {
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||
ItemKind: "ar", ItemID: a.ID, Priority: model.ArtworkPriorityBackfill,
|
||||
})).To(Succeed())
|
||||
}
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||
ItemKind: "al", ItemID: "alx", Priority: model.ArtworkPriorityBackfill,
|
||||
})).To(Succeed())
|
||||
|
||||
|
|
@ -832,12 +841,12 @@ var _ = Describe("Worker", func() {
|
|||
})
|
||||
|
||||
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 != ""
|
||||
}, 5*time.Second, 50*time.Millisecond).Should(BeTrue(),
|
||||
"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")
|
||||
})
|
||||
})
|
||||
|
|
@ -849,7 +858,7 @@ var _ = Describe("Worker", func() {
|
|||
for i := range 8 {
|
||||
id := fmt.Sprintf("alc%d", i)
|
||||
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,
|
||||
})).To(Succeed())
|
||||
}
|
||||
|
|
@ -875,21 +884,21 @@ var _ = Describe("Worker", func() {
|
|||
for i := range 8 {
|
||||
id := fmt.Sprintf("alp%d", i)
|
||||
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,
|
||||
})).To(Succeed())
|
||||
}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(albums)
|
||||
// Pauses as soon as the first item has left the queue.
|
||||
w.PauseWhile(func() bool {
|
||||
n, _ := queueRepo.Count()
|
||||
n, _ := queueRepo.Count(ctx)
|
||||
return n < 8
|
||||
})
|
||||
|
||||
_, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
count, err := queueRepo.Count()
|
||||
count, err := queueRepo.Count(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(int64(7)), "only the item dispatched before the pause may leave the queue")
|
||||
})
|
||||
|
|
@ -897,7 +906,7 @@ var _ = Describe("Worker", func() {
|
|||
It("dequeues past the worker pool so one drain covers many items", func() {
|
||||
for i := range 16 {
|
||||
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,
|
||||
})).To(Succeed())
|
||||
}
|
||||
|
|
@ -932,7 +941,7 @@ var _ = Describe("Worker", func() {
|
|||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{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,
|
||||
})).To(Succeed())
|
||||
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 {
|
||||
secret, err := ds.Property(ctx).Get(key)
|
||||
secret, err := ds.Property().Get(ctx, key)
|
||||
if err != nil || secret == "" {
|
||||
log.Info(ctx, "Creating new JWT secret", "key", 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 {
|
||||
u, err := ds.User(ctx).FindFirstAdmin()
|
||||
u, err := ds.User().FindFirstAdmin(ctx)
|
||||
if err != nil {
|
||||
c, err := ds.User(ctx).CountAll()
|
||||
c, err := ds.User().CountAll(ctx)
|
||||
if c == 0 && err == nil {
|
||||
log.Debug(ctx, "No admin user yet!", err)
|
||||
} 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)
|
||||
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)
|
||||
}
|
||||
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
|
||||
// TagLib and ffmpeg access files
|
||||
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 {
|
||||
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.
|
||||
func (m *mockArtistRepo) Get(id string) (*model.Artist, error) {
|
||||
func (m *mockArtistRepo) Get(_ context.Context, id string) (*model.Artist, error) {
|
||||
args := m.Called(id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
|
|
@ -40,7 +40,7 @@ func (m *mockArtistRepo) Get(id string) (*model.Artist, error) {
|
|||
}
|
||||
|
||||
// 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))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
|
|
@ -85,7 +85,7 @@ func (m *mockMediaFileRepo) SetData(mediaFiles model.MediaFiles) {
|
|||
}
|
||||
|
||||
// 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)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
|
|
@ -94,12 +94,12 @@ func (m *mockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
|
|||
}
|
||||
|
||||
// GetAllByTags implements model.MediaFileRepository.
|
||||
func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
return m.GetAll(options...)
|
||||
func (m *mockMediaFileRepo) GetAllByTags(ctx context.Context, _ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
return m.GetAll(ctx, options...)
|
||||
}
|
||||
|
||||
// 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))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
|
|
@ -112,7 +112,7 @@ func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFi
|
|||
}
|
||||
|
||||
// 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))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
|
|
@ -156,7 +156,7 @@ func newMockAlbumRepo() *mockAlbumRepo {
|
|||
}
|
||||
|
||||
// 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)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
|
|
@ -165,7 +165,7 @@ func (m *mockAlbumRepo) Get(id string) (*model.Album, error) {
|
|||
}
|
||||
|
||||
// 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))
|
||||
for i, v := range options {
|
||||
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 {
|
||||
log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", albumName,
|
||||
"elapsed", time.Since(start), err)
|
||||
|
|
@ -285,7 +285,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
|
|||
if !throttled {
|
||||
artist.ExternalInfoUpdatedAt = new(time.Now())
|
||||
}
|
||||
err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist)
|
||||
err := e.ds.Artist().UpdateExternalInfo(ctx, &artist.Artist)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName,
|
||||
"elapsed", time.Since(start), err)
|
||||
|
|
@ -548,7 +548,7 @@ func (e *provider) loadArtistsByID(ctx context.Context, similar []agents.Artist)
|
|||
if len(ids) == 0 {
|
||||
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},
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -577,7 +577,7 @@ func (e *provider) loadArtistsByMBID(ctx context.Context, similar []agents.Artis
|
|||
if len(mbids) == 0 {
|
||||
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},
|
||||
})
|
||||
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 {
|
||||
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),
|
||||
})
|
||||
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) {
|
||||
if id != "" {
|
||||
artist, err := e.ds.Artist(ctx).Get(id)
|
||||
artist, err := e.ds.Artist().Get(ctx, id)
|
||||
if err == 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
|
||||
}
|
||||
|
||||
artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
artists, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
|
||||
Filters: squirrel.Like{"artist.name": artistName},
|
||||
Max: 1,
|
||||
})
|
||||
|
|
@ -666,7 +666,7 @@ func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int
|
|||
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},
|
||||
})
|
||||
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)
|
||||
broker = &fakeBroker{}
|
||||
p = external.NewProvider(ds, ag, matcher.New(ds), broker)
|
||||
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
|
||||
mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
||||
mockArtistRepo = ds.Artist().(*tests.MockArtistRepo)
|
||||
mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo)
|
||||
})
|
||||
|
||||
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())
|
||||
|
||||
saved, err := mockArtistRepo.Get("ar-1")
|
||||
saved, err := mockArtistRepo.Get(ctx, "ar-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
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())
|
||||
|
||||
saved, err := mockAlbumRepo.Get("al-1")
|
||||
saved, err := mockAlbumRepo.Get(ctx, "al-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
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) {
|
||||
return nil, err
|
||||
}
|
||||
genre, err := e.ds.Genre(ctx).Get(id)
|
||||
genre, err := e.ds.Genre().Get(ctx, id)
|
||||
if err != nil {
|
||||
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) {
|
||||
// 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.
|
||||
repo := e.ds.Playlist(ctx).Tracks(playlistID, true)
|
||||
repo := e.ds.Playlist().Tracks(ctx, playlistID, true)
|
||||
if repo == nil {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
// 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.
|
||||
tracks, err := repo.GetAll(model.QueryOptions{
|
||||
tracks, err := repo.GetAll(ctx, model.QueryOptions{
|
||||
Sort: "random",
|
||||
Max: n * 4,
|
||||
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
|
||||
// missing files would surface as unplayable entries.
|
||||
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}},
|
||||
Max: n,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
|
|||
ds = new(tests.MockDataStore)
|
||||
ag = new(mockAgents)
|
||||
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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
|
|||
ds = new(tests.MockDataStore)
|
||||
ag = new(mockAgents)
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ type FFmpeg interface {
|
|||
Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error)
|
||||
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
|
||||
ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error)
|
||||
Probe(ctx context.Context, files []string) (string, error)
|
||||
ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error)
|
||||
CmdPath() (string, error)
|
||||
IsAvailable() bool
|
||||
|
|
@ -68,7 +67,6 @@ var ErrAnimatedWebPUnsupported = errors.New("ffmpeg lacks libwebp_anim encoder
|
|||
|
||||
const (
|
||||
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"
|
||||
)
|
||||
|
||||
|
|
@ -149,17 +147,6 @@ func fileExists(path string) error {
|
|||
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) {
|
||||
if _, err := ffmpegCmd(); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -593,20 +580,6 @@ func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
|
|||
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 {
|
||||
split := strings.Fields(cmd)
|
||||
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() {
|
||||
It("returns the correct ffmpeg path", func() {
|
||||
ffmpegPath = "/usr/bin/ffmpeg"
|
||||
args := createProbeCommand(probeCmd, []string{"one.mp3"})
|
||||
Expect(args).To(Equal([]string{"/usr/bin/ffmpeg", "-i", "one.mp3", "-f", "ffmetadata"}))
|
||||
args := createFFmpegCommand("ffmpeg -i %s -f mp3 -", "one.mp3", 0, 0)
|
||||
Expect(args).To(Equal([]string{"/usr/bin/ffmpeg", "-i", "one.mp3", "-f", "mp3", "-"}))
|
||||
})
|
||||
It("returns the correct ffmpeg path with spaces", func() {
|
||||
ffmpegPath = "/usr/bin/with spaces/ffmpeg.exe"
|
||||
args := createProbeCommand(probeCmd, []string{"one.mp3"})
|
||||
Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "ffmetadata"}))
|
||||
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", "mp3", "-"}))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
140
core/library.go
140
core/library.go
|
|
@ -32,25 +32,28 @@ type Library interface {
|
|||
SetUserLibraries(ctx context.Context, userID string, libraryIDs []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 {
|
||||
ds model.DataStore
|
||||
scanner model.Scanner
|
||||
watcher Watcher
|
||||
broker events.Broker
|
||||
pluginManager PluginUnloader
|
||||
ds model.DataStore
|
||||
broker events.Broker
|
||||
repo *libraryRepositoryWrapper
|
||||
}
|
||||
|
||||
// NewLibrary creates a new Library service
|
||||
func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, broker events.Broker, pluginManager PluginUnloader) Library {
|
||||
return &libraryService{
|
||||
ds: ds,
|
||||
scanner: scanner,
|
||||
watcher: watcher,
|
||||
broker: broker,
|
||||
pluginManager: pluginManager,
|
||||
ds: ds,
|
||||
broker: broker,
|
||||
repo: &libraryRepositoryWrapper{
|
||||
LibraryRepository: ds.Library(),
|
||||
ds: ds,
|
||||
scanner: scanner,
|
||||
watcher: watcher,
|
||||
broker: broker,
|
||||
pluginManager: pluginManager,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -58,16 +61,16 @@ func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, brok
|
|||
|
||||
func (s *libraryService) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) {
|
||||
// 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 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 {
|
||||
// Verify user exists
|
||||
user, err := s.ds.User(ctx).Get(userID)
|
||||
user, err := s.ds.User().Get(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -90,7 +93,7 @@ func (s *libraryService) SetUserLibraries(ctx context.Context, userID string, li
|
|||
}
|
||||
|
||||
// Set user libraries
|
||||
err = s.ds.User(ctx).SetUserLibraries(userID, libraryIDs)
|
||||
err = s.ds.User().SetUserLibraries(ctx, userID, libraryIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error setting user libraries: %w", err)
|
||||
}
|
||||
|
|
@ -115,7 +118,7 @@ func (s *libraryService) ValidateLibraryAccess(ctx context.Context, userID strin
|
|||
}
|
||||
|
||||
// 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 {
|
||||
log.Error(ctx, "Error checking library access", "userID", userID, "libraryID", libraryID, err)
|
||||
return fmt.Errorf("error checking library access: %w", err)
|
||||
|
|
@ -132,25 +135,14 @@ func (s *libraryService) ValidateLibraryAccess(ctx context.Context, userID strin
|
|||
|
||||
// REST repository wrapper
|
||||
|
||||
func (s *libraryService) NewRepository(ctx context.Context) rest.Repository {
|
||||
repo := s.ds.Library(ctx)
|
||||
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
|
||||
func (s *libraryService) Repository() rest.Repository[model.Library] {
|
||||
return s.repo
|
||||
}
|
||||
|
||||
var _ rest.Persistable[model.Library] = (*libraryRepositoryWrapper)(nil)
|
||||
|
||||
type libraryRepositoryWrapper struct {
|
||||
rest.Repository
|
||||
model.LibraryRepository
|
||||
ctx context.Context
|
||||
ds model.DataStore
|
||||
scanner model.Scanner
|
||||
watcher Watcher
|
||||
|
|
@ -158,59 +150,58 @@ type libraryRepositoryWrapper struct {
|
|||
pluginManager PluginUnloader
|
||||
}
|
||||
|
||||
func (r *libraryRepositoryWrapper) Save(entity any) (string, error) {
|
||||
lib := entity.(*model.Library)
|
||||
if err := r.validateLibrary(lib); err != nil {
|
||||
func (r *libraryRepositoryWrapper) Save(ctx context.Context, lib *model.Library) (string, error) {
|
||||
if err := r.validateLibrary(ctx, lib); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
err := r.LibraryRepository.Put(lib)
|
||||
err := r.LibraryRepository.Put(ctx, lib)
|
||||
if err != nil {
|
||||
return "", r.mapError(err)
|
||||
}
|
||||
|
||||
// Start watcher and trigger scan after successful library creation
|
||||
if r.watcher != nil {
|
||||
if err := r.watcher.Watch(r.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)
|
||||
if err := r.watcher.Watch(ctx, lib); err != nil {
|
||||
log.Warn(ctx, "Failed to start watcher for new library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if r.scanner != nil {
|
||||
go r.triggerScan(lib, "new")
|
||||
go r.triggerScan(ctx, lib, "new")
|
||||
}
|
||||
|
||||
// Send library refresh event to all clients
|
||||
if r.broker != nil {
|
||||
event := &events.RefreshResource{}
|
||||
r.broker.SendBroadcastMessage(r.ctx, event.With("library", strconv.Itoa(lib.ID)))
|
||||
log.Debug(r.ctx, "Library created - sent refresh event", "libraryID", lib.ID, "name", lib.Name)
|
||||
r.broker.SendBroadcastMessage(ctx, event.With("library", strconv.Itoa(lib.ID)))
|
||||
log.Debug(ctx, "Library created - sent refresh event", "libraryID", lib.ID, "name", lib.Name)
|
||||
}
|
||||
|
||||
return strconv.Itoa(lib.ID), nil
|
||||
}
|
||||
|
||||
func (r *libraryRepositoryWrapper) Update(id string, entity any, cols ...string) error {
|
||||
lib := entity.(*model.Library)
|
||||
func (r *libraryRepositoryWrapper) Update(ctx context.Context, id string, entity model.Library, cols ...string) error {
|
||||
lib := &entity
|
||||
libID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid library ID: %s", id)
|
||||
}
|
||||
|
||||
lib.ID = libID
|
||||
if err := r.validateLibrary(lib); err != nil {
|
||||
if err := r.validateLibrary(ctx, lib); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the original library to check if path changed
|
||||
originalLib, err := r.Get(libID)
|
||||
originalLib, err := r.Get(ctx, libID)
|
||||
if err != nil {
|
||||
return r.mapError(err)
|
||||
}
|
||||
|
||||
pathChanged := originalLib.Path != lib.Path
|
||||
|
||||
err = r.LibraryRepository.Put(lib, cols...)
|
||||
err = r.LibraryRepository.Put(ctx, lib, cols...)
|
||||
if err != nil {
|
||||
return r.mapError(err)
|
||||
}
|
||||
|
|
@ -218,27 +209,36 @@ func (r *libraryRepositoryWrapper) Update(id string, entity any, cols ...string)
|
|||
// Restart watcher and trigger scan if path was updated
|
||||
if pathChanged {
|
||||
if r.watcher != nil {
|
||||
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 err := r.watcher.Watch(ctx, lib); err != nil {
|
||||
log.Warn(ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if r.scanner != nil {
|
||||
go r.triggerScan(lib, "updated")
|
||||
go r.triggerScan(ctx, lib, "updated")
|
||||
}
|
||||
}
|
||||
|
||||
// Send library refresh event to all clients
|
||||
if r.broker != nil {
|
||||
event := &events.RefreshResource{}
|
||||
r.broker.SendBroadcastMessage(r.ctx, event.With("library", id))
|
||||
log.Debug(r.ctx, "Library updated - sent refresh event", "libraryID", libID, "name", lib.Name)
|
||||
r.broker.SendBroadcastMessage(ctx, event.With("library", id))
|
||||
log.Debug(ctx, "Library updated - sent refresh event", "libraryID", libID, "name", lib.Name)
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return &rest.ValidationError{Errors: map[string]string{
|
||||
|
|
@ -247,7 +247,7 @@ func (r *libraryRepositoryWrapper) Delete(id string) error {
|
|||
}
|
||||
|
||||
// Get library info before deletion for logging
|
||||
lib, err := r.Get(libID)
|
||||
lib, err := r.Get(ctx, libID)
|
||||
if err != nil {
|
||||
return r.mapError(err)
|
||||
}
|
||||
|
|
@ -255,7 +255,7 @@ func (r *libraryRepositoryWrapper) Delete(id string) error {
|
|||
// Run the deletion in a transaction so the cascade delete and the orphaned-artist
|
||||
// reconciliation it triggers (see libraryRepository.Delete) commit atomically.
|
||||
err = r.ds.WithTx(func(tx model.DataStore) error {
|
||||
return tx.Library(r.ctx).Delete(libID)
|
||||
return tx.Library().Delete(ctx, libID)
|
||||
}, "delete library")
|
||||
if err != nil {
|
||||
return r.mapError(err)
|
||||
|
|
@ -263,25 +263,25 @@ func (r *libraryRepositoryWrapper) Delete(id string) error {
|
|||
|
||||
// Stop watcher and trigger scan after successful library deletion to clean up orphaned data
|
||||
if r.watcher != nil {
|
||||
if err := r.watcher.StopWatching(r.ctx, libID); err != nil {
|
||||
log.Warn(r.ctx, "Failed to stop watcher for deleted library", "libraryID", libID, "name", lib.Name, "path", lib.Path, err)
|
||||
if err := r.watcher.StopWatching(ctx, libID); err != nil {
|
||||
log.Warn(ctx, "Failed to stop watcher for deleted library", "libraryID", libID, "name", lib.Name, "path", lib.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if r.scanner != nil {
|
||||
go r.triggerScan(lib, "deleted")
|
||||
go r.triggerScan(ctx, lib, "deleted")
|
||||
}
|
||||
|
||||
// Send library refresh event to all clients
|
||||
if r.broker != nil {
|
||||
event := &events.RefreshResource{}
|
||||
r.broker.SendBroadcastMessage(r.ctx, event.With("library", id))
|
||||
log.Debug(r.ctx, "Library deleted - sent refresh event", "libraryID", libID, "name", lib.Name)
|
||||
r.broker.SendBroadcastMessage(ctx, event.With("library", id))
|
||||
log.Debug(ctx, "Library deleted - sent refresh event", "libraryID", libID, "name", lib.Name)
|
||||
}
|
||||
|
||||
// After successful deletion, check if any plugins were auto-disabled
|
||||
// and need to be unloaded from memory
|
||||
r.pluginManager.UnloadDisabledPlugins(r.ctx)
|
||||
r.pluginManager.UnloadDisabledPlugins(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -309,7 +309,7 @@ func (r *libraryRepositoryWrapper) mapError(err error) error {
|
|||
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)
|
||||
|
||||
if library.Name == "" {
|
||||
|
|
@ -320,7 +320,7 @@ func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error
|
|||
validationErrors["path"] = "ra.validation.required"
|
||||
} else {
|
||||
// Validate path format and accessibility
|
||||
if err := r.validateLibraryPath(library); err != nil {
|
||||
if err := r.validateLibraryPath(ctx, library); err != nil {
|
||||
validationErrors["path"] = err.Error()
|
||||
}
|
||||
}
|
||||
|
|
@ -332,7 +332,7 @@ func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error
|
|||
return nil
|
||||
}
|
||||
|
||||
func (r *libraryRepositoryWrapper) validateLibraryPath(library *model.Library) error {
|
||||
func (r *libraryRepositoryWrapper) validateLibraryPath(ctx context.Context, library *model.Library) error {
|
||||
// Validate path format
|
||||
if !filepath.IsAbs(library.Path) {
|
||||
return fmt.Errorf("library path must be absolute")
|
||||
|
|
@ -350,7 +350,7 @@ func (r *libraryRepositoryWrapper) validateLibraryPath(library *model.Library) e
|
|||
|
||||
fsys, err := fileStore.FS()
|
||||
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")
|
||||
}
|
||||
|
||||
|
|
@ -358,7 +358,7 @@ func (r *libraryRepositoryWrapper) validateLibraryPath(library *model.Library) e
|
|||
info, err := fs.Stat(fsys, ".")
|
||||
if err != nil {
|
||||
// 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()
|
||||
if strings.Contains(errStr, "not a directory") ||
|
||||
strings.Contains(errStr, "The directory name is invalid.") {
|
||||
|
|
@ -385,7 +385,7 @@ func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []in
|
|||
}
|
||||
|
||||
// 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},
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -399,13 +399,13 @@ func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []in
|
|||
return nil
|
||||
}
|
||||
|
||||
func (r *libraryRepositoryWrapper) triggerScan(lib *model.Library, action string) {
|
||||
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) {
|
||||
log.Info(ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path)
|
||||
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 for new library
|
||||
if err != nil {
|
||||
log.Error(r.ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err)
|
||||
log.Error(ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err)
|
||||
} 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() {
|
||||
var repo rest.Persistable
|
||||
var repo rest.Persistable[model.Library]
|
||||
|
||||
BeforeEach(func() {
|
||||
r := service.NewRepository(ctx)
|
||||
repo = r.(rest.Persistable)
|
||||
r := service.Repository()
|
||||
repo = r.(rest.Persistable[model.Library])
|
||||
})
|
||||
|
||||
Describe("Create", func() {
|
||||
It("creates a new library successfully", func() {
|
||||
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
_, err := repo.Save(ctx, library)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
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() {
|
||||
library := &model.Library{Path: tempDir}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
_, err := repo.Save(ctx, library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
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() {
|
||||
library := &model.Library{Name: "Test"}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
_, err := repo.Save(ctx, library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
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() {
|
||||
library := &model.Library{Name: "Test", Path: "relative/path"}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
_, err := repo.Save(ctx, library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
var validationErr *rest.ValidationError
|
||||
|
|
@ -140,7 +140,7 @@ var _ = Describe("Library Service", func() {
|
|||
return errors.New("UNIQUE constraint failed: library.name")
|
||||
}
|
||||
|
||||
_, err = repo.Save(library)
|
||||
_, err = repo.Save(ctx, library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
var validationErr *rest.ValidationError
|
||||
|
|
@ -157,7 +157,7 @@ var _ = Describe("Library Service", func() {
|
|||
return errors.New("UNIQUE constraint failed: library.path")
|
||||
}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
_, err := repo.Save(ctx, library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
var validationErr *rest.ValidationError
|
||||
|
|
@ -181,7 +181,7 @@ var _ = Describe("Library Service", func() {
|
|||
|
||||
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(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() {
|
||||
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(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}
|
||||
|
||||
err = repo.Update("999", library)
|
||||
err = repo.Update(ctx, "999", *library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
|
|
@ -214,7 +214,7 @@ var _ = Describe("Library Service", func() {
|
|||
It("fails when library name is empty", func() {
|
||||
library := &model.Library{ID: 1, Path: tempDir}
|
||||
|
||||
err := repo.Update("1", library)
|
||||
err := repo.Update(ctx, "1", *library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("ra.validation.required"))
|
||||
|
|
@ -224,7 +224,7 @@ var _ = Describe("Library Service", func() {
|
|||
unnormalizedPath := tempDir + "//../" + filepath.Base(tempDir)
|
||||
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(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)
|
||||
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())
|
||||
})
|
||||
|
|
@ -253,7 +253,7 @@ var _ = Describe("Library Service", func() {
|
|||
// Update the library keeping the same path (should be allowed)
|
||||
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())
|
||||
})
|
||||
|
|
@ -284,7 +284,7 @@ var _ = Describe("Library Service", func() {
|
|||
// Try to update library 2 to have the same name as library 1
|
||||
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())
|
||||
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
|
||||
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())
|
||||
var validationErr *rest.ValidationError
|
||||
|
|
@ -327,7 +327,7 @@ var _ = Describe("Library Service", func() {
|
|||
It("fails when path is not absolute", func() {
|
||||
library := &model.Library{Name: "Test", Path: "relative/path"}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
_, err := repo.Save(ctx, library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
var validationErr *rest.ValidationError
|
||||
|
|
@ -339,7 +339,7 @@ var _ = Describe("Library Service", func() {
|
|||
nonExistentPath := filepath.Join(tempDir, "nonexistent")
|
||||
library := &model.Library{Name: "Test", Path: nonExistentPath}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
_, err := repo.Save(ctx, library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
var validationErr *rest.ValidationError
|
||||
|
|
@ -354,7 +354,7 @@ var _ = Describe("Library Service", func() {
|
|||
|
||||
library := &model.Library{Name: "Test", Path: testFile}
|
||||
|
||||
_, err = repo.Save(library)
|
||||
_, err = repo.Save(ctx, library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
var validationErr *rest.ValidationError
|
||||
|
|
@ -371,7 +371,7 @@ var _ = Describe("Library Service", func() {
|
|||
It("handles multiple validation errors", func() {
|
||||
library := &model.Library{Name: "", Path: "relative/path"}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
_, err := repo.Save(ctx, library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
var validationErr *rest.ValidationError
|
||||
|
|
@ -393,7 +393,7 @@ var _ = Describe("Library Service", func() {
|
|||
It("fails when updated path is not absolute", func() {
|
||||
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())
|
||||
var validationErr *rest.ValidationError
|
||||
|
|
@ -410,7 +410,7 @@ var _ = Describe("Library Service", func() {
|
|||
// Update the library keeping the same name (should be allowed)
|
||||
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())
|
||||
})
|
||||
|
|
@ -419,7 +419,7 @@ var _ = Describe("Library Service", func() {
|
|||
nonExistentPath := filepath.Join(tempDir, "nonexistent")
|
||||
library := &model.Library{ID: 1, Name: "Test", Path: nonExistentPath}
|
||||
|
||||
err := repo.Update("1", library)
|
||||
err := repo.Update(ctx, "1", *library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
var validationErr *rest.ValidationError
|
||||
|
|
@ -434,7 +434,7 @@ var _ = Describe("Library Service", func() {
|
|||
|
||||
library := &model.Library{ID: 1, Name: "Test", Path: testFile}
|
||||
|
||||
err = repo.Update("1", library)
|
||||
err = repo.Update(ctx, "1", *library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
var validationErr *rest.ValidationError
|
||||
|
|
@ -446,7 +446,7 @@ var _ = Describe("Library Service", func() {
|
|||
// Try to update with empty name and invalid 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())
|
||||
var validationErr *rest.ValidationError
|
||||
|
|
@ -467,14 +467,14 @@ var _ = Describe("Library Service", func() {
|
|||
})
|
||||
|
||||
It("deletes an existing library successfully", func() {
|
||||
err := repo.Delete("1")
|
||||
err := repo.Delete(ctx, "1")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(libraryRepo.Data).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("fails when library doesn't exist", func() {
|
||||
err := repo.Delete("999")
|
||||
err := repo.Delete(ctx, "999")
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
|
|
@ -613,17 +613,17 @@ var _ = Describe("Library Service", func() {
|
|||
})
|
||||
|
||||
Describe("Scan Triggering", func() {
|
||||
var repo rest.Persistable
|
||||
var repo rest.Persistable[model.Library]
|
||||
|
||||
BeforeEach(func() {
|
||||
r := service.NewRepository(ctx)
|
||||
repo = r.(rest.Persistable)
|
||||
r := service.Repository()
|
||||
repo = r.(rest.Persistable[model.Library])
|
||||
})
|
||||
|
||||
It("triggers scan when creating a new library", func() {
|
||||
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
_, err := repo.Save(ctx, library)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Wait briefly for the goroutine to complete
|
||||
|
|
@ -649,7 +649,7 @@ var _ = Describe("Library Service", func() {
|
|||
|
||||
// Update the library with a new path
|
||||
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())
|
||||
|
||||
// Wait briefly for the goroutine to complete
|
||||
|
|
@ -670,7 +670,7 @@ var _ = Describe("Library Service", func() {
|
|||
|
||||
// Update the library name only (same path)
|
||||
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())
|
||||
|
||||
// Wait a bit to ensure no scan was triggered
|
||||
|
|
@ -683,7 +683,7 @@ var _ = Describe("Library Service", func() {
|
|||
// Try to create library with invalid data (empty name)
|
||||
library := &model.Library{Path: tempDir}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
_, err := repo.Save(ctx, library)
|
||||
Expect(err).To(HaveOccurred())
|
||||
|
||||
// Ensure no scan was triggered since creation failed
|
||||
|
|
@ -700,7 +700,7 @@ var _ = Describe("Library Service", func() {
|
|||
|
||||
// Try to update with invalid data (empty name)
|
||||
library := &model.Library{ID: 1, Name: "", Path: tempDir}
|
||||
err := repo.Update("1", library)
|
||||
err := repo.Update(ctx, "1", *library)
|
||||
Expect(err).To(HaveOccurred())
|
||||
|
||||
// Ensure no scan was triggered since update failed
|
||||
|
|
@ -716,7 +716,7 @@ var _ = Describe("Library Service", func() {
|
|||
})
|
||||
|
||||
// Delete the library
|
||||
err := repo.Delete("1")
|
||||
err := repo.Delete(ctx, "1")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Wait briefly for the goroutine to complete
|
||||
|
|
@ -731,7 +731,7 @@ var _ = Describe("Library Service", func() {
|
|||
|
||||
It("does not trigger scan when library deletion fails", func() {
|
||||
// Try to delete a non-existent library
|
||||
err := repo.Delete("999")
|
||||
err := repo.Delete(ctx, "999")
|
||||
Expect(err).To(HaveOccurred())
|
||||
|
||||
// Ensure no scan was triggered since deletion failed
|
||||
|
|
@ -744,7 +744,7 @@ var _ = Describe("Library Service", func() {
|
|||
It("starts watcher when creating a new library", func() {
|
||||
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
_, err := repo.Save(ctx, library)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Verify watcher was started
|
||||
|
|
@ -773,7 +773,7 @@ var _ = Describe("Library Service", func() {
|
|||
|
||||
// Update library with new path
|
||||
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())
|
||||
|
||||
// Verify watcher was restarted
|
||||
|
|
@ -793,7 +793,7 @@ var _ = Describe("Library Service", func() {
|
|||
|
||||
// Update library with same path but different name
|
||||
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())
|
||||
|
||||
// Verify watcher was NOT restarted (since path didn't change)
|
||||
|
|
@ -808,7 +808,7 @@ var _ = Describe("Library Service", func() {
|
|||
{ID: 1, Name: "Test Library", Path: tempDir},
|
||||
})
|
||||
|
||||
err := repo.Delete("1")
|
||||
err := repo.Delete(ctx, "1")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Verify watcher was stopped
|
||||
|
|
@ -826,7 +826,7 @@ var _ = Describe("Library Service", func() {
|
|||
})
|
||||
|
||||
// Mock deletion to fail by trying to delete non-existent library
|
||||
err := repo.Delete("999")
|
||||
err := repo.Delete(ctx, "999")
|
||||
Expect(err).To(HaveOccurred())
|
||||
|
||||
// Verify watcher was NOT stopped since deletion failed
|
||||
|
|
@ -838,11 +838,11 @@ var _ = Describe("Library Service", func() {
|
|||
})
|
||||
|
||||
Describe("Event Broadcasting", func() {
|
||||
var repo rest.Persistable
|
||||
var repo rest.Persistable[model.Library]
|
||||
|
||||
BeforeEach(func() {
|
||||
r := service.NewRepository(ctx)
|
||||
repo = r.(rest.Persistable)
|
||||
r := service.Repository()
|
||||
repo = r.(rest.Persistable[model.Library])
|
||||
// Clear any events from broker
|
||||
broker.Events = []events.Event{}
|
||||
})
|
||||
|
|
@ -850,7 +850,7 @@ var _ = Describe("Library Service", func() {
|
|||
It("sends refresh event when creating a library", func() {
|
||||
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
_, err := repo.Save(ctx, library)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(broker.Events).To(HaveLen(1))
|
||||
|
|
@ -863,7 +863,7 @@ var _ = Describe("Library Service", func() {
|
|||
})
|
||||
|
||||
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(broker.Events).To(HaveLen(1))
|
||||
|
|
@ -875,7 +875,7 @@ var _ = Describe("Library Service", func() {
|
|||
{ID: 2, Name: "Library to Delete", Path: tempDir},
|
||||
})
|
||||
|
||||
err := repo.Delete("2")
|
||||
err := repo.Delete(ctx, "2")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(broker.Events).To(HaveLen(1))
|
||||
|
|
@ -883,13 +883,13 @@ var _ = Describe("Library Service", func() {
|
|||
})
|
||||
|
||||
Describe("Plugin Manager Integration", func() {
|
||||
var repo rest.Persistable
|
||||
var repo rest.Persistable[model.Library]
|
||||
|
||||
BeforeEach(func() {
|
||||
// Reset the call count for each test
|
||||
pluginManager.unloadCalls = 0
|
||||
r := service.NewRepository(ctx)
|
||||
repo = r.(rest.Persistable)
|
||||
r := service.Repository()
|
||||
repo = r.(rest.Persistable[model.Library])
|
||||
})
|
||||
|
||||
It("calls UnloadDisabledPlugins after successful library deletion", func() {
|
||||
|
|
@ -897,14 +897,14 @@ var _ = Describe("Library Service", func() {
|
|||
{ID: 2, Name: "Library to Delete", Path: tempDir},
|
||||
})
|
||||
|
||||
err := repo.Delete("2")
|
||||
err := repo.Delete(ctx, "2")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(pluginManager.unloadCalls).To(Equal(1))
|
||||
})
|
||||
|
||||
It("does not call UnloadDisabledPlugins when library deletion fails", func() {
|
||||
// Try to delete non-existent library
|
||||
err := repo.Delete("999")
|
||||
err := repo.Delete(ctx, "999")
|
||||
Expect(err).To(HaveOccurred())
|
||||
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) {
|
||||
opts := songsByArtistTitleWithLyricsFirst(artist, title)
|
||||
opts.Max = maxLegacyLyricsCandidates
|
||||
mediaFiles, err := l.ds.MediaFile(ctx).GetAll(opts)
|
||||
mediaFiles, err := l.ds.MediaFile().GetAll(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ func (s *maintenanceService) RemapMissingFile(ctx context.Context, missingID, ta
|
|||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
target, err := s.ds.MediaFile(ctx).GetWithParticipants(targetID)
|
||||
target, err := s.ds.MediaFile().GetWithParticipants(ctx, targetID)
|
||||
if err != nil {
|
||||
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"
|
||||
target.CreatedAt = missing.CreatedAt
|
||||
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)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("get old album tracks: %w", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
// 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)
|
||||
}
|
||||
affectedAlbumIDs := []string{newAlbumID}
|
||||
|
|
@ -146,10 +146,10 @@ func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) er
|
|||
// Delete missing files within a transaction
|
||||
err = s.ds.WithTx(func(tx model.DataStore) error {
|
||||
if len(ids) == 0 {
|
||||
_, err := tx.MediaFile(ctx).DeleteAllMissing()
|
||||
_, err := tx.MediaFile().DeleteAllMissing(ctx)
|
||||
return err
|
||||
}
|
||||
return tx.MediaFile(ctx).DeleteMissing(ids)
|
||||
return tx.MediaFile().DeleteMissing(ctx, ids)
|
||||
})
|
||||
if err != nil {
|
||||
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
|
||||
func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []string) error {
|
||||
albumRepo := s.ds.Album(ctx)
|
||||
mfRepo := s.ds.MediaFile(ctx)
|
||||
albumRepo := s.ds.Album()
|
||||
mfRepo := s.ds.MediaFile()
|
||||
|
||||
// Batch load existing albums
|
||||
albums, err := albumRepo.GetAll(model.QueryOptions{
|
||||
albums, err := albumRepo.GetAll(ctx, model.QueryOptions{
|
||||
Filters: squirrel.Eq{"album.id": albumIDs},
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -210,7 +210,7 @@ func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []s
|
|||
}
|
||||
|
||||
// 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},
|
||||
Sort: "album_id, path",
|
||||
})
|
||||
|
|
@ -243,7 +243,7 @@ func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []s
|
|||
newAlbum.UpdatedAt = time.Now()
|
||||
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)
|
||||
// Continue with other albums instead of failing entirely
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -293,7 +293,7 @@ func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbu
|
|||
// Refresh artist stats in background
|
||||
s.wg.Go(func() {
|
||||
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)
|
||||
} else {
|
||||
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())
|
||||
|
||||
got, err := mfRepo.Get("m1")
|
||||
got, err := mfRepo.Get(ctx, "m1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got.Path).To(Equal("new/song.mp3")) // moved to target's location
|
||||
Expect(got.Missing).To(BeFalse())
|
||||
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(ds.GCCalled).To(BeTrue())
|
||||
})
|
||||
|
|
@ -369,14 +369,14 @@ var _ = Describe("Maintenance", func() {
|
|||
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
|
||||
oldAlbum, err := albumRepo.Get("album1")
|
||||
oldAlbum, err := albumRepo.Get(ctx, "album1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(oldAlbum.SongCount).To(Equal(1))
|
||||
Expect(oldAlbum.Size).To(Equal(int64(1000)))
|
||||
Expect(oldAlbum.Duration).To(BeNumerically("==", 100))
|
||||
|
||||
// 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(newAlbum.SongCount).To(Equal(1))
|
||||
Expect(newAlbum.Size).To(Equal(int64(2000)))
|
||||
|
|
@ -407,7 +407,7 @@ var _ = Describe("Maintenance", func() {
|
|||
Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed())
|
||||
|
||||
// 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(got.Participants).To(HaveKeyWithValue(model.RoleArtist, model.ParticipantList{participant}))
|
||||
})
|
||||
|
|
@ -447,7 +447,7 @@ type extendedMediaFileRepo struct {
|
|||
deleteMissingError error
|
||||
}
|
||||
|
||||
func (m *extendedMediaFileRepo) DeleteMissing(ids []string) error {
|
||||
func (m *extendedMediaFileRepo) DeleteMissing(ctx context.Context, ids []string) error {
|
||||
m.deleteMissingCalled = true
|
||||
m.deletedIDs = ids
|
||||
if m.deleteMissingError != nil {
|
||||
|
|
@ -470,7 +470,7 @@ type extendedAlbumRepo struct {
|
|||
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.putCallCount++
|
||||
m.lastPutData = album
|
||||
|
|
@ -490,7 +490,7 @@ func (m *extendedAlbumRepo) Put(album *model.Album) error {
|
|||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
return m.MockAlbumRepo.Put(album)
|
||||
return m.MockAlbumRepo.Put(ctx, album)
|
||||
}
|
||||
|
||||
func (m *extendedAlbumRepo) GetPutCallCount() int {
|
||||
|
|
@ -507,7 +507,7 @@ type extendedArtistRepo struct {
|
|||
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.refreshStatsCalled = true
|
||||
err := m.refreshStatsError
|
||||
|
|
@ -516,7 +516,7 @@ func (m *extendedArtistRepo) RefreshStats(allArtists bool) (int64, error) {
|
|||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return m.MockArtistRepo.RefreshStats(allArtists)
|
||||
return m.MockArtistRepo.RefreshStats(ctx, allArtists)
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
res, err := m.ds.MediaFile().GetAll(ctx, model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"media_file.id": ids},
|
||||
squirrel.Eq{"missing": false},
|
||||
|
|
@ -134,7 +134,7 @@ func (m *Matcher) matchByMBID(ctx context.Context, songs []agents.Song, result m
|
|||
if len(mbids) == 0 {
|
||||
return nil
|
||||
}
|
||||
res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
res, err := m.ds.MediaFile().GetAll(ctx, model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"mbz_recording_id": mbids},
|
||||
squirrel.Eq{"missing": false},
|
||||
|
|
@ -180,7 +180,7 @@ func (m *Matcher) matchByISRC(ctx context.Context, songs []agents.Song, result m
|
|||
if len(isrcs) == 0 {
|
||||
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},
|
||||
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))})
|
||||
}
|
||||
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 {
|
||||
return resolvedArtists{}, err
|
||||
}
|
||||
|
|
@ -543,7 +543,7 @@ func (m *Matcher) fetchTracksCreditedTo(ctx context.Context, artistIDs []string)
|
|||
return nil, nil
|
||||
}
|
||||
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{
|
||||
squirrel.Expr(
|
||||
"media_file.id IN (SELECT media_file_id FROM media_file_artists "+
|
||||
|
|
|
|||
|
|
@ -1267,7 +1267,7 @@ func newMockMediaFileRepo() *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))
|
||||
for i, v := range options {
|
||||
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)
|
||||
}
|
||||
|
||||
func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
return m.GetAll(options...)
|
||||
func (m *mockMediaFileRepo) GetAllByTags(ctx context.Context, _ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
return m.GetAll(ctx, options...)
|
||||
}
|
||||
|
||||
func (m *mockMediaFileRepo) SetError(hasError bool) {
|
||||
|
|
@ -1298,7 +1298,7 @@ func newMockArtistRepo() *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))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
|
|
|
|||
|
|
@ -47,11 +47,11 @@ type insightsCollector struct {
|
|||
|
||||
func GetInstance(ds model.DataStore) Insights {
|
||||
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 {
|
||||
log.Trace("Could not get Insights ID from DB. Creating one", err)
|
||||
id = uuid.NewString()
|
||||
err = ds.Property(context.TODO()).Put(consts.InsightsIDKey, id)
|
||||
err = ds.Property().Put(context.TODO(), consts.InsightsIDKey, id)
|
||||
if err != nil {
|
||||
log.Trace("Could not save Insights ID to DB", err)
|
||||
}
|
||||
|
|
@ -87,7 +87,7 @@ func (c *insightsCollector) LastRun(context.Context) (timestamp time.Time, succe
|
|||
}
|
||||
|
||||
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 {
|
||||
log.Trace(ctx, "Could not check user count", err)
|
||||
return
|
||||
|
|
@ -245,41 +245,41 @@ func (c *insightsCollector) collect(ctx context.Context) []byte {
|
|||
|
||||
// Library info
|
||||
var err error
|
||||
data.Library.Tracks, err = c.ds.MediaFile(ctx).CountAll()
|
||||
data.Library.Tracks, err = c.ds.MediaFile().CountAll(ctx)
|
||||
if err != nil {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
log.Trace(ctx, "Error reading radios count", err)
|
||||
}
|
||||
data.Library.Libraries, err = c.ds.Library(ctx).CountAll()
|
||||
data.Library.Libraries, err = c.ds.Library().CountAll(ctx)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Error reading libraries count", err)
|
||||
}
|
||||
data.Library.ActiveUsers, err = c.ds.User(ctx).CountAll(model.QueryOptions{
|
||||
data.Library.ActiveUsers, err = c.ds.User().CountAll(ctx, model.QueryOptions{
|
||||
Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)},
|
||||
})
|
||||
if err != nil {
|
||||
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 {
|
||||
log.Trace(ctx, "Error reading file suffixes count", err)
|
||||
}
|
||||
|
|
@ -297,7 +297,7 @@ func (c *insightsCollector) collect(ctx context.Context) []byte {
|
|||
|
||||
// Collect active players if permitted
|
||||
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)},
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -324,7 +324,7 @@ func (c *insightsCollector) collect(ctx context.Context) []byte {
|
|||
|
||||
// hasSmartPlaylists checks if there are any smart playlists (playlists with rules)
|
||||
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}},
|
||||
})
|
||||
return count > 0, err
|
||||
|
|
|
|||
|
|
@ -197,28 +197,28 @@ var getPrometheusMetrics = sync.OnceValue(func() *prometheusMetrics {
|
|||
})
|
||||
|
||||
func processSqlAggregateMetrics(ctx context.Context, ds model.DataStore, targetGauge *prometheus.GaugeVec) {
|
||||
albumsCount, err := ds.Album(ctx).CountAll()
|
||||
albumsCount, err := ds.Album().CountAll(ctx)
|
||||
if err != nil {
|
||||
log.Warn("album CountAll error", err)
|
||||
return
|
||||
}
|
||||
targetGauge.With(prometheus.Labels{"model": "album"}).Set(float64(albumsCount))
|
||||
|
||||
artistCount, err := ds.Artist(ctx).CountAll()
|
||||
artistCount, err := ds.Artist().CountAll(ctx)
|
||||
if err != nil {
|
||||
log.Warn("artist CountAll error", err)
|
||||
return
|
||||
}
|
||||
targetGauge.With(prometheus.Labels{"model": "artist"}).Set(float64(artistCount))
|
||||
|
||||
songsCount, err := ds.MediaFile(ctx).CountAll()
|
||||
songsCount, err := ds.MediaFile().CountAll(ctx)
|
||||
if err != nil {
|
||||
log.Warn("media CountAll error", err)
|
||||
return
|
||||
}
|
||||
targetGauge.With(prometheus.Labels{"model": "media"}).Set(float64(songsCount))
|
||||
|
||||
usersCount, err := ds.User(ctx).CountAll()
|
||||
usersCount, err := ds.User().CountAll(ctx)
|
||||
if err != nil {
|
||||
log.Warn("user CountAll error", err)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ type Track interface {
|
|||
}
|
||||
|
||||
type playbackDevice struct {
|
||||
serviceCtx context.Context
|
||||
serviceCtx context.Context //nolint:containedctx // playback service lifecycle ctx
|
||||
ParentPlaybackServer PlaybackServer
|
||||
Default bool
|
||||
User string
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ func (ps *playbackServer) getDefaultDevice() (*playbackDevice, error) {
|
|||
|
||||
// GetMediaFile retrieves the MediaFile given by the id parameter
|
||||
func (ps *playbackServer) GetMediaFile(id string) (*model.MediaFile, error) {
|
||||
return ps.datastore.MediaFile(*ps.ctx).Get(id)
|
||||
return ps.datastore.MediaFile().Get(*ps.ctx, id)
|
||||
}
|
||||
|
||||
// GetDeviceForUser returns the audio playback device for the given user. As of now this is but only the default device.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import (
|
|||
type Players interface {
|
||||
Get(ctx context.Context, playerId string) (*model.Player, error)
|
||||
Register(ctx context.Context, id, client, userAgent, ip string) (*model.Player, *model.Transcoding, error)
|
||||
Touch(ctx context.Context, plr model.Player, client, userAgent, ip string) (*model.Player, *model.Transcoding, error)
|
||||
}
|
||||
|
||||
func NewPlayers(ds model.DataStore) Players {
|
||||
|
|
@ -33,18 +34,17 @@ type players struct {
|
|||
|
||||
func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) {
|
||||
var plr *model.Player
|
||||
var trc *model.Transcoding
|
||||
var err error
|
||||
user, _ := request.UserFrom(ctx)
|
||||
if playerID != "" {
|
||||
plr, err = p.ds.Player(ctx).Get(playerID)
|
||||
plr, err = p.ds.Player().Get(ctx, playerID)
|
||||
if err == nil && (plr.Client != client || plr.UserId != user.ID) {
|
||||
playerID = ""
|
||||
}
|
||||
}
|
||||
username := userName(ctx)
|
||||
if err != nil || playerID == "" {
|
||||
plr, err = p.ds.Player(ctx).FindMatch(user.ID, client, userAgent)
|
||||
plr, err = p.ds.Player().FindMatch(ctx, user.ID, client, userAgent)
|
||||
if err == nil {
|
||||
log.Debug(ctx, "Found matching player", "id", plr.ID, "client", client, "username", username, "type", userAgent)
|
||||
} else {
|
||||
|
|
@ -58,7 +58,21 @@ func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip
|
|||
log.Info(ctx, "Registering new player", "id", plr.ID, "client", client, "username", username, "type", userAgent)
|
||||
}
|
||||
}
|
||||
plr.Name = fmt.Sprintf("%s [%s]", client, userAgent)
|
||||
if !plr.HasAPIKey {
|
||||
plr.Name = fmt.Sprintf("%s [%s]", client, userAgent)
|
||||
}
|
||||
return p.refresh(ctx, plr, userAgent, ip)
|
||||
}
|
||||
|
||||
// Touch refreshes a player that the request already identified (by API key), without guessing or renaming it.
|
||||
func (p *players) Touch(ctx context.Context, plr model.Player, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) {
|
||||
if plr.Client == "" {
|
||||
plr.Client = client
|
||||
}
|
||||
return p.refresh(ctx, &plr, userAgent, ip)
|
||||
}
|
||||
|
||||
func (p *players) refresh(ctx context.Context, plr *model.Player, userAgent, ip string) (*model.Player, *model.Transcoding, error) {
|
||||
plr.UserAgent = userAgent
|
||||
plr.IP = ip
|
||||
plr.LastSeen = time.Now()
|
||||
|
|
@ -66,17 +80,17 @@ func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip
|
|||
ctx, cancel := context.WithTimeout(ctx, time.Second)
|
||||
defer cancel()
|
||||
|
||||
err = p.ds.Player(ctx).Put(plr)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not save player", "id", plr.ID, "client", client, "username", username, "type", userAgent, err)
|
||||
if err := p.ds.Player().Put(ctx, plr); err != nil {
|
||||
log.Warn(ctx, "Could not save player", "id", plr.ID, "client", plr.Client, "username", userName(ctx), "type", plr.UserAgent, err)
|
||||
}
|
||||
})
|
||||
if plr.TranscodingId != "" {
|
||||
trc, err = p.ds.Transcoding(ctx).Get(plr.TranscodingId)
|
||||
if plr.TranscodingId == "" {
|
||||
return plr, nil, nil
|
||||
}
|
||||
trc, err := p.ds.Transcoding().Get(ctx, plr.TranscodingId)
|
||||
return plr, trc, err
|
||||
}
|
||||
|
||||
func (p *players) Get(ctx context.Context, playerId string) (*model.Player, error) {
|
||||
return p.ds.Player(ctx).Get(playerId)
|
||||
return p.ds.Player().Get(ctx, playerId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,6 +114,15 @@ var _ = Describe("Players", func() {
|
|||
Expect(trc.ID).To(Equal("1"))
|
||||
})
|
||||
|
||||
It("does not rename a player that has an API key", func() {
|
||||
plr := &model.Player{ID: "123", Name: "My Phone", Client: "client", UserId: "userid", HasAPIKey: true}
|
||||
repo.add(plr)
|
||||
p, _, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).To(Equal("123"))
|
||||
Expect(p.Name).To(Equal("My Phone"))
|
||||
})
|
||||
|
||||
Context("bad username casing", func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "Johndoe"})
|
||||
|
|
@ -130,6 +139,34 @@ var _ = Describe("Players", func() {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Touch", func() {
|
||||
It("records usage but keeps the name and client", func() {
|
||||
plr := model.Player{ID: "123", Name: "My Phone", Client: "Symfonium", UserId: "userid", HasAPIKey: true}
|
||||
p, trc, err := players.Touch(ctx, plr, "OtherClient", "android", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.Name).To(Equal("My Phone"))
|
||||
Expect(p.Client).To(Equal("Symfonium"))
|
||||
Expect(p.UserAgent).To(Equal("android"))
|
||||
Expect(p.IP).To(Equal("1.2.3.4"))
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
Expect(trc).To(BeNil())
|
||||
})
|
||||
|
||||
It("fills in the client on first use", func() {
|
||||
p, _, err := players.Touch(ctx, model.Player{ID: "123", Name: "Manual", UserId: "userid"}, "Symfonium", "android", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.Client).To(Equal("Symfonium"))
|
||||
})
|
||||
|
||||
It("returns the player's transcoding", func() {
|
||||
p, trc, err := players.Touch(ctx, model.Player{ID: "123", UserId: "userid", TranscodingId: "1"}, "c", "ua", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).To(Equal("123"))
|
||||
Expect(trc.ID).To(Equal("1"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockPlayerRepository struct {
|
||||
|
|
@ -145,14 +182,14 @@ func (m *mockPlayerRepository) add(p *model.Player) {
|
|||
m.data[p.ID] = *p
|
||||
}
|
||||
|
||||
func (m *mockPlayerRepository) Get(id string) (*model.Player, error) {
|
||||
func (m *mockPlayerRepository) Get(_ context.Context, id string) (*model.Player, error) {
|
||||
if p, ok := m.data[id]; ok {
|
||||
return &p, nil
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockPlayerRepository) FindMatch(userId, client, userAgent string) (*model.Player, error) {
|
||||
func (m *mockPlayerRepository) FindMatch(_ context.Context, userId, client, userAgent string) (*model.Player, error) {
|
||||
for _, p := range m.data {
|
||||
if p.Client == client && p.UserId == userId && p.UserAgent == userAgent {
|
||||
return &p, nil
|
||||
|
|
@ -161,7 +198,7 @@ func (m *mockPlayerRepository) FindMatch(userId, client, userAgent string) (*mod
|
|||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockPlayerRepository) Put(p *model.Player) error {
|
||||
func (m *mockPlayerRepository) Put(_ context.Context, p *model.Player) error {
|
||||
m.lastSaved = p
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ func (s *playlists) ImportFile(ctx context.Context, absolutePath string, sync bo
|
|||
}
|
||||
if pls.ID != "" && pls.Sync != sync {
|
||||
pls.Sync = sync
|
||||
if putErr := s.ds.Playlist(ctx).Put(pls); putErr != nil {
|
||||
if putErr := s.ds.Playlist().Put(ctx, pls); putErr != nil {
|
||||
return nil, putErr
|
||||
}
|
||||
}
|
||||
|
|
@ -74,7 +74,7 @@ func (s *playlists) ImportFile(ctx context.Context, absolutePath string, sync bo
|
|||
var errNotInLibrary = fmt.Errorf("path not in any library")
|
||||
|
||||
func (s *playlists) resolveFolder(ctx context.Context, dir string) (*model.Folder, error) {
|
||||
libs, err := s.ds.Library(ctx).GetAll()
|
||||
libs, err := s.ds.Library().GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -84,7 +84,7 @@ func (s *playlists) resolveFolder(ctx context.Context, dir string) (*model.Folde
|
|||
return nil, fmt.Errorf("%w: %s", errNotInLibrary, dir)
|
||||
}
|
||||
|
||||
folder, err := s.ds.Folder(ctx).GetByPath(lib, dir)
|
||||
folder, err := s.ds.Folder().GetByPath(ctx, lib, dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolving folder for path %s: %w", dir, err)
|
||||
}
|
||||
|
|
@ -122,7 +122,7 @@ func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Pla
|
|||
log.Error(ctx, "Error parsing playlist", err)
|
||||
return nil, err
|
||||
}
|
||||
err = s.ds.Playlist(ctx).Put(pls)
|
||||
err = s.ds.Playlist().Put(ctx, pls)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error saving playlist", err)
|
||||
return nil, err
|
||||
|
|
@ -166,14 +166,14 @@ func fingerprint(h *xxh3.Hasher) string {
|
|||
// findByPathNormalized looks up a playlist by path, trying both NFC and NFD Unicode
|
||||
// normalization forms to handle cross-platform filesystem differences.
|
||||
func (s *playlists) findByPathNormalized(ctx context.Context, path string) (*model.Playlist, error) {
|
||||
pls, err := s.ds.Playlist(ctx).FindByPath(path)
|
||||
pls, err := s.ds.Playlist().FindByPath(ctx, path)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
altPath := norm.NFD.String(path)
|
||||
if altPath == path {
|
||||
altPath = norm.NFC.String(path)
|
||||
}
|
||||
if altPath != path {
|
||||
pls, err = s.ds.Playlist(ctx).FindByPath(altPath)
|
||||
pls, err = s.ds.Playlist().FindByPath(ctx, altPath)
|
||||
}
|
||||
}
|
||||
return pls, err
|
||||
|
|
@ -221,5 +221,5 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist,
|
|||
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
}
|
||||
}
|
||||
return s.ds.Playlist(ctx).Put(newPls)
|
||||
return s.ds.Playlist().Put(ctx, newPls)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1172,7 +1172,7 @@ type mockedMediaFileRepo struct {
|
|||
data map[string]model.MediaFile
|
||||
}
|
||||
|
||||
func (r *mockedMediaFileRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
|
||||
func (r *mockedMediaFileRepo) FindByPaths(ctx context.Context, paths []string) (model.MediaFiles, error) {
|
||||
var mfs model.MediaFiles
|
||||
|
||||
// If data map provided, look up files
|
||||
|
|
@ -1212,7 +1212,7 @@ type mockedMediaFileFromListRepo struct {
|
|||
data []string
|
||||
}
|
||||
|
||||
func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
|
||||
func (r *mockedMediaFileFromListRepo) FindByPaths(ctx context.Context, paths []string) (model.MediaFiles, error) {
|
||||
var mfs model.MediaFiles
|
||||
|
||||
for idx, dataPath := range r.data {
|
||||
|
|
@ -1247,7 +1247,7 @@ type mockFolderRepoForImport struct {
|
|||
folder *model.Folder
|
||||
}
|
||||
|
||||
func (m *mockFolderRepoForImport) GetByPath(_ model.Library, _ string) (*model.Folder, error) {
|
||||
func (m *mockFolderRepoForImport) GetByPath(_ context.Context, _ model.Library, _ string) (*model.Folder, error) {
|
||||
if m.folder != nil {
|
||||
return m.folder, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import (
|
|||
)
|
||||
|
||||
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error {
|
||||
mediaFileRepository := s.ds.MediaFile(ctx)
|
||||
mediaFileRepository := s.ds.MediaFile()
|
||||
resolver, err := newPathResolver(ctx, s.ds)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -96,7 +96,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
|||
}
|
||||
}
|
||||
|
||||
found, err := mediaFileRepository.FindByPaths(lookupCandidates)
|
||||
found, err := mediaFileRepository.FindByPaths(ctx, lookupCandidates)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
|
||||
continue
|
||||
|
|
@ -215,7 +215,7 @@ type pathResolver struct {
|
|||
|
||||
// newPathResolver creates a pathResolver with libraries loaded from the datastore.
|
||||
func newPathResolver(ctx context.Context, ds model.DataStore) (*pathResolver, error) {
|
||||
libs, err := ds.Library(ctx).GetAll()
|
||||
libs, err := ds.Library().GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, 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