mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 10:27:08 +02:00
Compare commits
21 commits
master
...
user-avata
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae8b736d07 | ||
|
|
56ec411cae | ||
|
|
114208e6d6 | ||
|
|
41d7d1d48b | ||
|
|
755ae5106f | ||
|
|
7b225f530f | ||
|
|
e405723c43 | ||
|
|
89d8fbbd13 | ||
|
|
c5303e06e1 | ||
|
|
c5a29983a5 | ||
|
|
669d9216ec | ||
|
|
d57bda78f8 | ||
|
|
e7f3245acd | ||
|
|
1421604d6a | ||
|
|
16c62e075e | ||
|
|
61bb68fc6b | ||
|
|
cac170781b | ||
|
|
15d6a2a728 | ||
|
|
ab2c77f7b2 | ||
|
|
42008a1b88 | ||
|
|
962910f0de |
656 changed files with 11340 additions and 23544 deletions
25
.github/workflows/pipeline.yml
vendored
25
.github/workflows/pipeline.yml
vendored
|
|
@ -92,23 +92,8 @@ jobs:
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Resolve OpenAPI tool versions
|
|
||||||
id: api-tools
|
|
||||||
run: echo "key=$(grep -E '^(VACUUM|OAPI_CODEGEN|OASDIFF)_VERSION' Makefile | tr -d ' \n')" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Cache OpenAPI tools
|
|
||||||
uses: actions/cache@v6
|
|
||||||
with:
|
|
||||||
path: bin
|
|
||||||
key: api-tools-${{ runner.os }}-${{ steps.api-tools.outputs.key }}
|
|
||||||
|
|
||||||
- name: Lint OpenAPI spec
|
|
||||||
run: make api-lint
|
|
||||||
|
|
||||||
- name: Run go generate
|
- name: Run go generate
|
||||||
run: |
|
run: go generate ./...
|
||||||
make api-gen
|
|
||||||
go generate ./...
|
|
||||||
- name: Verify no changes from go generate
|
- name: Verify no changes from go generate
|
||||||
run: |
|
run: |
|
||||||
git status --porcelain
|
git status --porcelain
|
||||||
|
|
@ -117,12 +102,6 @@ jobs:
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Check for breaking OpenAPI changes
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
run: |
|
|
||||||
git fetch --no-tags --depth=1 origin ${{ github.event.pull_request.base.sha }}
|
|
||||||
make api-diff API_DIFF_BASE=${{ github.event.pull_request.base.sha }}
|
|
||||||
|
|
||||||
validate-migrations:
|
validate-migrations:
|
||||||
name: Validate DB migrations
|
name: Validate DB migrations
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -570,7 +549,7 @@ jobs:
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
for artifact in $(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
|
for artifact in $(gh api repos/${{ github.repository }}/actions/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
|
||||||
gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
|
gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ linters:
|
||||||
- asciicheck
|
- asciicheck
|
||||||
- bidichk
|
- bidichk
|
||||||
- bodyclose
|
- bodyclose
|
||||||
- containedctx
|
|
||||||
- copyloopvar
|
- copyloopvar
|
||||||
- dogsled
|
- dogsled
|
||||||
- durationcheck
|
- durationcheck
|
||||||
|
|
@ -58,9 +57,6 @@ linters:
|
||||||
- gosec
|
- gosec
|
||||||
path: _test\.go
|
path: _test\.go
|
||||||
text: "G703"
|
text: "G703"
|
||||||
- path: _test\.go
|
|
||||||
linters:
|
|
||||||
- containedctx
|
|
||||||
- path-except: 'db/migrations/'
|
- path-except: 'db/migrations/'
|
||||||
linters:
|
linters:
|
||||||
- forbidigo
|
- forbidigo
|
||||||
|
|
|
||||||
47
Makefile
47
Makefile
|
|
@ -20,11 +20,7 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin
|
||||||
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
||||||
DOCKER_TAG ?= deluan/navidrome:develop
|
DOCKER_TAG ?= deluan/navidrome:develop
|
||||||
|
|
||||||
GOLANGCI_LINT_VERSION ?= v2.14.0
|
GOLANGCI_LINT_VERSION ?= v2.13.2
|
||||||
VACUUM_VERSION ?= v0.30.6
|
|
||||||
OAPI_CODEGEN_VERSION ?= v2.8.0
|
|
||||||
OASDIFF_VERSION ?= v1.32.1
|
|
||||||
API_DIFF_BASE ?= origin/master
|
|
||||||
|
|
||||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||||
|
|
||||||
|
|
@ -96,45 +92,6 @@ install-golangci-lint: ##@Development Install golangci-lint if not present
|
||||||
fi
|
fi
|
||||||
.PHONY: install-golangci-lint
|
.PHONY: install-golangci-lint
|
||||||
|
|
||||||
install-api-tools: ##@Development Install OpenAPI tools (vacuum, oapi-codegen, oasdiff) into ./bin
|
|
||||||
@STAMP=bin/.api-tools-$(VACUUM_VERSION)-$(OAPI_CODEGEN_VERSION)-$(OASDIFF_VERSION); \
|
|
||||||
if [ ! -f $$STAMP ] || [ ! -x bin/vacuum ] || [ ! -x bin/oapi-codegen ] || [ ! -x bin/oasdiff ]; then \
|
|
||||||
echo "Installing OpenAPI tools..."; \
|
|
||||||
GOBIN=$(CURDIR)/bin go install github.com/daveshanley/vacuum@$(VACUUM_VERSION) && \
|
|
||||||
GOBIN=$(CURDIR)/bin go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@$(OAPI_CODEGEN_VERSION) && \
|
|
||||||
GOBIN=$(CURDIR)/bin go install github.com/oasdiff/oasdiff@$(OASDIFF_VERSION) && \
|
|
||||||
rm -f bin/.api-tools-* && touch $$STAMP; \
|
|
||||||
fi
|
|
||||||
.PHONY: install-api-tools
|
|
||||||
|
|
||||||
api-lint: install-api-tools ##@Development Lint the OpenAPI spec
|
|
||||||
./bin/vacuum lint -r api/.vacuum.yaml -d -q -b --fail-severity error api/openapi/openapi.yaml
|
|
||||||
.PHONY: api-lint
|
|
||||||
|
|
||||||
api-bundle: install-api-tools ##@Development Bundle the multi-file OpenAPI spec into api/bundled
|
|
||||||
./bin/vacuum bundle -q --composed -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.yaml
|
|
||||||
./bin/vacuum bundle -q --composed --format json -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.json
|
|
||||||
.PHONY: api-bundle
|
|
||||||
|
|
||||||
api-gen: api-bundle ##@Development Generate the API v1 server code from the bundled spec
|
|
||||||
./bin/oapi-codegen -config server/apiv1/oapi-codegen.yaml api/bundled/openapi.json
|
|
||||||
.PHONY: api-gen
|
|
||||||
|
|
||||||
api-diff: api-bundle ##@Development Fail on breaking OpenAPI changes against the merge-base with $(API_DIFF_BASE)
|
|
||||||
@git rev-parse --verify --quiet $(API_DIFF_BASE)^{commit} >/dev/null || { echo "Base ref $(API_DIFF_BASE) not found; set API_DIFF_BASE"; exit 1; }; \
|
|
||||||
BASE="$$(git merge-base HEAD $(API_DIFF_BASE) 2>/dev/null)"; \
|
|
||||||
if [ -z "$$BASE" ]; then \
|
|
||||||
echo "No merge-base with $(API_DIFF_BASE); falling back to its tip"; \
|
|
||||||
BASE=$(API_DIFF_BASE); \
|
|
||||||
fi; \
|
|
||||||
if git cat-file -e $$BASE:api/bundled/openapi.json 2>/dev/null; then \
|
|
||||||
git show $$BASE:api/bundled/openapi.json > $(CURDIR)/bin/api-base.json && \
|
|
||||||
./bin/oasdiff breaking $(CURDIR)/bin/api-base.json api/bundled/openapi.json --fail-on ERR --severity-levels api/.oasdiff-levels.txt; \
|
|
||||||
else \
|
|
||||||
echo "No bundled spec at $$BASE; skipping breaking-change check"; \
|
|
||||||
fi
|
|
||||||
.PHONY: api-diff
|
|
||||||
|
|
||||||
lint: install-golangci-lint ##@Development Lint Go code
|
lint: install-golangci-lint ##@Development Lint Go code
|
||||||
PATH=./bin:$$PATH golangci-lint run --timeout 5m
|
PATH=./bin:$$PATH golangci-lint run --timeout 5m
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
|
|
@ -154,7 +111,7 @@ wire: check_go_env ##@Development Update Dependency Injection
|
||||||
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
|
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
|
||||||
.PHONY: wire
|
.PHONY: wire
|
||||||
|
|
||||||
gen: check_go_env api-gen ##@Development Run go generate for code generation
|
gen: check_go_env ##@Development Run go generate for code generation
|
||||||
go generate ./...
|
go generate ./...
|
||||||
cd plugins/cmd/ndpgen && go run . -shared-types -input=../../types -output=../../pdk -go -rust
|
cd plugins/cmd/ndpgen && go run . -shared-types -input=../../types -output=../../pdk -go -rust
|
||||||
cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host -shared=../../types
|
cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host -shared=../../types
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ var _ = Describe("Extractor", func() {
|
||||||
info.FileInfo = testFileInfo{FileInfo: fileInfo}
|
info.FileInfo = testFileInfo{FileInfo: fileInfo}
|
||||||
|
|
||||||
metadata := metadata.New(path, info)
|
metadata := metadata.New(path, info)
|
||||||
return new(metadata.ToMediaFile(model.Library{ID: 1}, "folderID"))
|
return new(metadata.ToMediaFile(1, "folderID"))
|
||||||
}
|
}
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
|
|
||||||
|
|
@ -241,10 +241,6 @@ func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, arti
|
||||||
var (
|
var (
|
||||||
artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
|
artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
|
||||||
artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name
|
artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name
|
||||||
|
|
||||||
// Not a RetryLaterError on purpose: parking the agent would also stall its API-backed
|
|
||||||
// methods, which the page block does not affect.
|
|
||||||
errNoArtistPage = errors.New("no artist image in Last.fm page")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
|
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
|
||||||
|
|
@ -271,9 +267,7 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
|
||||||
var res []agents.ExternalImage
|
var res []agents.ExternalImage
|
||||||
n := cascadia.Query(node, artistOpenGraphQuery)
|
n := cascadia.Query(node, artistOpenGraphQuery)
|
||||||
if n == nil {
|
if n == nil {
|
||||||
// A real artist page always has og:image; its absence means a bot challenge or a redesign.
|
return res, nil
|
||||||
log.Warn(ctx, "Last.fm did not return a usable artist page", "name", name, "url", a.URL)
|
|
||||||
return nil, errNoArtistPage
|
|
||||||
}
|
}
|
||||||
for _, attr := range n.Attr {
|
for _, attr := range n.Attr {
|
||||||
if attr.Key != "content" {
|
if attr.Key != "content" {
|
||||||
|
|
|
||||||
|
|
@ -357,7 +357,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||||
var httpClient *tests.FakeHttpClient
|
var httpClient *tests.FakeHttpClient
|
||||||
var track *model.MediaFile
|
var track *model.MediaFile
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
|
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
||||||
httpClient = &tests.FakeHttpClient{}
|
httpClient = &tests.FakeHttpClient{}
|
||||||
client := newClient("API_KEY", "SECRET", httpClient)
|
client := newClient("API_KEY", "SECRET", httpClient)
|
||||||
agent = lastFMConstructor(ds)
|
agent = lastFMConstructor(ds)
|
||||||
|
|
@ -648,41 +648,18 @@ var _ = Describe("lastfmAgent", func() {
|
||||||
Expect(images).To(BeEmpty())
|
Expect(images).To(BeEmpty())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("errors when the page has no meta tags", func() {
|
It("returns empty list if page has no meta tags", func() {
|
||||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
||||||
|
|
||||||
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html")
|
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html")
|
||||||
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
||||||
|
|
||||||
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
|
||||||
Expect(err).To(MatchError(errNoArtistPage))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("errors when Last.fm serves a bot challenge page", func() {
|
|
||||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
||||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
|
||||||
|
|
||||||
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html")
|
|
||||||
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
|
||||||
|
|
||||||
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
||||||
Expect(err).To(MatchError(errNoArtistPage))
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(images).To(BeEmpty())
|
Expect(images).To(BeEmpty())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("does not park the agent: the failure is not a retry-later", func() {
|
|
||||||
// A RetryLaterError would cool down the agent's API-backed methods too.
|
|
||||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
||||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
|
||||||
|
|
||||||
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html")
|
|
||||||
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
|
||||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error if API call fails", func() {
|
It("returns error if API call fails", func() {
|
||||||
apiClient.Err = errors.New("api error")
|
apiClient.Err = errors.New("api error")
|
||||||
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
|
||||||
func (s *Router) fetchSessionKey(ctx context.Context, uid, token string) error {
|
func (s *Router) fetchSessionKey(ctx context.Context, uid, token string) error {
|
||||||
sessionKey, err := s.client.getSession(ctx, token)
|
sessionKey, err := s.client.getSession(ctx, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Could not fetch LastFM session key", "userId", uid,
|
log.Error(ctx, "Could not fetch LastFM session key", "userId", uid, "token", token,
|
||||||
"requestId", middleware.GetReqID(ctx), err)
|
"requestId", middleware.GetReqID(ctx), err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ var _ = Describe("auth_router", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
storedSessionKey := func(userID string) string {
|
storedSessionKey := func(userID string) string {
|
||||||
key, _ := userProps.Get(GinkgoT().Context(), userID, sessionKeyProperty)
|
key, _ := userProps.Get(userID, sessionKeyProperty)
|
||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,12 +49,6 @@ type client struct {
|
||||||
hc httpDoer
|
hc httpDoer
|
||||||
}
|
}
|
||||||
|
|
||||||
// escapePlus works around Last.fm decoding artist.* and track.* params twice, turning "+" into a space.
|
|
||||||
// album.getInfo decodes only once, so it must not use this.
|
|
||||||
func escapePlus(s string) string {
|
|
||||||
return strings.ReplaceAll(s, "+", "%2B")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string, lang string) (*Album, error) {
|
func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string, lang string) (*Album, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Add("method", "album.getInfo")
|
params.Add("method", "album.getInfo")
|
||||||
|
|
@ -72,7 +66,7 @@ func (c *client) albumGetInfo(ctx context.Context, name string, artist string, m
|
||||||
func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
|
func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Add("method", "artist.getInfo")
|
params.Add("method", "artist.getInfo")
|
||||||
params.Add("artist", escapePlus(name))
|
params.Add("artist", name)
|
||||||
params.Add("lang", lang)
|
params.Add("lang", lang)
|
||||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -84,7 +78,7 @@ func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*
|
||||||
func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (*SimilarArtists, error) {
|
func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (*SimilarArtists, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Add("method", "artist.getSimilar")
|
params.Add("method", "artist.getSimilar")
|
||||||
params.Add("artist", escapePlus(name))
|
params.Add("artist", name)
|
||||||
params.Add("limit", strconv.Itoa(limit))
|
params.Add("limit", strconv.Itoa(limit))
|
||||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -96,7 +90,7 @@ func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (
|
||||||
func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int) (*TopTracks, error) {
|
func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int) (*TopTracks, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Add("method", "artist.getTopTracks")
|
params.Add("method", "artist.getTopTracks")
|
||||||
params.Add("artist", escapePlus(name))
|
params.Add("artist", name)
|
||||||
params.Add("limit", strconv.Itoa(limit))
|
params.Add("limit", strconv.Itoa(limit))
|
||||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -108,8 +102,8 @@ func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int)
|
||||||
func (c *client) trackGetSimilar(ctx context.Context, name, artist string, limit int) (*SimilarTracks, error) {
|
func (c *client) trackGetSimilar(ctx context.Context, name, artist string, limit int) (*SimilarTracks, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Add("method", "track.getSimilar")
|
params.Add("method", "track.getSimilar")
|
||||||
params.Add("track", escapePlus(name))
|
params.Add("track", name)
|
||||||
params.Add("artist", escapePlus(artist))
|
params.Add("artist", artist)
|
||||||
params.Add("limit", strconv.Itoa(limit))
|
params.Add("limit", strconv.Itoa(limit))
|
||||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -35,15 +35,6 @@ var _ = Describe("client", func() {
|
||||||
Expect(album.Name).To(Equal("Believe"))
|
Expect(album.Name).To(Equal("Believe"))
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?album=Believe&api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=mbid-1234&method=album.getInfo"))
|
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?album=Believe&api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=mbid-1234&method=album.getInfo"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("does not double-encode plus signs", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := client.albumGetInfo(context.Background(), "Lungs", "Florence + the Machine", "", "en")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Florence + the Machine"))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("artistGetInfo", func() {
|
Describe("artistGetInfo", func() {
|
||||||
|
|
@ -57,15 +48,6 @@ var _ = Describe("client", func() {
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo"))
|
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("double-encodes plus signs in the artist name", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := client.artistGetInfo(context.Background(), "Florence + the Machine", "en")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Florence %2B the Machine"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("fails if Last.fm returns an http status != 200", func() {
|
It("fails if Last.fm returns an http status != 200", func() {
|
||||||
httpClient.Res = http.Response{
|
httpClient.Res = http.Response{
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`Internal Server Error`)),
|
Body: io.NopCloser(bytes.NewBufferString(`Internal Server Error`)),
|
||||||
|
|
@ -125,15 +107,6 @@ var _ = Describe("client", func() {
|
||||||
Expect(len(similar.Artists)).To(Equal(2))
|
Expect(len(similar.Artists)).To(Equal(2))
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getSimilar"))
|
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getSimilar"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("double-encodes plus signs in the artist name", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := client.artistGetSimilar(context.Background(), "+44", 2)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("%2B44"))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("artistGetTopTracks", func() {
|
Describe("artistGetTopTracks", func() {
|
||||||
|
|
@ -146,15 +119,6 @@ var _ = Describe("client", func() {
|
||||||
Expect(len(top.Track)).To(Equal(2))
|
Expect(len(top.Track)).To(Equal(2))
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getTopTracks"))
|
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getTopTracks"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("double-encodes plus signs in the artist name", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := client.artistGetTopTracks(context.Background(), "C+C Music Factory", 2)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("C%2BC Music Factory"))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("trackGetSimilar", func() {
|
Describe("trackGetSimilar", func() {
|
||||||
|
|
@ -171,17 +135,6 @@ var _ = Describe("client", func() {
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=Depeche+Mode&format=json&limit=5&method=track.getSimilar&track=Just+Can%27t+Get+Enough"))
|
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=Depeche+Mode&format=json&limit=5&method=track.getSimilar&track=Just+Can%27t+Get+Enough"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("double-encodes plus signs in the track and artist names", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := client.trackGetSimilar(context.Background(), "1+1", "Queen + Paul Rodgers", 5)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
query := httpClient.SavedRequest.URL.Query()
|
|
||||||
Expect(query.Get("track")).To(Equal("1%2B1"))
|
|
||||||
Expect(query.Get("artist")).To(Equal("Queen %2B Paul Rodgers"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns empty list when no similar tracks found", func() {
|
It("returns empty list when no similar tracks found", func() {
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
|
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ var _ = Describe("listenBrainzAgent", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
|
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
||||||
httpClient = &tests.FakeHttpClient{}
|
httpClient = &tests.FakeHttpClient{}
|
||||||
agent = listenBrainzConstructor(ds)
|
agent = listenBrainzConstructor(ds)
|
||||||
agent.client = newClient("http://localhost:8080", httpClient)
|
agent.client = newClient("http://localhost:8080", httpClient)
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
response-property-enum-value-added INFO
|
|
||||||
155
api/.vacuum.yaml
155
api/.vacuum.yaml
|
|
@ -1,155 +0,0 @@
|
||||||
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,262 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
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
35
api/embed.go
|
|
@ -1,35 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
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())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
description: Entity tag for `If-None-Match` revalidation.
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
name: limit
|
|
||||||
in: query
|
|
||||||
description: Maximum number of items to return.
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
minimum: 1
|
|
||||||
maximum: 2000
|
|
||||||
default: 100
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
name: offset
|
|
||||||
in: query
|
|
||||||
description: Zero-based index of the first item to return.
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
minimum: 0
|
|
||||||
default: 0
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
description: The request is malformed or fails validation.
|
|
||||||
content:
|
|
||||||
application/problem+json:
|
|
||||||
schema:
|
|
||||||
$ref: ../schemas/Problem.yaml
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
description: The caller is authenticated but not allowed to do this.
|
|
||||||
content:
|
|
||||||
application/problem+json:
|
|
||||||
schema:
|
|
||||||
$ref: ../schemas/Problem.yaml
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
description: Unexpected server failure. Details are in the server log.
|
|
||||||
content:
|
|
||||||
application/problem+json:
|
|
||||||
schema:
|
|
||||||
$ref: ../schemas/Problem.yaml
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
description: No such resource or endpoint.
|
|
||||||
content:
|
|
||||||
application/problem+json:
|
|
||||||
schema:
|
|
||||||
$ref: ../schemas/Problem.yaml
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
description: Not modified.
|
|
||||||
headers:
|
|
||||||
ETag:
|
|
||||||
$ref: ../headers/ETag.yaml
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
description: Missing, invalid, or expired credentials.
|
|
||||||
content:
|
|
||||||
application/problem+json:
|
|
||||||
schema:
|
|
||||||
$ref: ../schemas/Problem.yaml
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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.
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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]
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
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.
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
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.
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
get:
|
|
||||||
operationId: getServerInfo
|
|
||||||
x-module: core
|
|
||||||
x-stability-level: alpha
|
|
||||||
tags: [server]
|
|
||||||
summary: Describe the server
|
|
||||||
description: |
|
|
||||||
Returns the public server description. No authentication required.
|
|
||||||
Authenticated requests will additionally receive the implemented capability modules
|
|
||||||
once authentication is available.
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Server description.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: ../components/schemas/ServerInfo.yaml
|
|
||||||
'500':
|
|
||||||
$ref: ../components/responses/InternalError.yaml
|
|
||||||
|
|
@ -174,28 +174,28 @@ func queueTotal(stats []model.ArtworkQueueStat) int64 {
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error) {
|
func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error) {
|
||||||
q := ds.ArtworkQueue()
|
q := ds.ArtworkQueue(ctx)
|
||||||
var rep statusReport
|
var rep statusReport
|
||||||
var err error
|
var err error
|
||||||
if rep.queue, err = q.CountQueued(ctx, nil, nil); err != nil {
|
if rep.queue, err = q.CountQueued(nil, nil); err != nil {
|
||||||
return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err)
|
return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, k := range artwork.ReprocessKinds {
|
for _, k := range artwork.ReprocessKinds {
|
||||||
sources, err := q.SourcesInUse(ctx, k)
|
sources, err := q.SourcesInUse(k)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return rep, fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
|
return rep, fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
|
||||||
}
|
}
|
||||||
slices.Sort(sources)
|
slices.Sort(sources)
|
||||||
for _, s := range sources {
|
for _, s := range sources {
|
||||||
n, err := q.CountBySource(ctx, k, []string{s})
|
n, err := q.CountBySource(k, []string{s})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return rep, fmt.Errorf("counting %s artwork resolved from %s: %w", k, displaySource(s), err)
|
return rep, fmt.Errorf("counting %s artwork resolved from %s: %w", k, displaySource(s), err)
|
||||||
}
|
}
|
||||||
rep.sources = append(rep.sources, sourceCount{kind: k, source: s, count: n})
|
rep.sources = append(rep.sources, sourceCount{kind: k, source: s, count: n})
|
||||||
// An absent state is exactly a row with no source, so it needs no second query.
|
// An absent state is exactly a row with no source, so it needs no second query.
|
||||||
if s == "" {
|
if s == "" {
|
||||||
failed, err := q.CountBySource(ctx, k, []string{model.ArtworkSourceFailed})
|
failed, err := q.CountBySource(k, []string{model.ArtworkSourceFailed})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return rep, fmt.Errorf("counting failed %s artwork: %w", k, err)
|
return rep, fmt.Errorf("counting failed %s artwork: %w", k, err)
|
||||||
}
|
}
|
||||||
|
|
@ -205,7 +205,7 @@ func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error
|
||||||
}
|
}
|
||||||
|
|
||||||
rep.current, rep.inputs = artwork.ConfigFingerprint(), artwork.FingerprintInputs()
|
rep.current, rep.inputs = artwork.ConfigFingerprint(), artwork.FingerprintInputs()
|
||||||
if rep.stored, err = ds.Property().DefaultGet(ctx, consts.ArtConfFingerprintPropertyKey, ""); err != nil {
|
if rep.stored, err = ds.Property(ctx).DefaultGet(consts.ArtConfFingerprintPropertyKey, ""); err != nil {
|
||||||
return rep, fmt.Errorf("reading the stored artwork fingerprint: %w", err)
|
return rep, fmt.Errorf("reading the stored artwork fingerprint: %w", err)
|
||||||
}
|
}
|
||||||
return rep, nil
|
return rep, nil
|
||||||
|
|
@ -442,13 +442,13 @@ func promptConfirm(in io.Reader, verb string) confirmFunc {
|
||||||
|
|
||||||
// validateSources rejects a typo'd source: matching nothing silently reads as "nothing to do" when
|
// validateSources rejects a typo'd source: matching nothing silently reads as "nothing to do" when
|
||||||
// it means the filter was wrong. Checked table-wide, so a filter is never a typo for one --kind only.
|
// it means the filter was wrong. Checked table-wide, so a filter is never a typo for one --kind only.
|
||||||
func validateSources(ctx context.Context, q model.ArtworkQueueRepository, sources []string) error {
|
func validateSources(q model.ArtworkQueueRepository, sources []string) error {
|
||||||
if len(sources) == 0 {
|
if len(sources) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var inUse []string
|
var inUse []string
|
||||||
for _, k := range artwork.ReprocessKinds {
|
for _, k := range artwork.ReprocessKinds {
|
||||||
found, err := q.SourcesInUse(ctx, k)
|
found, err := q.SourcesInUse(k)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
|
return fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
|
||||||
}
|
}
|
||||||
|
|
@ -475,8 +475,8 @@ func validateSources(ctx context.Context, q model.ArtworkQueueRepository, source
|
||||||
// actually inserted; the two differ because an already-queued row is left untouched.
|
// actually inserted; the two differ because an already-queued row is left untouched.
|
||||||
func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, sources []string,
|
func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, sources []string,
|
||||||
imageAgents artwork.ImageAgentCount, dryRun bool, confirm confirmFunc, out io.Writer) error {
|
imageAgents artwork.ImageAgentCount, dryRun bool, confirm confirmFunc, out io.Writer) error {
|
||||||
q := ds.ArtworkQueue()
|
q := ds.ArtworkQueue(ctx)
|
||||||
if err := validateSources(ctx, q, sources); err != nil {
|
if err := validateSources(q, sources); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -495,7 +495,7 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin
|
||||||
matched := make([]int64, len(kinds))
|
matched := make([]int64, len(kinds))
|
||||||
var total, external int64
|
var total, external int64
|
||||||
for i, k := range kinds {
|
for i, k := range kinds {
|
||||||
n, err := q.CountBySource(ctx, k, sources)
|
n, err := q.CountBySource(k, sources)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("counting %s artwork: %w", k, err)
|
return fmt.Errorf("counting %s artwork: %w", k, err)
|
||||||
}
|
}
|
||||||
|
|
@ -523,7 +523,7 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin
|
||||||
if matched[i] == 0 {
|
if matched[i] == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
n, err := q.EnqueueBySource(ctx, k, sources, model.ArtworkPriorityRecheck)
|
n, err := q.EnqueueBySource(k, sources, model.ArtworkPriorityRecheck)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("queueing %s artwork: %w", k, err)
|
return fmt.Errorf("queueing %s artwork: %w", k, err)
|
||||||
}
|
}
|
||||||
|
|
@ -590,8 +590,8 @@ func parseAll[T comparable](values []string, parse func(string) (T, error)) ([]T
|
||||||
|
|
||||||
func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, priorities []int,
|
func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, priorities []int,
|
||||||
dryRun bool, confirm confirmFunc, out io.Writer) error {
|
dryRun bool, confirm confirmFunc, out io.Writer) error {
|
||||||
q := ds.ArtworkQueue()
|
q := ds.ArtworkQueue(ctx)
|
||||||
matched, err := q.CountQueued(ctx, kinds, priorities)
|
matched, err := q.CountQueued(kinds, priorities)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("counting queued artwork: %w", err)
|
return fmt.Errorf("counting queued artwork: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -612,7 +612,7 @@ func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind,
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelled, err := q.PurgeQueued(ctx, kinds, priorities)
|
cancelled, err := q.PurgeQueued(kinds, priorities)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cancelling queued artwork: %w", err)
|
return fmt.Errorf("cancelling queued artwork: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -984,11 +984,11 @@ func runExplain(ctx context.Context, args []string) {
|
||||||
}
|
}
|
||||||
rep := explainReport{kind: kind, id: id, name: name}
|
rep := explainReport{kind: kind, id: id, name: name}
|
||||||
if artwork.KeepsState(kind) {
|
if artwork.KeepsState(kind) {
|
||||||
rep.stored, err = ds.Artwork().GetItemArtwork(ctx, kind, id, model.ImageTypePrimary)
|
rep.stored, err = ds.Artwork(ctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
log.Fatal(ctx, "Failed to read artwork state", "kind", kind, "id", id, err)
|
log.Fatal(ctx, "Failed to read artwork state", "kind", kind, "id", id, err)
|
||||||
}
|
}
|
||||||
rep.queued, err = ds.ArtworkQueue().Get(ctx, kind, id, model.ImageTypePrimary)
|
rep.queued, err = ds.ArtworkQueue(ctx).Get(kind, id, model.ImageTypePrimary)
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
log.Fatal(ctx, "Failed to read the artwork queue", "kind", kind, "id", id, err)
|
log.Fatal(ctx, "Failed to read the artwork queue", "kind", kind, "id", id, err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -491,17 +491,17 @@ var _ = Describe("explain/reprocess source round trip", func() {
|
||||||
|
|
||||||
It("names the absent state as reprocess --source accepts it", func() {
|
It("names the absent state as reprocess --source accepts it", func() {
|
||||||
ds := &tests.MockDataStore{}
|
ds := &tests.MockDataStore{}
|
||||||
art := ds.Artwork().(*tests.MockArtworkRepo)
|
art := ds.Artwork(ctx).(*tests.MockArtworkRepo)
|
||||||
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(),
|
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(),
|
||||||
ItemID: "ar-1", ImageType: model.ImageTypePrimary})).To(Succeed())
|
ItemID: "ar-1", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||||
|
|
||||||
shown := storedSource(formatExplain(explainReport{kind: model.KindArtistArtwork, id: "ar-1",
|
shown := storedSource(formatExplain(explainReport{kind: model.KindArtistArtwork, id: "ar-1",
|
||||||
stored: &model.ItemArtwork{AttemptedAt: time.Now()}}))
|
stored: &model.ItemArtwork{AttemptedAt: time.Now()}}))
|
||||||
|
|
||||||
q := ds.ArtworkQueue()
|
q := ds.ArtworkQueue(ctx)
|
||||||
Expect(validateSources(ctx, q, repositorySources([]string{shown}))).To(Succeed(),
|
Expect(validateSources(q, repositorySources([]string{shown}))).To(Succeed(),
|
||||||
"explain's spelling of a source must be pasteable into --source")
|
"explain's spelling of a source must be pasteable into --source")
|
||||||
Expect(validateSources(ctx, q, repositorySources([]string{"(" + shown + ")"}))).ToNot(Succeed(),
|
Expect(validateSources(q, repositorySources([]string{"(" + shown + ")"}))).ToNot(Succeed(),
|
||||||
"a parenthesised name would be rejected, so explain must not print one")
|
"a parenthesised name would be rejected, so explain must not print one")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -573,7 +573,7 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
decline := func(io.Writer, int64, int64) bool { return false }
|
decline := func(io.Writer, int64, int64) bool { return false }
|
||||||
|
|
||||||
put := func(kind model.Kind, id, source string) {
|
put := func(kind model.Kind, id, source string) {
|
||||||
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
|
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
|
||||||
ImageType: model.ImageTypePrimary, Hash: "h" + id, Source: source})).To(Succeed())
|
ImageType: model.ImageTypePrimary, Hash: "h" + id, Source: source})).To(Succeed())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -584,8 +584,8 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
conf.Server.EnableM3UExternalAlbumArt = false
|
conf.Server.EnableM3UExternalAlbumArt = false
|
||||||
imageAgents = artwork.ImageAgentCount{Artist: 1, Album: 1}
|
imageAgents = artwork.ImageAgentCount{Artist: 1, Album: 1}
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
art = ds.Artwork().(*tests.MockArtworkRepo)
|
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
|
||||||
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
|
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
||||||
out.Reset()
|
out.Reset()
|
||||||
put(model.KindArtistArtwork, "ar-1", "external:deezer")
|
put(model.KindArtistArtwork, "ar-1", "external:deezer")
|
||||||
put(model.KindArtistArtwork, "ar-2", "")
|
put(model.KindArtistArtwork, "ar-2", "")
|
||||||
|
|
@ -601,19 +601,19 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
Expect(out.String()).To(ContainSubstring("album"))
|
Expect(out.String()).To(ContainSubstring("album"))
|
||||||
Expect(out.String()).To(ContainSubstring("TOTAL"))
|
Expect(out.String()).To(ContainSubstring("TOTAL"))
|
||||||
Expect(out.String()).To(ContainSubstring("Dry run"))
|
Expect(out.String()).To(ContainSubstring("Dry run"))
|
||||||
Expect(queue.Count(ctx)).To(BeZero())
|
Expect(queue.Count()).To(BeZero())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("queues nothing when the operator declines", func() {
|
It("queues nothing when the operator declines", func() {
|
||||||
Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, decline, &out)).To(Succeed())
|
Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, decline, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(out.String()).To(ContainSubstring("Aborted"))
|
Expect(out.String()).To(ContainSubstring("Aborted"))
|
||||||
Expect(queue.Count(ctx)).To(BeZero())
|
Expect(queue.Count()).To(BeZero())
|
||||||
})
|
})
|
||||||
|
|
||||||
DescribeTable("records the applied config only for a run that leaves nothing on the old one",
|
DescribeTable("records the applied config only for a run that leaves nothing on the old one",
|
||||||
func(selected []model.Kind, sources []string, dryRun, applied bool) {
|
func(selected []model.Kind, sources []string, dryRun, applied bool) {
|
||||||
Expect(ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
Expect(ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
||||||
|
|
||||||
Expect(reprocessArtwork(ctx, ds, selected, sources, imageAgents, dryRun, accept, &out)).To(Succeed())
|
Expect(reprocessArtwork(ctx, ds, selected, sources, imageAgents, dryRun, accept, &out)).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -621,7 +621,7 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
if applied {
|
if applied {
|
||||||
want = artwork.ConfigFingerprint()
|
want = artwork.ConfigFingerprint()
|
||||||
}
|
}
|
||||||
Expect(ds.Property().Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal(want))
|
Expect(ds.Property(ctx).Get(consts.ArtConfFingerprintPropertyKey)).To(Equal(want))
|
||||||
},
|
},
|
||||||
Entry("every kind, unfiltered", artwork.ReprocessKinds, nil, false, true),
|
Entry("every kind, unfiltered", artwork.ReprocessKinds, nil, false, true),
|
||||||
Entry("every kind, but nothing matched", artwork.ReprocessKinds, []string{}, false, true),
|
Entry("every kind, but nothing matched", artwork.ReprocessKinds, []string{}, false, true),
|
||||||
|
|
@ -633,14 +633,14 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
It("queues the matching items at recheck priority, leaving their artwork state alone", func() {
|
It("queues the matching items at recheck priority, leaving their artwork state alone", func() {
|
||||||
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
|
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(queue.Count(ctx)).To(Equal(int64(2)))
|
Expect(queue.Count()).To(Equal(int64(2)))
|
||||||
queued, err := queue.Get(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(queued.Priority).To(Equal(model.ArtworkPriorityRecheck))
|
Expect(queued.Priority).To(Equal(model.ArtworkPriorityRecheck))
|
||||||
_, err = queue.Get(ctx, model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
|
_, err = queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound), "a non-matching source must not be queued")
|
Expect(err).To(MatchError(model.ErrNotFound), "a non-matching source must not be queued")
|
||||||
|
|
||||||
stored, err := art.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
stored, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(stored.Hash).To(Equal("hal-1"), "bulk reprocessing must not blank the current artwork")
|
Expect(stored.Hash).To(Equal("hal-1"), "bulk reprocessing must not blank the current artwork")
|
||||||
})
|
})
|
||||||
|
|
@ -648,20 +648,20 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
It("targets the absent state", func() {
|
It("targets the absent state", func() {
|
||||||
Expect(reprocessArtwork(ctx, ds, kinds, []string{""}, imageAgents, false, accept, &out)).To(Succeed())
|
Expect(reprocessArtwork(ctx, ds, kinds, []string{""}, imageAgents, false, accept, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(queue.Count(ctx)).To(Equal(int64(1)))
|
Expect(queue.Count()).To(Equal(int64(1)))
|
||||||
_, err := queue.Get(ctx, model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
_, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("reports matched and queued separately when part of the set is already queued", func() {
|
It("reports matched and queued separately when part of the set is already queued", func() {
|
||||||
Expect(queue.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1",
|
Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1",
|
||||||
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump})).To(Succeed())
|
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump})).To(Succeed())
|
||||||
|
|
||||||
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
|
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(out.String()).To(ContainSubstring("Queued 1 of 2 matched items"))
|
Expect(out.String()).To(ContainSubstring("Queued 1 of 2 matched items"))
|
||||||
Expect(out.String()).To(ContainSubstring("Already queued, left unchanged: 1"))
|
Expect(out.String()).To(ContainSubstring("Already queued, left unchanged: 1"))
|
||||||
queued, err := queue.Get(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
queued, err := queue.Get(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump),
|
Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump),
|
||||||
"an already-queued row keeps its priority and backoff")
|
"an already-queued row keeps its priority and backoff")
|
||||||
|
|
@ -675,7 +675,7 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
}, &out)).To(Succeed())
|
}, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(out.String()).To(ContainSubstring("Nothing"))
|
Expect(out.String()).To(ContainSubstring("Nothing"))
|
||||||
Expect(queue.Count(ctx)).To(BeZero())
|
Expect(queue.Count()).To(BeZero())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("reports an empty selection as a dry run when one was asked for", func() {
|
It("reports an empty selection as a dry run when one was asked for", func() {
|
||||||
|
|
@ -767,7 +767,7 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
Expect(err.Error()).To(ContainSubstring("external:deezer"))
|
Expect(err.Error()).To(ContainSubstring("external:deezer"))
|
||||||
Expect(err.Error()).To(ContainSubstring("folder"))
|
Expect(err.Error()).To(ContainSubstring("folder"))
|
||||||
Expect(err.Error()).To(ContainSubstring("absent"), "the empty source prints under its user-facing name")
|
Expect(err.Error()).To(ContainSubstring("absent"), "the empty source prints under its user-facing name")
|
||||||
Expect(queue.Count(ctx)).To(BeZero())
|
Expect(queue.Count()).To(BeZero())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("accepts the absent filter with nothing absent, still rejecting a typo", func() {
|
It("accepts the absent filter with nothing absent, still rejecting a typo", func() {
|
||||||
|
|
@ -777,7 +777,7 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
imageAgents, false, accept, &out)).To(Succeed(),
|
imageAgents, false, accept, &out)).To(Succeed(),
|
||||||
"a reserved source must stay valid once the library has none of it")
|
"a reserved source must stay valid once the library has none of it")
|
||||||
Expect(out.String()).To(ContainSubstring("Nothing matches"))
|
Expect(out.String()).To(ContainSubstring("Nothing matches"))
|
||||||
Expect(queue.Count(ctx)).To(BeZero())
|
Expect(queue.Count()).To(BeZero())
|
||||||
|
|
||||||
Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{"absnt"}),
|
Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{"absnt"}),
|
||||||
imageAgents, true, accept, &out)).ToNot(Succeed(), "a typo must still be rejected")
|
imageAgents, true, accept, &out)).ToNot(Succeed(), "a typo must still be rejected")
|
||||||
|
|
@ -797,7 +797,7 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
|
|
||||||
Expect(out.String()).To(ContainSubstring("Nothing matches"),
|
Expect(out.String()).To(ContainSubstring("Nothing matches"),
|
||||||
"a well-formed filter must not be reported as a typo because of the kinds selected")
|
"a well-formed filter must not be reported as a typo because of the kinds selected")
|
||||||
Expect(queue.Count(ctx)).To(BeZero())
|
Expect(queue.Count()).To(BeZero())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -816,19 +816,19 @@ var _ = Describe("collectStatus", func() {
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
art = ds.Artwork().(*tests.MockArtworkRepo)
|
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
|
||||||
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
|
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
||||||
put := func(kind model.Kind, id, source, hash string, attempted time.Time) {
|
put := func(kind model.Kind, id, source, hash string, attempted time.Time) {
|
||||||
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
|
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
|
||||||
ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed())
|
ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed())
|
||||||
}
|
}
|
||||||
put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now())
|
put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now())
|
||||||
put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-24*time.Hour))
|
put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-24*time.Hour))
|
||||||
// ar-3 is absent because it gave up, so the two absent artists split across the columns.
|
// ar-3 is absent because it gave up, so the two absent artists split across the columns.
|
||||||
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "ar-3",
|
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "ar-3",
|
||||||
ImageType: model.ImageTypePrimary, LastFailure: "[]", AttemptedAt: time.Now()})).To(Succeed())
|
ImageType: model.ImageTypePrimary, LastFailure: "[]", AttemptedAt: time.Now()})).To(Succeed())
|
||||||
put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now())
|
put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now())
|
||||||
Expect(queue.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9",
|
Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9",
|
||||||
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill})).To(Succeed())
|
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill})).To(Succeed())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -848,7 +848,7 @@ var _ = Describe("collectStatus", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("compares the stored fingerprint against the current one", func() {
|
It("compares the stored fingerprint against the current one", func() {
|
||||||
Expect(ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, "old-fingerprint")).To(Succeed())
|
Expect(ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, "old-fingerprint")).To(Succeed())
|
||||||
|
|
||||||
rep, err := collectStatus(ctx, ds)
|
rep, err := collectStatus(ctx, ds)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -860,7 +860,7 @@ var _ = Describe("collectStatus", func() {
|
||||||
It("queues nothing", func() {
|
It("queues nothing", func() {
|
||||||
_, err := collectStatus(ctx, ds)
|
_, err := collectStatus(ctx, ds)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(queue.Count(ctx)).To(Equal(int64(1)), "status must not enqueue anything")
|
Expect(queue.Count()).To(Equal(int64(1)), "status must not enqueue anything")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -973,21 +973,21 @@ var _ = Describe("refreshItems", func() {
|
||||||
albums := tests.CreateMockAlbumRepo()
|
albums := tests.CreateMockAlbumRepo()
|
||||||
albums.SetData(model.Albums{{ID: "al-1"}, {ID: "al-3"}})
|
albums.SetData(model.Albums{{ID: "al-1"}, {ID: "al-3"}})
|
||||||
ds = &tests.MockDataStore{MockedAlbum: albums}
|
ds = &tests.MockDataStore{MockedAlbum: albums}
|
||||||
art = ds.Artwork().(*tests.MockArtworkRepo)
|
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
|
||||||
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
|
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
||||||
out.Reset()
|
out.Reset()
|
||||||
})
|
})
|
||||||
|
|
||||||
It("clears the stored state and queues each id at Bump priority", func() {
|
It("clears the stored state and queues each id at Bump priority", func() {
|
||||||
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: model.KindAlbumArtwork.Prefix(),
|
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindAlbumArtwork.Prefix(),
|
||||||
ItemID: "al-1", ImageType: model.ImageTypePrimary, Hash: "abc123"})).To(Succeed())
|
ItemID: "al-1", ImageType: model.ImageTypePrimary, Hash: "abc123"})).To(Succeed())
|
||||||
|
|
||||||
Expect(refreshItems(ctx, ds, []model.ArtworkID{
|
Expect(refreshItems(ctx, ds, []model.ArtworkID{
|
||||||
{Kind: model.KindAlbumArtwork, ID: "al-1"}, {Kind: model.KindAlbumArtwork, ID: "al-3"}}, &out)).To(BeZero())
|
{Kind: model.KindAlbumArtwork, ID: "al-1"}, {Kind: model.KindAlbumArtwork, ID: "al-3"}}, &out)).To(BeZero())
|
||||||
|
|
||||||
_, err := art.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
_, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
queued, err := queue.Get(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump))
|
Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump))
|
||||||
Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n"))
|
Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n"))
|
||||||
|
|
@ -996,7 +996,7 @@ var _ = Describe("refreshItems", func() {
|
||||||
It("skips an id that does not exist instead of queuing it", func() {
|
It("skips an id that does not exist instead of queuing it", func() {
|
||||||
Expect(refreshItems(ctx, ds, []model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "al-2"}}, &out)).To(Equal(1))
|
Expect(refreshItems(ctx, ds, []model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "al-2"}}, &out)).To(Equal(1))
|
||||||
|
|
||||||
_, err := queue.Get(ctx, model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
|
_, err := queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound), "a typo must not leave an orphan queue row")
|
Expect(err).To(MatchError(model.ErrNotFound), "a typo must not leave an orphan queue row")
|
||||||
Expect(out.String()).To(BeEmpty())
|
Expect(out.String()).To(BeEmpty())
|
||||||
})
|
})
|
||||||
|
|
@ -1145,9 +1145,9 @@ var _ = Describe("cancelArtwork", func() {
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
|
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
||||||
out.Reset()
|
out.Reset()
|
||||||
Expect(queue.Enqueue(ctx,
|
Expect(queue.Enqueue(
|
||||||
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", ImageType: model.ImageTypePrimary,
|
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", ImageType: model.ImageTypePrimary,
|
||||||
Priority: model.ArtworkPriorityBackfill},
|
Priority: model.ArtworkPriorityBackfill},
|
||||||
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-2", ImageType: model.ImageTypePrimary,
|
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-2", ImageType: model.ImageTypePrimary,
|
||||||
|
|
@ -1164,28 +1164,28 @@ var _ = Describe("cancelArtwork", func() {
|
||||||
Expect(out.String()).To(ContainSubstring("backfill"))
|
Expect(out.String()).To(ContainSubstring("backfill"))
|
||||||
Expect(out.String()).To(ContainSubstring("TOTAL"))
|
Expect(out.String()).To(ContainSubstring("TOTAL"))
|
||||||
Expect(out.String()).To(ContainSubstring("Dry run"))
|
Expect(out.String()).To(ContainSubstring("Dry run"))
|
||||||
Expect(queue.Count(ctx)).To(BeNumerically("==", 3))
|
Expect(queue.Count()).To(BeNumerically("==", 3))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("cancels nothing when the operator declines", func() {
|
It("cancels nothing when the operator declines", func() {
|
||||||
Expect(cancelArtwork(ctx, ds, nil, nil, false, decline, &out)).To(Succeed())
|
Expect(cancelArtwork(ctx, ds, nil, nil, false, decline, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(out.String()).To(ContainSubstring("Aborted"))
|
Expect(out.String()).To(ContainSubstring("Aborted"))
|
||||||
Expect(queue.Count(ctx)).To(BeNumerically("==", 3))
|
Expect(queue.Count()).To(BeNumerically("==", 3))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("deletes the selected rows and leaves the rest queued", func() {
|
It("deletes the selected rows and leaves the rest queued", func() {
|
||||||
Expect(cancelArtwork(ctx, ds, nil, []int{model.ArtworkPriorityBackfill}, false, accept, &out)).To(Succeed())
|
Expect(cancelArtwork(ctx, ds, nil, []int{model.ArtworkPriorityBackfill}, false, accept, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(queue.Count(ctx)).To(BeNumerically("==", 1))
|
Expect(queue.Count()).To(BeNumerically("==", 1))
|
||||||
_, err := queue.Get(ctx, model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
_, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred(), "a non-matching priority must stay queued")
|
Expect(err).ToNot(HaveOccurred(), "a non-matching priority must stay queued")
|
||||||
Expect(out.String()).To(ContainSubstring("Cancelled 2 of 2 matched items."))
|
Expect(out.String()).To(ContainSubstring("Cancelled 2 of 2 matched items."))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("cancels every kind and priority when neither filter is given", func() {
|
It("cancels every kind and priority when neither filter is given", func() {
|
||||||
Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(Succeed())
|
Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(Succeed())
|
||||||
Expect(queue.Count(ctx)).To(BeZero())
|
Expect(queue.Count()).To(BeZero())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("stops at a selection that matches nothing instead of prompting", func() {
|
It("stops at a selection that matches nothing instead of prompting", func() {
|
||||||
|
|
@ -1196,7 +1196,7 @@ var _ = Describe("cancelArtwork", func() {
|
||||||
Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, false, refuse, &out)).To(Succeed())
|
Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, false, refuse, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(out.String()).To(ContainSubstring("Nothing matches this selection."))
|
Expect(out.String()).To(ContainSubstring("Nothing matches this selection."))
|
||||||
Expect(queue.Count(ctx)).To(BeNumerically("==", 3))
|
Expect(queue.Count()).To(BeNumerically("==", 3))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("reports a queue read failure instead of reporting nothing to cancel", func() {
|
It("reports a queue read failure instead of reporting nothing to cancel", func() {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"path/filepath"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
|
@ -29,7 +31,7 @@ func init() {
|
||||||
pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero")
|
pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero")
|
||||||
backupRoot.AddCommand(pruneCmd)
|
backupRoot.AddCommand(pruneCmd)
|
||||||
|
|
||||||
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "file name of the backup database to restore (resolved against the backup directory unless it is an absolute path)")
|
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "path of backup database to restore")
|
||||||
restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
|
restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
|
||||||
_ = restoreCommand.MarkFlagRequired("backup-file")
|
_ = restoreCommand.MarkFlagRequired("backup-file")
|
||||||
backupRoot.AddCommand(restoreCommand)
|
backupRoot.AddCommand(restoreCommand)
|
||||||
|
|
@ -76,12 +78,24 @@ func runBackup(ctx context.Context) {
|
||||||
conf.Server.Backup.Path = conf.NewDir(backupDir)
|
conf.Server.Backup.Path = conf.NewDir(backupDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
requireExistingDB()
|
idx := strings.LastIndex(conf.Server.DbPath, "?")
|
||||||
|
var path string
|
||||||
|
|
||||||
|
if idx == -1 {
|
||||||
|
path = conf.Server.DbPath
|
||||||
|
} else {
|
||||||
|
path = conf.Server.DbPath[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
log.Fatal("No existing database", "path", path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
path, err := db.Backup(ctx)
|
path, err := db.Backup(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error backing up database", "backupPath", conf.Server.Backup.Path, err)
|
log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsed := time.Since(start)
|
elapsed := time.Since(start)
|
||||||
|
|
@ -97,17 +111,36 @@ func runPrune(ctx context.Context) {
|
||||||
conf.Server.Backup.Count = backupCount
|
conf.Server.Backup.Count = backupCount
|
||||||
}
|
}
|
||||||
|
|
||||||
if conf.Server.Backup.Count == 0 && !force && !confirmYES("Warning: pruning ALL backups") {
|
if conf.Server.Backup.Count == 0 && !force {
|
||||||
log.Warn("Prune cancelled")
|
fmt.Println("Warning: pruning ALL backups")
|
||||||
return
|
fmt.Printf("Please enter YES (all caps) to continue: ")
|
||||||
|
var input string
|
||||||
|
_, err := fmt.Scanln(&input)
|
||||||
|
|
||||||
|
if input != "YES" || err != nil {
|
||||||
|
log.Warn("Prune cancelled")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
requireExistingDB()
|
idx := strings.LastIndex(conf.Server.DbPath, "?")
|
||||||
|
var path string
|
||||||
|
|
||||||
|
if idx == -1 {
|
||||||
|
path = conf.Server.DbPath
|
||||||
|
} else {
|
||||||
|
path = conf.Server.DbPath[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
log.Fatal("No existing database", "path", path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
count, err := db.Prune(ctx)
|
count, err := db.Prune(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error pruning database", "backupPath", conf.Server.Backup.Path, err)
|
log.Fatal("Error pruning up database", "backup path", conf.Server.BasePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsed := time.Since(start)
|
elapsed := time.Since(start)
|
||||||
|
|
@ -116,29 +149,36 @@ func runPrune(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func runRestore(ctx context.Context) {
|
func runRestore(ctx context.Context) {
|
||||||
requireExistingDB()
|
idx := strings.LastIndex(conf.Server.DbPath, "?")
|
||||||
|
var path string
|
||||||
|
|
||||||
// A relative --backup-file is resolved against Backup.Path, the same folder
|
if idx == -1 {
|
||||||
// `backup create` writes to. Without this, the value was treated as relative
|
path = conf.Server.DbPath
|
||||||
// to the working directory, where the file does not exist.
|
} else {
|
||||||
if !filepath.IsAbs(restorePath) {
|
path = conf.Server.DbPath[:idx]
|
||||||
backupPath, err := conf.Server.Backup.Path.Path()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Backup directory not available", "backupPath", conf.Server.Backup.Path, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
restorePath = filepath.Join(backupPath, restorePath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !force && !confirmYES("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.") {
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
log.Warn("Restore cancelled")
|
log.Fatal("No existing database", "path", path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !force {
|
||||||
|
fmt.Println("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.")
|
||||||
|
fmt.Printf("Please enter YES (all caps) to continue: ")
|
||||||
|
var input string
|
||||||
|
_, err := fmt.Scanln(&input)
|
||||||
|
|
||||||
|
if input != "YES" || err != nil {
|
||||||
|
log.Warn("Restore cancelled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
err := db.Restore(ctx, restorePath)
|
err := db.Restore(ctx, restorePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error restoring database", "backupFile", restorePath, err)
|
log.Fatal("Error restoring database", "backup path", conf.Server.BasePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsed := time.Since(start)
|
elapsed := time.Since(start)
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/db"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(doctorCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
var doctorCmd = &cobra.Command{
|
|
||||||
Use: "doctor",
|
|
||||||
Short: "Check your Navidrome installation for problems",
|
|
||||||
Long: "Run read-only health checks and report what was found. Checks the database for " +
|
|
||||||
"corruption and foreign key violations, and reports whether 'navidrome search rebuild' " +
|
|
||||||
"can fix what it finds. This command never alters your data",
|
|
||||||
Run: func(cmd *cobra.Command, _ []string) {
|
|
||||||
runDoctor(cmd.Context())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func runDoctor(ctx context.Context) {
|
|
||||||
requireExistingDB()
|
|
||||||
|
|
||||||
healthy := doctor(ctx, db.Db(), os.Stdout)
|
|
||||||
db.Close(ctx)
|
|
||||||
if !healthy {
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const recoveryAdvice = "Restore a backup (navidrome backup restore), or try SQLite's '.recover' command."
|
|
||||||
|
|
||||||
func printFindings(out io.Writer, check, noun string, items []string) {
|
|
||||||
fmt.Fprintf(out, "%s reported %d %s:\n", check, len(items), noun)
|
|
||||||
for _, item := range items {
|
|
||||||
fmt.Fprintln(out, " "+item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func doctor(ctx context.Context, database *sql.DB, out io.Writer) bool {
|
|
||||||
healthy := true
|
|
||||||
|
|
||||||
fmt.Fprintln(out, "Checking database integrity...")
|
|
||||||
issues, truncated, err := db.IntegrityCheck(ctx, database)
|
|
||||||
switch {
|
|
||||||
case err != nil:
|
|
||||||
fmt.Fprintln(out, "The integrity check could not complete: "+err.Error())
|
|
||||||
fmt.Fprintln(out, recoveryAdvice)
|
|
||||||
return false
|
|
||||||
case len(issues) == 0:
|
|
||||||
fmt.Fprintln(out, "Integrity check passed.")
|
|
||||||
default:
|
|
||||||
healthy = false
|
|
||||||
printFindings(out, "Integrity check", "issue(s)", issues)
|
|
||||||
switch {
|
|
||||||
case truncated:
|
|
||||||
fmt.Fprintln(out, "The integrity check stopped at its limit, so the damage may reach further than listed.")
|
|
||||||
fmt.Fprintln(out, recoveryAdvice)
|
|
||||||
case db.IsFTSCorruptionOnly(issues):
|
|
||||||
fmt.Fprintln(out, "Corruption is limited to the search index. Run 'navidrome search rebuild' to fix it.")
|
|
||||||
default:
|
|
||||||
fmt.Fprintln(out, "Corruption is not limited to the search index, and cannot be repaired automatically.")
|
|
||||||
fmt.Fprintln(out, recoveryAdvice)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintln(out, "Checking foreign keys...")
|
|
||||||
violations, err := db.ForeignKeyCheck(ctx, database)
|
|
||||||
switch {
|
|
||||||
case err != nil:
|
|
||||||
healthy = false
|
|
||||||
fmt.Fprintln(out, "The foreign key check could not complete: "+err.Error())
|
|
||||||
case len(violations) == 0:
|
|
||||||
fmt.Fprintln(out, "Foreign key check passed.")
|
|
||||||
default:
|
|
||||||
healthy = false
|
|
||||||
lines := make([]string, 0, len(violations))
|
|
||||||
for _, v := range violations {
|
|
||||||
lines = append(lines,
|
|
||||||
fmt.Sprintf("%s: %d row(s) reference missing rows in %s", v.Table, v.Count, v.Parent))
|
|
||||||
}
|
|
||||||
printFindings(out, "Foreign key check", "violation(s)", lines)
|
|
||||||
fmt.Fprintln(out, "These are orphaned rows, not corruption. 'navidrome scan -f' clears some of them "+
|
|
||||||
"in library data; the rest have to be removed by hand.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if healthy {
|
|
||||||
fmt.Fprintln(out, "Database is healthy.")
|
|
||||||
}
|
|
||||||
return healthy
|
|
||||||
}
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/db"
|
|
||||||
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("doctor", func() {
|
|
||||||
var (
|
|
||||||
ctx context.Context
|
|
||||||
dbPath string
|
|
||||||
database *sql.DB
|
|
||||||
out *strings.Builder
|
|
||||||
reopen func()
|
|
||||||
)
|
|
||||||
|
|
||||||
// A file-backed DB so specs can corrupt raw pages; a table named like a real FTS
|
|
||||||
// search table so IsFTSCorruptionOnly matches, plus a parent/child pair for FK checks.
|
|
||||||
BeforeEach(func() {
|
|
||||||
ctx = context.Background()
|
|
||||||
dbPath = filepath.Join(GinkgoT().TempDir(), "doctor.db")
|
|
||||||
reopen = func() {
|
|
||||||
var err error
|
|
||||||
database, err = sql.Open(db.Dialect, dbPath)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
database.SetMaxOpenConns(1)
|
|
||||||
}
|
|
||||||
reopen()
|
|
||||||
DeferCleanup(func() { _ = database.Close() })
|
|
||||||
|
|
||||||
for _, stmt := range []string{
|
|
||||||
`create virtual table media_file_fts using fts5(title, content='', content_rowid='rowid')`,
|
|
||||||
`insert into media_file_fts(rowid, title) values (1, 'teenage lobotomy'), (2, 'rockaway beach')`,
|
|
||||||
`create table library(id integer primary key)`,
|
|
||||||
`create table media_file(id integer primary key, library_id integer references library(id))`,
|
|
||||||
} {
|
|
||||||
_, err := database.ExecContext(ctx, stmt)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
}
|
|
||||||
out = &strings.Builder{}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reports a healthy database", func() {
|
|
||||||
Expect(doctor(ctx, database, out)).To(BeTrue())
|
|
||||||
Expect(out.String()).To(ContainSubstring("Database is healthy."))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("points to 'search rebuild' when corruption is limited to the search index", func() {
|
|
||||||
_, err := database.ExecContext(ctx,
|
|
||||||
`update media_file_fts_data set block = x'deadbeefdeadbeef' where id > 1`)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
Expect(doctor(ctx, database, out)).To(BeFalse())
|
|
||||||
Expect(out.String()).To(ContainSubstring("navidrome search rebuild"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("points to a backup restore when corruption is not limited to the search index", func() {
|
|
||||||
_, err := database.ExecContext(ctx,
|
|
||||||
`insert into library(id)
|
|
||||||
with recursive s(x) as (select 1 union all select x+1 from s where x < 200)
|
|
||||||
select x from s`)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
var rootPage, pageSize int64
|
|
||||||
Expect(database.QueryRowContext(ctx,
|
|
||||||
`select rootpage from sqlite_master where name = 'library'`).Scan(&rootPage)).To(Succeed())
|
|
||||||
Expect(database.QueryRowContext(ctx, `pragma page_size`).Scan(&pageSize)).To(Succeed())
|
|
||||||
Expect(database.Close()).To(Succeed())
|
|
||||||
f, err := os.OpenFile(dbPath, os.O_WRONLY, 0600)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
_, err = f.WriteAt([]byte{0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef}, (rootPage-1)*pageSize+40)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(f.Close()).To(Succeed())
|
|
||||||
reopen()
|
|
||||||
|
|
||||||
Expect(doctor(ctx, database, out)).To(BeFalse())
|
|
||||||
Expect(out.String()).To(ContainSubstring("backup restore"))
|
|
||||||
Expect(out.String()).ToNot(ContainSubstring("search rebuild"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reports foreign key violations", func() {
|
|
||||||
_, err := database.ExecContext(ctx, `pragma foreign_keys = off`)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
_, err = database.ExecContext(ctx, `insert into media_file(id, library_id) values (1, 999)`)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
Expect(doctor(ctx, database, out)).To(BeFalse())
|
|
||||||
Expect(out.String()).To(ContainSubstring("Foreign key check reported"))
|
|
||||||
Expect(out.String()).To(ContainSubstring("media_file"))
|
|
||||||
Expect(out.String()).To(ContainSubstring("navidrome scan -f"))
|
|
||||||
// GC never touches player, share or playqueue, so don't promise a full cleanup.
|
|
||||||
Expect(out.String()).To(ContainSubstring("removed by hand"))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Every issue names an FTS-like index, so IsFTSCorruptionOnly alone would send the
|
|
||||||
// user to 'search rebuild', but the pragma stopped at its limit without saying so.
|
|
||||||
It("does not blame the search index when the issue list is truncated", func() {
|
|
||||||
for _, stmt := range []string{
|
|
||||||
`create table t(a, b)`,
|
|
||||||
`with recursive s(x) as (select 1 union all select x+1 from s where x < 300)
|
|
||||||
insert into t select x, x + 10000 from s`,
|
|
||||||
`create index media_file_fts_probe on t(a)`,
|
|
||||||
`pragma writable_schema=on`,
|
|
||||||
`update sqlite_master set sql = 'CREATE INDEX media_file_fts_probe ON t(b)'
|
|
||||||
where name = 'media_file_fts_probe'`,
|
|
||||||
} {
|
|
||||||
_, err := database.ExecContext(ctx, stmt)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
}
|
|
||||||
Expect(database.Close()).To(Succeed())
|
|
||||||
reopen()
|
|
||||||
|
|
||||||
Expect(doctor(ctx, database, out)).To(BeFalse())
|
|
||||||
Expect(out.String()).ToNot(ContainSubstring("search rebuild"))
|
|
||||||
Expect(out.String()).To(ContainSubstring("backup restore"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,17 +1,13 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/db"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/persistence"
|
|
||||||
"github.com/pelletier/go-toml/v2"
|
"github.com/pelletier/go-toml/v2"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
@ -32,7 +28,7 @@ var inspectCmd = &cobra.Command{
|
||||||
Long: "Show file tags as seen by Navidrome",
|
Long: "Show file tags as seen by Navidrome",
|
||||||
Args: cobra.MinimumNArgs(1),
|
Args: cobra.MinimumNArgs(1),
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
runInspector(cmd.Context(), args)
|
runInspector(args)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,24 +55,18 @@ func prettyMarshal(v any) ([]byte, error) {
|
||||||
return []byte(res.String()), nil
|
return []byte(res.String()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runInspector(ctx context.Context, args []string) {
|
func runInspector(args []string) {
|
||||||
marshal := marshalers[format]
|
marshal := marshalers[format]
|
||||||
if marshal == nil {
|
if marshal == nil {
|
||||||
log.Fatal("Invalid format", "format", format)
|
log.Fatal("Invalid format", "format", format)
|
||||||
}
|
}
|
||||||
libs := loadLibraries(ctx)
|
|
||||||
matcher := model.NewLibraryMatcher(libs)
|
|
||||||
var out []core.InspectOutput
|
var out []core.InspectOutput
|
||||||
for _, filePath := range args {
|
for _, filePath := range args {
|
||||||
if !model.IsAudioFile(filePath) {
|
if !model.IsAudioFile(filePath) {
|
||||||
log.Warn("Not an audio file", "file", filePath)
|
log.Warn("Not an audio file", "file", filePath)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
lib, ok := libraryForFile(matcher, filePath)
|
output, err := core.Inspect(filePath, 1, "")
|
||||||
if !ok && len(libs) > 0 {
|
|
||||||
log.Warn("File is not in any library, using the global PID config", "file", filePath)
|
|
||||||
}
|
|
||||||
output, err := core.Inspect(filePath, lib, "")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("Unable to process file", "file", filePath, "error", err)
|
log.Warn("Unable to process file", "file", filePath, "error", err)
|
||||||
continue
|
continue
|
||||||
|
|
@ -87,33 +77,3 @@ func runInspector(ctx context.Context, args []string) {
|
||||||
data, _ := marshal(out)
|
data, _ := marshal(out)
|
||||||
fmt.Println(string(data))
|
fmt.Println(string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadLibraries reads the libraries, so each file gets its library's PID config. It never creates a DB.
|
|
||||||
func loadLibraries(ctx context.Context) model.Libraries {
|
|
||||||
if dbFile, ok := existingDBFile(); !ok {
|
|
||||||
log.Warn(ctx, "No database found, using the global PID config", "path", dbFile)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
defer db.Init(ctx)()
|
|
||||||
libs, err := persistence.New(db.Db()).Library().GetAll(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn(ctx, "Could not load libraries, using the global PID config", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for i := range libs {
|
|
||||||
if absPath, err := filepath.Abs(libs[i].Path); err == nil {
|
|
||||||
libs[i].Path = absPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return libs
|
|
||||||
}
|
|
||||||
|
|
||||||
// libraryForFile falls back to the default library with no overrides, which uses the global PID config.
|
|
||||||
func libraryForFile(matcher *model.LibraryMatcher, filePath string) (model.Library, bool) {
|
|
||||||
if absPath, err := filepath.Abs(filePath); err == nil {
|
|
||||||
if lib, ok := matcher.FindLibrary(absPath); ok {
|
|
||||||
return lib, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return model.Library{ID: model.DefaultLibraryID}, false
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("inspect", func() {
|
|
||||||
Describe("libraryForFile", func() {
|
|
||||||
var matcher *model.LibraryMatcher
|
|
||||||
var root string
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
root = GinkgoT().TempDir()
|
|
||||||
cwd, err := os.Getwd()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
matcher = model.NewLibraryMatcher(model.Libraries{
|
|
||||||
{ID: 1, Path: filepath.Join(root, "music")},
|
|
||||||
{ID: 2, Path: filepath.Join(cwd, "loose"), PIDAlbum: "folder"},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns the library that contains an absolute path", func() {
|
|
||||||
lib, ok := libraryForFile(matcher, filepath.Join(root, "music", "album", "track.mp3"))
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(lib.ID).To(Equal(1))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("resolves a relative path against the working directory", func() {
|
|
||||||
lib, ok := libraryForFile(matcher, filepath.Join("loose", "track.mp3"))
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(lib.PIDAlbum).To(Equal("folder"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("falls back to the default library without overrides", func() {
|
|
||||||
lib, ok := libraryForFile(matcher, filepath.Join(root, "elsewhere", "track.mp3"))
|
|
||||||
Expect(ok).To(BeFalse())
|
|
||||||
Expect(lib).To(Equal(model.Library{ID: model.DefaultLibraryID}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("loadLibraries", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("does not create a database when there is none", func() {
|
|
||||||
dbFile := filepath.Join(GinkgoT().TempDir(), "navidrome.db")
|
|
||||||
conf.Server.DbPath = dbFile + "?_journal_mode=WAL"
|
|
||||||
|
|
||||||
Expect(loadLibraries(GinkgoT().Context())).To(BeNil())
|
|
||||||
Expect(dbFile).ToNot(BeAnExistingFile())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
171
cmd/missing.go
171
cmd/missing.go
|
|
@ -1,171 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"context"
|
|
||||||
"encoding/csv"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
|
||||||
"github.com/navidrome/navidrome/core"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var missingListFormat string
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
missingListCmd.Flags().StringVarP(&missingListFormat, "format", "f", "csv", "output format [supported values: csv, json]")
|
|
||||||
missingCmd.AddCommand(missingListCmd)
|
|
||||||
missingCmd.AddCommand(missingFixCmd)
|
|
||||||
rootCmd.AddCommand(missingCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
missingCmd = &cobra.Command{
|
|
||||||
Use: "missing",
|
|
||||||
Short: "Manage missing files",
|
|
||||||
Long: "List files marked as missing and remap them onto existing files",
|
|
||||||
}
|
|
||||||
|
|
||||||
missingListCmd = &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List missing files",
|
|
||||||
Run: func(cmd *cobra.Command, _ []string) {
|
|
||||||
runMissingList(cmd.Context())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
missingFixCmd = &cobra.Command{
|
|
||||||
Use: "fix <missing path|id> <target path|id>",
|
|
||||||
Short: "Remap a missing file onto an existing file",
|
|
||||||
Long: "Remap a file marked as missing onto an existing (non-missing) file, the same way\n" +
|
|
||||||
"the scanner reconciles moved or renamed files. Each argument may be a media file ID,\n" +
|
|
||||||
"a library-relative path, or a libraryID:path pair.",
|
|
||||||
Args: cobra.ExactArgs(2),
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
runMissingFix(cmd.Context(), args[0], args[1])
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
type displayMissingFile struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
LibraryID int `json:"libraryId"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Album string `json:"album"`
|
|
||||||
Artist string `json:"artist"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func runMissingList(ctx context.Context) {
|
|
||||||
if missingListFormat != "csv" && missingListFormat != "json" {
|
|
||||||
log.Fatal("Invalid output format. Must be one of csv, json", "format", missingListFormat)
|
|
||||||
}
|
|
||||||
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
|
||||||
mfs, err := ds.MediaFile().GetCursor(ctx, model.QueryOptions{
|
|
||||||
Filters: squirrel.Eq{"missing": true},
|
|
||||||
Sort: "path",
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
err = writeMissingList(os.Stdout, missingListFormat, mfs)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to retrieve missing files", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeMissingList streams the cursor so a library with many missing files doesn't get loaded into memory
|
|
||||||
func writeMissingList(w io.Writer, format string, mfs model.MediaFileCursor) error {
|
|
||||||
if format == "json" {
|
|
||||||
bw := bufio.NewWriter(w)
|
|
||||||
_, _ = io.WriteString(bw, "[")
|
|
||||||
sep := ""
|
|
||||||
for mf, err := range mfs {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
j, _ := json.Marshal(displayMissingFile{ID: mf.ID, LibraryID: mf.LibraryID, Title: mf.Title, Album: mf.Album, Artist: mf.Artist, Path: mf.Path})
|
|
||||||
_, _ = fmt.Fprintf(bw, "%s%s", sep, j)
|
|
||||||
sep = ","
|
|
||||||
}
|
|
||||||
_, _ = io.WriteString(bw, "]\n")
|
|
||||||
return bw.Flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
cw := csv.NewWriter(w)
|
|
||||||
_ = cw.Write([]string{"id", "library id", "title", "album", "artist", "path"})
|
|
||||||
for mf, err := range mfs {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_ = cw.Write([]string{mf.ID, strconv.Itoa(mf.LibraryID), mf.Title, mf.Album, mf.Artist, mf.Path})
|
|
||||||
}
|
|
||||||
cw.Flush()
|
|
||||||
return cw.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
func runMissingFix(ctx context.Context, missingRef, targetRef string) {
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
|
||||||
|
|
||||||
missing := resolveMediaFile(ctx, ds, missingRef)
|
|
||||||
target := resolveMediaFile(ctx, ds, targetRef)
|
|
||||||
|
|
||||||
if err := core.NewMaintenance(ds).RemapMissingFile(ctx, missing.ID, target.ID); err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to remap missing file", "missing", missing.Path, "target", target.Path, err)
|
|
||||||
}
|
|
||||||
fmt.Printf("Remapped %q onto %q\n", missing.Path, target.Path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveMediaFile looks up a media file by ID first, then by path (optionally libraryID:path).
|
|
||||||
func resolveMediaFile(ctx context.Context, ds model.DataStore, ref string) *model.MediaFile {
|
|
||||||
mf, err := ds.MediaFile().Get(ctx, ref)
|
|
||||||
if err == nil {
|
|
||||||
return mf
|
|
||||||
}
|
|
||||||
if !errors.Is(err, model.ErrNotFound) {
|
|
||||||
log.Fatal(ctx, "Error looking up media file", "ref", ref, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mfs, err := ds.MediaFile().FindByPaths(ctx, []string{ref})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Error looking up media file by path", "ref", ref, err)
|
|
||||||
}
|
|
||||||
if len(mfs) == 0 {
|
|
||||||
log.Fatal(ctx, "No media file found", "ref", ref)
|
|
||||||
}
|
|
||||||
mfs = preferQualified(ref, mfs)
|
|
||||||
if len(mfs) > 1 {
|
|
||||||
log.Fatal(ctx, "Path matches multiple files; disambiguate with an ID or libraryID:path", "ref", ref, "matches", len(mfs))
|
|
||||||
}
|
|
||||||
return &mfs[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// preferQualified resolves the ambiguity FindByPaths creates by searching a "libraryID:path"
|
|
||||||
// reference both ways: an explicit library wins over a file literally named like one.
|
|
||||||
func preferQualified(ref string, mfs model.MediaFiles) model.MediaFiles {
|
|
||||||
id, path, ok := strings.Cut(ref, ":")
|
|
||||||
if !ok {
|
|
||||||
return mfs
|
|
||||||
}
|
|
||||||
libraryID, err := strconv.Atoi(id)
|
|
||||||
if err != nil {
|
|
||||||
return mfs
|
|
||||||
}
|
|
||||||
qualified := slice.Filter(mfs, func(mf model.MediaFile) bool {
|
|
||||||
return mf.LibraryID == libraryID && strings.EqualFold(mf.Path, path)
|
|
||||||
})
|
|
||||||
if len(qualified) == 0 {
|
|
||||||
return mfs
|
|
||||||
}
|
|
||||||
return qualified
|
|
||||||
}
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("writeMissingList", func() {
|
|
||||||
cursor := func(err error, mfs ...model.MediaFile) model.MediaFileCursor {
|
|
||||||
return func(yield func(model.MediaFile, error) bool) {
|
|
||||||
for _, mf := range mfs {
|
|
||||||
if !yield(mf, nil) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
yield(model.MediaFile{}, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
song := model.MediaFile{ID: "1", LibraryID: 1, Path: "Bach: Goldberg/01.mp3", Title: "Aria", Album: "Goldberg", Artist: "Bach"}
|
|
||||||
|
|
||||||
It("writes csv with a header, quoting as needed", func() {
|
|
||||||
var out strings.Builder
|
|
||||||
Expect(writeMissingList(&out, "csv", cursor(nil, song))).To(Succeed())
|
|
||||||
Expect(out.String()).To(Equal("id,library id,title,album,artist,path\n1,1,Aria,Goldberg,Bach,Bach: Goldberg/01.mp3\n"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("writes a json array", func() {
|
|
||||||
var out strings.Builder
|
|
||||||
Expect(writeMissingList(&out, "json", cursor(nil, song, song))).To(Succeed())
|
|
||||||
Expect(out.String()).To(MatchJSON(`[
|
|
||||||
{"id":"1","libraryId":1,"path":"Bach: Goldberg/01.mp3","title":"Aria","album":"Goldberg","artist":"Bach"},
|
|
||||||
{"id":"1","libraryId":1,"path":"Bach: Goldberg/01.mp3","title":"Aria","album":"Goldberg","artist":"Bach"}
|
|
||||||
]`))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("writes an empty json array when nothing is missing", func() {
|
|
||||||
var out strings.Builder
|
|
||||||
Expect(writeMissingList(&out, "json", cursor(nil))).To(Succeed())
|
|
||||||
Expect(out.String()).To(MatchJSON(`[]`))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns the cursor's error", func() {
|
|
||||||
var out strings.Builder
|
|
||||||
Expect(writeMissingList(&out, "csv", cursor(errors.New("boom"), song))).To(MatchError("boom"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("preferQualified", func() {
|
|
||||||
target := model.MediaFile{ID: "want", LibraryID: 1, Path: "foo.mp3"}
|
|
||||||
decoy := model.MediaFile{ID: "decoy", LibraryID: 1, Path: "1:foo.mp3"}
|
|
||||||
|
|
||||||
It("picks the library-qualified match over a literal path that looks like one", func() {
|
|
||||||
Expect(preferQualified("1:foo.mp3", model.MediaFiles{target, decoy})).To(Equal(model.MediaFiles{target}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("picks the named library when the same path exists in two", func() {
|
|
||||||
other := model.MediaFile{ID: "other", LibraryID: 2, Path: "foo.mp3"}
|
|
||||||
Expect(preferQualified("1:foo.mp3", model.MediaFiles{target, other})).To(Equal(model.MediaFiles{target}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("leaves an unqualified reference ambiguous", func() {
|
|
||||||
both := model.MediaFiles{target, {ID: "other", LibraryID: 2, Path: "foo.mp3"}}
|
|
||||||
Expect(preferQualified("foo.mp3", both)).To(Equal(both))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("leaves it alone when the prefix is not a library id", func() {
|
|
||||||
both := model.MediaFiles{decoy, {ID: "other", LibraryID: 2, Path: "1:foo.mp3"}}
|
|
||||||
Expect(preferQualified("x:foo.mp3", both)).To(Equal(both))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("leaves it alone when no candidate matches the qualified form", func() {
|
|
||||||
both := model.MediaFiles{decoy, {ID: "other", LibraryID: 2, Path: "1:foo.mp3"}}
|
|
||||||
Expect(preferQualified("9:nope.mp3", both)).To(Equal(both))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
10
cmd/pls.go
10
cmd/pls.go
|
|
@ -109,7 +109,7 @@ func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.
|
||||||
}
|
}
|
||||||
options.Filters = squirrel.Eq{"owner_id": user.ID}
|
options.Filters = squirrel.Eq{"owner_id": user.ID}
|
||||||
}
|
}
|
||||||
pls, err := ds.Playlist().GetAll(ctx, options)
|
pls, err := ds.Playlist(ctx).GetAll(options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Failed to retrieve playlists", err)
|
log.Fatal(ctx, "Failed to retrieve playlists", err)
|
||||||
}
|
}
|
||||||
|
|
@ -117,17 +117,17 @@ func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.
|
||||||
}
|
}
|
||||||
|
|
||||||
func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist {
|
func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist {
|
||||||
playlist, err := ds.Playlist().GetWithTracks(ctx, nameOrID, true, false)
|
playlist, err := ds.Playlist(ctx).GetWithTracks(nameOrID, true, false)
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||||
}
|
}
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
playlists, err := ds.Playlist().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
|
playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||||
}
|
}
|
||||||
if len(playlists) > 0 {
|
if len(playlists) > 0 {
|
||||||
playlist, err = ds.Playlist().GetWithTracks(ctx, playlists[0].ID, true, false)
|
playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||||
}
|
}
|
||||||
|
|
@ -194,7 +194,7 @@ func runExport(ctx context.Context) {
|
||||||
|
|
||||||
exported := 0
|
exported := 0
|
||||||
for _, pls := range allPls {
|
for _, pls := range allPls {
|
||||||
plsWithTracks, err := ds.Playlist().GetWithTracks(ctx, pls.ID, true, false)
|
plsWithTracks, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Error loading playlist tracks", "playlist", pls.Name, err)
|
log.Error("Error loading playlist tracks", "playlist", pls.Name, err)
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -243,7 +243,7 @@ func runPluginInfo(ctx context.Context, arg string) {
|
||||||
}
|
}
|
||||||
requirePluginsEnabled(ctx)
|
requirePluginsEnabled(ctx)
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
p, err := ds.Plugin().Get(ctx, arg)
|
p, err := ds.Plugin(ctx).Get(arg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
||||||
}
|
}
|
||||||
|
|
@ -264,7 +264,7 @@ func runPluginValidate(ctx context.Context, arg string) {
|
||||||
}
|
}
|
||||||
requirePluginsEnabled(ctx)
|
requirePluginsEnabled(ctx)
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
p, err := ds.Plugin().Get(ctx, arg)
|
p, err := ds.Plugin(ctx).Get(arg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
||||||
}
|
}
|
||||||
|
|
@ -329,7 +329,7 @@ func formatPluginList(list model.Plugins, format string) (string, error) {
|
||||||
func runPluginList(ctx context.Context) {
|
func runPluginList(ctx context.Context) {
|
||||||
requirePluginsEnabled(ctx)
|
requirePluginsEnabled(ctx)
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
list, err := ds.Plugin().GetAll(ctx)
|
list, err := ds.Plugin(ctx).GetAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Failed to list plugins", err)
|
log.Fatal(ctx, "Failed to list plugins", err)
|
||||||
}
|
}
|
||||||
|
|
@ -372,7 +372,7 @@ var pluginEditCmd = &cobra.Command{
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
requirePluginsEnabled(cmd.Context())
|
requirePluginsEnabled(cmd.Context())
|
||||||
ds, ctx := getAdminContext(cmd.Context())
|
ds, ctx := getAdminContext(cmd.Context())
|
||||||
cur, err := ds.Plugin().Get(ctx, args[0])
|
cur, err := ds.Plugin(ctx).Get(args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Plugin not found", "id", args[0], err)
|
log.Fatal(ctx, "Plugin not found", "id", args[0], err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
98
cmd/root.go
98
cmd/root.go
|
|
@ -44,9 +44,7 @@ Complete documentation is available at https://www.navidrome.org/docs`,
|
||||||
preRun()
|
preRun()
|
||||||
},
|
},
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
if err := runNavidrome(cmd.Context()); err != nil {
|
runNavidrome(cmd.Context())
|
||||||
log.Fatal("Fatal error in Navidrome. Aborting", err)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
PostRun: func(cmd *cobra.Command, args []string) {
|
PostRun: func(cmd *cobra.Command, args []string) {
|
||||||
postRun()
|
postRun()
|
||||||
|
|
@ -78,17 +76,16 @@ func postRun() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks.
|
// runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks.
|
||||||
// If any of the services returns an error, it stops the others and returns that error, so the caller can
|
// If any of the services returns an error, it will log it and exit. If the process receives a signal to exit,
|
||||||
// exit with a non-zero code. If the context is cancelled (a signal or a service stop), it returns nil.
|
// it will cancel the context and exit gracefully.
|
||||||
func runNavidrome(parentCtx context.Context) error {
|
func runNavidrome(ctx context.Context) {
|
||||||
defer db.Init(parentCtx)()
|
defer db.Init(ctx)()
|
||||||
|
|
||||||
g, ctx := errgroup.WithContext(parentCtx)
|
g, ctx := errgroup.WithContext(ctx)
|
||||||
g.Go(startServer(ctx))
|
g.Go(startServer(ctx))
|
||||||
g.Go(startSignaller(ctx))
|
g.Go(startSignaller(ctx))
|
||||||
g.Go(startScheduler(ctx))
|
g.Go(startScheduler(ctx))
|
||||||
g.Go(startPlaybackServer(ctx))
|
g.Go(startPlaybackServer(ctx))
|
||||||
g.Go(startJellyfinDiscovery(ctx))
|
|
||||||
g.Go(schedulePeriodicBackup(ctx))
|
g.Go(schedulePeriodicBackup(ctx))
|
||||||
g.Go(startInsightsCollector(ctx))
|
g.Go(startInsightsCollector(ctx))
|
||||||
g.Go(scheduleDBAnalyzer(ctx))
|
g.Go(scheduleDBAnalyzer(ctx))
|
||||||
|
|
@ -104,11 +101,9 @@ func runNavidrome(parentCtx context.Context) error {
|
||||||
log.Warn(ctx, "Automatic Scanning is DISABLED")
|
log.Warn(ctx, "Automatic Scanning is DISABLED")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Errors caused by a normal shutdown are not failures
|
if err := g.Wait(); err != nil {
|
||||||
if err := g.Wait(); err != nil && parentCtx.Err() == nil {
|
log.Error("Fatal error in Navidrome. Aborting", err)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// mainContext returns a context that is cancelled when the process receives a signal to exit.
|
// mainContext returns a context that is cancelled when the process receives a signal to exit.
|
||||||
|
|
@ -137,9 +132,6 @@ func startServer(ctx context.Context) func() error {
|
||||||
if conf.Server.Jellyfin.Enabled {
|
if conf.Server.Jellyfin.Enabled {
|
||||||
a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx))
|
a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx))
|
||||||
}
|
}
|
||||||
if conf.Server.DevAPIv1 {
|
|
||||||
a.MountRouter("API v1", consts.URLPathAPIv1, CreateAPIv1Router(ctx))
|
|
||||||
}
|
|
||||||
if conf.Server.Prometheus.Enabled {
|
if conf.Server.Prometheus.Enabled {
|
||||||
p := CreatePrometheus()
|
p := CreatePrometheus()
|
||||||
// blocking call because takes <100ms but useful if fails
|
// blocking call because takes <100ms but useful if fails
|
||||||
|
|
@ -190,50 +182,46 @@ func schedulePeriodicScan(ctx context.Context) func() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// librariesWithChangedPID returns the names of the libraries whose effective PID config differs from
|
func pidHashChanged(ds model.DataStore) (bool, error) {
|
||||||
// the one used by their last finished scan
|
pidAlbum, err := ds.Property(context.Background()).DefaultGet(consts.PIDAlbumKey, "")
|
||||||
func librariesWithChangedPID(ctx context.Context, ds model.DataStore) ([]string, error) {
|
|
||||||
libs, err := ds.Library().GetAll(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return false, err
|
||||||
}
|
}
|
||||||
var names []string
|
pidTrack, err := ds.Property(context.Background()).DefaultGet(consts.PIDTrackKey, "")
|
||||||
for _, lib := range libs {
|
if err != nil {
|
||||||
if lib.PIDChanged() {
|
return false, err
|
||||||
names = append(names, lib.Name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return names, nil
|
return !strings.EqualFold(pidAlbum, conf.Server.PID.Album) || !strings.EqualFold(pidTrack, conf.Server.PID.Track), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// runInitialScan runs an initial scan of the music library if needed.
|
// runInitialScan runs an initial scan of the music library if needed.
|
||||||
func runInitialScan(ctx context.Context) func() error {
|
func runInitialScan(ctx context.Context) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
ds := CreateDataStore()
|
ds := CreateDataStore()
|
||||||
fullScanRequired, err := ds.Property().DefaultGet(ctx, consts.FullScanAfterMigrationFlagKey, "0")
|
fullScanRequired, err := ds.Property(ctx).DefaultGet(consts.FullScanAfterMigrationFlagKey, "0")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
inProgress, err := ds.Library().ScanInProgress(ctx)
|
inProgress, err := ds.Library(ctx).ScanInProgress()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
pidChangedLibs, err := librariesWithChangedPID(ctx, ds)
|
pidHasChanged, err := pidHashChanged(ds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup
|
scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup
|
||||||
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || len(pidChangedLibs) > 0
|
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
|
||||||
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
||||||
if scanNeeded {
|
if scanNeeded {
|
||||||
s := CreateScanner(ctx)
|
s := CreateScanner(ctx)
|
||||||
switch {
|
switch {
|
||||||
case fullScanRequired == "1":
|
case fullScanRequired == "1":
|
||||||
log.Warn(ctx, "Full scan required after migration")
|
log.Warn(ctx, "Full scan required after migration")
|
||||||
_ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey)
|
_ = ds.Property(ctx).Delete(consts.FullScanAfterMigrationFlagKey)
|
||||||
case len(pidChangedLibs) > 0:
|
case pidHasChanged:
|
||||||
// Includes never-scanned libraries. The scanner rescans in full only the ones that need it
|
log.Warn(ctx, "PID config changed, performing full scan")
|
||||||
log.Warn(ctx, "Libraries with a new or changed PID config, scanning", "libraries", pidChangedLibs)
|
fullScanRequired = "1"
|
||||||
case inProgress:
|
case inProgress:
|
||||||
log.Warn(ctx, "Resuming interrupted scan")
|
log.Warn(ctx, "Resuming interrupted scan")
|
||||||
default:
|
default:
|
||||||
|
|
@ -355,18 +343,6 @@ func startInsightsCollector(ctx context.Context) func() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startJellyfinDiscovery never returns an error: a discovery failure must not stop the server.
|
|
||||||
func startJellyfinDiscovery(ctx context.Context) func() error {
|
|
||||||
return func() error {
|
|
||||||
if !conf.Server.Jellyfin.Enabled || !conf.Server.Jellyfin.AutoDiscovery {
|
|
||||||
log.Debug("Jellyfin auto-discovery is DISABLED")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
CreateJellyfinDiscovery().Serve(ctx)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// startPlaybackServer starts the Navidrome playback server, if configured.
|
// startPlaybackServer starts the Navidrome playback server, if configured.
|
||||||
// It is responsible for the Jukebox functionality
|
// It is responsible for the Jukebox functionality
|
||||||
func startPlaybackServer(ctx context.Context) func() error {
|
func startPlaybackServer(ctx context.Context) func() error {
|
||||||
|
|
@ -386,26 +362,10 @@ func startPlaybackServer(ctx context.Context) func() error {
|
||||||
func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() error {
|
func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
log.Info(ctx, "Starting artwork worker")
|
log.Info(ctx, "Starting artwork worker")
|
||||||
// The scanner writes to the DB for its whole run; competing for the write lock makes both fail.
|
|
||||||
worker.PauseWhile(scanner.IsScanning)
|
|
||||||
return worker.Run(ctx)
|
return worker.Run(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// outsideScan runs a DB maintenance job unless a scan is running, and keeps a scan from starting
|
|
||||||
// until it ends; both write to the DB, and competing for the lock can make either fail.
|
|
||||||
func outsideScan(ctx context.Context, job string, run func(context.Context) error) {
|
|
||||||
release, ok := scanner.LockForMaintenance()
|
|
||||||
if !ok {
|
|
||||||
log.Debug(ctx, "Skipping "+job+" because a scan is in progress")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
if err := run(ctx); err != nil {
|
|
||||||
log.Error(ctx, "Error running "+job, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// scheduleArtworkHousekeeping registers the recurring missing-state and prune jobs, and
|
// scheduleArtworkHousekeeping registers the recurring missing-state and prune jobs, and
|
||||||
// reports an artwork config change without acting on it.
|
// reports an artwork config change without acting on it.
|
||||||
func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error {
|
func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error {
|
||||||
|
|
@ -413,20 +373,26 @@ func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) fu
|
||||||
schedulerInstance := scheduler.GetInstance()
|
schedulerInstance := scheduler.GetInstance()
|
||||||
|
|
||||||
if _, err := schedulerInstance.Add(consts.ArtworkEnqueueMissingSchedule, func() {
|
if _, err := schedulerInstance.Add(consts.ArtworkEnqueueMissingSchedule, func() {
|
||||||
outsideScan(ctx, "artwork missing-state recheck", worker.EnqueueMissingAll)
|
if err := worker.EnqueueMissingAll(ctx); err != nil {
|
||||||
|
log.Error(ctx, "Error enqueueing missing artwork rechecks", err)
|
||||||
|
}
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error(ctx, "Error scheduling artwork missing-state recheck", err)
|
log.Error(ctx, "Error scheduling artwork missing-state recheck", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() {
|
if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() {
|
||||||
outsideScan(ctx, "artwork prune", worker.RunPrune)
|
if err := worker.RunPrune(ctx); err != nil {
|
||||||
|
log.Error(ctx, "Error running artwork prune", err)
|
||||||
|
}
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error(ctx, "Error scheduling artwork prune", err)
|
log.Error(ctx, "Error scheduling artwork prune", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also run the missing-row recheck once at startup so a never-scanned entity is picked up
|
// Also run the missing-row recheck once at startup so a never-scanned entity is picked up
|
||||||
// immediately, not only on the next hourly tick (e.g. after enabling the feature).
|
// immediately, not only on the next hourly tick (e.g. after enabling the feature).
|
||||||
outsideScan(ctx, "artwork missing-state recheck", worker.EnqueueMissingAll)
|
if err := worker.EnqueueMissingAll(ctx); err != nil {
|
||||||
|
log.Error(ctx, "Error enqueueing missing artwork rechecks", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := worker.ReconcileConfig(ctx); err != nil {
|
if err := worker.ReconcileConfig(ctx); err != nil {
|
||||||
log.Error(ctx, "Error checking the artwork config fingerprint", err)
|
log.Error(ctx, "Error checking the artwork config fingerprint", err)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path"
|
"path"
|
||||||
|
|
@ -10,8 +9,6 @@ import (
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/tests"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
@ -47,30 +44,3 @@ var _ = Describe("profilerHandler", func() {
|
||||||
Entry("with a trailing-slash BasePath", "/music/"),
|
Entry("with a trailing-slash BasePath", "/music/"),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
var _ = Describe("librariesWithChangedPID", func() {
|
|
||||||
var ds *tests.MockDataStore
|
|
||||||
var libs *tests.MockLibraryRepo
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
libs = &tests.MockLibraryRepo{}
|
|
||||||
ds = &tests.MockDataStore{MockedLibrary: libs}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns only the libraries whose PID config changed", func() {
|
|
||||||
pid := model.Library{}.EffectivePID()
|
|
||||||
libs.SetData(model.Libraries{
|
|
||||||
{ID: 1, Name: "Same", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track},
|
|
||||||
{ID: 2, Name: "Changed", PIDAlbum: "folder", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track},
|
|
||||||
{ID: 3, Name: "Never scanned"},
|
|
||||||
})
|
|
||||||
Expect(librariesWithChangedPID(GinkgoT().Context(), ds)).To(ConsistOf("Changed", "Never scanned"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns the error from the repository", func() {
|
|
||||||
libs.Err = errors.New("db down")
|
|
||||||
_, err := librariesWithChangedPID(GinkgoT().Context(), ds)
|
|
||||||
Expect(err).To(MatchError("db down"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/db"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var searchRebuildForce bool
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(searchRoot)
|
|
||||||
|
|
||||||
searchRebuildCmd.Flags().BoolVarP(&searchRebuildForce, "force", "f", false, "bypass rebuild confirmation")
|
|
||||||
searchRoot.AddCommand(searchRebuildCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
searchRoot = &cobra.Command{
|
|
||||||
Use: "search",
|
|
||||||
Short: "Search index maintenance",
|
|
||||||
}
|
|
||||||
|
|
||||||
searchRebuildCmd = &cobra.Command{
|
|
||||||
Use: "rebuild",
|
|
||||||
Short: "Rebuild the full-text search index",
|
|
||||||
Long: "Drop and rebuild the full-text search index from the library data. Fixes a corrupted " +
|
|
||||||
"or desynced search index without any data loss. Note that 'navidrome doctor' detects a " +
|
|
||||||
"corrupted index, but cannot tell when the index has merely drifted out of sync with the " +
|
|
||||||
"library. This must be done offline",
|
|
||||||
Run: func(cmd *cobra.Command, _ []string) {
|
|
||||||
runSearchRebuild(cmd.Context())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
func runSearchRebuild(ctx context.Context) {
|
|
||||||
requireExistingDB()
|
|
||||||
|
|
||||||
if !searchRebuildForce && !confirmYES("This will rebuild the search index. Make sure Navidrome is not running.") {
|
|
||||||
log.Warn("Rebuild cancelled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Rebuilding the search index...")
|
|
||||||
err := db.RebuildFTS(ctx, db.Db())
|
|
||||||
db.Close(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Error rebuilding the search index", err)
|
|
||||||
}
|
|
||||||
fmt.Println("Search index rebuilt successfully.")
|
|
||||||
}
|
|
||||||
11
cmd/svc.go
11
cmd/svc.go
|
|
@ -44,7 +44,7 @@ var svcCmd = &cobra.Command{
|
||||||
}
|
}
|
||||||
|
|
||||||
type svcControl struct {
|
type svcControl struct {
|
||||||
ctx context.Context //nolint:containedctx // service lifecycle ctx, cancelled by Stop
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
}
|
}
|
||||||
|
|
@ -53,13 +53,8 @@ func (p *svcControl) Start(service.Service) error {
|
||||||
p.done = make(chan struct{})
|
p.done = make(chan struct{})
|
||||||
p.ctx, p.cancel = context.WithCancel(context.Background())
|
p.ctx, p.cancel = context.WithCancel(context.Background())
|
||||||
go func() {
|
go func() {
|
||||||
err := runNavidrome(p.ctx)
|
runNavidrome(p.ctx)
|
||||||
close(p.done)
|
close(p.done)
|
||||||
// service.Run() only returns when it gets a stop request, so exit here to let the
|
|
||||||
// service manager see the failure and restart the service
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Fatal error in Navidrome. Aborting", err)
|
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -79,7 +74,7 @@ func (p *svcControl) Stop(service.Service) error {
|
||||||
var svcInstance = sync.OnceValue(func() service.Service {
|
var svcInstance = sync.OnceValue(func() service.Service {
|
||||||
options := make(service.KeyValue)
|
options := make(service.KeyValue)
|
||||||
options["Restart"] = "on-failure"
|
options["Restart"] = "on-failure"
|
||||||
options["SuccessExitStatus"] = "SIGKILL"
|
options["SuccessExitStatus"] = "1 2 8 SIGKILL"
|
||||||
options["UserService"] = false
|
options["UserService"] = false
|
||||||
options["LogDirectory"] = conf.Server.DataFolder.String()
|
options["LogDirectory"] = conf.Server.DataFolder.String()
|
||||||
options["SystemdScript"] = systemdScript
|
options["SystemdScript"] = systemdScript
|
||||||
|
|
|
||||||
26
cmd/user.go
26
cmd/user.go
|
|
@ -183,7 +183,7 @@ func runCreateUser(ctx context.Context) {
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
|
|
||||||
err := ds.WithTx(func(tx model.DataStore) error {
|
err := ds.WithTx(func(tx model.DataStore) error {
|
||||||
existingUser, err := tx.User().FindByUsername(ctx, userID)
|
existingUser, err := tx.User(ctx).FindByUsername(userID)
|
||||||
if existingUser != nil {
|
if existingUser != nil {
|
||||||
return fmt.Errorf("existing user '%s'", userID)
|
return fmt.Errorf("existing user '%s'", userID)
|
||||||
}
|
}
|
||||||
|
|
@ -193,7 +193,7 @@ func runCreateUser(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(libraryIds) > 0 && !setAdmin {
|
if len(libraryIds) > 0 && !setAdmin {
|
||||||
user.Libraries, err = tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
user.Libraries, err = tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -202,13 +202,13 @@ func runCreateUser(ctx context.Context) {
|
||||||
return libraryError(user.Libraries)
|
return libraryError(user.Libraries)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
user.Libraries, err = tx.Library().GetAll(ctx)
|
user.Libraries, err = tx.Library(ctx).GetAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.User().Put(ctx, &user)
|
err = tx.User(ctx).Put(&user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -218,7 +218,7 @@ func runCreateUser(ctx context.Context) {
|
||||||
updatedIds[idx] = lib.ID
|
updatedIds[idx] = lib.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
|
err = tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -236,7 +236,7 @@ func runDeleteUser(ctx context.Context) {
|
||||||
var user *model.User
|
var user *model.User
|
||||||
|
|
||||||
err = ds.WithTx(func(tx model.DataStore) error {
|
err = ds.WithTx(func(tx model.DataStore) error {
|
||||||
count, err := tx.User().CountAll(ctx)
|
count, err := tx.User(ctx).CountAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -250,7 +250,7 @@ func runDeleteUser(ctx context.Context) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.User().Delete(ctx, user.ID)
|
return tx.User(ctx).Delete(user.ID)
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -276,7 +276,7 @@ func runUserEdit(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(libraryIds) > 0 && !setAdmin {
|
if len(libraryIds) > 0 && !setAdmin {
|
||||||
libraries, err := tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
libraries, err := tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -291,7 +291,7 @@ func runUserEdit(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if setAdmin && !user.IsAdmin {
|
if setAdmin && !user.IsAdmin {
|
||||||
libraries, err := tx.Library().GetAll(ctx)
|
libraries, err := tx.Library(ctx).GetAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -337,7 +337,7 @@ func runUserEdit(ctx context.Context) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err := tx.User().Put(ctx, user)
|
err := tx.User(ctx).Put(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -348,7 +348,7 @@ func runUserEdit(ctx context.Context) {
|
||||||
updatedIds[idx] = lib.ID
|
updatedIds[idx] = lib.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
err := tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
|
err := tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -393,11 +393,13 @@ func runUserList(ctx context.Context) {
|
||||||
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
|
|
||||||
userList, err := ds.User().ReadAll(ctx)
|
users, err := ds.User(ctx).ReadAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Failed to retrieve users", err)
|
log.Fatal(ctx, "Failed to retrieve users", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
userList := users.(model.Users)
|
||||||
|
|
||||||
if outputFormat == "csv" {
|
if outputFormat == "csv" {
|
||||||
w := csv.NewWriter(os.Stdout)
|
w := csv.NewWriter(os.Stdout)
|
||||||
_ = w.Write([]string{
|
_ = w.Write([]string{
|
||||||
|
|
|
||||||
29
cmd/utils.go
29
cmd/utils.go
|
|
@ -5,11 +5,8 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/core/auth"
|
"github.com/navidrome/navidrome/core/auth"
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
|
|
@ -18,28 +15,6 @@ import (
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
)
|
)
|
||||||
|
|
||||||
// existingDBFile returns the database file (DbPath minus DSN params), and whether it exists.
|
|
||||||
func existingDBFile() (string, bool) {
|
|
||||||
path, _, _ := strings.Cut(conf.Server.DbPath, "?")
|
|
||||||
_, err := os.Stat(path)
|
|
||||||
return path, err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireExistingDB aborts the command when the database file does not exist.
|
|
||||||
func requireExistingDB() {
|
|
||||||
if path, ok := existingDBFile(); !ok {
|
|
||||||
log.Fatal("No existing database", "path", path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func confirmYES(warning string) bool {
|
|
||||||
fmt.Println(warning)
|
|
||||||
fmt.Printf("Please enter YES (all caps) to continue: ")
|
|
||||||
var input string
|
|
||||||
_, err := fmt.Scanln(&input)
|
|
||||||
return input == "YES" && err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTabWriter keeps every CLI table on the same column settings.
|
// newTabWriter keeps every CLI table on the same column settings.
|
||||||
func newTabWriter(out io.Writer) *tabwriter.Writer {
|
func newTabWriter(out io.Writer) *tabwriter.Writer {
|
||||||
return tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
return tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||||
|
|
@ -57,14 +32,14 @@ func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) {
|
func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) {
|
||||||
user, err := ds.User().FindByUsername(ctx, id)
|
user, err := ds.User(ctx).FindByUsername(id)
|
||||||
|
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
return nil, fmt.Errorf("finding user by name: %w", err)
|
return nil, fmt.Errorf("finding user by name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
user, err = ds.User().Get(ctx, id)
|
user, err = ds.User(ctx).Get(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("finding user by id: %w", err)
|
return nil, fmt.Errorf("finding user by id: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ import (
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
"github.com/navidrome/navidrome/core/playlists"
|
||||||
"github.com/navidrome/navidrome/core/quickconnect"
|
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
"github.com/navidrome/navidrome/core/sonic"
|
"github.com/navidrome/navidrome/core/sonic"
|
||||||
"github.com/navidrome/navidrome/core/stream"
|
"github.com/navidrome/navidrome/core/stream"
|
||||||
|
|
@ -31,7 +30,6 @@ import (
|
||||||
"github.com/navidrome/navidrome/plugins"
|
"github.com/navidrome/navidrome/plugins"
|
||||||
"github.com/navidrome/navidrome/scanner"
|
"github.com/navidrome/navidrome/scanner"
|
||||||
"github.com/navidrome/navidrome/server"
|
"github.com/navidrome/navidrome/server"
|
||||||
"github.com/navidrome/navidrome/server/apiv1"
|
|
||||||
"github.com/navidrome/navidrome/server/events"
|
"github.com/navidrome/navidrome/server/events"
|
||||||
"github.com/navidrome/navidrome/server/jellyfin"
|
"github.com/navidrome/navidrome/server/jellyfin"
|
||||||
"github.com/navidrome/navidrome/server/nativeapi"
|
"github.com/navidrome/navidrome/server/nativeapi"
|
||||||
|
|
@ -72,7 +70,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||||
insights := metrics.GetInstance(dataStore)
|
insights := metrics.GetInstance(dataStore)
|
||||||
broker := events.GetBroker()
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||||
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
|
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
|
||||||
|
|
@ -81,8 +79,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
matcherMatcher := matcher.New(dataStore)
|
matcherMatcher := matcher.New(dataStore)
|
||||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
||||||
quickConnect := quickconnect.GetInstance()
|
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider)
|
||||||
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider, quickConnect)
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,9 +92,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
||||||
transcodingCache := stream.GetTranscodingCache()
|
transcodingCache := stream.GetTranscodingCache()
|
||||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
|
||||||
share := core.NewShare(dataStore)
|
share := core.NewShare(dataStore)
|
||||||
archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork)
|
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||||
players := core.NewPlayers(dataStore)
|
players := core.NewPlayers(dataStore)
|
||||||
broker := events.GetBroker()
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
|
|
@ -107,10 +103,11 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
||||||
uploader := artwork.NewUploader(dataStore)
|
uploader := artwork.NewUploader(dataStore)
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||||
playbackServer := playback.GetInstance(dataStore)
|
playbackServer := playback.GetInstance(dataStore)
|
||||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
||||||
|
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
||||||
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
||||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
|
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
|
||||||
return router
|
return router
|
||||||
|
|
@ -138,15 +135,7 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
||||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
||||||
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
||||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
||||||
quickConnect := quickconnect.GetInstance()
|
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker, uploader)
|
||||||
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker, quickConnect)
|
|
||||||
return router
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateAPIv1Router(ctx context.Context) *apiv1.Router {
|
|
||||||
sqlDB := db.Db()
|
|
||||||
dataStore := persistence.New(sqlDB)
|
|
||||||
router := apiv1.New(dataStore)
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,10 +148,9 @@ func CreatePublicRouter() *public.Router {
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
||||||
transcodingCache := stream.GetTranscodingCache()
|
transcodingCache := stream.GetTranscodingCache()
|
||||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
|
||||||
share := core.NewShare(dataStore)
|
share := core.NewShare(dataStore)
|
||||||
archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork)
|
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||||
router := public.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, share, archiver)
|
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,13 +168,6 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateJellyfinDiscovery() *jellyfin.Discovery {
|
|
||||||
sqlDB := db.Db()
|
|
||||||
dataStore := persistence.New(sqlDB)
|
|
||||||
discovery := jellyfin.NewDiscovery(dataStore)
|
|
||||||
return discovery
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateInsights() metrics.Insights {
|
func CreateInsights() metrics.Insights {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
|
|
@ -208,7 +189,7 @@ func CreateScanner(ctx context.Context) model.Scanner {
|
||||||
uploader := artwork.NewUploader(dataStore)
|
uploader := artwork.NewUploader(dataStore)
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||||
return modelScanner
|
return modelScanner
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,7 +200,7 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
||||||
uploader := artwork.NewUploader(dataStore)
|
uploader := artwork.NewUploader(dataStore)
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||||
return watcher
|
return watcher
|
||||||
}
|
}
|
||||||
|
|
@ -268,7 +249,7 @@ func getPluginManager() *plugins.Manager {
|
||||||
|
|
||||||
// wire_injectors.go:
|
// wire_injectors.go:
|
||||||
|
|
||||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, jellyfin.NewDiscovery, 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)))
|
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
|
||||||
|
|
||||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||||
manager := getPluginManager()
|
manager := getPluginManager()
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import (
|
||||||
"github.com/navidrome/navidrome/plugins"
|
"github.com/navidrome/navidrome/plugins"
|
||||||
"github.com/navidrome/navidrome/scanner"
|
"github.com/navidrome/navidrome/scanner"
|
||||||
"github.com/navidrome/navidrome/server"
|
"github.com/navidrome/navidrome/server"
|
||||||
"github.com/navidrome/navidrome/server/apiv1"
|
|
||||||
"github.com/navidrome/navidrome/server/events"
|
"github.com/navidrome/navidrome/server/events"
|
||||||
"github.com/navidrome/navidrome/server/jellyfin"
|
"github.com/navidrome/navidrome/server/jellyfin"
|
||||||
"github.com/navidrome/navidrome/server/nativeapi"
|
"github.com/navidrome/navidrome/server/nativeapi"
|
||||||
|
|
@ -37,15 +36,13 @@ var allProviders = wire.NewSet(
|
||||||
server.New,
|
server.New,
|
||||||
subsonic.New,
|
subsonic.New,
|
||||||
jellyfin.New,
|
jellyfin.New,
|
||||||
jellyfin.NewDiscovery,
|
|
||||||
apiv1.New,
|
|
||||||
nativeapi.New,
|
nativeapi.New,
|
||||||
public.New,
|
public.New,
|
||||||
persistence.New,
|
persistence.New,
|
||||||
lastfm.NewRouter,
|
lastfm.NewRouter,
|
||||||
listenbrainz.NewRouter,
|
listenbrainz.NewRouter,
|
||||||
events.GetBroker,
|
events.GetBroker,
|
||||||
scanner.GetInstance,
|
scanner.New,
|
||||||
scanner.GetWatcher,
|
scanner.GetWatcher,
|
||||||
metrics.GetPrometheusInstance,
|
metrics.GetPrometheusInstance,
|
||||||
db.Db,
|
db.Db,
|
||||||
|
|
@ -93,12 +90,6 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateAPIv1Router(ctx context.Context) *apiv1.Router {
|
|
||||||
panic(wire.Build(
|
|
||||||
allProviders,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreatePublicRouter() *public.Router {
|
func CreatePublicRouter() *public.Router {
|
||||||
panic(wire.Build(
|
panic(wire.Build(
|
||||||
allProviders,
|
allProviders,
|
||||||
|
|
@ -117,12 +108,6 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateJellyfinDiscovery() *jellyfin.Discovery {
|
|
||||||
panic(wire.Build(
|
|
||||||
allProviders,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateInsights() metrics.Insights {
|
func CreateInsights() metrics.Insights {
|
||||||
panic(wire.Build(
|
panic(wire.Build(
|
||||||
allProviders,
|
allProviders,
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ type configOptions struct {
|
||||||
EnableStarRating bool
|
EnableStarRating bool
|
||||||
EnableUserEditing bool
|
EnableUserEditing bool
|
||||||
EnableArtworkUpload bool
|
EnableArtworkUpload bool
|
||||||
|
EnableUserAvatarUpload bool
|
||||||
MaxImageUploadSize string
|
MaxImageUploadSize string
|
||||||
MaxImageSize string
|
MaxImageSize string
|
||||||
EnableSharing bool
|
EnableSharing bool
|
||||||
|
|
@ -161,7 +162,6 @@ type configOptions struct {
|
||||||
DevExternalArtistFetchMultiplier float64
|
DevExternalArtistFetchMultiplier float64
|
||||||
DevPreserveUnicodeInExternalCalls bool
|
DevPreserveUnicodeInExternalCalls bool
|
||||||
DevEnableMediaFileProbe bool
|
DevEnableMediaFileProbe bool
|
||||||
DevAPIv1 bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type scannerOptions struct {
|
type scannerOptions struct {
|
||||||
|
|
@ -236,8 +236,6 @@ type jellyfinOptions struct {
|
||||||
// ExposedPublicUsers is a comma-separated list of usernames to advertise on the unauthenticated
|
// ExposedPublicUsers is a comma-separated list of usernames to advertise on the unauthenticated
|
||||||
// GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users.
|
// GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users.
|
||||||
ExposedPublicUsers string
|
ExposedPublicUsers string
|
||||||
AutoDiscovery bool
|
|
||||||
QuickConnect bool
|
|
||||||
// MaxConcurrentStreams bounds how many collection responses can stream at once. Each holds a DB
|
// MaxConcurrentStreams bounds how many collection responses can stream at once. Each holds a DB
|
||||||
// cursor — and its pooled connection — for the whole client-paced response, so without a bound
|
// cursor — and its pooled connection — for the whole client-paced response, so without a bound
|
||||||
// enough slow clients would take the entire pool and stall the scanner, scrobbles and the UI.
|
// enough slow clients would take the entire pool and stall the scanner, scrobbles and the UI.
|
||||||
|
|
@ -410,7 +408,7 @@ func Load(noConfigDump bool) {
|
||||||
if mkErr := os.MkdirAll(filepath.Dir(Server.LogFile), os.ModePerm); mkErr != nil {
|
if mkErr := os.MkdirAll(filepath.Dir(Server.LogFile), os.ModePerm); mkErr != nil {
|
||||||
logFatal(fmt.Sprintf("Error creating log file directory: %s", mkErr.Error()))
|
logFatal(fmt.Sprintf("Error creating log file directory: %s", mkErr.Error()))
|
||||||
}
|
}
|
||||||
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
|
logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
|
||||||
}
|
}
|
||||||
|
|
@ -515,11 +513,6 @@ func Load(noConfigDump bool) {
|
||||||
Server.UICoverArtSize = newValue
|
Server.UICoverArtSize = newValue
|
||||||
}
|
}
|
||||||
|
|
||||||
if Server.Scanner.Extractor != consts.DefaultScannerExtractor {
|
|
||||||
log.Warn("Invalid Scanner.Extractor, using default", "value", Server.Scanner.Extractor, "default", consts.DefaultScannerExtractor)
|
|
||||||
Server.Scanner.Extractor = consts.DefaultScannerExtractor
|
|
||||||
}
|
|
||||||
|
|
||||||
// Floor MaxImageSize at MaxImageUploadSize so accepted uploads can always be read back.
|
// Floor MaxImageSize at MaxImageUploadSize so accepted uploads can always be read back.
|
||||||
imgSize, _ := humanize.ParseBytes(Server.MaxImageSize)
|
imgSize, _ := humanize.ParseBytes(Server.MaxImageSize)
|
||||||
uploadSize, _ := humanize.ParseBytes(Server.MaxImageUploadSize)
|
uploadSize, _ := humanize.ParseBytes(Server.MaxImageUploadSize)
|
||||||
|
|
@ -1036,6 +1029,7 @@ func setViperDefaults() {
|
||||||
viper.SetDefault("enablenowplaying", true)
|
viper.SetDefault("enablenowplaying", true)
|
||||||
viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval)
|
viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval)
|
||||||
viper.SetDefault("enableartworkupload", true)
|
viper.SetDefault("enableartworkupload", true)
|
||||||
|
viper.SetDefault("enableuseravatarupload", true)
|
||||||
viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize)
|
viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize)
|
||||||
viper.SetDefault("maximagesize", consts.DefaultMaxImageSize)
|
viper.SetDefault("maximagesize", consts.DefaultMaxImageSize)
|
||||||
viper.SetDefault("enablesharing", true)
|
viper.SetDefault("enablesharing", true)
|
||||||
|
|
@ -1095,8 +1089,6 @@ func setViperDefaults() {
|
||||||
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
|
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
|
||||||
viper.SetDefault("jellyfin.enabled", false)
|
viper.SetDefault("jellyfin.enabled", false)
|
||||||
viper.SetDefault("jellyfin.servername", "")
|
viper.SetDefault("jellyfin.servername", "")
|
||||||
viper.SetDefault("jellyfin.autodiscovery", false)
|
|
||||||
viper.SetDefault("jellyfin.quickconnect", true)
|
|
||||||
viper.SetDefault("enablescrobblehistory", true)
|
viper.SetDefault("enablescrobblehistory", true)
|
||||||
viper.SetDefault("httpheaders.frameoptions", "DENY")
|
viper.SetDefault("httpheaders.frameoptions", "DENY")
|
||||||
viper.SetDefault("backup.path", "")
|
viper.SetDefault("backup.path", "")
|
||||||
|
|
@ -1125,7 +1117,6 @@ func setViperDefaults() {
|
||||||
viper.SetDefault("devshowartistpage", true)
|
viper.SetDefault("devshowartistpage", true)
|
||||||
viper.SetDefault("devuishowconfig", true)
|
viper.SetDefault("devuishowconfig", true)
|
||||||
viper.SetDefault("devneweventstream", true)
|
viper.SetDefault("devneweventstream", true)
|
||||||
viper.SetDefault("devapiv1", false)
|
|
||||||
viper.SetDefault("devoffsetoptimize", 50000)
|
viper.SetDefault("devoffsetoptimize", 50000)
|
||||||
// Half the pool: streams may take up to this many connections, leaving the rest for the scanner,
|
// Half the pool: streams may take up to this many connections, leaving the rest for the scanner,
|
||||||
// scrobbles and the UI. See MaxOpenConns.
|
// scrobbles and the UI. See MaxOpenConns.
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -77,6 +76,13 @@ var _ = Describe("Configuration", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("user avatar upload", func() {
|
||||||
|
It("is enabled by default", func() {
|
||||||
|
conf.Load(true)
|
||||||
|
Expect(conf.Server.EnableUserAvatarUpload).To(BeTrue())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Describe("ValidateURL", func() {
|
Describe("ValidateURL", func() {
|
||||||
It("accepts a valid http URL", func() {
|
It("accepts a valid http URL", func() {
|
||||||
fn := conf.ValidateURL("TestOption", "http://example.com/path")
|
fn := conf.ValidateURL("TestOption", "http://example.com/path")
|
||||||
|
|
@ -330,21 +336,6 @@ var _ = Describe("Configuration", func() {
|
||||||
}).To(PanicWith(ContainSubstring("Error creating log file directory")))
|
}).To(PanicWith(ContainSubstring("Error creating log file directory")))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("creates the log file readable only by the owner", func() {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
Skip("file modes are not enforced on Windows")
|
|
||||||
}
|
|
||||||
logFile := filepath.Join(GinkgoT().TempDir(), "navidrome.log")
|
|
||||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
|
||||||
viper.SetDefault("logfile", logFile)
|
|
||||||
DeferCleanup(log.SetOutput, os.Stderr)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
info, err := os.Stat(logFile)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(info.Mode().Perm()).To(Equal(os.FileMode(0600)))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("is called when BaseURL is invalid", func() {
|
It("is called when BaseURL is invalid", func() {
|
||||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||||
viper.SetDefault("baseurl", "://invalid")
|
viper.SetDefault("baseurl", "://invalid")
|
||||||
|
|
@ -402,27 +393,6 @@ var _ = Describe("Configuration", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Scanner.Extractor", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
viper.Reset()
|
|
||||||
conf.SetViperDefaults()
|
|
||||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
|
||||||
viper.SetDefault("loglevel", "error")
|
|
||||||
conf.ResetConf()
|
|
||||||
})
|
|
||||||
|
|
||||||
It("falls back to taglib for an unknown extractor", func() {
|
|
||||||
viper.SetDefault("scanner.extractor", "ffmpeg")
|
|
||||||
conf.Load(true)
|
|
||||||
Expect(conf.Server.Scanner.Extractor).To(Equal("taglib"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("keeps taglib", func() {
|
|
||||||
conf.Load(true)
|
|
||||||
Expect(conf.Server.Scanner.Extractor).To(Equal("taglib"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("EnforceNonRootUser", func() {
|
Describe("EnforceNonRootUser", func() {
|
||||||
It("defaults to false", func() {
|
It("defaults to false", func() {
|
||||||
conf.Load(true)
|
conf.Load(true)
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@ const (
|
||||||
DefaultEncryptionKey = "just for obfuscation"
|
DefaultEncryptionKey = "just for obfuscation"
|
||||||
PasswordsEncryptedKey = "PasswordsEncryptedKey"
|
PasswordsEncryptedKey = "PasswordsEncryptedKey"
|
||||||
PasswordAutogenPrefix = "__NAVIDROME_AUTOGEN__" //nolint:gosec
|
PasswordAutogenPrefix = "__NAVIDROME_AUTOGEN__" //nolint:gosec
|
||||||
APIKeyPrefix = "nds_"
|
|
||||||
|
|
||||||
DevInitialUserName = "admin"
|
DevInitialUserName = "admin"
|
||||||
DevInitialName = "Dev Admin"
|
DevInitialName = "Dev Admin"
|
||||||
|
|
@ -60,7 +59,6 @@ const (
|
||||||
URLPathPublic = "/share"
|
URLPathPublic = "/share"
|
||||||
URLPathPublicImages = URLPathPublic + "/img"
|
URLPathPublicImages = URLPathPublic + "/img"
|
||||||
URLPathJellyfinAPI = "/jellyfin"
|
URLPathJellyfinAPI = "/jellyfin"
|
||||||
URLPathAPIv1 = "/api/v1"
|
|
||||||
|
|
||||||
// JellyfinServerIDKey is the Property key for the stable, persisted server Id reported by the
|
// JellyfinServerIDKey is the Property key for the stable, persisted server Id reported by the
|
||||||
// Jellyfin API. Jellyfin clients cache this value, so it must survive process restarts.
|
// Jellyfin API. Jellyfin clients cache this value, so it must survive process restarts.
|
||||||
|
|
@ -91,6 +89,7 @@ const (
|
||||||
I18nFolder = "i18n"
|
I18nFolder = "i18n"
|
||||||
ScanIgnoreFile = ".ndignore"
|
ScanIgnoreFile = ".ndignore"
|
||||||
ArtworkFolder = "artwork"
|
ArtworkFolder = "artwork"
|
||||||
|
AvatarFolder = "avatar"
|
||||||
// HashedArtworkFolder is a subtree of ArtworkFolder, kept apart from the name-addressed
|
// HashedArtworkFolder is a subtree of ArtworkFolder, kept apart from the name-addressed
|
||||||
// upload folders beside it so Prune's sweep never reaches them.
|
// upload folders beside it so Prune's sweep never reaches them.
|
||||||
HashedArtworkFolder = "hashed"
|
HashedArtworkFolder = "hashed"
|
||||||
|
|
@ -120,6 +119,7 @@ const (
|
||||||
DefaultUICoverArtSize = 300
|
DefaultUICoverArtSize = 300
|
||||||
DefaultMaxImageUploadSize = "10MB"
|
DefaultMaxImageUploadSize = "10MB"
|
||||||
DefaultMaxImageSize = "20MB"
|
DefaultMaxImageSize = "20MB"
|
||||||
|
MaxAvatarSize = 512
|
||||||
)
|
)
|
||||||
|
|
||||||
// Prometheus options
|
// Prometheus options
|
||||||
|
|
@ -145,6 +145,7 @@ const (
|
||||||
EntityArtist = "artist"
|
EntityArtist = "artist"
|
||||||
EntityPlaylist = "playlist"
|
EntityPlaylist = "playlist"
|
||||||
EntityRadio = "radio"
|
EntityRadio = "radio"
|
||||||
|
EntityUser = "user"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -156,6 +157,8 @@ const (
|
||||||
//DefaultAlbumPID = "album_legacy"
|
//DefaultAlbumPID = "album_legacy"
|
||||||
DefaultAlbumPID = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
|
DefaultAlbumPID = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
|
||||||
DefaultTrackPID = "musicbrainz_trackid|albumid,discnumber,tracknumber,title"
|
DefaultTrackPID = "musicbrainz_trackid|albumid,discnumber,tracknumber,title"
|
||||||
|
PIDAlbumKey = "PIDAlbum"
|
||||||
|
PIDTrackKey = "PIDTrack"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ RestrictNamespaces=yes
|
||||||
RestrictRealtime=yes
|
RestrictRealtime=yes
|
||||||
SystemCallFilter=@system-service
|
SystemCallFilter=@system-service
|
||||||
SystemCallFilter=~@privileged @resources
|
SystemCallFilter=~@privileged @resources
|
||||||
SystemCallFilter=setrlimit mbind
|
SystemCallFilter=setrlimit
|
||||||
SystemCallArchitectures=native
|
SystemCallArchitectures=native
|
||||||
UMask=0066
|
UMask=0066
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ func (p *localAgent) AgentName() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
|
func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
|
||||||
top, err := p.ds.MediaFile().GetAll(ctx, model.QueryOptions{
|
top, err := p.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||||
Sort: "playCount",
|
Sort: "playCount",
|
||||||
Order: "desc",
|
Order: "desc",
|
||||||
Max: count,
|
Max: count,
|
||||||
|
|
@ -43,7 +43,7 @@ func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
|
func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
|
||||||
seed, err := p.ds.MediaFile().Get(ctx, id)
|
seed, err := p.ds.MediaFile(ctx).Get(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -53,7 +53,7 @@ func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artis
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
// Ask for extra so we can drop the seed itself and still fill the count.
|
// Ask for extra so we can drop the seed itself and still fill the count.
|
||||||
candidates, err := p.ds.MediaFile().GetRandom(ctx, model.QueryOptions{
|
candidates, err := p.ds.MediaFile(ctx).GetRandom(model.QueryOptions{
|
||||||
Filters: squirrel.And{
|
Filters: squirrel.And{
|
||||||
persistence.SongGenres.ByID(genreIDs),
|
persistence.SongGenres.ByID(genreIDs),
|
||||||
squirrel.Eq{"missing": false},
|
squirrel.Eq{"missing": false},
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,13 @@ type SessionKeys struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error {
|
func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error {
|
||||||
return sk.DataStore.UserProps().Put(ctx, userId, sk.KeyName, sessionKey)
|
return sk.DataStore.UserProps(ctx).Put(userId, sk.KeyName, sessionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sk *SessionKeys) Get(ctx context.Context, userId string) (string, error) {
|
func (sk *SessionKeys) Get(ctx context.Context, userId string) (string, error) {
|
||||||
return sk.DataStore.UserProps().Get(ctx, userId, sk.KeyName)
|
return sk.DataStore.UserProps(ctx).Get(userId, sk.KeyName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sk *SessionKeys) Delete(ctx context.Context, userId string) error {
|
func (sk *SessionKeys) Delete(ctx context.Context, userId string) error {
|
||||||
return sk.DataStore.UserProps().Delete(ctx, userId, sk.KeyName)
|
return sk.DataStore.UserProps(ctx).Delete(userId, sk.KeyName)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
194
core/archiver.go
194
core/archiver.go
|
|
@ -2,32 +2,23 @@ package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"cmp"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"path"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
|
||||||
"github.com/navidrome/navidrome/core/stream"
|
"github.com/navidrome/navidrome/core/stream"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
"github.com/navidrome/navidrome/utils/slice"
|
||||||
"github.com/navidrome/navidrome/utils/str"
|
"github.com/navidrome/navidrome/utils/str"
|
||||||
)
|
)
|
||||||
|
|
||||||
const archiveCoverArtSize = 500
|
|
||||||
|
|
||||||
type Archiver interface {
|
type Archiver interface {
|
||||||
ZipAlbum(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
ZipAlbum(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||||
ZipArtist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
ZipArtist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||||
|
|
@ -35,20 +26,18 @@ type Archiver interface {
|
||||||
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewArchiver(ms stream.MediaStreamer, decider stream.TranscodeDecider, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver {
|
func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share) Archiver {
|
||||||
return &archiver{ds: ds, ms: ms, decider: decider, shares: shares, artwork: artwork}
|
return &archiver{ds: ds, ms: ms, shares: shares}
|
||||||
}
|
}
|
||||||
|
|
||||||
type archiver struct {
|
type archiver struct {
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
ms stream.MediaStreamer
|
ms stream.MediaStreamer
|
||||||
decider stream.TranscodeDecider
|
shares Share
|
||||||
shares Share
|
|
||||||
artwork artwork.Artwork
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||||
return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_id": id}, model.ArtworkID{})
|
return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_id": id})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||||
|
|
@ -58,30 +47,28 @@ func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitr
|
||||||
persistence.ParticipantIDFilter("media_file", id, model.RoleAlbumArtist),
|
persistence.ParticipantIDFilter("media_file", id, model.RoleAlbumArtist),
|
||||||
squirrel.Eq{"missing": false},
|
squirrel.Eq{"missing": false},
|
||||||
}
|
}
|
||||||
return a.zipAlbums(ctx, id, format, bitrate, out, filter, model.Artist{ID: id}.CoverArtID())
|
return a.zipAlbums(ctx, id, format, bitrate, out, filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) error {
|
||||||
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(ctx).GetAll(model.QueryOptions{Filters: filters, Sort: "album"})
|
||||||
mfs, err := a.ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: filters, Sort: "album"})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error loading mediafiles from artist", "id", id, err)
|
log.Error(ctx, "Error loading mediafiles from artist", "id", id, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
z := createZipWriter(out, format, bitrate)
|
z := createZipWriter(out, format, bitrate)
|
||||||
albums := slice.GroupOrdered(mfs, func(mf model.MediaFile) string { return mf.AlbumID })
|
albums := slice.Group(mfs, func(mf model.MediaFile) string {
|
||||||
folders := albumFolders(albums)
|
return mf.AlbumID
|
||||||
|
})
|
||||||
for _, album := range albums {
|
for _, album := range albums {
|
||||||
discs := slice.Group(album, func(mf model.MediaFile) int { return mf.DiscNumber })
|
discs := slice.Group(album, func(mf model.MediaFile) int { return mf.DiscNumber })
|
||||||
isMultiDisc := len(discs) > 1
|
isMultiDisc := len(discs) > 1
|
||||||
folder := folders[album[0].AlbumID]
|
log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist,
|
||||||
log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist, "folder", folder,
|
|
||||||
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
|
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
|
||||||
for _, mf := range album {
|
for _, mf := range album {
|
||||||
req := a.resolveRequest(ctx, &mf, format, bitrate)
|
file := a.albumFilename(mf, format, isMultiDisc)
|
||||||
file := a.albumFilename(mf, req.Format, isMultiDisc, folder)
|
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||||
if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
|
||||||
// Stop iterating: continuing would just rack up more
|
// Stop iterating: continuing would just rack up more
|
||||||
// rejections from the limiter. Close finalises whatever
|
// rejections from the limiter. Close finalises whatever
|
||||||
// tracks were already written; the rejected one is not
|
// tracks were already written; the rejected one is not
|
||||||
|
|
@ -91,10 +78,7 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr
|
||||||
return addErr
|
return addErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// After the tracks, so a slow artwork lookup doesn't delay the first bytes.
|
|
||||||
a.addCoverArtToZip(ctx, z, album[0].AlbumCoverArtID(), folder)
|
|
||||||
}
|
}
|
||||||
a.addCoverArtToZip(ctx, z, rootArt, "")
|
|
||||||
err = z.Close()
|
err = z.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error closing zip file", "id", id, err)
|
log.Error(ctx, "Error closing zip file", "id", id, err)
|
||||||
|
|
@ -112,61 +96,7 @@ func createZipWriter(out io.Writer, format string, bitrate int) *zip.Writer {
|
||||||
return z
|
return z
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tried in order; the first one whose values are distinct across the clashing albums wins.
|
func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc bool) string {
|
||||||
// One album may have an empty value: it keeps the plain name, which the others can't clash with.
|
|
||||||
var albumDisambiguators = []func(model.MediaFile) string{
|
|
||||||
func(mf model.MediaFile) string { return mf.Tags.First(model.TagAlbumVersion) },
|
|
||||||
func(mf model.MediaFile) string {
|
|
||||||
// Reissues share Year (often the original's) but not ReleaseYear.
|
|
||||||
if y := cmp.Or(mf.ReleaseYear, mf.Year); y != 0 {
|
|
||||||
return strconv.Itoa(y)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
},
|
|
||||||
func(mf model.MediaFile) string { return mf.MbzAlbumType },
|
|
||||||
func(mf model.MediaFile) string { return mf.Tags.First(model.TagRecordLabel) },
|
|
||||||
func(mf model.MediaFile) string { return mf.CatalogNum },
|
|
||||||
func(mf model.MediaFile) string { return mf.AlbumID[:min(6, len(mf.AlbumID))] },
|
|
||||||
func(mf model.MediaFile) string { return mf.AlbumID },
|
|
||||||
}
|
|
||||||
|
|
||||||
// albumFolders maps each album id to its zip folder. Albums whose names sanitize to the
|
|
||||||
// same folder get a " [suffix]" from the first disambiguator that tells them all apart.
|
|
||||||
func albumFolders(albums [][]model.MediaFile) map[string]string {
|
|
||||||
byName := map[string][]model.MediaFile{}
|
|
||||||
for _, album := range albums {
|
|
||||||
name := str.SanitizeFilename(album[0].FullAlbumName())
|
|
||||||
byName[name] = append(byName[name], album[0])
|
|
||||||
}
|
|
||||||
folders := make(map[string]string, len(albums))
|
|
||||||
for name, group := range byName {
|
|
||||||
if len(group) == 1 {
|
|
||||||
folders[group[0].AlbumID] = name
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fields:
|
|
||||||
for _, field := range albumDisambiguators {
|
|
||||||
ids := make(map[string]string, len(group)) // suffix -> album id
|
|
||||||
for _, mf := range group {
|
|
||||||
s := str.SanitizeFilename(field(mf))
|
|
||||||
if _, dup := ids[s]; dup {
|
|
||||||
continue fields
|
|
||||||
}
|
|
||||||
ids[s] = mf.AlbumID
|
|
||||||
}
|
|
||||||
for s, id := range ids {
|
|
||||||
folders[id] = name
|
|
||||||
if s != "" {
|
|
||||||
folders[id] = fmt.Sprintf("%s [%s]", name, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return folders
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc bool, folder string) string {
|
|
||||||
_, file := filepath.Split(mf.Path)
|
_, file := filepath.Split(mf.Path)
|
||||||
if format != "raw" {
|
if format != "raw" {
|
||||||
file = strings.TrimSuffix(file, mf.Suffix) + format
|
file = strings.TrimSuffix(file, mf.Suffix) + format
|
||||||
|
|
@ -174,7 +104,7 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc
|
||||||
if isMultiDisc {
|
if isMultiDisc {
|
||||||
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
|
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s/%s", folder, file)
|
return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ZipShare takes an already-loaded share: Share.Load records a visit, so
|
// ZipShare takes an already-loaded share: Share.Load records a visit, so
|
||||||
|
|
@ -184,31 +114,27 @@ func (a *archiver) ZipShare(ctx context.Context, s *model.Share, out io.Writer)
|
||||||
return model.ErrNotAuthorized
|
return model.ErrNotAuthorized
|
||||||
}
|
}
|
||||||
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
|
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
|
||||||
// The share is the authorization (as in the public image handler): an anonymous lookup would
|
return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false)
|
||||||
// hide a private playlist. Only the cover read is elevated.
|
|
||||||
coverCtx := request.WithUser(ctx, model.User{IsAdmin: true})
|
|
||||||
return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, coverCtx, s.CoverArtID(), false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||||
pls, err := a.ds.Playlist().GetWithTracks(ctx, id, true, false)
|
pls, err := a.ds.Playlist(ctx).GetWithTracks(id, true, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err)
|
log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
mfs := pls.MediaFiles()
|
mfs := pls.MediaFiles()
|
||||||
log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs))
|
log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs))
|
||||||
return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, ctx, pls.CoverArtID(), true)
|
return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, addM3U bool) error {
|
||||||
z := createZipWriter(out, format, bitrate)
|
z := createZipWriter(out, format, bitrate)
|
||||||
|
|
||||||
zippedMfs := make(model.MediaFiles, len(mfs))
|
zippedMfs := make(model.MediaFiles, len(mfs))
|
||||||
for idx, mf := range mfs {
|
for idx, mf := range mfs {
|
||||||
req := a.resolveRequest(ctx, &mf, format, bitrate)
|
file := a.playlistFilename(mf, format, idx)
|
||||||
file := a.playlistFilename(mf, req.Format, idx)
|
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||||
if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
|
||||||
// Abort the whole archive: continuing would silently emit
|
// Abort the whole archive: continuing would silently emit
|
||||||
// empty zip entries since the headers are already written.
|
// empty zip entries since the headers are already written.
|
||||||
_ = z.Close()
|
_ = z.Close()
|
||||||
|
|
@ -217,7 +143,6 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st
|
||||||
mf.Path = file
|
mf.Path = file
|
||||||
zippedMfs[idx] = mf
|
zippedMfs[idx] = mf
|
||||||
}
|
}
|
||||||
a.addCoverArtToZip(coverCtx, z, coverArt, "")
|
|
||||||
|
|
||||||
// Add M3U file if requested
|
// Add M3U file if requested
|
||||||
if addM3U && len(zippedMfs) > 0 {
|
if addM3U && len(zippedMfs) > 0 {
|
||||||
|
|
@ -254,14 +179,7 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int)
|
||||||
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext)
|
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) resolveRequest(ctx context.Context, mf *model.MediaFile, format string, bitrate int) stream.Request {
|
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {
|
||||||
if format == "" || format == "raw" {
|
|
||||||
return stream.Request{Format: "raw"}
|
|
||||||
}
|
|
||||||
return a.decider.ResolveRequest(ctx, mf, format, bitrate, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, req stream.Request, filename string) error {
|
|
||||||
path := mf.AbsolutePath()
|
path := mf.AbsolutePath()
|
||||||
|
|
||||||
// Open the source before writing the zip entry header so a rejection
|
// Open the source before writing the zip entry header so a rejection
|
||||||
|
|
@ -269,13 +187,13 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
||||||
// archive.
|
// archive.
|
||||||
var r io.ReadCloser
|
var r io.ReadCloser
|
||||||
var err error
|
var err error
|
||||||
if req.Format != "raw" {
|
if format != "raw" && format != "" {
|
||||||
r, err = a.ms.NewStream(ctx, &mf, req)
|
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
|
||||||
} else {
|
} else {
|
||||||
r, err = os.Open(path)
|
r, err = os.Open(path)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error opening file for zipping", "file", path, "format", req.Format, err)
|
log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|
@ -302,61 +220,3 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// addCoverArtToZip adds the cover as dir/folder.<ext>. Errors are logged, never returned.
|
|
||||||
func (a *archiver) addCoverArtToZip(ctx context.Context, z *zip.Writer, artID model.ArtworkID, dir string) {
|
|
||||||
if artID.ID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Buffered so a failed read leaves no empty entry.
|
|
||||||
data, err := a.readCoverArt(ctx, artID)
|
|
||||||
if errors.Is(err, artwork.ErrUnavailable) || errors.Is(err, model.ErrNotFound) {
|
|
||||||
log.Debug(ctx, "No cover art to add to zip", "artID", artID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
log.Warn(ctx, "Error reading cover art for zipping", "artID", artID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ext := coverArtExtension(data)
|
|
||||||
if ext == "" {
|
|
||||||
log.Warn(ctx, "Unknown cover art image type, not adding it to zip", "artID", artID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w, err := z.CreateHeader(&zip.FileHeader{
|
|
||||||
Name: path.Join(dir, "folder."+ext),
|
|
||||||
Modified: time.Now(),
|
|
||||||
Method: zip.Store,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Warn(ctx, "Error creating cover art zip entry", "artID", artID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err = w.Write(data); err != nil {
|
|
||||||
log.Warn(ctx, "Error zipping cover art", "artID", artID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *archiver) readCoverArt(ctx context.Context, artID model.ArtworkID) ([]byte, error) {
|
|
||||||
img, err := a.artwork.Get(ctx, artID, archiveCoverArtSize, false)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer img.Close()
|
|
||||||
return io.ReadAll(img)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resizing may re-encode the image, so the type comes from its bytes.
|
|
||||||
func coverArtExtension(data []byte) string {
|
|
||||||
switch http.DetectContentType(data) {
|
|
||||||
case "image/jpeg":
|
|
||||||
return "jpg"
|
|
||||||
case "image/png":
|
|
||||||
return "png"
|
|
||||||
case "image/webp":
|
|
||||||
return "webp"
|
|
||||||
case "image/gif":
|
|
||||||
return "gif"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,13 @@ import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
|
||||||
"github.com/navidrome/navidrome/core/stream"
|
"github.com/navidrome/navidrome/core/stream"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
|
|
@ -26,19 +21,15 @@ var _ = Describe("Archiver", func() {
|
||||||
var (
|
var (
|
||||||
arch core.Archiver
|
arch core.Archiver
|
||||||
ms *mockMediaStreamer
|
ms *mockMediaStreamer
|
||||||
dc *fakeDecider
|
|
||||||
ds *mockDataStore
|
ds *mockDataStore
|
||||||
sh *mockShare
|
sh *mockShare
|
||||||
ca *mockCoverArt
|
|
||||||
)
|
)
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ms = &mockMediaStreamer{}
|
ms = &mockMediaStreamer{}
|
||||||
dc = &fakeDecider{}
|
|
||||||
sh = &mockShare{}
|
sh = &mockShare{}
|
||||||
ds = &mockDataStore{}
|
ds = &mockDataStore{}
|
||||||
ca = &mockCoverArt{images: map[string][]byte{}}
|
arch = core.NewArchiver(ms, ds, sh)
|
||||||
arch = core.NewArchiver(ms, dc, ds, sh, ca)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Context("ZipAlbum", func() {
|
Context("ZipAlbum", func() {
|
||||||
|
|
@ -54,7 +45,7 @@ var _ = Describe("Archiver", func() {
|
||||||
Sort: "album",
|
Sort: "album",
|
||||||
}}).Return(mfs, nil)
|
}}).Return(mfs, nil)
|
||||||
|
|
||||||
ds.On("MediaFile").Return(mfRepo)
|
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
|
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
out := new(bytes.Buffer)
|
||||||
|
|
@ -68,23 +59,6 @@ var _ = Describe("Archiver", func() {
|
||||||
Expect(zr.File[0].Name).To(Equal("Album_Promo/01 - track1.mp3"))
|
Expect(zr.File[0].Name).To(Equal("Album_Promo/01 - track1.mp3"))
|
||||||
Expect(zr.File[1].Name).To(Equal("Album_Promo/02 - track2.mp3"))
|
Expect(zr.File[1].Name).To(Equal("Album_Promo/02 - track2.mp3"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("streams the request resolved by the transcode decider and names the entry after its format", func() {
|
|
||||||
mfRepo := &mockMediaFileRepository{}
|
|
||||||
mfRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{Path: "test_data/01 - track1.flac", Suffix: "flac", AlbumID: "1"}}, nil)
|
|
||||||
ds.On("MediaFile").Return(mfRepo)
|
|
||||||
resolved := stream.Request{Format: "opus", BitRate: 128, SampleRate: 48000, Channels: 2}
|
|
||||||
dc.resolved = &resolved
|
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, resolved).Return(io.NopCloser(strings.NewReader("test")), nil).Once()
|
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
|
||||||
Expect(arch.ZipAlbum(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed())
|
|
||||||
ms.AssertExpectations(GinkgoT())
|
|
||||||
|
|
||||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(zr.File[0].Name).To(HaveSuffix("01 - track1.opus"))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Context("ZipArtist", func() {
|
Context("ZipArtist", func() {
|
||||||
|
|
@ -103,7 +77,7 @@ var _ = Describe("Archiver", func() {
|
||||||
Sort: "album",
|
Sort: "album",
|
||||||
}}).Return(mfs, nil)
|
}}).Return(mfs, nil)
|
||||||
|
|
||||||
ds.On("MediaFile").Return(mfRepo)
|
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
out := new(bytes.Buffer)
|
||||||
|
|
@ -117,140 +91,6 @@ var _ = Describe("Archiver", func() {
|
||||||
Expect(zr.File[0].Name).To(Equal("Album 1/01 - track1.mp3"))
|
Expect(zr.File[0].Name).To(Equal("Album 1/01 - track1.mp3"))
|
||||||
Expect(zr.File[1].Name).To(Equal("Album 1/02 - track2.mp3"))
|
Expect(zr.File[1].Name).To(Equal("Album 1/02 - track2.mp3"))
|
||||||
})
|
})
|
||||||
|
|
||||||
When("albums that share a name", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
})
|
|
||||||
|
|
||||||
// zipArtistEntries zips the given tracks as artist "1" and returns the entry names in zip order.
|
|
||||||
zipArtistEntries := func(mfs model.MediaFiles) []string {
|
|
||||||
mfRepo := &mockMediaFileRepository{}
|
|
||||||
mfRepo.On("GetAll", mock.Anything).Return(mfs, nil)
|
|
||||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
|
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
|
||||||
Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
|
||||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
names := make([]string, len(zr.File))
|
|
||||||
for i, f := range zr.File {
|
|
||||||
names[i] = f.Name
|
|
||||||
}
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
It("keeps the albums in query order", func() {
|
|
||||||
names := zipArtistEntries(model.MediaFiles{
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Album C"},
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album A"},
|
|
||||||
{Path: "a/02.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album A"},
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Album B"},
|
|
||||||
})
|
|
||||||
Expect(names).To(Equal([]string{"Album C/01.mp3", "Album A/01.mp3", "Album A/02.mp3", "Album B/01.mp3"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("suffixes the year when it tells the albums apart", func() {
|
|
||||||
names := zipArtistEntries(model.MediaFiles{
|
|
||||||
{Path: "a/01 - Intro.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
|
|
||||||
{Path: "b/01 - Intro.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005},
|
|
||||||
})
|
|
||||||
Expect(names).To(Equal([]string{"Greatest Hits [2001]/01 - Intro.mp3", "Greatest Hits [2005]/01 - Intro.mp3"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("prefers the release year, so reissues of the same original are told apart", func() {
|
|
||||||
names := zipArtistEntries(model.MediaFiles{
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 1996, ReleaseYear: 2001},
|
|
||||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 1996, ReleaseYear: 2011},
|
|
||||||
{Path: "c/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Greatest Hits", Year: 1996},
|
|
||||||
})
|
|
||||||
Expect(names).To(Equal([]string{"Greatest Hits [2001]/01.mp3", "Greatest Hits [2011]/01.mp3", "Greatest Hits [1996]/01.mp3"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("names the folder after the full album name", func() {
|
|
||||||
names := zipArtistEntries(model.MediaFiles{
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001,
|
|
||||||
Tags: model.Tags{model.TagAlbumVersion: {"Original"}}},
|
|
||||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
|
|
||||||
Tags: model.Tags{model.TagAlbumVersion: {"CD/Digital"}}},
|
|
||||||
})
|
|
||||||
Expect(names).To(Equal([]string{"Greatest Hits (Original)/01.mp3", "Greatest Hits (CD_Digital)/01.mp3"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("prefers the album version over the year when it is not part of the name", func() {
|
|
||||||
conf.Server.Subsonic.AppendAlbumVersion = false
|
|
||||||
names := zipArtistEntries(model.MediaFiles{
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001,
|
|
||||||
Tags: model.Tags{model.TagAlbumVersion: {"Original"}}},
|
|
||||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
|
|
||||||
Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}},
|
|
||||||
})
|
|
||||||
Expect(names).To(Equal([]string{"Greatest Hits [Original]/01.mp3", "Greatest Hits [Deluxe Edition]/01.mp3"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("leaves the one album without the field unsuffixed", func() {
|
|
||||||
conf.Server.Subsonic.AppendAlbumVersion = false
|
|
||||||
names := zipArtistEntries(model.MediaFiles{
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
|
|
||||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
|
|
||||||
Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}},
|
|
||||||
})
|
|
||||||
Expect(names).To(Equal([]string{"Greatest Hits/01.mp3", "Greatest Hits [Deluxe Edition]/01.mp3"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("skips a field that is empty on more than one album", func() {
|
|
||||||
conf.Server.Subsonic.AppendAlbumVersion = false
|
|
||||||
names := zipArtistEntries(model.MediaFiles{
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
|
|
||||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005},
|
|
||||||
{Path: "c/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Greatest Hits", Year: 2010,
|
|
||||||
Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}},
|
|
||||||
})
|
|
||||||
Expect(names).To(Equal([]string{"Greatest Hits [2001]/01.mp3", "Greatest Hits [2005]/01.mp3", "Greatest Hits [2010]/01.mp3"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("skips a field that is the same on every album", func() {
|
|
||||||
names := zipArtistEntries(model.MediaFiles{
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Live", Year: 2001, MbzAlbumType: "album", CatalogNum: "CAT-1"},
|
|
||||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Live", Year: 2001, MbzAlbumType: "album", CatalogNum: "CAT-2"},
|
|
||||||
})
|
|
||||||
Expect(names).To(Equal([]string{"Live [CAT-1]/01.mp3", "Live [CAT-2]/01.mp3"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("falls back to the album id when nothing differs", func() {
|
|
||||||
names := zipArtistEntries(model.MediaFiles{
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "0123456789abcdef", Album: "Greatest Hits", Year: 2001},
|
|
||||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "fedcba9876543210", Album: "Greatest Hits", Year: 2001},
|
|
||||||
})
|
|
||||||
Expect(names).To(Equal([]string{"Greatest Hits [012345]/01.mp3", "Greatest Hits [fedcba]/01.mp3"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("treats names that sanitize to the same folder as a clash", func() {
|
|
||||||
names := zipArtistEntries(model.MediaFiles{
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "A/B", Year: 2001},
|
|
||||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: `A\B`, Year: 2005},
|
|
||||||
})
|
|
||||||
Expect(names).To(Equal([]string{"A_B [2001]/01.mp3", "A_B [2005]/01.mp3"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("sanitizes the suffix", func() {
|
|
||||||
conf.Server.Subsonic.AppendAlbumVersion = false
|
|
||||||
names := zipArtistEntries(model.MediaFiles{
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Hits", Tags: model.Tags{model.TagAlbumVersion: {"Vinyl"}}},
|
|
||||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Hits", Tags: model.Tags{model.TagAlbumVersion: {"CD/Digital"}}},
|
|
||||||
})
|
|
||||||
Expect(names).To(Equal([]string{"Hits [Vinyl]/01.mp3", "Hits [CD_Digital]/01.mp3"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("leaves the folder name alone when only one album has it", func() {
|
|
||||||
names := zipArtistEntries(model.MediaFiles{
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
|
|
||||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Other", Year: 2005},
|
|
||||||
})
|
|
||||||
Expect(names).To(Equal([]string{"Greatest Hits/01.mp3", "Other/01.mp3"}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Context("when the transcode limiter rejects a file", func() {
|
Context("when the transcode limiter rejects a file", func() {
|
||||||
|
|
@ -265,7 +105,7 @@ var _ = Describe("Archiver", func() {
|
||||||
Filters: squirrel.Eq{"album_id": "1"},
|
Filters: squirrel.Eq{"album_id": "1"},
|
||||||
Sort: "album",
|
Sort: "album",
|
||||||
}}).Return(mfs, nil)
|
}}).Return(mfs, nil)
|
||||||
ds.On("MediaFile").Return(mfRepo)
|
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||||
|
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).
|
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).
|
||||||
Return(nil, stream.ErrTooManyTranscodes).Once()
|
Return(nil, stream.ErrTooManyTranscodes).Once()
|
||||||
|
|
@ -315,30 +155,6 @@ var _ = Describe("Archiver", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
Context("ZipPlaylist", func() {
|
Context("ZipPlaylist", func() {
|
||||||
It("names the entries and the M3U lines after the resolved format", func() {
|
|
||||||
pls := &model.Playlist{ID: "1", Name: "Test Playlist", Tracks: []model.PlaylistTrack{
|
|
||||||
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.flac", Suffix: "flac", Artist: "Artist 1", Title: "track1"}},
|
|
||||||
}}
|
|
||||||
plRepo := &mockPlaylistRepository{}
|
|
||||||
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
|
|
||||||
ds.On("Playlist").Return(plRepo)
|
|
||||||
dc.resolved = &stream.Request{Format: "opus", BitRate: 128}
|
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, *dc.resolved).Return(io.NopCloser(strings.NewReader("test")), nil)
|
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
|
||||||
Expect(arch.ZipPlaylist(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed())
|
|
||||||
|
|
||||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(zr.File[0].Name).To(Equal("01 - Artist 1 - track1.opus"))
|
|
||||||
m3u, err := zr.File[1].Open()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
defer m3u.Close()
|
|
||||||
content, err := io.ReadAll(m3u)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(string(content)).To(ContainSubstring("01 - Artist 1 - track1.opus"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("zips a playlist correctly", func() {
|
It("zips a playlist correctly", func() {
|
||||||
tracks := []model.PlaylistTrack{
|
tracks := []model.PlaylistTrack{
|
||||||
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "AC/DC", Title: "track1"}},
|
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "AC/DC", Title: "track1"}},
|
||||||
|
|
@ -353,7 +169,7 @@ var _ = Describe("Archiver", func() {
|
||||||
|
|
||||||
plRepo := &mockPlaylistRepository{}
|
plRepo := &mockPlaylistRepository{}
|
||||||
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
|
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
|
||||||
ds.On("Playlist").Return(plRepo)
|
ds.On("Playlist", mock.Anything).Return(plRepo)
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
out := new(bytes.Buffer)
|
||||||
|
|
@ -380,195 +196,24 @@ var _ = Describe("Archiver", func() {
|
||||||
Expect(string(m3uContent)).To(Equal(expectedM3U))
|
Expect(string(m3uContent)).To(Equal(expectedM3U))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
Context("cover art", func() {
|
|
||||||
var (
|
|
||||||
jpegData = []byte("\xff\xd8\xff\xe0 fake jpeg")
|
|
||||||
pngData = []byte("\x89PNG\x0d\x0a\x1a\x0a fake png")
|
|
||||||
)
|
|
||||||
|
|
||||||
mockAlbumTracks := func(filter squirrel.Sqlizer, mfs model.MediaFiles) {
|
|
||||||
mfRepo := &mockMediaFileRepository{}
|
|
||||||
mfRepo.On("GetAll", []model.QueryOptions{{Filters: filter, Sort: "album"}}).Return(mfs, nil)
|
|
||||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
It("adds the album cover to the album folder", func() {
|
|
||||||
ca.images["al-1"] = jpegData
|
|
||||||
mockAlbumTracks(squirrel.Eq{"album_id": "1"}, model.MediaFiles{
|
|
||||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1},
|
|
||||||
})
|
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
|
||||||
Expect(arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
|
||||||
|
|
||||||
files := readZip(out)
|
|
||||||
Expect(files).To(HaveLen(2))
|
|
||||||
Expect(files).To(HaveKeyWithValue("Album_Promo/folder.jpg", jpegData))
|
|
||||||
Expect(ca.requests).To(ConsistOf(coverRequest{id: "al-1", size: 500, square: false}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("adds the artist image to the root and each album cover to its folder", func() {
|
|
||||||
ca.images["ar-1"] = pngData
|
|
||||||
ca.images["al-1"] = jpegData
|
|
||||||
ca.images["al-2"] = jpegData
|
|
||||||
mockAlbumTracks(squirrel.And{
|
|
||||||
persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist),
|
|
||||||
squirrel.Eq{"missing": false},
|
|
||||||
}, model.MediaFiles{
|
|
||||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1},
|
|
||||||
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "2", Album: "Album 2", DiscNumber: 1},
|
|
||||||
})
|
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
|
||||||
Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
|
||||||
|
|
||||||
files := readZip(out)
|
|
||||||
Expect(files).To(HaveLen(5))
|
|
||||||
Expect(files).To(HaveKeyWithValue("folder.png", pngData))
|
|
||||||
Expect(files).To(HaveKeyWithValue("Album 1/folder.jpg", jpegData))
|
|
||||||
Expect(files).To(HaveKeyWithValue("Album 2/folder.jpg", jpegData))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("puts each same-named album's cover in that album's own folder", func() {
|
|
||||||
ca.images["al-1"] = jpegData
|
|
||||||
ca.images["al-2"] = pngData
|
|
||||||
mockAlbumTracks(squirrel.And{
|
|
||||||
persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist),
|
|
||||||
squirrel.Eq{"missing": false},
|
|
||||||
}, model.MediaFiles{
|
|
||||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001, DiscNumber: 1},
|
|
||||||
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005, DiscNumber: 1},
|
|
||||||
})
|
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
|
||||||
Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
|
||||||
|
|
||||||
files := readZip(out)
|
|
||||||
Expect(files).To(HaveKeyWithValue("Greatest Hits [2001]/folder.jpg", jpegData))
|
|
||||||
Expect(files).To(HaveKeyWithValue("Greatest Hits [2005]/folder.png", pngData))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("adds the playlist cover to the root", func() {
|
|
||||||
ca.images["pl-1"] = jpegData
|
|
||||||
plRepo := &mockPlaylistRepository{}
|
|
||||||
plRepo.On("GetWithTracks", "1", true, false).Return(&model.Playlist{
|
|
||||||
ID: "1",
|
|
||||||
Name: "Test Playlist",
|
|
||||||
Tracks: []model.PlaylistTrack{
|
|
||||||
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Artist: "Artist 1", Title: "track1"}},
|
|
||||||
},
|
|
||||||
}, nil)
|
|
||||||
ds.On("Playlist", mock.Anything).Return(plRepo)
|
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
|
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
|
||||||
Expect(arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
|
||||||
|
|
||||||
files := readZip(out)
|
|
||||||
Expect(files).To(HaveLen(3))
|
|
||||||
Expect(files).To(HaveKeyWithValue("folder.jpg", jpegData))
|
|
||||||
Expect(files).To(HaveKey("Test Playlist.m3u"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("adds the shared item's cover to the root, even for a private playlist", func() {
|
|
||||||
ca.images["pl-10"] = jpegData
|
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
|
|
||||||
share := &model.Share{
|
|
||||||
ID: "1",
|
|
||||||
Downloadable: true,
|
|
||||||
Format: "mp3",
|
|
||||||
MaxBitRate: 128,
|
|
||||||
ResourceType: "playlist",
|
|
||||||
ResourceIDs: "10",
|
|
||||||
Tracks: model.MediaFiles{
|
|
||||||
{ID: "1", Path: "test_data/01 - track1.mp3", Suffix: "mp3", Artist: "Artist 1", Title: "track1"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
|
||||||
Expect(arch.ZipShare(context.Background(), share, out)).To(Succeed())
|
|
||||||
|
|
||||||
files := readZip(out)
|
|
||||||
Expect(files).To(HaveLen(2))
|
|
||||||
Expect(files).To(HaveKeyWithValue("folder.jpg", jpegData))
|
|
||||||
Expect(ca.requests).To(ConsistOf(coverRequest{id: "pl-10", size: 500, square: false, admin: true}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("still builds the archive when the cover cannot be read", func() {
|
|
||||||
ca.err = errors.New("boom")
|
|
||||||
mockAlbumTracks(squirrel.Eq{"album_id": "1"}, model.MediaFiles{
|
|
||||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
|
|
||||||
})
|
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
|
||||||
Expect(arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
|
||||||
|
|
||||||
files := readZip(out)
|
|
||||||
Expect(files).To(HaveLen(1))
|
|
||||||
Expect(files).To(HaveKey("Album/01 - track1.mp3"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
func readZip(out *bytes.Buffer) map[string][]byte {
|
|
||||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
files := make(map[string][]byte, len(zr.File))
|
|
||||||
for _, f := range zr.File {
|
|
||||||
r, err := f.Open()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
data, err := io.ReadAll(r)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
_ = r.Close()
|
|
||||||
files[f.Name] = data
|
|
||||||
}
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
|
|
||||||
type coverRequest struct {
|
|
||||||
id string
|
|
||||||
size int
|
|
||||||
square bool
|
|
||||||
admin bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type mockCoverArt struct {
|
|
||||||
artwork.Artwork
|
|
||||||
images map[string][]byte
|
|
||||||
err error
|
|
||||||
requests []coverRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockCoverArt) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*artwork.Image, error) {
|
|
||||||
user, _ := request.UserFrom(ctx)
|
|
||||||
m.requests = append(m.requests, coverRequest{id: artID.String(), size: size, square: square, admin: user.IsAdmin})
|
|
||||||
if m.err != nil {
|
|
||||||
return nil, m.err
|
|
||||||
}
|
|
||||||
data, ok := m.images[artID.String()]
|
|
||||||
if !ok {
|
|
||||||
return nil, artwork.ErrUnavailable
|
|
||||||
}
|
|
||||||
return &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader(data))}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type mockDataStore struct {
|
type mockDataStore struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
model.DataStore
|
model.DataStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDataStore) MediaFile() model.MediaFileRepository {
|
func (m *mockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository {
|
||||||
args := m.Called()
|
args := m.Called(ctx)
|
||||||
return args.Get(0).(model.MediaFileRepository)
|
return args.Get(0).(model.MediaFileRepository)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDataStore) Playlist() model.PlaylistRepository {
|
func (m *mockDataStore) Playlist(ctx context.Context) model.PlaylistRepository {
|
||||||
args := m.Called()
|
args := m.Called(ctx)
|
||||||
return args.Get(0).(model.PlaylistRepository)
|
return args.Get(0).(model.PlaylistRepository)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDataStore) Library() model.LibraryRepository {
|
func (m *mockDataStore) Library(context.Context) model.LibraryRepository {
|
||||||
return &mockLibraryRepository{}
|
return &mockLibraryRepository{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -577,7 +222,7 @@ type mockLibraryRepository struct {
|
||||||
model.LibraryRepository
|
model.LibraryRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockLibraryRepository) GetPath(_ context.Context, id int) (string, error) {
|
func (m *mockLibraryRepository) GetPath(id int) (string, error) {
|
||||||
return "/music", nil
|
return "/music", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -586,7 +231,7 @@ type mockMediaFileRepository struct {
|
||||||
model.MediaFileRepository
|
model.MediaFileRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockMediaFileRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) {
|
func (m *mockMediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||||
args := m.Called(options)
|
args := m.Called(options)
|
||||||
return args.Get(0).(model.MediaFiles), args.Error(1)
|
return args.Get(0).(model.MediaFiles), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
@ -596,7 +241,7 @@ type mockPlaylistRepository struct {
|
||||||
model.PlaylistRepository
|
model.PlaylistRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockPlaylistRepository) GetWithTracks(_ context.Context, id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) {
|
func (m *mockPlaylistRepository) GetWithTracks(id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) {
|
||||||
args := m.Called(id, refreshSmartPlaylists, includeMissing)
|
args := m.Called(id, refreshSmartPlaylists, includeMissing)
|
||||||
return args.Get(0).(*model.Playlist), args.Error(1)
|
return args.Get(0).(*model.Playlist), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
@ -614,19 +259,6 @@ func (m *mockMediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile,
|
||||||
return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
|
return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fakeDecider echoes the legacy format/bitrate unless a resolved request is set.
|
|
||||||
type fakeDecider struct {
|
|
||||||
stream.TranscodeDecider
|
|
||||||
resolved *stream.Request
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeDecider) ResolveRequest(_ context.Context, _ *model.MediaFile, format string, bitRate int, offset int) stream.Request {
|
|
||||||
if f.resolved != nil {
|
|
||||||
return *f.resolved
|
|
||||||
}
|
|
||||||
return stream.Request{Format: format, BitRate: bitRate, Offset: offset}
|
|
||||||
}
|
|
||||||
|
|
||||||
type mockShare struct {
|
type mockShare struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
core.Share
|
core.Share
|
||||||
|
|
|
||||||
|
|
@ -59,21 +59,21 @@ func entityExists(ctx context.Context, ds model.DataStore, artID model.ArtworkID
|
||||||
var err error
|
var err error
|
||||||
switch artID.Kind {
|
switch artID.Kind {
|
||||||
case model.KindArtistArtwork:
|
case model.KindArtistArtwork:
|
||||||
found, err = ds.Artist().Exists(ctx, artID.ID)
|
found, err = ds.Artist(ctx).Exists(artID.ID)
|
||||||
case model.KindAlbumArtwork:
|
case model.KindAlbumArtwork:
|
||||||
found, err = ds.Album().Exists(ctx, artID.ID)
|
found, err = ds.Album(ctx).Exists(artID.ID)
|
||||||
case model.KindMediaFileArtwork:
|
case model.KindMediaFileArtwork:
|
||||||
found, err = ds.MediaFile().Exists(ctx, artID.ID)
|
found, err = ds.MediaFile(ctx).Exists(artID.ID)
|
||||||
case model.KindPlaylistArtwork:
|
case model.KindPlaylistArtwork:
|
||||||
found, err = ds.Playlist().Exists(ctx, artID.ID)
|
found, err = ds.Playlist(ctx).Exists(artID.ID)
|
||||||
case model.KindRadioArtwork:
|
case model.KindRadioArtwork:
|
||||||
found, err = ds.Radio().Exists(ctx, artID.ID)
|
found, err = ds.Radio(ctx).Exists(artID.ID)
|
||||||
case model.KindDiscArtwork:
|
case model.KindDiscArtwork:
|
||||||
albumID, _, perr := model.ParseDiscArtworkID(artID.ID)
|
albumID, _, perr := model.ParseDiscArtworkID(artID.ID)
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
found, err = ds.Album().Exists(ctx, albumID)
|
found, err = ds.Album(ctx).Exists(albumID)
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -119,7 +119,7 @@ func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, squa
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
||||||
ia, err := s.ds.Artwork().GetItemArtwork(ctx, artID.Kind, artID.ID, model.ImageTypePrimary)
|
ia, err := s.ds.Artwork(ctx).GetItemArtwork(artID.Kind, artID.ID, model.ImageTypePrimary)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, model.ErrNotFound):
|
case errors.Is(err, model.ErrNotFound):
|
||||||
return s.provisional(ctx, artID, size, square)
|
return s.provisional(ctx, artID, size, square)
|
||||||
|
|
@ -168,12 +168,7 @@ func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *mode
|
||||||
if !entityExists(ctx, s.ds, artID) {
|
if !entityExists(ctx, s.ds, artID) {
|
||||||
return nil, ErrUnavailable
|
return nil, ErrUnavailable
|
||||||
}
|
}
|
||||||
// Checked here, not in openOriginal: a resize-cache hit never opens the source.
|
art, err := s.ds.Artwork(ctx).GetImage(ia.Hash)
|
||||||
if isFileBacked(ia.Source) && !model.IsImageFile(ia.SourcePath) {
|
|
||||||
log.Warn(ctx, "Artwork: Stored source is not an image file, re-resolving", "artID", artID, "path", ia.SourcePath)
|
|
||||||
return s.dangling(ctx, artID)
|
|
||||||
}
|
|
||||||
art, err := s.ds.Artwork().GetImage(ctx, ia.Hash)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
return s.dangling(ctx, artID)
|
return s.dangling(ctx, artID)
|
||||||
|
|
@ -264,13 +259,13 @@ func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, siz
|
||||||
// The setting is not in the config fingerprint, so honor it at serve time: a direct mf- URL
|
// The setting is not in the config fingerprint, so honor it at serve time: a direct mf- URL
|
||||||
// must fall back to disc/album instead of serving stale persisted embedded art.
|
// must fall back to disc/album instead of serving stale persisted embedded art.
|
||||||
if !conf.Server.EnableMediaFileCoverArt {
|
if !conf.Server.EnableMediaFileCoverArt {
|
||||||
mf, err := s.ds.MediaFile().Get(ctx, artID.ID)
|
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
|
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
|
||||||
}
|
}
|
||||||
ia, err := s.ds.Artwork().GetItemArtwork(ctx, model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary)
|
ia, err := s.ds.Artwork(ctx).GetItemArtwork(model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary)
|
||||||
switch {
|
switch {
|
||||||
case err == nil && ia.Hash != "":
|
case err == nil && ia.Hash != "":
|
||||||
return s.serveHash(ctx, artID, ia, size, square)
|
return s.serveHash(ctx, artID, ia, size, square)
|
||||||
|
|
@ -283,7 +278,7 @@ func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, siz
|
||||||
}
|
}
|
||||||
noRow := errors.Is(err, model.ErrNotFound)
|
noRow := errors.Is(err, model.ErrNotFound)
|
||||||
|
|
||||||
mf, err := s.ds.MediaFile().Get(ctx, artID.ID)
|
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -342,7 +337,7 @@ func (s *service) dangling(ctx context.Context, artID model.ArtworkID) (*Image,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) {
|
func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) {
|
||||||
err := s.ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{
|
err := s.ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
||||||
ItemKind: artID.Kind.Prefix(),
|
ItemKind: artID.Kind.Prefix(),
|
||||||
ItemID: artID.ID,
|
ItemID: artID.ID,
|
||||||
ImageType: model.ImageTypePrimary,
|
ImageType: model.ImageTypePrimary,
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,19 @@
|
||||||
package artwork
|
package artwork
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/netip"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/core/storage"
|
"github.com/navidrome/navidrome/core/storage"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/metadata"
|
"github.com/navidrome/navidrome/model/metadata"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
"github.com/navidrome/navidrome/utils/httpclient"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
"go.uber.org/goleak"
|
"go.uber.org/goleak"
|
||||||
|
|
@ -41,14 +37,6 @@ func TestArtwork(t *testing.T) {
|
||||||
RunSpecs(t, "Artwork Suite")
|
RunSpecs(t, "Artwork Suite")
|
||||||
}
|
}
|
||||||
|
|
||||||
// productionImageClient keeps the guarded client for the specs that assert it refuses loopback.
|
|
||||||
var productionImageClient = remoteImageClient
|
|
||||||
|
|
||||||
// httptest servers listen on loopback, which the production client refuses.
|
|
||||||
var _ = BeforeSuite(func() {
|
|
||||||
remoteImageClient = httpclient.NewExternal(5*time.Second, netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("::1/128"))
|
|
||||||
})
|
|
||||||
|
|
||||||
// osDirFS wraps os.DirFS as a storage.MusicFS for integration tests.
|
// osDirFS wraps os.DirFS as a storage.MusicFS for integration tests.
|
||||||
type osDirFS struct{ fs.FS }
|
type osDirFS struct{ fs.FS }
|
||||||
|
|
||||||
|
|
@ -109,15 +97,15 @@ type fakeFolderRepo struct {
|
||||||
otherAudioErr error
|
otherAudioErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFolderRepo) GetAll(context.Context, ...model.QueryOptions) ([]model.Folder, error) {
|
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) {
|
||||||
return f.result, f.err
|
return f.result, f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFolderRepo) HasAudioOutsideFolders(context.Context, model.Folder, []string) (bool, error) {
|
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
|
||||||
return f.hasOtherAudio, f.otherAudioErr
|
return f.hasOtherAudio, f.otherAudioErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFolderRepo) Get(context.Context, string) (*model.Folder, error) {
|
func (f *fakeFolderRepo) Get(string) (*model.Folder, error) {
|
||||||
f.getCallCount++
|
f.getCallCount++
|
||||||
if f.getErr != nil {
|
if f.getErr != nil {
|
||||||
return nil, f.getErr
|
return nil, f.getErr
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,8 @@ var _ = Describe("Artwork", func() {
|
||||||
hash, err := hashImage(bytes.NewReader(imgBytes))
|
hash, err := hashImage(bytes.NewReader(imgBytes))
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(store.Write(hash, "image/jpeg", bytes.NewReader(imgBytes))).To(Succeed())
|
Expect(store.Write(hash, "image/jpeg", bytes.NewReader(imgBytes))).To(Succeed())
|
||||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed())
|
Expect(artRepo.PutImage(&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())
|
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed())
|
||||||
seedEntity(kind, id)
|
seedEntity(kind, id)
|
||||||
return hash
|
return hash
|
||||||
}
|
}
|
||||||
|
|
@ -56,9 +56,9 @@ var _ = Describe("Artwork", func() {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
switch kind {
|
switch kind {
|
||||||
case "al":
|
case "al":
|
||||||
Expect(albumRepo.Put(ctx, &model.Album{ID: id, Name: "Album"})).To(Succeed())
|
Expect(albumRepo.Put(&model.Album{ID: id, Name: "Album"})).To(Succeed())
|
||||||
case "mf":
|
case "mf":
|
||||||
Expect(mfRepo.Put(ctx, &model.MediaFile{ID: id})).To(Succeed())
|
Expect(mfRepo.Put(&model.MediaFile{ID: id})).To(Succeed())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,9 +147,9 @@ var _ = Describe("Artwork", func() {
|
||||||
imgPath := filepath.Join(dir, "cover.jpg")
|
imgPath := filepath.Join(dir, "cover.jpg")
|
||||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||||
mtime := fileMtime(imgPath)
|
mtime := fileMtime(imgPath)
|
||||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed())
|
Expect(artRepo.PutImage(&model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed())
|
||||||
seedEntity("al", "al2")
|
seedEntity("al", "al2")
|
||||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "al2", Hash: "aaaaaaaaaaaaaaaa",
|
ItemKind: "al", ItemID: "al2", Hash: "aaaaaaaaaaaaaaaa",
|
||||||
Source: "folder", SourcePath: imgPath, RefMtime: mtime,
|
Source: "folder", SourcePath: imgPath, RefMtime: mtime,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
@ -159,60 +159,13 @@ var _ = Describe("Artwork", func() {
|
||||||
Expect(readAll(img)).To(Equal(coverBytes))
|
Expect(readAll(img)).To(Equal(coverBytes))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("treats a file-backed row pointing at a non-image file as dangling", func() {
|
|
||||||
dir := GinkgoT().TempDir()
|
|
||||||
secretPath := filepath.Join(dir, "config.ini")
|
|
||||||
Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed())
|
|
||||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "dddddddddddddddd", Mime: "image/jpeg"})).To(Succeed())
|
|
||||||
seedEntity("al", "alni")
|
|
||||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
|
||||||
ItemKind: "al", ItemID: "alni", Hash: "dddddddddddddddd",
|
|
||||||
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
|
|
||||||
})).To(Succeed())
|
|
||||||
|
|
||||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-alni"), 0, false)
|
|
||||||
Expect(err).To(MatchError(ErrUnavailable))
|
|
||||||
Expect(queueRepo.Data[primaryKey("al", "alni")].Priority).To(Equal(model.ArtworkPriorityScan))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("refuses a non-image file-backed row even when a resized copy is already cached", func() {
|
|
||||||
secret := []byte("password=secret")
|
|
||||||
dir := GinkgoT().TempDir()
|
|
||||||
secretPath := filepath.Join(dir, "config.ini")
|
|
||||||
Expect(os.WriteFile(secretPath, secret, 0600)).To(Succeed())
|
|
||||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "eeeeeeeeeeeeeeee", Mime: "image/jpeg"})).To(Succeed())
|
|
||||||
seedEntity("al", "alnic")
|
|
||||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
|
||||||
ItemKind: "al", ItemID: "alnic", Hash: "eeeeeeeeeeeeeeee",
|
|
||||||
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
|
|
||||||
})).To(Succeed())
|
|
||||||
|
|
||||||
// Older versions cached the raw bytes when the resize failed.
|
|
||||||
seed := func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(secret)), nil }
|
|
||||||
stream, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, open: seed, ffmpeg: ffm})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(io.ReadAll(stream)).To(Equal(secret))
|
|
||||||
Expect(stream.Close()).To(Succeed())
|
|
||||||
Eventually(func(g Gomega) {
|
|
||||||
s, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, ffmpeg: ffm,
|
|
||||||
open: func() (io.ReadCloser, error) { return nil, os.ErrNotExist }})
|
|
||||||
g.Expect(err).ToNot(HaveOccurred())
|
|
||||||
g.Expect(s.Cached).To(BeTrue())
|
|
||||||
_ = s.Close()
|
|
||||||
}).Should(Succeed())
|
|
||||||
|
|
||||||
_, err = svc.Get(ctx, model.MustParseArtworkID("al-alnic"), 100, false)
|
|
||||||
Expect(err).To(MatchError(ErrUnavailable))
|
|
||||||
Expect(queueRepo.Data[primaryKey("al", "alnic")].Priority).To(Equal(model.ArtworkPriorityScan))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("treats a full-size mtime mismatch as dangling: unavailable, re-enqueued at Scan, state untouched", func() {
|
It("treats a full-size mtime mismatch as dangling: unavailable, re-enqueued at Scan, state untouched", func() {
|
||||||
dir := GinkgoT().TempDir()
|
dir := GinkgoT().TempDir()
|
||||||
imgPath := filepath.Join(dir, "cover.jpg")
|
imgPath := filepath.Join(dir, "cover.jpg")
|
||||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed())
|
Expect(artRepo.PutImage(&model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed())
|
||||||
seedEntity("al", "al3")
|
seedEntity("al", "al3")
|
||||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "al3", Hash: "bbbbbbbbbbbbbbbb",
|
ItemKind: "al", ItemID: "al3", Hash: "bbbbbbbbbbbbbbbb",
|
||||||
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
@ -220,7 +173,7 @@ var _ = Describe("Artwork", func() {
|
||||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al3"), 0, false)
|
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al3"), 0, false)
|
||||||
Expect(err).To(MatchError(ErrUnavailable))
|
Expect(err).To(MatchError(ErrUnavailable))
|
||||||
Expect(queueRepo.Data[primaryKey("al", "al3")].Priority).To(Equal(model.ArtworkPriorityScan))
|
Expect(queueRepo.Data[primaryKey("al", "al3")].Priority).To(Equal(model.ArtworkPriorityScan))
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb"))
|
Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb"))
|
||||||
})
|
})
|
||||||
|
|
@ -229,9 +182,9 @@ var _ = Describe("Artwork", func() {
|
||||||
dir := GinkgoT().TempDir()
|
dir := GinkgoT().TempDir()
|
||||||
imgPath := filepath.Join(dir, "cover.jpg")
|
imgPath := filepath.Join(dir, "cover.jpg")
|
||||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed())
|
Expect(artRepo.PutImage(&model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed())
|
||||||
seedEntity("al", "al3b")
|
seedEntity("al", "al3b")
|
||||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "al3b", Hash: "cccccccccccccccc",
|
ItemKind: "al", ItemID: "al3b", Hash: "cccccccccccccccc",
|
||||||
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
@ -252,7 +205,7 @@ var _ = Describe("Artwork", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("never re-enqueues an absent state on view, however old", func() {
|
It("never re-enqueues an absent state on view, however old", func() {
|
||||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now().Add(-365 * 24 * time.Hour),
|
ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now().Add(-365 * 24 * time.Hour),
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -272,7 +225,7 @@ var _ = Describe("Artwork", func() {
|
||||||
Expect(readAll(img)).To(Equal(coverBytes))
|
Expect(readAll(img)).To(Equal(coverBytes))
|
||||||
|
|
||||||
Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump))
|
Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||||
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -283,7 +236,7 @@ var _ = Describe("Artwork", func() {
|
||||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al6"), 0, false)
|
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al6"), 0, false)
|
||||||
Expect(err).To(MatchError(ErrUnavailable))
|
Expect(err).To(MatchError(ErrUnavailable))
|
||||||
Expect(queueRepo.Data[primaryKey("al", "al6")].Priority).To(Equal(model.ArtworkPriorityBump))
|
Expect(queueRepo.Data[primaryKey("al", "al6")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||||
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -310,7 +263,7 @@ var _ = Describe("Artwork", func() {
|
||||||
|
|
||||||
It("delegates to the album when the track's state is absent", func() {
|
It("delegates to the album when the track's state is absent", func() {
|
||||||
seedFoundStore("al", "albm", coverBytes)
|
seedFoundStore("al", "albm", coverBytes)
|
||||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed())
|
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed())
|
||||||
mfRepo.SetData(model.MediaFiles{{ID: "mf2", AlbumID: "albm"}})
|
mfRepo.SetData(model.MediaFiles{{ID: "mf2", AlbumID: "albm"}})
|
||||||
|
|
||||||
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf2"), 0, false)
|
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf2"), 0, false)
|
||||||
|
|
@ -343,7 +296,7 @@ var _ = Describe("Artwork", func() {
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
||||||
Expect(queueRepo.Data[primaryKey("mf", "mf4")].Priority).To(Equal(model.ArtworkPriorityBump))
|
Expect(queueRepo.Data[primaryKey("mf", "mf4")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||||
_, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -494,7 +447,7 @@ var _ = Describe("Artwork", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("falls back to the artist placeholder for an absent artist", func() {
|
It("falls back to the artist placeholder for an absent artist", func() {
|
||||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed())
|
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed())
|
||||||
|
|
||||||
img, err := svc.GetOrPlaceholder(ctx, "ar-arph", 300, false)
|
img, err := svc.GetOrPlaceholder(ctx, "ar-arph", 300, false)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -535,7 +488,7 @@ var _ = Describe("EntityExists", func() {
|
||||||
artistRepo := tests.CreateMockArtistRepo()
|
artistRepo := tests.CreateMockArtistRepo()
|
||||||
artistRepo.SetData(model.Artists{{ID: "ar1"}})
|
artistRepo.SetData(model.Artists{{ID: "ar1"}})
|
||||||
radioRepo := tests.CreateMockedRadioRepo()
|
radioRepo := tests.CreateMockedRadioRepo()
|
||||||
Expect(radioRepo.Put(ctx, &model.Radio{ID: "ra1", Name: "R"})).To(Succeed())
|
Expect(radioRepo.Put(&model.Radio{ID: "ra1", Name: "R"})).To(Succeed())
|
||||||
ds = &tests.MockDataStore{MockedAlbum: albumRepo, MockedArtist: artistRepo, MockedRadio: radioRepo}
|
ds = &tests.MockDataStore{MockedAlbum: albumRepo, MockedArtist: artistRepo, MockedRadio: radioRepo}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
|
||||||
return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err)
|
return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
al, err := ds.Album().Get(ctx, albumID)
|
al, err := ds.Album(ctx).Get(albumID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +61,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query mediafiles for this album + disc to find folder associations and first track
|
// Query mediafiles for this album + disc to find folder associations and first track
|
||||||
mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{
|
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||||
Sort: "track_number",
|
Sort: "track_number",
|
||||||
Order: "ASC",
|
Order: "ASC",
|
||||||
Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber},
|
Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber},
|
||||||
|
|
@ -88,7 +88,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
|
||||||
// Resolve folder IDs to library-relative paths
|
// Resolve folder IDs to library-relative paths
|
||||||
discFoldersRel := make(map[string]bool)
|
discFoldersRel := make(map[string]bool)
|
||||||
if len(folderIDs) > 0 {
|
if len(folderIDs) > 0 {
|
||||||
folders, err := ds.Folder().GetAll(ctx, model.QueryOptions{
|
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{
|
||||||
Filters: squirrel.Eq{"folder.id": folderIDs},
|
Filters: squirrel.Eq{"folder.id": folderIDs},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -44,20 +44,20 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
|
|
||||||
itemFound := func(kind model.Kind, id string) func() bool {
|
itemFound := func(kind model.Kind, id string) func() bool {
|
||||||
return func() bool {
|
return func() bool {
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, kind, id, model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||||
return err == nil && ia.Hash != ""
|
return err == nil && ia.Hash != ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
itemAbsent := func(kind model.Kind, id string) func() bool {
|
itemAbsent := func(kind model.Kind, id string) func() bool {
|
||||||
return func() bool {
|
return func() bool {
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, kind, id, model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||||
return err == nil && ia.Hash == ""
|
return err == nil && ia.Hash == ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
|
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
|
||||||
bump := func(kind, id string) {
|
bump := func(kind, id string) {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
Expect(ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{
|
Expect(ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
||||||
ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary,
|
ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary,
|
||||||
Priority: model.ArtworkPriorityBump,
|
Priority: model.ArtworkPriorityBump,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
@ -141,7 +141,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("al", "al1")
|
bump("al", "al1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("folder"))
|
Expect(ia.Source).To(Equal("folder"))
|
||||||
|
|
||||||
|
|
@ -158,7 +158,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("ar", "ar1")
|
bump("ar", "ar1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindArtistArtwork, "ar1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindArtistArtwork, "ar1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("upload"))
|
Expect(ia.Source).To(Equal("upload"))
|
||||||
|
|
||||||
|
|
@ -175,14 +175,14 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("pl", "pl1")
|
bump("pl", "pl1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindPlaylistArtwork, "pl1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindPlaylistArtwork, "pl1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("generated"))
|
Expect(ia.Source).To(Equal("generated"))
|
||||||
|
|
||||||
img, err := svc.Get(ctx, model.MustParseArtworkID("pl-pl1"), 0, false)
|
img, err := svc.Get(ctx, model.MustParseArtworkID("pl-pl1"), 0, false)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(img.Hash).To(Equal(ia.Hash))
|
Expect(img.Hash).To(Equal(ia.Hash))
|
||||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
art, err := artRepo.GetImage(ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(art.Mime).To(Equal("image/png"))
|
Expect(art.Mime).To(Equal("image/png"))
|
||||||
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
||||||
|
|
@ -194,7 +194,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("ra", "ra1")
|
bump("ra", "ra1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("upload"))
|
Expect(ia.Source).To(Equal("upload"))
|
||||||
|
|
||||||
|
|
@ -216,12 +216,12 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
provisionalBytes := readAll(provisional)
|
provisionalBytes := readAll(provisional)
|
||||||
Expect(len(provisionalBytes)).To(BeNumerically(">", 0))
|
Expect(len(provisionalBytes)).To(BeNumerically(">", 0))
|
||||||
|
|
||||||
_, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound), "provisional serving must not write a state row")
|
Expect(err).To(MatchError(model.ErrNotFound), "provisional serving must not write a state row")
|
||||||
|
|
||||||
// The provisional read enqueued a Bump; drain it.
|
// The provisional read enqueued a Bump; drain it.
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindMediaFileArtwork, "mf1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindMediaFileArtwork, "mf1"))
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("embedded"))
|
Expect(ia.Source).To(Equal("embedded"))
|
||||||
Expect(ia.Hash).To(Equal(provisional.Hash))
|
Expect(ia.Hash).To(Equal(provisional.Hash))
|
||||||
|
|
@ -237,9 +237,9 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("al", "al1")
|
bump("al", "al1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
art, err := artRepo.GetImage(ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(art.Mime).To(Equal("image/jpeg"))
|
Expect(art.Mime).To(Equal("image/jpeg"))
|
||||||
Expect(art.Width).To(BeNumerically(">", 0))
|
Expect(art.Width).To(BeNumerically(">", 0))
|
||||||
|
|
@ -259,9 +259,9 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("ra", "ra1")
|
bump("ra", "ra1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
art, err := artRepo.GetImage(ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(art.Mime).To(Equal("image/gif"))
|
Expect(art.Mime).To(Equal("image/gif"))
|
||||||
Expect(art.Width).To(BeNumerically("==", 4))
|
Expect(art.Width).To(BeNumerically("==", 4))
|
||||||
|
|
@ -279,9 +279,9 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
return itemFound(model.KindAlbumArtwork, "al1")() && itemFound(model.KindAlbumArtwork, "al2")()
|
return itemFound(model.KindAlbumArtwork, "al1")() && itemFound(model.KindAlbumArtwork, "al2")()
|
||||||
})
|
})
|
||||||
|
|
||||||
ia1, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
ia2, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
|
ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia1.Hash).To(Equal(ia2.Hash), "identical bytes must share one content hash")
|
Expect(ia1.Hash).To(Equal(ia2.Hash), "identical bytes must share one content hash")
|
||||||
Expect(readAll(mustGet(svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false)))).To(Equal(coverBytes))
|
Expect(readAll(mustGet(svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false)))).To(Equal(coverBytes))
|
||||||
|
|
@ -293,7 +293,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("ra", "ra1")
|
bump("ra", "ra1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
staleHash := ia.Hash
|
staleHash := ia.Hash
|
||||||
|
|
||||||
|
|
@ -308,7 +308,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
|
|
||||||
// That failed read enqueued a re-resolution.
|
// That failed read enqueued a re-resolution.
|
||||||
runWorkerUntil(ctx, worker, func() bool {
|
runWorkerUntil(ctx, worker, func() bool {
|
||||||
cur, gerr := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
cur, gerr := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||||
return gerr == nil && cur.Hash != "" && cur.Hash != staleHash
|
return gerr == nil && cur.Hash != "" && cur.Hash != staleHash
|
||||||
})
|
})
|
||||||
img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false)
|
img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false)
|
||||||
|
|
|
||||||
|
|
@ -90,28 +90,6 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
When("the artist's only album folder has no images of its own", func() {
|
|
||||||
// Artist/
|
|
||||||
// ├── backdrop1.jpg
|
|
||||||
// ├── folder.jpg ← matched by folder.*
|
|
||||||
// ├── logo.png
|
|
||||||
// └── Album/
|
|
||||||
// ├── 01 - Track.mp3
|
|
||||||
// └── 02 - Track.mp3
|
|
||||||
It("resolves the artist folder, not the library root", func() {
|
|
||||||
conf.Server.ArtistArtPriority = "folder.*, artist.*, album/artist.*"
|
|
||||||
setLayout(fstest.MapFS{
|
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album"}),
|
|
||||||
"Artist/Album/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album"}),
|
|
||||||
"Artist/backdrop1.jpg": smallPNG("backdrop"),
|
|
||||||
"Artist/folder.jpg": smallPNG("artist-folder"),
|
|
||||||
"Artist/logo.png": smallPNG("logo"),
|
|
||||||
})
|
|
||||||
scan()
|
|
||||||
expectArtistFolder(soleArtist(), "Artist/folder.jpg")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
When("the artist's only album has its tracks in disc subfolders", func() {
|
When("the artist's only album has its tracks in disc subfolders", func() {
|
||||||
// Artist/
|
// Artist/
|
||||||
// ├── artist.jpg ← wins (artist.* before album/artist.*)
|
// ├── artist.jpg ← wins (artist.* before album/artist.*)
|
||||||
|
|
@ -201,7 +179,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
uploaded := ar.ID + "_upload.jpg"
|
uploaded := ar.ID + "_upload.jpg"
|
||||||
writeUploadedImage(consts.EntityArtist, uploaded, pngBytes("artist-uploaded"))
|
writeUploadedImage(consts.EntityArtist, uploaded, pngBytes("artist-uploaded"))
|
||||||
ar.UploadedImage = uploaded
|
ar.UploadedImage = uploaded
|
||||||
Expect(rds.Artist().Put(rctx, &ar)).To(Succeed())
|
Expect(rds.Artist(rctx).Put(&ar)).To(Succeed())
|
||||||
|
|
||||||
ia := acquire(model.KindArtistArtwork, ar.ID)
|
ia := acquire(model.KindArtistArtwork, ar.ID)
|
||||||
Expect(ia.Source).To(Equal("upload"))
|
Expect(ia.Source).To(Equal("upload"))
|
||||||
|
|
@ -279,7 +257,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
|
|
||||||
func soleArtist() model.Artist {
|
func soleArtist() model.Artist {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
artists, err := rds.Artist().GetAll(rctx, model.QueryOptions{
|
artists, err := rds.Artist(rctx).GetAll(model.QueryOptions{
|
||||||
Filters: squirrel.Eq{"artist.name": "Artist"},
|
Filters: squirrel.Eq{"artist.name": "Artist"},
|
||||||
})
|
})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,7 @@ func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bo
|
||||||
runCtx, cancel := context.WithCancel(ctx)
|
runCtx, cancel := context.WithCancel(ctx)
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
go func() { done <- worker.Run(runCtx) }()
|
go func() { done <- worker.Run(runCtx) }()
|
||||||
// Long enough for one retry (3-7s backoff, 5s poll tick).
|
Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue())
|
||||||
Eventually(until, 15*time.Second, 10*time.Millisecond).Should(BeTrue())
|
|
||||||
cancel()
|
cancel()
|
||||||
Eventually(done, 2*time.Second).Should(Receive(BeNil()))
|
Eventually(done, 2*time.Second).Should(Receive(BeNil()))
|
||||||
}
|
}
|
||||||
|
|
@ -67,17 +66,13 @@ type fakeFolderRepo struct {
|
||||||
result []model.Folder
|
result []model.Folder
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFolderRepo) GetAll(context.Context, ...model.QueryOptions) ([]model.Folder, error) {
|
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { return f.result, nil }
|
||||||
return f.result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeFolderRepo) HasAudioOutsideFolders(context.Context, model.Folder, []string) (bool, error) {
|
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFolderRepo) Get(context.Context, string) (*model.Folder, error) {
|
func (f *fakeFolderRepo) Get(string) (*model.Folder, error) { return nil, model.ErrNotFound }
|
||||||
return nil, model.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeUpload(entityType, name, srcFixture string) string {
|
func writeUpload(entityType, name, srcFixture string) string {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,7 @@ var _ = Describe("MediaFile artwork resolution", func() {
|
||||||
|
|
||||||
func mediafileOn(relPath string) model.MediaFile {
|
func mediafileOn(relPath string) model.MediaFile {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{
|
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{
|
||||||
Filters: squirrel.Like{"media_file.path": relPath},
|
Filters: squirrel.Like{"media_file.path": relPath},
|
||||||
})
|
})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
|
||||||
|
|
@ -142,13 +142,13 @@ var _ = Describe("Playlist artwork resolution", func() {
|
||||||
})
|
})
|
||||||
scan()
|
scan()
|
||||||
|
|
||||||
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{})
|
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(mfs).To(HaveLen(2))
|
Expect(mfs).To(HaveLen(2))
|
||||||
|
|
||||||
pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"}
|
pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"}
|
||||||
pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID})
|
pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID})
|
||||||
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
|
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
||||||
|
|
||||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||||
Expect(ia.Source).To(Equal("generated"))
|
Expect(ia.Source).To(Equal("generated"))
|
||||||
|
|
@ -180,14 +180,14 @@ var _ = Describe("Playlist artwork resolution", func() {
|
||||||
setLayout(layout)
|
setLayout(layout)
|
||||||
scan()
|
scan()
|
||||||
|
|
||||||
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{})
|
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(mfs).To(HaveLen(4))
|
Expect(mfs).To(HaveLen(4))
|
||||||
ids := slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID })
|
ids := slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID })
|
||||||
|
|
||||||
pl := model.Playlist{ID: "pl-8", Name: "Four", OwnerID: "admin-1"}
|
pl := model.Playlist{ID: "pl-8", Name: "Four", OwnerID: "admin-1"}
|
||||||
pl.AddMediaFilesByID(ids)
|
pl.AddMediaFilesByID(ids)
|
||||||
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
|
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
||||||
|
|
||||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||||
Expect(ia.Source).To(Equal("generated"))
|
Expect(ia.Source).To(Equal("generated"))
|
||||||
|
|
@ -208,6 +208,6 @@ func putPlaylist(pl model.Playlist) model.Playlist {
|
||||||
if pl.OwnerID == "" {
|
if pl.OwnerID == "" {
|
||||||
pl.OwnerID = "admin-1"
|
pl.OwnerID = "admin-1"
|
||||||
}
|
}
|
||||||
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
|
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
||||||
return pl
|
return pl
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ var _ = Describe("Radio artwork resolution", func() {
|
||||||
It("returns the uploaded image bytes", func() {
|
It("returns the uploaded image bytes", func() {
|
||||||
writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", pngBytes("radio-logo"))
|
writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", pngBytes("radio-logo"))
|
||||||
rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"}
|
rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"}
|
||||||
Expect(rds.Radio().Put(rctx, &rd)).To(Succeed())
|
Expect(rds.Radio(rctx).Put(&rd)).To(Succeed())
|
||||||
|
|
||||||
ia := acquire(model.KindRadioArtwork, rd.ID)
|
ia := acquire(model.KindRadioArtwork, rd.ID)
|
||||||
Expect(ia.Source).To(Equal("upload"))
|
Expect(ia.Source).To(Equal("upload"))
|
||||||
|
|
@ -35,7 +35,7 @@ var _ = Describe("Radio artwork resolution", func() {
|
||||||
// (no files on disk — the resolver has no sources to fall back to)
|
// (no files on disk — the resolver has no sources to fall back to)
|
||||||
It("settles absent", func() {
|
It("settles absent", func() {
|
||||||
rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"}
|
rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"}
|
||||||
Expect(rds.Radio().Put(rctx, &rd)).To(Succeed())
|
Expect(rds.Radio(rctx).Put(&rd)).To(Succeed())
|
||||||
|
|
||||||
ia := acquire(model.KindRadioArtwork, rd.ID)
|
ia := acquire(model.KindRadioArtwork, rd.ID)
|
||||||
Expect(ia.Hash).To(BeEmpty())
|
Expect(ia.Hash).To(BeEmpty())
|
||||||
|
|
|
||||||
|
|
@ -99,11 +99,11 @@ func setupResolutionHarness() {
|
||||||
rds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
rds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||||
|
|
||||||
adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"}
|
adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"}
|
||||||
Expect(rds.User().Put(rctx, &adminUser)).To(Succeed())
|
Expect(rds.User(rctx).Put(&adminUser)).To(Succeed())
|
||||||
|
|
||||||
lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath}
|
lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath}
|
||||||
Expect(rds.Library().Put(rctx, &lib)).To(Succeed())
|
Expect(rds.Library(rctx).Put(&lib)).To(Succeed())
|
||||||
Expect(rds.User().SetUserLibraries(rctx, adminUser.ID, []int{lib.ID})).To(Succeed())
|
Expect(rds.User(rctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())
|
||||||
|
|
||||||
loadEmbeddedFixture()
|
loadEmbeddedFixture()
|
||||||
|
|
||||||
|
|
@ -140,13 +140,13 @@ func scan() {
|
||||||
func acquire(kind model.Kind, id string) model.ItemArtwork {
|
func acquire(kind model.Kind, id string) model.ItemArtwork {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
|
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
|
||||||
Expect(rds.ArtworkQueue().EnqueuePreservingBackoff(rctx, model.ArtworkQueueItem{
|
Expect(rds.ArtworkQueue(rctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
||||||
ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary,
|
ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary,
|
||||||
Priority: model.ArtworkPriorityBump,
|
Priority: model.ArtworkPriorityBump,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
var ia *model.ItemArtwork
|
var ia *model.ItemArtwork
|
||||||
runResolutionWorkerUntil(func() bool {
|
runResolutionWorkerUntil(func() bool {
|
||||||
got, err := rds.Artwork().GetItemArtwork(rctx, kind, id, model.ImageTypePrimary)
|
got, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -211,7 +211,7 @@ func expectAlbumFolderCover(al model.Album, suffix string) {
|
||||||
// A drain settles every ready item, so byte-level folder assertions must precede any acquire.
|
// A drain settles every ready item, so byte-level folder assertions must precede any acquire.
|
||||||
func requireNoStateRow(kind model.Kind, id string) {
|
func requireNoStateRow(kind model.Kind, id string) {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
_, err := rds.Artwork().GetItemArtwork(rctx, kind, id, model.ImageTypePrimary)
|
_, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound),
|
Expect(err).To(MatchError(model.ErrNotFound),
|
||||||
"assert %s %q before acquiring any other entity in this spec", kind, id)
|
"assert %s %q before acquiring any other entity in this spec", kind, id)
|
||||||
}
|
}
|
||||||
|
|
@ -266,7 +266,7 @@ func gridQuadrants(data []byte) [4]color.RGBA {
|
||||||
// Store-backed sources only (embedded/generated); file-backed ones assert on ia.SourcePath.
|
// Store-backed sources only (embedded/generated); file-backed ones assert on ia.SourcePath.
|
||||||
func storedBytes(ia model.ItemArtwork) []byte {
|
func storedBytes(ia model.ItemArtwork) []byte {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
art, err := rds.Artwork().GetImage(rctx, ia.Hash)
|
art, err := rds.Artwork(rctx).GetImage(ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
r, err := rstore.Open(ia.Hash, art.Mime)
|
r, err := rstore.Open(ia.Hash, art.Mime)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -345,7 +345,7 @@ func replaceWithRealMP3(relPath string) {
|
||||||
|
|
||||||
func firstAlbum() model.Album {
|
func firstAlbum() model.Album {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
albums, err := rds.Album().GetAll(rctx, model.QueryOptions{})
|
albums, err := rds.Album(rctx).GetAll(model.QueryOptions{})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums))
|
Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums))
|
||||||
return albums[0]
|
return albums[0]
|
||||||
|
|
@ -353,7 +353,7 @@ func firstAlbum() model.Album {
|
||||||
|
|
||||||
func albumByName(name string) model.Album {
|
func albumByName(name string) model.Album {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
albums, err := rds.Album().GetAll(rctx, model.QueryOptions{})
|
albums, err := rds.Album(rctx).GetAll(model.QueryOptions{})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
for _, al := range albums {
|
for _, al := range albums {
|
||||||
if al.Name == name {
|
if al.Name == name {
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, album model.
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadFolders(ctx context.Context, ds model.DataStore, folderIDs []string) ([]model.Folder, error) {
|
func loadFolders(ctx context.Context, ds model.DataStore, folderIDs []string) ([]model.Folder, error) {
|
||||||
return ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}})
|
return ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}})
|
||||||
}
|
}
|
||||||
|
|
||||||
// folderImages collects the folders' image files, sorted so files without
|
// folderImages collects the folders' image files, sorted so files without
|
||||||
|
|
@ -79,7 +79,7 @@ func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Fo
|
||||||
if len(folders) < 2 && anyFolderHasImages(folders) {
|
if len(folders) < 2 && anyFolderHasImages(folders) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
parent, err := ds.Folder().Get(ctx, commonParentID)
|
parent, err := ds.Folder(ctx).Get(commonParentID)
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
log.Warn(ctx, "Artwork: Parent folder not found for album cover art lookup", "parentID", commonParentID)
|
log.Warn(ctx, "Artwork: Parent folder not found for album cover art lookup", "parentID", commonParentID)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|
@ -91,7 +91,7 @@ func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Fo
|
||||||
// The library root can never be an album root
|
// The library root can never be an album root
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
hasOtherAudio, err := ds.Folder().HasAudioOutsideFolders(ctx, *parent, folderIDs)
|
hasOtherAudio, err := ds.Folder(ctx).HasAudioOutsideFolders(*parent, folderIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/utils"
|
"github.com/navidrome/navidrome/utils"
|
||||||
|
|
@ -168,15 +169,14 @@ func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albu
|
||||||
folderPath = filepath.Dir(folderPath)
|
folderPath = filepath.Dir(folderPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleaned like the album paths; Join keeps an empty path empty, Clean would return ".".
|
// TODO: Hacky, but the easiest way to get the folder ID ATM
|
||||||
libPath, _ := ds.Library().GetPath(ctx, libID)
|
libPath := core.AbsolutePath(ctx, ds, libID, "")
|
||||||
libPath = filepath.Join(libPath)
|
|
||||||
folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath)
|
folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath)
|
||||||
|
|
||||||
log.Trace(ctx, "Artwork: Calculating artist folder details", "folderPath", folderPath, "folderID", folderID,
|
log.Trace(ctx, "Artwork: Calculating artist folder details", "folderPath", folderPath, "folderID", folderID,
|
||||||
"libPath", libPath, "libID", libID, "albumPaths", paths)
|
"libPath", libPath, "libID", libID, "albumPaths", paths)
|
||||||
|
|
||||||
folders, err := ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}})
|
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}})
|
||||||
if err != nil || len(folders) == 0 {
|
if err != nil || len(folders) == 0 {
|
||||||
log.Warn(ctx, "Artwork: Could not find folder for artist", "folderPath", folderPath, "id", folderID,
|
log.Warn(ctx, "Artwork: Could not find folder for artist", "folderPath", folderPath, "id", folderID,
|
||||||
"libPath", libPath, "libID", libID, err)
|
"libPath", libPath, "libID", libID, err)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
|
@ -46,11 +47,11 @@ var _ = Describe("loadArtistFolder", func() {
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
|
DeferCleanup(stubCoreAbsolutePath())
|
||||||
|
|
||||||
updatedAt = time.Now().Truncate(time.Second).Add(5 * time.Minute)
|
updatedAt = time.Now().Truncate(time.Second).Add(5 * time.Minute)
|
||||||
repo = &fakeFolderRepo{result: []model.Folder{{ImagesUpdatedAt: updatedAt}}}
|
repo = &fakeFolderRepo{result: []model.Folder{{ImagesUpdatedAt: updatedAt}}}
|
||||||
libRepo := &tests.MockLibraryRepo{}
|
ds = &tests.MockDataStore{MockedFolder: repo}
|
||||||
libRepo.SetData(model.Libraries{{ID: 1, Path: filepath.FromSlash("/music")}})
|
|
||||||
ds = &tests.MockDataStore{MockedFolder: repo, MockedLibrary: libRepo}
|
|
||||||
albums = model.Albums{{LibraryID: 1, ID: "album1", Name: "Album 1"}}
|
albums = model.Albums{{LibraryID: 1, ID: "album1", Name: "Album 1"}}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -106,3 +107,11 @@ var _ = Describe("loadArtistFolder", func() {
|
||||||
Expect(upd).To(BeZero())
|
Expect(upd).To(BeZero())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
func stubCoreAbsolutePath() func() {
|
||||||
|
original := core.AbsolutePath
|
||||||
|
core.AbsolutePath = func(context.Context, model.DataStore, int, string) string {
|
||||||
|
return filepath.FromSlash("/music")
|
||||||
|
}
|
||||||
|
return func() { core.AbsolutePath = original }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ func ConfigFingerprint() string {
|
||||||
// resolved under it. Nothing re-resolves on its own; applying a change is an explicit reprocess.
|
// resolved under it. Nothing re-resolves on its own; applying a change is an explicit reprocess.
|
||||||
func ReconcileConfigFingerprint(ctx context.Context, ds model.DataStore) error {
|
func ReconcileConfigFingerprint(ctx context.Context, ds model.DataStore) error {
|
||||||
current := ConfigFingerprint()
|
current := ConfigFingerprint()
|
||||||
stored, err := ds.Property().DefaultGet(ctx, consts.ArtConfFingerprintPropertyKey, "")
|
stored, err := ds.Property(ctx).DefaultGet(consts.ArtConfFingerprintPropertyKey, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -89,14 +89,14 @@ func ReconcileConfigFingerprint(ctx context.Context, ds model.DataStore) error {
|
||||||
|
|
||||||
// MarkConfigApplied records the current fingerprint as the one the library is resolved under.
|
// MarkConfigApplied records the current fingerprint as the one the library is resolved under.
|
||||||
func MarkConfigApplied(ctx context.Context, ds model.DataStore) error {
|
func MarkConfigApplied(ctx context.Context, ds model.DataStore) error {
|
||||||
return ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())
|
return ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())
|
||||||
}
|
}
|
||||||
|
|
||||||
// enqueueMissingAll is the safety net for entities a scan never enqueued (added between scans, or scanner off).
|
// enqueueMissingAll is the safety net for entities a scan never enqueued (added between scans, or scanner off).
|
||||||
func enqueueMissingAll(ctx context.Context, ds model.DataStore) error {
|
func enqueueMissingAll(ctx context.Context, ds model.DataStore) error {
|
||||||
queue := ds.ArtworkQueue()
|
queue := ds.ArtworkQueue(ctx)
|
||||||
for _, kind := range ReprocessKinds {
|
for _, kind := range ReprocessKinds {
|
||||||
if _, err := queue.EnqueueAllMissing(ctx, kind, model.ArtworkPriorityRecheck); err != nil {
|
if _, err := queue.EnqueueAllMissing(kind, model.ArtworkPriorityRecheck); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -108,31 +108,31 @@ func enqueueMissingAll(ctx context.Context, ds model.DataStore) error {
|
||||||
func ItemName(ctx context.Context, ds model.DataStore, kind model.Kind, id string) (string, error) {
|
func ItemName(ctx context.Context, ds model.DataStore, kind model.Kind, id string) (string, error) {
|
||||||
switch kind {
|
switch kind {
|
||||||
case model.KindArtistArtwork:
|
case model.KindArtistArtwork:
|
||||||
ar, err := ds.Artist().Get(ctx, id)
|
ar, err := ds.Artist(ctx).Get(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return ar.Name, nil
|
return ar.Name, nil
|
||||||
case model.KindAlbumArtwork:
|
case model.KindAlbumArtwork:
|
||||||
al, err := ds.Album().Get(ctx, id)
|
al, err := ds.Album(ctx).Get(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return al.Name, nil
|
return al.Name, nil
|
||||||
case model.KindPlaylistArtwork:
|
case model.KindPlaylistArtwork:
|
||||||
pls, err := ds.Playlist().Get(ctx, id)
|
pls, err := ds.Playlist(ctx).Get(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return pls.Name, nil
|
return pls.Name, nil
|
||||||
case model.KindRadioArtwork:
|
case model.KindRadioArtwork:
|
||||||
rd, err := ds.Radio().Get(ctx, id)
|
rd, err := ds.Radio(ctx).Get(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return rd.Name, nil
|
return rd.Name, nil
|
||||||
case model.KindMediaFileArtwork:
|
case model.KindMediaFileArtwork:
|
||||||
mf, err := ds.MediaFile().Get(ctx, id)
|
mf, err := ds.MediaFile(ctx).Get(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -148,7 +148,7 @@ func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
al, err := ds.Album().Get(ctx, albumID)
|
al, err := ds.Album(ctx).Get(albumID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -162,11 +162,11 @@ func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string
|
||||||
|
|
||||||
// Refresh drops an item's resolved artwork state and re-queues it at Bump priority.
|
// Refresh drops an item's resolved artwork state and re-queues it at Bump priority.
|
||||||
func Refresh(ctx context.Context, ds model.DataStore, kind model.Kind, id string) error {
|
func Refresh(ctx context.Context, ds model.DataStore, kind model.Kind, id string) error {
|
||||||
if err := ds.Artwork().DeleteForItems(ctx, kind, []string{id}); err != nil {
|
if err := ds.Artwork(ctx).DeleteForItems(kind, []string{id}); err != nil {
|
||||||
return fmt.Errorf("clearing artwork state: %w", err)
|
return fmt.Errorf("clearing artwork state: %w", err)
|
||||||
}
|
}
|
||||||
item := model.ArtworkQueueItem{ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump}
|
item := model.ArtworkQueueItem{ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump}
|
||||||
if err := ds.ArtworkQueue().Enqueue(ctx, item); err != nil {
|
if err := ds.ArtworkQueue(ctx).Enqueue(item); err != nil {
|
||||||
return fmt.Errorf("enqueuing artwork refresh: %w", err)
|
return fmt.Errorf("enqueuing artwork refresh: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -95,15 +95,15 @@ var _ = Describe("Housekeeping", func() {
|
||||||
It("records the current fingerprint when none was ever stored", func() {
|
It("records the current fingerprint when none was ever stored", func() {
|
||||||
Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed())
|
Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed())
|
||||||
|
|
||||||
Expect(propRepo.Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal(ConfigFingerprint()))
|
Expect(propRepo.Get(consts.ArtConfFingerprintPropertyKey)).To(Equal(ConfigFingerprint()))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("leaves a stale fingerprint stored, so the warning survives a restart", func() {
|
It("leaves a stale fingerprint stored, so the warning survives a restart", func() {
|
||||||
Expect(propRepo.Put(ctx, consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
||||||
|
|
||||||
Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed())
|
Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed())
|
||||||
|
|
||||||
Expect(propRepo.Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal("stale-fingerprint"))
|
Expect(propRepo.Get(consts.ArtConfFingerprintPropertyKey)).To(Equal("stale-fingerprint"))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -153,7 +153,7 @@ var _ = Describe("ItemName", func() {
|
||||||
{ID: "al-2", Name: "Sandinista!", Discs: model.Discs{2: "Side Three"}},
|
{ID: "al-2", Name: "Sandinista!", Discs: model.Discs{2: "Side Three"}},
|
||||||
})
|
})
|
||||||
ds = &tests.MockDataStore{MockedAlbum: albumRepo}
|
ds = &tests.MockDataStore{MockedAlbum: albumRepo}
|
||||||
Expect(ds.Artist().(*tests.MockArtistRepo).Put(ctx, &model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed())
|
Expect(ds.Artist(ctx).(*tests.MockArtistRepo).Put(&model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns the album name", func() {
|
It("returns the album name", func() {
|
||||||
|
|
|
||||||
|
|
@ -98,9 +98,6 @@ func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error {
|
||||||
if err := tmp.Close(); err != nil {
|
if err := tmp.Close(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.Chmod(tmp.Name(), 0640); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.Rename(tmp.Name(), dst)
|
return os.Rename(tmp.Name(), dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,17 +48,6 @@ var _ = Describe("ImageStore", func() {
|
||||||
Expect(got).To(Equal(data))
|
Expect(got).To(Equal(data))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("writes group-readable image files", func() {
|
|
||||||
tests.SkipOnWindows("uses Unix file permission bits")
|
|
||||||
data := []byte("jpeg-bytes")
|
|
||||||
h, _ := hashImage(bytes.NewReader(data))
|
|
||||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
|
||||||
|
|
||||||
info, err := os.Stat(store.path(h, "image/jpeg"))
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(info.Mode().Perm()).To(Equal(os.FileMode(0640)))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("is idempotent on duplicate writes and preserves the original content", func() {
|
It("is idempotent on duplicate writes and preserves the original content", func() {
|
||||||
data := []byte("dup")
|
data := []byte("dup")
|
||||||
h, _ := hashImage(bytes.NewReader(data))
|
h, _ := hashImage(bytes.NewReader(data))
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ func (v libraryView) Abs(rel string) string {
|
||||||
// loadLibraryView resolves the MusicFS and absolute root path in a single
|
// loadLibraryView resolves the MusicFS and absolute root path in a single
|
||||||
// library lookup.
|
// library lookup.
|
||||||
func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (libraryView, error) {
|
func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (libraryView, error) {
|
||||||
lib, err := ds.Library().Get(ctx, libID)
|
lib, err := ds.Library(ctx).Get(libID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return libraryView{}, err
|
return libraryView{}, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ var _ = Describe("loadLibraryView", Ordered, func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns a view for a library backed by registered storage", func() {
|
It("returns a view for a library backed by registered storage", func() {
|
||||||
Expect(ds.Library().Put(ctx, &model.Library{ID: 1, Path: "fake:///music"})).To(Succeed())
|
Expect(ds.Library(ctx).Put(&model.Library{ID: 1, Path: "fake:///music"})).To(Succeed())
|
||||||
|
|
||||||
lib, err := loadLibraryView(ctx, ds, 1)
|
lib, err := loadLibraryView(ctx, ds, 1)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -45,7 +45,7 @@ var _ = Describe("loadLibraryView", Ordered, func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns an error when the library path uses an unregistered scheme", func() {
|
It("returns an error when the library path uses an unregistered scheme", func() {
|
||||||
Expect(ds.Library().Put(ctx, &model.Library{ID: 2, Path: "unsupported:///music"})).To(Succeed())
|
Expect(ds.Library(ctx).Put(&model.Library{ID: 2, Path: "unsupported:///music"})).To(Succeed())
|
||||||
_, err := loadLibraryView(ctx, ds, 2)
|
_, err := loadLibraryView(ctx, ds, 2)
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ type processor struct {
|
||||||
// acquire resolves one queue item end to end: find an image, hash/decode/
|
// acquire resolves one queue item end to end: find an image, hash/decode/
|
||||||
// blurhash it, place its bytes, and persist the resulting state.
|
// blurhash it, place its bytes, and persist the resulting state.
|
||||||
func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) {
|
func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) {
|
||||||
repo := p.ds.Artwork()
|
repo := p.ds.Artwork(ctx)
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() {
|
defer func() {
|
||||||
log.Debug(ctx, "Artwork: Acquisition finished", "kind", item.ItemKind, "id", item.ItemID,
|
log.Debug(ctx, "Artwork: Acquisition finished", "kind", item.ItemKind, "id", item.ItemID,
|
||||||
|
|
@ -137,7 +137,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
|
||||||
log.Trace(ctx, "Artwork: Hashed image", "kind", item.ItemKind, "id", item.ItemID,
|
log.Trace(ctx, "Artwork: Hashed image", "kind", item.ItemKind, "id", item.ItemID,
|
||||||
"hash", hash, "bytes", len(data), "elapsed", time.Since(hashStart))
|
"hash", hash, "bytes", len(data), "elapsed", time.Since(hashStart))
|
||||||
|
|
||||||
art, err := repo.GetImage(ctx, hash)
|
art, err := repo.GetImage(hash)
|
||||||
switch {
|
switch {
|
||||||
case err == nil && art.Width > 0:
|
case err == nil && art.Width > 0:
|
||||||
log.Debug(ctx, "Artwork: Reusing a known image, skipping decode", "kind", item.ItemKind,
|
log.Debug(ctx, "Artwork: Reusing a known image, skipping decode", "kind", item.ItemKind,
|
||||||
|
|
@ -195,7 +195,7 @@ func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, i
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("writing image store: %w", err)
|
return nil, fmt.Errorf("writing image store: %w", err)
|
||||||
}
|
}
|
||||||
if err := repo.PutImage(ctx, art); err != nil {
|
if err := repo.PutImage(art); err != nil {
|
||||||
return nil, fmt.Errorf("persisting artwork image: %w", err)
|
return nil, fmt.Errorf("persisting artwork image: %w", err)
|
||||||
}
|
}
|
||||||
ia := &model.ItemArtwork{
|
ia := &model.ItemArtwork{
|
||||||
|
|
@ -210,7 +210,7 @@ func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, i
|
||||||
Trace: traceFrom(ctx).encode(sourcePath),
|
Trace: traceFrom(ctx).encode(sourcePath),
|
||||||
}
|
}
|
||||||
// PutItemArtwork stamps UpdatedAt on ia, so the returned struct matches the persisted row.
|
// PutItemArtwork stamps UpdatedAt on ia, so the returned struct matches the persisted row.
|
||||||
if err := repo.PutItemArtwork(ctx, ia); err != nil {
|
if err := repo.PutItemArtwork(ia); err != nil {
|
||||||
return nil, fmt.Errorf("persisting item artwork state: %w", err)
|
return nil, fmt.Errorf("persisting item artwork state: %w", err)
|
||||||
}
|
}
|
||||||
return ia, nil
|
return ia, nil
|
||||||
|
|
@ -218,7 +218,7 @@ func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, i
|
||||||
|
|
||||||
// writeAbsent records a known-absent state: every source answered definitively "no".
|
// writeAbsent records a known-absent state: every source answered definitively "no".
|
||||||
func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem) outcome {
|
func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem) outcome {
|
||||||
err := repo.PutItemArtwork(ctx, &model.ItemArtwork{
|
err := repo.PutItemArtwork(&model.ItemArtwork{
|
||||||
ItemKind: item.ItemKind,
|
ItemKind: item.ItemKind,
|
||||||
ItemID: item.ItemID,
|
ItemID: item.ItemID,
|
||||||
ImageType: item.ImageType,
|
ImageType: item.ImageType,
|
||||||
|
|
|
||||||
|
|
@ -93,14 +93,14 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"})
|
||||||
Expect(out).To(Equal(outcomeFound))
|
Expect(out).To(Equal(outcomeFound))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).ToNot(BeEmpty())
|
Expect(ia.Hash).ToNot(BeEmpty())
|
||||||
Expect(ia.Source).To(Equal("folder"))
|
Expect(ia.Source).To(Equal("folder"))
|
||||||
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg"))
|
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg"))
|
||||||
Expect(ia.RefMtime).To(BeNumerically(">", 0))
|
Expect(ia.RefMtime).To(BeNumerically(">", 0))
|
||||||
|
|
||||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
art, err := artRepo.GetImage(ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
// Every placeholder is derived from the one shared thumbnail, so all three land together.
|
// Every placeholder is derived from the one shared thumbnail, so all three land together.
|
||||||
Expect(art.BlurHash).ToNot(BeEmpty())
|
Expect(art.BlurHash).ToNot(BeEmpty())
|
||||||
|
|
@ -156,12 +156,12 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"})
|
||||||
Expect(out).To(Equal(outcomeFound))
|
Expect(out).To(Equal(outcomeFound))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("embedded"))
|
Expect(ia.Source).To(Equal("embedded"))
|
||||||
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3"))
|
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3"))
|
||||||
|
|
||||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
art, err := artRepo.GetImage(ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(art.BlurHash).ToNot(BeEmpty())
|
Expect(art.BlurHash).ToNot(BeEmpty())
|
||||||
|
|
||||||
|
|
@ -179,7 +179,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"})
|
||||||
Expect(out).To(Equal(outcomeAbsent))
|
Expect(out).To(Equal(outcomeAbsent))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).To(BeEmpty())
|
Expect(ia.Hash).To(BeEmpty())
|
||||||
Expect(ia.Source).To(BeEmpty())
|
Expect(ia.Source).To(BeEmpty())
|
||||||
|
|
@ -200,7 +200,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-io"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-io"})
|
||||||
Expect(out).To(Equal(outcomeFailed))
|
Expect(out).To(Equal(outcomeFailed))
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-io", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al-io", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound), "an I/O fault must not be recorded as absent")
|
Expect(err).To(MatchError(model.ErrNotFound), "an I/O fault must not be recorded as absent")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -225,7 +225,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-io"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-io"})
|
||||||
Expect(out).To(Equal(outcomeFailed))
|
Expect(out).To(Equal(outcomeFailed))
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra-io", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra-io", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound), "an unreadable upload must not be recorded as absent")
|
Expect(err).To(MatchError(model.ErrNotFound), "an unreadable upload must not be recorded as absent")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -269,7 +269,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
Expect(out).To(Equal(outcomeFailed))
|
Expect(out).To(Equal(outcomeFailed))
|
||||||
Expect(retryIn).To(BeZero(), "a plain failure asks for no particular delay")
|
Expect(retryIn).To(BeZero(), "a plain failure asks for no particular delay")
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -299,7 +299,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})
|
||||||
Expect(out).To(Equal(outcomeFoundStale))
|
Expect(out).To(Equal(outcomeFoundStale))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).ToNot(BeEmpty())
|
Expect(ia.Hash).ToNot(BeEmpty())
|
||||||
Expect(ia.Source).To(Equal("folder"))
|
Expect(ia.Source).To(Equal("folder"))
|
||||||
|
|
@ -316,10 +316,10 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alU"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alU"})
|
||||||
Expect(out).To(Equal(outcomeFound))
|
Expect(out).To(Equal(outcomeFound))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alU", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alU", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("folder"))
|
Expect(ia.Source).To(Equal("folder"))
|
||||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
art, err := artRepo.GetImage(ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(art.Width).To(BeZero())
|
Expect(art.Width).To(BeZero())
|
||||||
Expect(art.BlurHash).To(BeEmpty())
|
Expect(art.BlurHash).To(BeEmpty())
|
||||||
|
|
@ -336,7 +336,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"})
|
||||||
Expect(out).To(Equal(outcomeFailed))
|
Expect(out).To(Equal(outcomeFailed))
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alE", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alE", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -354,7 +354,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"})
|
||||||
Expect(out).To(Equal(outcomeFailed))
|
Expect(out).To(Equal(outcomeFailed))
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alX", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alX", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -373,12 +373,12 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"})
|
||||||
Expect(out).To(Equal(outcomeFound))
|
Expect(out).To(Equal(outcomeFound))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alext", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alext", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("external:deezerFake"))
|
Expect(ia.Source).To(Equal("external:deezerFake"))
|
||||||
Expect(ia.Hash).ToNot(BeEmpty())
|
Expect(ia.Hash).ToNot(BeEmpty())
|
||||||
|
|
||||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
art, err := artRepo.GetImage(ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
rc, err := store.Open(ia.Hash, art.Mime)
|
rc, err := store.Open(ia.Hash, art.Mime)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -397,7 +397,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
|
|
||||||
out1, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"})
|
out1, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"})
|
||||||
Expect(out1).To(Equal(outcomeFound))
|
Expect(out1).To(Equal(outcomeFound))
|
||||||
ia1, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
// A re-decode instead of a hash dedup would overwrite this sentinel.
|
// A re-decode instead of a hash dedup would overwrite this sentinel.
|
||||||
|
|
@ -407,11 +407,11 @@ var _ = Describe("processor.acquire", func() {
|
||||||
|
|
||||||
out2, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"})
|
out2, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"})
|
||||||
Expect(out2).To(Equal(outcomeFound))
|
Expect(out2).To(Equal(outcomeFound))
|
||||||
ia2, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia2.Hash).To(Equal(ia1.Hash))
|
Expect(ia2.Hash).To(Equal(ia1.Hash))
|
||||||
|
|
||||||
reused, err := artRepo.GetImage(ctx, ia1.Hash)
|
reused, err := artRepo.GetImage(ia1.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(reused.BlurHash).To(Equal("SENTINEL"))
|
Expect(reused.BlurHash).To(Equal("SENTINEL"))
|
||||||
})
|
})
|
||||||
|
|
@ -437,7 +437,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
folderRepo.result = []model.Folder{{Path: "album-a", ImageFiles: []string{"cover.jpg"}}}
|
folderRepo.result = []model.Folder{{Path: "album-a", ImageFiles: []string{"cover.jpg"}}}
|
||||||
outN, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"})
|
outN, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"})
|
||||||
Expect(outN).To(Equal(outcomeFound))
|
Expect(outN).To(Equal(outcomeFound))
|
||||||
iaA, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
|
iaA, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(iaA.Source).To(Equal("folder"))
|
Expect(iaA.Source).To(Equal("folder"))
|
||||||
Expect(filepath.ToSlash(iaA.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
|
Expect(filepath.ToSlash(iaA.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
|
||||||
|
|
@ -451,19 +451,19 @@ var _ = Describe("processor.acquire", func() {
|
||||||
folderRepo.result = []model.Folder{{Path: "album-b", ImageFiles: []string{"cover.jpg"}}}
|
folderRepo.result = []model.Folder{{Path: "album-b", ImageFiles: []string{"cover.jpg"}}}
|
||||||
outN, _, _ = proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"})
|
outN, _, _ = proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"})
|
||||||
Expect(outN).To(Equal(outcomeFound))
|
Expect(outN).To(Equal(outcomeFound))
|
||||||
iaB, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alB", model.ImageTypePrimary)
|
iaB, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alB", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(iaB.Hash).To(Equal(iaA.Hash))
|
Expect(iaB.Hash).To(Equal(iaA.Hash))
|
||||||
Expect(filepath.ToSlash(iaB.SourcePath)).To(HaveSuffix("album-b/cover.jpg"))
|
Expect(filepath.ToSlash(iaB.SourcePath)).To(HaveSuffix("album-b/cover.jpg"))
|
||||||
Expect(iaB.RefMtime).To(Equal(time.Unix(2000, 0).UnixNano()))
|
Expect(iaB.RefMtime).To(Equal(time.Unix(2000, 0).UnixNano()))
|
||||||
|
|
||||||
iaAafter, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
|
iaAafter, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(filepath.ToSlash(iaAafter.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
|
Expect(filepath.ToSlash(iaAafter.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
|
||||||
Expect(iaAafter.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano()))
|
Expect(iaAafter.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano()))
|
||||||
|
|
||||||
Expect(artRepo.Data).To(HaveLen(1))
|
Expect(artRepo.Data).To(HaveLen(1))
|
||||||
reused, err := artRepo.GetImage(ctx, iaA.Hash)
|
reused, err := artRepo.GetImage(iaA.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(reused.BlurHash).To(Equal("SENTINEL"))
|
Expect(reused.BlurHash).To(Equal("SENTINEL"))
|
||||||
})
|
})
|
||||||
|
|
@ -483,7 +483,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"})
|
||||||
Expect(out).To(Equal(outcomeFailed))
|
Expect(out).To(Equal(outcomeFailed))
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -504,7 +504,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"})
|
||||||
Expect(out).To(Equal(outcomeFailed))
|
Expect(out).To(Equal(outcomeFailed))
|
||||||
|
|
||||||
_, err = artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "big", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(model.KindRadioArtwork, "big", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -565,12 +565,12 @@ var _ = Describe("processor.acquire", func() {
|
||||||
|
|
||||||
hash, err := hashImage(bytes.NewReader(imgBytes))
|
hash, err := hashImage(bytes.NewReader(imgBytes))
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: hash, Mime: "application/octet-stream"})).To(Succeed())
|
Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "application/octet-stream"})).To(Succeed())
|
||||||
|
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alM"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alM"})
|
||||||
Expect(out).To(Equal(outcomeFound))
|
Expect(out).To(Equal(outcomeFound))
|
||||||
|
|
||||||
upgraded, err := artRepo.GetImage(ctx, hash)
|
upgraded, err := artRepo.GetImage(hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(upgraded.Width).To(BeNumerically(">", 0))
|
Expect(upgraded.Width).To(BeNumerically(">", 0))
|
||||||
Expect(upgraded.BlurHash).ToNot(BeEmpty())
|
Expect(upgraded.BlurHash).ToNot(BeEmpty())
|
||||||
|
|
@ -590,7 +590,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"})
|
||||||
Expect(out).To(Equal(outcomeFailed))
|
Expect(out).To(Equal(outcomeFailed))
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al7", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,9 @@ const pruneMinAge = time.Hour
|
||||||
func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
|
func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() { log.Debug(ctx, "Artwork: Prune finished", "elapsed", time.Since(start)) }()
|
defer func() { log.Debug(ctx, "Artwork: Prune finished", "elapsed", time.Since(start)) }()
|
||||||
repo := ds.Artwork()
|
repo := ds.Artwork(ctx)
|
||||||
|
|
||||||
purged, err := repo.PurgeDanglingItems(ctx)
|
purged, err := repo.PurgeDanglingItems()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -25,7 +25,7 @@ func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue rows for deleted entities would otherwise retry forever (Get -> not found -> failed).
|
// Queue rows for deleted entities would otherwise retry forever (Get -> not found -> failed).
|
||||||
queuePurged, err := ds.ArtworkQueue().PurgeDangling(ctx)
|
queuePurged, err := ds.ArtworkQueue(ctx).PurgeDangling()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -35,7 +35,7 @@ func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
|
||||||
|
|
||||||
// Files younger than the grace window may belong to acquisitions whose rows aren't committed yet.
|
// Files younger than the grace window may belong to acquisitions whose rows aren't committed yet.
|
||||||
cutoff := time.Now().Add(-pruneMinAge)
|
cutoff := time.Now().Add(-pruneMinAge)
|
||||||
orphans, err := repo.PurgeOrphans(ctx, cutoff)
|
orphans, err := repo.PurgeOrphans(cutoff)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -44,7 +44,7 @@ func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read after the delete, so the sweep below reclaims the files of the rows just removed.
|
// Read after the delete, so the sweep below reclaims the files of the rows just removed.
|
||||||
mimes, err := repo.GetMimeByHash(ctx)
|
mimes, err := repo.GetMimeByHash()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,20 +18,18 @@ type flakyGetArtworkRepo struct {
|
||||||
*tests.MockArtworkRepo
|
*tests.MockArtworkRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *flakyGetArtworkRepo) GetMimeByHash(context.Context) (map[string]string, error) {
|
func (f *flakyGetArtworkRepo) GetMimeByHash() (map[string]string, error) {
|
||||||
return nil, errors.New("db locked")
|
return nil, errors.New("db locked")
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = Describe("Prune", func() {
|
var _ = Describe("Prune", func() {
|
||||||
var ctx context.Context
|
|
||||||
var ds *tests.MockDataStore
|
var ds *tests.MockDataStore
|
||||||
var store *ImageStore
|
var store *ImageStore
|
||||||
var awRepo *tests.MockArtworkRepo
|
var awRepo *tests.MockArtworkRepo
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ctx = GinkgoT().Context()
|
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
awRepo = ds.Artwork().(*tests.MockArtworkRepo)
|
awRepo = ds.Artwork(context.Background()).(*tests.MockArtworkRepo)
|
||||||
store = NewImageStore(GinkgoT().TempDir())
|
store = NewImageStore(GinkgoT().TempDir())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -43,9 +41,9 @@ var _ = Describe("Prune", func() {
|
||||||
}
|
}
|
||||||
|
|
||||||
It("purges dangling item_artwork state for gone entities, summed across kinds", func() {
|
It("purges dangling item_artwork state for gone entities, summed across kinds", func() {
|
||||||
Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary})).To(Succeed())
|
Expect(awRepo.PutItemArtwork(&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(&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())
|
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "live-artist", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||||
awRepo.ExistingIDs = map[string]map[string]bool{
|
awRepo.ExistingIDs = map[string]map[string]bool{
|
||||||
"al": {},
|
"al": {},
|
||||||
"ar": {"live-artist": true},
|
"ar": {"live-artist": true},
|
||||||
|
|
@ -53,17 +51,17 @@ var _ = Describe("Prune", func() {
|
||||||
|
|
||||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||||
|
|
||||||
_, err := awRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "gone-album", model.ImageTypePrimary)
|
_, err := awRepo.GetItemArtwork(model.KindAlbumArtwork, "gone-album", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
_, err = awRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "gone-artist", model.ImageTypePrimary)
|
_, err = awRepo.GetItemArtwork(model.KindArtistArtwork, "gone-artist", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
_, err = awRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "live-artist", model.ImageTypePrimary)
|
_, err = awRepo.GetItemArtwork(model.KindArtistArtwork, "live-artist", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("purges dangling artwork_queue rows for gone entities", func() {
|
It("purges dangling artwork_queue rows for gone entities", func() {
|
||||||
queueRepo := tests.CreateMockArtworkQueueRepo()
|
queueRepo := tests.CreateMockArtworkQueueRepo()
|
||||||
Expect(queueRepo.Enqueue(ctx,
|
Expect(queueRepo.Enqueue(
|
||||||
model.ArtworkQueueItem{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary},
|
model.ArtworkQueueItem{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary},
|
||||||
model.ArtworkQueueItem{ItemKind: "al", ItemID: "live-album", ImageType: model.ImageTypePrimary},
|
model.ArtworkQueueItem{ItemKind: "al", ItemID: "live-album", ImageType: model.ImageTypePrimary},
|
||||||
)).To(Succeed())
|
)).To(Succeed())
|
||||||
|
|
@ -82,17 +80,17 @@ var _ = Describe("Prune", func() {
|
||||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||||
old := time.Now().Add(-2 * time.Hour)
|
old := time.Now().Add(-2 * time.Hour)
|
||||||
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
|
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
|
||||||
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||||
ageArtwork(h, old)
|
ageArtwork(h, old)
|
||||||
|
|
||||||
kept := []byte("kept-bytes")
|
kept := []byte("kept-bytes")
|
||||||
hk, _ := hashImage(bytes.NewReader(kept))
|
hk, _ := hashImage(bytes.NewReader(kept))
|
||||||
Expect(store.Write(hk, "image/jpeg", bytes.NewReader(kept))).To(Succeed())
|
Expect(store.Write(hk, "image/jpeg", bytes.NewReader(kept))).To(Succeed())
|
||||||
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: hk, Mime: "image/jpeg"})).To(Succeed())
|
Expect(awRepo.PutImage(&model.Artwork{Hash: hk, Mime: "image/jpeg"})).To(Succeed())
|
||||||
|
|
||||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||||
|
|
||||||
_, err := awRepo.GetImage(ctx, h)
|
_, err := awRepo.GetImage(h)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
_, err = store.Open(h, "image/jpeg")
|
_, err = store.Open(h, "image/jpeg")
|
||||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||||
|
|
@ -105,14 +103,14 @@ var _ = Describe("Prune", func() {
|
||||||
data := []byte("reacquired-bytes")
|
data := []byte("reacquired-bytes")
|
||||||
h, _ := hashImage(bytes.NewReader(data))
|
h, _ := hashImage(bytes.NewReader(data))
|
||||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||||
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||||
ageArtwork(h, time.Now().Add(-2*time.Hour))
|
ageArtwork(h, time.Now().Add(-2*time.Hour))
|
||||||
Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "a1",
|
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "a1",
|
||||||
ImageType: model.ImageTypePrimary, Hash: h, Source: "folder"})).To(Succeed())
|
ImageType: model.ImageTypePrimary, Hash: h, Source: "folder"})).To(Succeed())
|
||||||
|
|
||||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||||
|
|
||||||
_, err := awRepo.GetImage(ctx, h)
|
_, err := awRepo.GetImage(h)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
rc, err := store.Open(h, "image/jpeg")
|
rc, err := store.Open(h, "image/jpeg")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -124,11 +122,11 @@ var _ = Describe("Prune", func() {
|
||||||
h, _ := hashImage(bytes.NewReader(data))
|
h, _ := hashImage(bytes.NewReader(data))
|
||||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||||
// Reacquisition refreshed created_at, so the row is unreferenced but too young to drop.
|
// Reacquisition refreshed created_at, so the row is unreferenced but too young to drop.
|
||||||
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||||
|
|
||||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||||
|
|
||||||
_, err := awRepo.GetImage(ctx, h)
|
_, err := awRepo.GetImage(h)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
rc, err := store.Open(h, "image/jpeg")
|
rc, err := store.Open(h, "image/jpeg")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -139,7 +137,7 @@ var _ = Describe("Prune", func() {
|
||||||
data := []byte("racing-bytes")
|
data := []byte("racing-bytes")
|
||||||
h, _ := hashImage(bytes.NewReader(data))
|
h, _ := hashImage(bytes.NewReader(data))
|
||||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||||
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||||
ageArtwork(h, time.Now().Add(-2*time.Hour))
|
ageArtwork(h, time.Now().Add(-2*time.Hour))
|
||||||
// The row is orphaned, but a concurrent acquisition just touched the file's mtime.
|
// The row is orphaned, but a concurrent acquisition just touched the file's mtime.
|
||||||
|
|
||||||
|
|
@ -172,7 +170,7 @@ var _ = Describe("Prune", func() {
|
||||||
Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed())
|
Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed())
|
||||||
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
|
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
|
||||||
// The row records the current mime; the .png file is a superseded variant.
|
// The row records the current mime; the .png file is a superseded variant.
|
||||||
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||||
|
|
||||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -194,14 +192,14 @@ var _ = Describe("Prune", func() {
|
||||||
hb, _ := hashImage(bytes.NewReader(blocked))
|
hb, _ := hashImage(bytes.NewReader(blocked))
|
||||||
Expect(store.Write(hb, "image/jpeg", bytes.NewReader(blocked))).To(Succeed())
|
Expect(store.Write(hb, "image/jpeg", bytes.NewReader(blocked))).To(Succeed())
|
||||||
Expect(os.Chtimes(store.path(hb, "image/jpeg"), old, old)).To(Succeed())
|
Expect(os.Chtimes(store.path(hb, "image/jpeg"), old, old)).To(Succeed())
|
||||||
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: hb, Mime: "image/jpeg"})).To(Succeed())
|
Expect(awRepo.PutImage(&model.Artwork{Hash: hb, Mime: "image/jpeg"})).To(Succeed())
|
||||||
ageArtwork(hb, old)
|
ageArtwork(hb, old)
|
||||||
|
|
||||||
good := []byte("good-bytes")
|
good := []byte("good-bytes")
|
||||||
hg, _ := hashImage(bytes.NewReader(good))
|
hg, _ := hashImage(bytes.NewReader(good))
|
||||||
Expect(store.Write(hg, "image/jpeg", bytes.NewReader(good))).To(Succeed())
|
Expect(store.Write(hg, "image/jpeg", bytes.NewReader(good))).To(Succeed())
|
||||||
Expect(os.Chtimes(store.path(hg, "image/jpeg"), old, old)).To(Succeed())
|
Expect(os.Chtimes(store.path(hg, "image/jpeg"), old, old)).To(Succeed())
|
||||||
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: hg, Mime: "image/jpeg"})).To(Succeed())
|
Expect(awRepo.PutImage(&model.Artwork{Hash: hg, Mime: "image/jpeg"})).To(Succeed())
|
||||||
ageArtwork(hg, old)
|
ageArtwork(hg, old)
|
||||||
|
|
||||||
// A read-only shard directory makes os.Remove fail (EACCES) for hb's file only.
|
// A read-only shard directory makes os.Remove fail (EACCES) for hb's file only.
|
||||||
|
|
@ -212,13 +210,13 @@ var _ = Describe("Prune", func() {
|
||||||
|
|
||||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||||
|
|
||||||
_, err := awRepo.GetImage(ctx, hg)
|
_, err := awRepo.GetImage(hg)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
_, err = store.Open(hg, "image/jpeg")
|
_, err = store.Open(hg, "image/jpeg")
|
||||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||||
|
|
||||||
// The row purge does not depend on file removal, so only the file survives.
|
// The row purge does not depend on file removal, so only the file survives.
|
||||||
_, err = awRepo.GetImage(ctx, hb)
|
_, err = awRepo.GetImage(hb)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
rc, err := store.Open(hb, "image/jpeg")
|
rc, err := store.Open(hb, "image/jpeg")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ func toFastScaleType(img image.Image) image.Image {
|
||||||
}
|
}
|
||||||
|
|
||||||
func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) {
|
func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) {
|
||||||
original, format, err := decodeCapped(data)
|
original, format, err := image.Decode(bytes.NewReader(data))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
package artwork
|
|
||||||
|
|
||||||
import (
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("resizeStaticImage", func() {
|
|
||||||
It("rejects images whose declared dimensions exceed the pixel cap before decoding", func() {
|
|
||||||
_, _, err := resizeStaticImage(pngHeaderWithDims(9000, 9000), 300, false)
|
|
||||||
Expect(err).To(MatchError(ContainSubstring("exceed pixel cap")))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -198,7 +198,7 @@ func (r *resolver) fetchExternalArtist(ctx context.Context, ar model.Artist) (io
|
||||||
|
|
||||||
// resolveAlbum walks conf.Server.CoverArtPriority over the folder, embedded and external sources.
|
// resolveAlbum walks conf.Server.CoverArtPriority over the folder, embedded and external sources.
|
||||||
func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution, error) {
|
func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution, error) {
|
||||||
al, err := r.ds.Album().Get(ctx, albumID)
|
al, err := r.ds.Album(ctx).Get(albumID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolution{}, err
|
return resolution{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -243,7 +243,7 @@ func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution
|
||||||
|
|
||||||
// resolveArtist tries the uploaded image first, then walks conf.Server.ArtistArtPriority.
|
// resolveArtist tries the uploaded image first, then walks conf.Server.ArtistArtPriority.
|
||||||
func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resolution, error) {
|
func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resolution, error) {
|
||||||
ar, err := r.ds.Artist().Get(ctx, artistID)
|
ar, err := r.ds.Artist(ctx).Get(artistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolution{}, err
|
return resolution{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -259,7 +259,7 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only consider albums where the artist is the sole album artist.
|
// Only consider albums where the artist is the sole album artist.
|
||||||
als, err := r.ds.Album().GetAll(ctx, model.QueryOptions{Filters: persistence.SoleAlbumArtistFilter(artistID)})
|
als, err := r.ds.Album(ctx).GetAll(model.QueryOptions{Filters: persistence.SoleAlbumArtistFilter(artistID)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolution{}, err
|
return resolution{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -328,7 +328,7 @@ const PlaylistGridSamples = 4
|
||||||
|
|
||||||
// resolvePlaylist tries the uploaded image, the sidecar and ExternalImageURL, then a generated grid.
|
// resolvePlaylist tries the uploaded image, the sidecar and ExternalImageURL, then a generated grid.
|
||||||
func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (resolution, error) {
|
func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (resolution, error) {
|
||||||
pl, err := r.ds.Playlist().Get(ctx, playlistID)
|
pl, err := r.ds.Playlist(ctx).Get(playlistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolution{}, err
|
return resolution{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -374,11 +374,8 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tracks := r.ds.Playlist().Tracks(ctx, pl.ID, false)
|
albumIDs, err := r.ds.Playlist(ctx).Tracks(pl.ID, false).
|
||||||
if tracks == nil {
|
GetAlbumIDs(model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
|
||||||
return resolution{}, fmt.Errorf("resolvePlaylist: could not load tracks for playlist %s", pl.ID)
|
|
||||||
}
|
|
||||||
albumIDs, err := tracks.GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolution{}, err
|
return resolution{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -431,7 +428,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
|
||||||
|
|
||||||
// resolveRadio serves only an uploaded image; there is no fallback.
|
// resolveRadio serves only an uploaded image; there is no fallback.
|
||||||
func (r *resolver) resolveRadio(ctx context.Context, radioID string) (resolution, error) {
|
func (r *resolver) resolveRadio(ctx context.Context, radioID string) (resolution, error) {
|
||||||
radio, err := r.ds.Radio().Get(ctx, radioID)
|
radio, err := r.ds.Radio(ctx).Get(radioID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolution{}, err
|
return resolution{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -442,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
|
// resolveMediaFile resolves a track's own embedded art only, so disabled or missing cover art
|
||||||
// is a definitive absent.
|
// is a definitive absent.
|
||||||
func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution, error) {
|
func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution, error) {
|
||||||
mf, err := r.ds.MediaFile().Get(ctx, id)
|
mf, err := r.ds.MediaFile(ctx).Get(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolution{}, err
|
return resolution{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -575,7 +572,7 @@ func resolveArtistFolderPattern(ctx context.Context, lib libraryView, artistFold
|
||||||
// resolveLocalFile opens an absolute path directly. A missing path is "no source"; any other
|
// resolveLocalFile opens an absolute path directly. A missing path is "no source"; any other
|
||||||
// open failure says nothing about whether the image exists.
|
// open failure says nothing about whether the image exists.
|
||||||
func resolveLocalFile(path, source string) (resolution, bool) {
|
func resolveLocalFile(path, source string) (resolution, bool) {
|
||||||
if path == "" || !model.IsImageFile(path) {
|
if path == "" {
|
||||||
return resolution{}, false
|
return resolution{}, false
|
||||||
}
|
}
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
|
|
|
||||||
|
|
@ -498,23 +498,6 @@ var _ = Describe("resolveItem", func() {
|
||||||
Expect(res.refMtime).To(BeNumerically(">", 0))
|
Expect(res.refMtime).To(BeNumerically(">", 0))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("never opens a local ExternalImageURL that is not an image file", func() {
|
|
||||||
folderRepo.result = nil // no grid tiles, so only the local file could produce a reader
|
|
||||||
dir := GinkgoT().TempDir()
|
|
||||||
secretPath := filepath.Join(dir, "config.ini")
|
|
||||||
Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed())
|
|
||||||
|
|
||||||
plRepo := tests.CreateMockPlaylistRepo()
|
|
||||||
plRepo.SetData(model.Playlists{{ID: "plni", Name: "Playlist", ExternalImageURL: secretPath}})
|
|
||||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
|
||||||
ds.MockedPlaylist = plRepo
|
|
||||||
|
|
||||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plni"})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(res.reader).To(BeNil())
|
|
||||||
Expect(res.sourcePath).ToNot(Equal(secretPath))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("routes ExternalImageURL through extGate and sets extError on transient failure", func() {
|
It("routes ExternalImageURL through extGate and sets extError on transient failure", func() {
|
||||||
conf.Server.EnableM3UExternalAlbumArt = true
|
conf.Server.EnableM3UExternalAlbumArt = true
|
||||||
folderRepo.result = nil // no grid tiles, so the external failure is what surfaces
|
folderRepo.result = nil // no grid tiles, so the external failure is what surfaces
|
||||||
|
|
@ -707,16 +690,6 @@ var _ = Describe("resolveItem", func() {
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
Expect(res).To(Equal(resolution{}))
|
Expect(res).To(Equal(resolution{}))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns an error when the playlist tracks cannot be loaded", func() {
|
|
||||||
plRepo := tests.CreateMockPlaylistRepo()
|
|
||||||
plRepo.SetData(model.Playlists{{ID: "pl4", Name: "Playlist"}})
|
|
||||||
ds.MockedPlaylist = plRepo
|
|
||||||
|
|
||||||
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl4"})
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(res).To(Equal(resolution{}))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import (
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/utils/httpclient"
|
"github.com/navidrome/navidrome/utils/httpclient"
|
||||||
"github.com/navidrome/navidrome/utils/netguard"
|
|
||||||
"go.senan.xyz/taglib"
|
"go.senan.xyz/taglib"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -38,7 +37,7 @@ func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern
|
||||||
log.Warn(ctx, "Artwork: Error matching cover art file to pattern", "pattern", pattern, "file", file)
|
log.Warn(ctx, "Artwork: Error matching cover art file to pattern", "pattern", pattern, "file", file)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !match || !model.IsImageFile(name) {
|
if !match {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
f, err := libFS.Open(file)
|
f, err := libFS.Open(file)
|
||||||
|
|
@ -151,18 +150,10 @@ type readCloser struct {
|
||||||
io.Closer
|
io.Closer
|
||||||
}
|
}
|
||||||
|
|
||||||
// remoteImageClient fetches URLs from playlists and agents (plugins included), so it must not reach
|
|
||||||
// internal hosts. Shared so fetches reuse connections.
|
|
||||||
var remoteImageClient = httpclient.NewExternal(5 * time.Second)
|
|
||||||
|
|
||||||
func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) {
|
func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) {
|
||||||
|
hc := httpclient.New(5 * time.Second)
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
|
||||||
resp, err := remoteImageClient.Do(req)
|
resp, err := hc.Do(req) //nolint:gosec
|
||||||
if errors.Is(err, netguard.ErrPrivateAddress) {
|
|
||||||
// Retrying cannot change where the URL points: settle absent instead of tripping the breaker.
|
|
||||||
log.Warn(ctx, "Artwork: Refused to fetch image from a private or loopback address", "url", imageUrl, err)
|
|
||||||
return nil, "", model.ErrNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,87 +5,13 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/netip"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/utils/httpclient"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ = Describe("fromURL", func() {
|
|
||||||
var (
|
|
||||||
hits atomic.Int32
|
|
||||||
target *httptest.Server
|
|
||||||
)
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
hits.Store(0)
|
|
||||||
target = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
hits.Add(1)
|
|
||||||
_, _ = w.Write([]byte("image-bytes"))
|
|
||||||
}))
|
|
||||||
DeferCleanup(target.Close)
|
|
||||||
})
|
|
||||||
|
|
||||||
useClient := func(c *http.Client) {
|
|
||||||
prev := remoteImageClient
|
|
||||||
remoteImageClient = c
|
|
||||||
DeferCleanup(func() { remoteImageClient = prev })
|
|
||||||
}
|
|
||||||
fetch := func(rawURL string) ([]byte, error) {
|
|
||||||
u, err := url.Parse(rawURL)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
r, _, err := fromURL(GinkgoT().Context(), u)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer r.Close()
|
|
||||||
return io.ReadAll(r)
|
|
||||||
}
|
|
||||||
// Stand-in for a public host: only 127.0.0.1 is allowed, so every other private address stays refused.
|
|
||||||
onlyLocalhostV4 := func() *http.Client {
|
|
||||||
return httpclient.NewExternal(5*time.Second, netip.MustParsePrefix("127.0.0.1/32"))
|
|
||||||
}
|
|
||||||
|
|
||||||
DescribeTable("refuses private and loopback targets as a definitive miss",
|
|
||||||
func(rawURL string) {
|
|
||||||
useClient(productionImageClient)
|
|
||||||
u, _ := url.Parse(target.URL)
|
|
||||||
_, err := fetch(strings.ReplaceAll(rawURL, "PORT", u.Port()))
|
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
|
||||||
Expect(hits.Load()).To(BeZero())
|
|
||||||
},
|
|
||||||
Entry("IPv4 loopback", "http://127.0.0.1:PORT/x"),
|
|
||||||
Entry("localhost", "http://localhost:PORT/x"),
|
|
||||||
Entry("cloud metadata", "http://169.254.169.254/"),
|
|
||||||
Entry("IPv6 loopback", "http://[::1]/"),
|
|
||||||
)
|
|
||||||
|
|
||||||
It("refuses a redirect from an allowed host to a loopback address", func() {
|
|
||||||
useClient(onlyLocalhostV4())
|
|
||||||
redirector := httptest.NewServer(http.RedirectHandler(strings.Replace(target.URL, "127.0.0.1", "127.0.0.2", 1), http.StatusFound))
|
|
||||||
DeferCleanup(redirector.Close)
|
|
||||||
|
|
||||||
_, err := fetch(redirector.URL)
|
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
|
||||||
Expect(hits.Load()).To(BeZero())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("fetches from an allowed address", func() {
|
|
||||||
useClient(onlyLocalhostV4())
|
|
||||||
Expect(fetch(target.URL + "/cover.jpg")).To(Equal([]byte("image-bytes")))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("fromExternalFile", func() {
|
var _ = Describe("fromExternalFile", func() {
|
||||||
It("opens a matching file via the library FS", func() {
|
It("opens a matching file via the library FS", func() {
|
||||||
fsys := fstest.MapFS{
|
fsys := fstest.MapFS{
|
||||||
|
|
@ -122,18 +48,6 @@ var _ = Describe("fromExternalFile", func() {
|
||||||
Expect(b).To(Equal([]byte("a")))
|
Expect(b).To(Equal([]byte("a")))
|
||||||
Expect(path).To(Equal("a/cover.jpg"))
|
Expect(path).To(Equal("a/cover.jpg"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("skips a matching file that is not an image", func() {
|
|
||||||
fsys := fstest.MapFS{
|
|
||||||
"a/cover.ini": &fstest.MapFile{Data: []byte("password=secret")},
|
|
||||||
"a/cover.jpg": &fstest.MapFile{Data: []byte("a")},
|
|
||||||
}
|
|
||||||
f := fromExternalFile(GinkgoT().Context(), fsys, []string{"a/cover.ini", "a/cover.jpg"}, "cover.*")
|
|
||||||
r, path, err := f()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
defer r.Close()
|
|
||||||
Expect(path).To(Equal("a/cover.jpg"))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
var _ = Describe("fromTag", func() {
|
var _ = Describe("fromTag", func() {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package artwork
|
package artwork
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
@ -31,6 +32,9 @@ func parseSize(value, fallback string) int64 {
|
||||||
// Uploader stores a user-uploaded entity image and invalidates that entity's artwork state.
|
// Uploader stores a user-uploaded entity image and invalidates that entity's artwork state.
|
||||||
type Uploader interface {
|
type Uploader interface {
|
||||||
SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error)
|
SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error)
|
||||||
|
// SetAvatar resizes a user avatar to consts.MaxAvatarSize (aspect ratio preserved,
|
||||||
|
// no square padding) and stores it under the avatar folder.
|
||||||
|
SetAvatar(ctx context.Context, userID, username, oldPath string, reader io.Reader, ext string) (filename string, err error)
|
||||||
RemoveImage(ctx context.Context, path string) error
|
RemoveImage(ctx context.Context, path string) error
|
||||||
// EnqueueArtwork re-resolves the item's artwork. Call it AFTER persisting the new
|
// EnqueueArtwork re-resolves the item's artwork. Call it AFTER persisting the new
|
||||||
// filename, or the worker resolves the old one.
|
// filename, or the worker resolves the old one.
|
||||||
|
|
@ -77,6 +81,22 @@ func (s *uploader) SetImage(ctx context.Context, entityType string, entityID str
|
||||||
return filename, nil
|
return filename, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *uploader) SetAvatar(ctx context.Context, userID, username, oldPath string, reader io.Reader, ext string) (string, error) {
|
||||||
|
data, err := io.ReadAll(io.LimitReader(reader, MaxImageUploadSize()))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading avatar: %w", err)
|
||||||
|
}
|
||||||
|
// square=false: square padding would put transparent bars around a non-square photo.
|
||||||
|
resized, _, err := resizeStaticImage(data, consts.MaxAvatarSize, false)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resizing avatar: %w", err)
|
||||||
|
}
|
||||||
|
if resized == nil {
|
||||||
|
resized = bytes.NewReader(data) // already within bounds: resizeStaticImage returns nil, not the original
|
||||||
|
}
|
||||||
|
return s.SetImage(ctx, consts.EntityUser, userID, username, oldPath, resized, ext)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *uploader) EnqueueArtwork(ctx context.Context, entityType, id string) {
|
func (s *uploader) EnqueueArtwork(ctx context.Context, entityType, id string) {
|
||||||
kind, ok := uploadEntityKind[entityType]
|
kind, ok := uploadEntityKind[entityType]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
package artwork
|
package artwork
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -16,14 +19,12 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ = Describe("Uploader", func() {
|
var _ = Describe("Uploader", func() {
|
||||||
var ctx context.Context
|
|
||||||
var svc Uploader
|
var svc Uploader
|
||||||
var tmpDir string
|
var tmpDir string
|
||||||
var artRepo *tests.MockArtworkRepo
|
var artRepo *tests.MockArtworkRepo
|
||||||
var queueRepo *tests.MockArtworkQueueRepo
|
var queueRepo *tests.MockArtworkQueueRepo
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ctx = GinkgoT().Context()
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
tmpDir = GinkgoT().TempDir()
|
tmpDir = GinkgoT().TempDir()
|
||||||
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
||||||
|
|
@ -35,6 +36,7 @@ var _ = Describe("Uploader", func() {
|
||||||
|
|
||||||
Describe("SetImage", func() {
|
Describe("SetImage", func() {
|
||||||
It("creates directory and saves image file", func() {
|
It("creates directory and saves image file", func() {
|
||||||
|
ctx := context.Background()
|
||||||
reader := strings.NewReader("fake image data")
|
reader := strings.NewReader("fake image data")
|
||||||
filename, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", reader, ".jpg")
|
filename, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", reader, ".jpg")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -47,6 +49,7 @@ var _ = Describe("Uploader", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("falls back to ID-only filename when name cleans to empty", func() {
|
It("falls back to ID-only filename when name cleans to empty", func() {
|
||||||
|
ctx := context.Background()
|
||||||
reader := strings.NewReader("data")
|
reader := strings.NewReader("data")
|
||||||
filename, err := svc.SetImage(ctx, consts.EntityPlaylist, "pl-1", "!!!", "", reader, ".png")
|
filename, err := svc.SetImage(ctx, consts.EntityPlaylist, "pl-1", "!!!", "", reader, ".png")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -54,6 +57,7 @@ var _ = Describe("Uploader", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("removes old image when replacing", func() {
|
It("removes old image when replacing", func() {
|
||||||
|
ctx := context.Background()
|
||||||
oldDir := filepath.Join(tmpDir, "artwork", "artist")
|
oldDir := filepath.Join(tmpDir, "artwork", "artist")
|
||||||
Expect(os.MkdirAll(oldDir, 0755)).To(Succeed())
|
Expect(os.MkdirAll(oldDir, 0755)).To(Succeed())
|
||||||
oldFile := filepath.Join(oldDir, "ar-1_old.png")
|
oldFile := filepath.Join(oldDir, "ar-1_old.png")
|
||||||
|
|
@ -69,13 +73,15 @@ var _ = Describe("Uploader", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("ignores missing old file without error", func() {
|
It("ignores missing old file without error", func() {
|
||||||
|
ctx := context.Background()
|
||||||
reader := strings.NewReader("data")
|
reader := strings.NewReader("data")
|
||||||
_, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Name", "/nonexistent/path.jpg", reader, ".jpg")
|
_, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Name", "/nonexistent/path.jpg", reader, ".jpg")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("does not touch artwork state or the queue (that is EnqueueArtwork's job, post-Put)", func() {
|
It("does not touch artwork state or the queue (that is EnqueueArtwork's job, post-Put)", func() {
|
||||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
ctx := context.Background()
|
||||||
|
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||||
ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external",
|
ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external",
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -84,24 +90,84 @@ var _ = Describe("Uploader", func() {
|
||||||
|
|
||||||
// SetImage only writes the file; the state row survives and nothing is queued until
|
// SetImage only writes the file; the state row survives and nothing is queued until
|
||||||
// the caller has persisted the new filename and called EnqueueArtwork.
|
// the caller has persisted the new filename and called EnqueueArtwork.
|
||||||
_, err = artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(queueRepo.DequeueBatch(ctx, 1000)).To(BeEmpty())
|
Expect(queueRepo.DequeueBatch(1000)).To(BeEmpty())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("SetAvatar", func() {
|
||||||
|
It("writes into the avatar folder, named after id and username", func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
big := makePNG(1024, 1024)
|
||||||
|
filename, err := svc.SetAvatar(ctx, "u1", "deluan", "", bytes.NewReader(big), ".png")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(filename).To(Equal("u1_deluan.png"))
|
||||||
|
Expect(filepath.Join(tmpDir, "avatar", filename)).To(BeAnExistingFile())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("shrinks a large image to MaxAvatarSize, preserving aspect ratio", func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
big := makePNG(1024, 768)
|
||||||
|
filename, err := svc.SetAvatar(ctx, "u1", "deluan", "", bytes.NewReader(big), ".png")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
f, err := os.Open(filepath.Join(tmpDir, "avatar", filename))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
defer f.Close()
|
||||||
|
cfg, _, err := image.DecodeConfig(f)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(cfg.Width).To(Equal(consts.MaxAvatarSize))
|
||||||
|
Expect(cfg.Height).To(Equal(384)) // aspect ratio kept, not padded to a square
|
||||||
|
})
|
||||||
|
|
||||||
|
It("keeps a small image intact instead of writing an empty file", func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
small := makePNG(64, 64)
|
||||||
|
filename, err := svc.SetAvatar(ctx, "u1", "deluan", "", bytes.NewReader(small), ".png")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(tmpDir, "avatar", filename))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(data).ToNot(BeEmpty())
|
||||||
|
|
||||||
|
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(cfg.Width).To(Equal(64))
|
||||||
|
Expect(cfg.Height).To(Equal(64))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("removes the previous file", func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
old := filepath.Join(tmpDir, "avatar", "u1_old.png")
|
||||||
|
Expect(os.MkdirAll(filepath.Dir(old), 0755)).To(Succeed())
|
||||||
|
Expect(os.WriteFile(old, []byte("x"), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
_, err := svc.SetAvatar(ctx, "u1", "deluan", old, bytes.NewReader(makePNG(64, 64)), ".png")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(old).ToNot(BeAnExistingFile())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects a body that is not a decodable image", func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err := svc.SetAvatar(ctx, "u1", "deluan", "", strings.NewReader("not an image"), ".png")
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("EnqueueArtwork", func() {
|
Describe("EnqueueArtwork", func() {
|
||||||
It("clears artwork state and enqueues a Bump", func() {
|
It("clears artwork state and enqueues a Bump", func() {
|
||||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
ctx := context.Background()
|
||||||
|
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||||
ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external",
|
ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external",
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
||||||
svc.EnqueueArtwork(ctx, consts.EntityArtist, "ar-1")
|
svc.EnqueueArtwork(ctx, consts.EntityArtist, "ar-1")
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
|
|
||||||
queued, err := queueRepo.DequeueBatch(ctx, 1000)
|
queued, err := queueRepo.DequeueBatch(1000)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(queued).To(ContainElement(SatisfyAll(
|
Expect(queued).To(ContainElement(SatisfyAll(
|
||||||
HaveField("ItemKind", "ar"),
|
HaveField("ItemKind", "ar"),
|
||||||
|
|
@ -112,12 +178,13 @@ var _ = Describe("Uploader", func() {
|
||||||
|
|
||||||
It("is a no-op for an unknown entity type", func() {
|
It("is a no-op for an unknown entity type", func() {
|
||||||
svc.EnqueueArtwork(context.Background(), "unknown", "x-1")
|
svc.EnqueueArtwork(context.Background(), "unknown", "x-1")
|
||||||
Expect(queueRepo.DequeueBatch(ctx, 1000)).To(BeEmpty())
|
Expect(queueRepo.DequeueBatch(1000)).To(BeEmpty())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("RemoveImage", func() {
|
Describe("RemoveImage", func() {
|
||||||
It("removes the file at the given path", func() {
|
It("removes the file at the given path", func() {
|
||||||
|
ctx := context.Background()
|
||||||
dir := filepath.Join(tmpDir, "artwork", "artist")
|
dir := filepath.Join(tmpDir, "artwork", "artist")
|
||||||
Expect(os.MkdirAll(dir, 0755)).To(Succeed())
|
Expect(os.MkdirAll(dir, 0755)).To(Succeed())
|
||||||
path := filepath.Join(dir, "ar-1_test.jpg")
|
path := filepath.Join(dir, "ar-1_test.jpg")
|
||||||
|
|
@ -129,11 +196,13 @@ var _ = Describe("Uploader", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("succeeds when file does not exist", func() {
|
It("succeeds when file does not exist", func() {
|
||||||
|
ctx := context.Background()
|
||||||
err := svc.RemoveImage(ctx, "/nonexistent/file.jpg")
|
err := svc.RemoveImage(ctx, "/nonexistent/file.jpg")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("succeeds with empty path", func() {
|
It("succeeds with empty path", func() {
|
||||||
|
ctx := context.Background()
|
||||||
err := svc.RemoveImage(ctx, "")
|
err := svc.RemoveImage(ctx, "")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
})
|
})
|
||||||
|
|
@ -165,3 +234,10 @@ var _ = Describe("MaxImageUploadSize", func() {
|
||||||
Expect(MaxImageUploadSize()).To(Equal(int64(52_428_800)))
|
Expect(MaxImageUploadSize()).To(Equal(int64(52_428_800)))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
func makePNG(w, h int) []byte {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
var buf bytes.Buffer
|
||||||
|
Expect(png.Encode(&buf, img)).To(Succeed())
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,9 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"cmp"
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"runtime/debug"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -47,8 +45,7 @@ type Worker struct {
|
||||||
broker events.Broker
|
broker events.Broker
|
||||||
pruneMu sync.RWMutex
|
pruneMu sync.RWMutex
|
||||||
pools []*drainPool
|
pools []*drainPool
|
||||||
runCtx context.Context //nolint:containedctx // worker lifecycle ctx, set at Run
|
runCtx context.Context
|
||||||
paused func() bool
|
|
||||||
|
|
||||||
gatesMu sync.Mutex
|
gatesMu sync.Mutex
|
||||||
gates map[string]*extGate
|
gates map[string]*extGate
|
||||||
|
|
@ -62,7 +59,6 @@ func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg
|
||||||
broker: broker,
|
broker: broker,
|
||||||
pools: newDrainPools(),
|
pools: newDrainPools(),
|
||||||
runCtx: context.Background(),
|
runCtx: context.Background(),
|
||||||
paused: func() bool { return false },
|
|
||||||
gates: map[string]*extGate{},
|
gates: map[string]*extGate{},
|
||||||
}
|
}
|
||||||
w.proc.resolver = newResolver(ds, ag, ffmpeg, w.gate)
|
w.proc.resolver = newResolver(ds, ag, ffmpeg, w.gate)
|
||||||
|
|
@ -94,11 +90,6 @@ var (
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// PauseWhile holds off queue draining whenever paused reports true. Call it before Run.
|
|
||||||
func (w *Worker) PauseWhile(paused func() bool) {
|
|
||||||
w.paused = paused
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run blocks draining the queue until ctx is cancelled.
|
// Run blocks draining the queue until ctx is cancelled.
|
||||||
func (w *Worker) Run(ctx context.Context) error {
|
func (w *Worker) Run(ctx context.Context) error {
|
||||||
w.runCtx = ctx
|
w.runCtx = ctx
|
||||||
|
|
@ -152,12 +143,9 @@ func (w *Worker) EnqueueMissingAll(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (int, error) {
|
func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (int, error) {
|
||||||
if w.paused() {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
// Dequeue well past the pool size so a slow external lookup never idles the other slots.
|
// Dequeue well past the pool size so a slow external lookup never idles the other slots.
|
||||||
// DequeueBatch does not mark rows taken, so this is one query per pass, not per slot.
|
// DequeueBatch does not mark rows taken, so this is one query per pass, not per slot.
|
||||||
items, err := w.proc.ds.ArtworkQueue().DequeueBatch(ctx, max(16, 4*concurrency), kinds...)
|
items, err := w.proc.ds.ArtworkQueue(ctx).DequeueBatch(max(16, 4*concurrency), kinds...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
@ -182,9 +170,6 @@ func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (i
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
return len(items), nil //nolint:nilerr // a cancelled drain is a clean stop, not an error
|
return len(items), nil //nolint:nilerr // a cancelled drain is a clean stop, not an error
|
||||||
}
|
}
|
||||||
if w.paused() {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
wg.Go(func() {
|
wg.Go(func() {
|
||||||
defer func() { <-sem }()
|
defer func() { <-sem }()
|
||||||
out, got := w.process(ctx, item)
|
out, got := w.process(ctx, item)
|
||||||
|
|
@ -246,14 +231,14 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
||||||
item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary)
|
item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary)
|
||||||
trace := &ChainTrace{}
|
trace := &ChainTrace{}
|
||||||
ctx = withTrace(ctx, trace)
|
ctx = withTrace(ctx, trace)
|
||||||
out, got, retryIn := w.safeAcquire(ctx, item)
|
out, got, retryIn := w.proc.acquire(ctx, item)
|
||||||
|
|
||||||
queue := w.proc.ds.ArtworkQueue()
|
queue := w.proc.ds.ArtworkQueue(ctx)
|
||||||
switch out {
|
switch out {
|
||||||
case outcomeFound, outcomeAbsent:
|
case outcomeFound, outcomeAbsent:
|
||||||
// A scan that re-enqueued this row mid-flight reset its retry_at, so the row survives
|
// A scan that re-enqueued this row mid-flight reset its retry_at, so the row survives
|
||||||
// here and the next drain re-resolves it.
|
// here and the next drain re-resolves it.
|
||||||
if err := queue.DeleteIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
|
if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
|
||||||
log.Warn(ctx, "Artwork: Could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
log.Warn(ctx, "Artwork: Could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||||
}
|
}
|
||||||
case outcomeFoundStale, outcomeFailed:
|
case outcomeFoundStale, outcomeFailed:
|
||||||
|
|
@ -262,7 +247,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
||||||
if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) {
|
if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) {
|
||||||
// A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its
|
// A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its
|
||||||
// fresh, immediate eligibility.
|
// fresh, immediate eligibility.
|
||||||
if err := queue.MarkFailedIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt, encoded); err != nil {
|
if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt, encoded); err != nil {
|
||||||
log.Warn(ctx, "Artwork: Could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
log.Warn(ctx, "Artwork: Could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||||
}
|
}
|
||||||
log.Debug(ctx, "Artwork: Rescheduled item", "kind", item.ItemKind, "id", item.ItemID,
|
log.Debug(ctx, "Artwork: Rescheduled item", "kind", item.ItemKind, "id", item.ItemID,
|
||||||
|
|
@ -273,7 +258,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
||||||
// Art already being served is kept: exhaustion means unreachable, not removed.
|
// Art already being served is kept: exhaustion means unreachable, not removed.
|
||||||
settled := "kept previous state"
|
settled := "kept previous state"
|
||||||
if out == outcomeFailed && settlesAbsentOnGiveUp(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) {
|
if out == outcomeFailed && settlesAbsentOnGiveUp(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) {
|
||||||
writeAbsent(ctx, w.proc.ds.Artwork(), item)
|
writeAbsent(ctx, w.proc.ds.Artwork(ctx), item)
|
||||||
settled = "recorded absent"
|
settled = "recorded absent"
|
||||||
}
|
}
|
||||||
// The queue row is about to go, taking the only record of the failure with it. This write is
|
// The queue row is about to go, taking the only record of the failure with it. This write is
|
||||||
|
|
@ -281,27 +266,13 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
||||||
w.recordGiveUp(ctx, item, encoded)
|
w.recordGiveUp(ctx, item, encoded)
|
||||||
log.Info(ctx, "Artwork: Retry budget exhausted, giving up", "kind", item.ItemKind, "id", item.ItemID,
|
log.Info(ctx, "Artwork: Retry budget exhausted, giving up", "kind", item.ItemKind, "id", item.ItemID,
|
||||||
"outcome", out, "attempts", item.Attempts+1, "budget", giveUpAfter, "settled", settled)
|
"outcome", out, "attempts", item.Attempts+1, "budget", giveUpAfter, "settled", settled)
|
||||||
if err := queue.DeleteIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
|
if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
|
||||||
log.Warn(ctx, "Artwork: Could not remove exhausted queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
log.Warn(ctx, "Artwork: Could not remove exhausted queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, got
|
return out, got
|
||||||
}
|
}
|
||||||
|
|
||||||
// safeAcquire turns a panic into a failed attempt: the drain runs on a bare goroutine, so an
|
|
||||||
// unrecovered panic would crash the server, and the still-queued row would crash it again on restart.
|
|
||||||
func (w *Worker) safeAcquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
log.Error(ctx, "Artwork: Panic while processing item", "kind", item.ItemKind, "id", item.ItemID,
|
|
||||||
"imageType", item.ImageType, "attempts", item.Attempts, "panic", r, "stack", string(debug.Stack()))
|
|
||||||
traceStage(ctx, "panic", fmt.Errorf("%v", r))
|
|
||||||
out, got, retryIn = outcomeFailed, nil, 0
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return w.proc.acquire(ctx, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
// recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item
|
// recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item
|
||||||
// that never resolved has no row to update, and creating one would settle it absent.
|
// that never resolved has no row to update, and creating one would settle it absent.
|
||||||
func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) {
|
func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) {
|
||||||
|
|
@ -309,7 +280,7 @@ func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem,
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := w.proc.ds.Artwork().PutLastFailure(ctx, kind, item.ItemID, item.ImageType, trace); err != nil {
|
if err := w.proc.ds.Artwork(ctx).PutLastFailure(kind, item.ItemID, item.ImageType, trace); err != nil {
|
||||||
log.Warn(ctx, "Artwork: Could not record the last failure", "kind", item.ItemKind, "id", item.ItemID, err)
|
log.Warn(ctx, "Artwork: Could not record the last failure", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -319,7 +290,7 @@ func (w *Worker) hasResolvedArtwork(ctx context.Context, item model.ArtworkQueue
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
ia, err := w.proc.ds.Artwork().GetItemArtwork(ctx, kind, item.ItemID, item.ImageType)
|
ia, err := w.proc.ds.Artwork(ctx).GetItemArtwork(kind, item.ItemID, item.ImageType)
|
||||||
return err == nil && ia.Hash != ""
|
return err == nil && ia.Hash != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,6 @@ import (
|
||||||
const soakCycles = 2200
|
const soakCycles = 2200
|
||||||
|
|
||||||
var _ = Describe("Worker soak", func() {
|
var _ = Describe("Worker soak", func() {
|
||||||
var ctx context.Context
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
ctx = GinkgoT().Context()
|
|
||||||
})
|
|
||||||
|
|
||||||
It("does not leak goroutines, heap, or fds over many acquisition cycles", func() {
|
It("does not leak goroutines, heap, or fds over many acquisition cycles", func() {
|
||||||
if testing.Short() {
|
if testing.Short() {
|
||||||
Skip("skipping soak test in short mode")
|
Skip("skipping soak test in short mode")
|
||||||
|
|
@ -106,9 +100,9 @@ var _ = Describe("Worker soak", func() {
|
||||||
// Read-back exercises the surfaces a caller would use after acquisition.
|
// Read-back exercises the surfaces a caller would use after acquisition.
|
||||||
if out == outcomeFound {
|
if out == outcomeFound {
|
||||||
kind, _ := model.ParseKind(it.ItemKind)
|
kind, _ := model.ParseKind(it.ItemKind)
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, kind, it.ItemID, model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(kind, it.ItemID, model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred(), "cycle %d: GetItemArtwork", i)
|
Expect(err).ToNot(HaveOccurred(), "cycle %d: GetItemArtwork", i)
|
||||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
art, err := artRepo.GetImage(ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred(), "cycle %d: GetImage", i)
|
Expect(err).ToNot(HaveOccurred(), "cycle %d: GetImage", i)
|
||||||
rc, err := store.Open(ia.Hash, art.Mime)
|
rc, err := store.Open(ia.Hash, art.Mime)
|
||||||
switch {
|
switch {
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,8 @@ type reenqueueOnDequeue struct {
|
||||||
done bool
|
done bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *reenqueueOnDequeue) DequeueBatch(ctx context.Context, n int, kinds ...string) ([]model.ArtworkQueueItem, error) {
|
func (r *reenqueueOnDequeue) DequeueBatch(n int, kinds ...string) ([]model.ArtworkQueueItem, error) {
|
||||||
items, err := r.MockArtworkQueueRepo.DequeueBatch(ctx, n, kinds...)
|
items, err := r.MockArtworkQueueRepo.DequeueBatch(n, kinds...)
|
||||||
if !r.done && len(items) > 0 {
|
if !r.done && len(items) > 0 {
|
||||||
r.done = true
|
r.done = true
|
||||||
for k, it := range r.Data {
|
for k, it := range r.Data {
|
||||||
|
|
@ -124,39 +124,18 @@ type visibilityPlaylistDS struct {
|
||||||
tracks model.PlaylistTrackRepository
|
tracks model.PlaylistTrackRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visibilityPlaylistDS) Playlist() model.PlaylistRepository {
|
func (v *visibilityPlaylistDS) Playlist(ctx context.Context) model.PlaylistRepository {
|
||||||
repo := tests.CreateMockPlaylistRepo()
|
repo := tests.CreateMockPlaylistRepo()
|
||||||
repo.TracksRepo = v.tracks
|
repo.TracksRepo = v.tracks
|
||||||
repo.SetData(model.Playlists{v.private})
|
if u, ok := request.UserFrom(ctx); ok && u.IsAdmin {
|
||||||
return &visibilityPlaylistRepo{MockPlaylistRepo: repo}
|
repo.SetData(model.Playlists{v.private})
|
||||||
}
|
|
||||||
|
|
||||||
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 v.MockPlaylistRepo.Get(ctx, id)
|
return repo
|
||||||
}
|
|
||||||
|
|
||||||
type panickingAlbumRepo struct {
|
|
||||||
*tests.MockAlbumRepo
|
|
||||||
panicID string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *panickingAlbumRepo) Get(ctx context.Context, id string) (*model.Album, error) {
|
|
||||||
if id == r.panicID {
|
|
||||||
panic("boom")
|
|
||||||
}
|
|
||||||
return r.MockAlbumRepo.Get(ctx, id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func adminUserRepo() *tests.MockedUserRepo {
|
func adminUserRepo() *tests.MockedUserRepo {
|
||||||
repo := tests.CreateMockUserRepo()
|
repo := tests.CreateMockUserRepo()
|
||||||
Expect(repo.Put(GinkgoT().Context(), &model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed())
|
Expect(repo.Put(&model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed())
|
||||||
return repo
|
return repo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,8 +157,8 @@ var _ = Describe("Worker", func() {
|
||||||
)
|
)
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ctx = GinkgoT().Context()
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
ctx = context.Background()
|
||||||
var err error
|
var err error
|
||||||
repoRoot, err = os.Getwd()
|
repoRoot, err = os.Getwd()
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -221,7 +200,7 @@ var _ = Describe("Worker", func() {
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||||
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}},
|
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}},
|
||||||
})
|
})
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||||
ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan,
|
ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -229,11 +208,11 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(n).To(Equal(1))
|
Expect(n).To(Equal(1))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("folder"))
|
Expect(ia.Source).To(Equal("folder"))
|
||||||
|
|
||||||
count, err := queueRepo.Count(ctx)
|
count, err := queueRepo.Count()
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(count).To(BeZero(), "a found item must be deleted from the queue")
|
Expect(count).To(BeZero(), "a found item must be deleted from the queue")
|
||||||
})
|
})
|
||||||
|
|
@ -244,7 +223,7 @@ var _ = Describe("Worker", func() {
|
||||||
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||||
{ID: "mf1", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true},
|
{ID: "mf1", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true},
|
||||||
})
|
})
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||||
ItemKind: "mf", ItemID: "mf1", Priority: model.ArtworkPriorityBump,
|
ItemKind: "mf", ItemID: "mf1", Priority: model.ArtworkPriorityBump,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -252,12 +231,12 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(n).To(Equal(1))
|
Expect(n).To(Equal(1))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("embedded"))
|
Expect(ia.Source).To(Equal("embedded"))
|
||||||
Expect(ia.Hash).ToNot(BeEmpty())
|
Expect(ia.Hash).ToNot(BeEmpty())
|
||||||
|
|
||||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
art, err := artRepo.GetImage(ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
r, err := store.Open(ia.Hash, art.Mime)
|
r, err := store.Open(ia.Hash, art.Mime)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -266,7 +245,7 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(data).ToNot(BeEmpty(), "embedded bytes must be written to the store")
|
Expect(data).ToNot(BeEmpty(), "embedded bytes must be written to the store")
|
||||||
|
|
||||||
count, err := queueRepo.Count(ctx)
|
count, err := queueRepo.Count()
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(count).To(BeZero())
|
Expect(count).To(BeZero())
|
||||||
})
|
})
|
||||||
|
|
@ -275,7 +254,7 @@ var _ = Describe("Worker", func() {
|
||||||
conf.Server.CoverArtPriority = "external"
|
conf.Server.CoverArtPriority = "external"
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al4", Name: "Album"}})
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al4", Name: "Album"}})
|
||||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})).To(Succeed())
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})).To(Succeed())
|
||||||
|
|
||||||
n, err := w.drain(ctx, 2)
|
n, err := w.drain(ctx, 2)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -286,47 +265,17 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(it.Attempts).To(Equal(1))
|
Expect(it.Attempts).To(Equal(1))
|
||||||
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
|
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
|
||||||
|
|
||||||
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent")
|
Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent")
|
||||||
})
|
})
|
||||||
|
|
||||||
It("fails an item that panics, without stopping the rest of the batch", func() {
|
|
||||||
folderRepo.result = []model.Folder{{
|
|
||||||
Path: "tests/fixtures/artist/an-album",
|
|
||||||
ImageFiles: []string{"cover.jpg"},
|
|
||||||
}}
|
|
||||||
albums := tests.CreateMockAlbumRepo()
|
|
||||||
albums.SetData(model.Albums{
|
|
||||||
{ID: "alboom", Name: "Album", FolderIDs: []string{"f1"}},
|
|
||||||
{ID: "alok", Name: "Album", FolderIDs: []string{"f1"}},
|
|
||||||
})
|
|
||||||
ds.MockedAlbum = &panickingAlbumRepo{MockAlbumRepo: albums, panicID: "alboom"}
|
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alboom"})).To(Succeed())
|
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alok"})).To(Succeed())
|
|
||||||
|
|
||||||
n, err := w.drain(ctx, 1)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(n).To(Equal(2))
|
|
||||||
|
|
||||||
it := findQueued(queueRepo, "al", "alboom")
|
|
||||||
Expect(it).ToNot(BeNil(), "a panicking item must be rescheduled, not dropped")
|
|
||||||
Expect(it.Attempts).To(Equal(1))
|
|
||||||
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
|
|
||||||
Expect(it.Trace).To(ContainSubstring("boom"))
|
|
||||||
|
|
||||||
Expect(findQueued(queueRepo, "al", "alok")).To(BeNil())
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alok", model.ImageTypePrimary)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(ia.Source).To(Equal("folder"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reschedules past the provider's requested delay when it exceeds the backoff", func() {
|
It("reschedules past the provider's requested delay when it exceeds the backoff", func() {
|
||||||
conf.Server.CoverArtPriority = "external"
|
conf.Server.CoverArtPriority = "external"
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})
|
||||||
// Well above backoff(0)'s jittered ceiling, so only the hint can produce this retry_at.
|
// Well above backoff(0)'s jittered ceiling, so only the hint can produce this retry_at.
|
||||||
const askedFor = 90 * time.Minute
|
const askedFor = 90 * time.Minute
|
||||||
imageAgents(&fakeImageAgent{name: "throttledAgent", err: &agents.RetryLaterError{RetryIn: askedFor}})
|
imageAgents(&fakeImageAgent{name: "throttledAgent", err: &agents.RetryLaterError{RetryIn: askedFor}})
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
|
||||||
|
|
||||||
n, err := w.drain(ctx, 2)
|
n, err := w.drain(ctx, 2)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -347,7 +296,7 @@ var _ = Describe("Worker", func() {
|
||||||
{ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}},
|
{ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}},
|
||||||
})
|
})
|
||||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})).To(Succeed())
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})).To(Succeed())
|
||||||
|
|
||||||
n, err := w.drain(ctx, 2)
|
n, err := w.drain(ctx, 2)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -358,7 +307,7 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(it.Attempts).To(Equal(1))
|
Expect(it.Attempts).To(Equal(1))
|
||||||
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
|
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("folder"), "the fallback art is served meanwhile")
|
Expect(ia.Source).To(Equal("folder"), "the fallback art is served meanwhile")
|
||||||
|
|
||||||
|
|
@ -378,7 +327,7 @@ var _ = Describe("Worker", func() {
|
||||||
racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo}
|
racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo}
|
||||||
ds.MockedArtworkQueue = racing
|
ds.MockedArtworkQueue = racing
|
||||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||||
ItemKind: "al", ItemID: "al7", Priority: model.ArtworkPriorityScan,
|
ItemKind: "al", ItemID: "al7", Priority: model.ArtworkPriorityScan,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -388,7 +337,7 @@ var _ = Describe("Worker", func() {
|
||||||
|
|
||||||
// The concurrent re-enqueue changed retry_at, so the found-path delete was a no-op.
|
// The concurrent re-enqueue changed retry_at, so the found-path delete was a no-op.
|
||||||
Expect(findQueued(queueRepo, "al", "al7")).ToNot(BeNil())
|
Expect(findQueued(queueRepo, "al", "al7")).ToNot(BeNil())
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al7", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("folder"))
|
Expect(ia.Source).To(Equal("folder"))
|
||||||
})
|
})
|
||||||
|
|
@ -400,7 +349,7 @@ var _ = Describe("Worker", func() {
|
||||||
racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo}
|
racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo}
|
||||||
ds.MockedArtworkQueue = racing
|
ds.MockedArtworkQueue = racing
|
||||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al8"})).To(Succeed())
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al8"})).To(Succeed())
|
||||||
dequeued := findQueued(queueRepo, "al", "al8").RetryAt
|
dequeued := findQueued(queueRepo, "al", "al8").RetryAt
|
||||||
|
|
||||||
n, err := w.drain(ctx, 1)
|
n, err := w.drain(ctx, 1)
|
||||||
|
|
@ -419,7 +368,7 @@ var _ = Describe("Worker", func() {
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})
|
||||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
|
||||||
// Age the row past the retry budget.
|
// Age the row past the retry budget.
|
||||||
expireQueued(queueRepo, "al9")
|
expireQueued(queueRepo, "al9")
|
||||||
|
|
||||||
|
|
@ -428,7 +377,7 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(n).To(Equal(1))
|
Expect(n).To(Equal(1))
|
||||||
|
|
||||||
Expect(findQueued(queueRepo, "al", "al9")).To(BeNil())
|
Expect(findQueued(queueRepo, "al", "al9")).To(BeNil())
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al9", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al9", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).To(BeEmpty())
|
Expect(ia.Hash).To(BeEmpty())
|
||||||
})
|
})
|
||||||
|
|
@ -436,13 +385,13 @@ var _ = Describe("Worker", func() {
|
||||||
It("keeps already-served art when the retry budget is exhausted", func() {
|
It("keeps already-served art when the retry budget is exhausted", func() {
|
||||||
conf.Server.CoverArtPriority = "external"
|
conf.Server.CoverArtPriority = "external"
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al10", Name: "Album"}})
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al10", Name: "Album"}})
|
||||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "al10", ImageType: model.ImageTypePrimary,
|
ItemKind: "al", ItemID: "al10", ImageType: model.ImageTypePrimary,
|
||||||
Hash: "cafebabe", Source: "external:lastfm",
|
Hash: "cafebabe", Source: "external:lastfm",
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al10"})).To(Succeed())
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al10"})).To(Succeed())
|
||||||
expireQueued(queueRepo, "al10")
|
expireQueued(queueRepo, "al10")
|
||||||
|
|
||||||
n, err := w.drain(ctx, 1)
|
n, err := w.drain(ctx, 1)
|
||||||
|
|
@ -450,7 +399,7 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(n).To(Equal(1))
|
Expect(n).To(Equal(1))
|
||||||
|
|
||||||
Expect(findQueued(queueRepo, "al", "al10")).To(BeNil())
|
Expect(findQueued(queueRepo, "al", "al10")).To(BeNil())
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al10", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al10", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art")
|
Expect(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art")
|
||||||
})
|
})
|
||||||
|
|
@ -460,7 +409,7 @@ var _ = Describe("Worker", func() {
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al11", Name: "Album"}})
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al11", Name: "Album"}})
|
||||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al11"})).To(Succeed())
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al11"})).To(Succeed())
|
||||||
|
|
||||||
_, err := w.drain(ctx, 1)
|
_, err := w.drain(ctx, 1)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -481,13 +430,13 @@ var _ = Describe("Worker", func() {
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al13", Name: "Album"}})
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al13", Name: "Album"}})
|
||||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al13"})).To(Succeed())
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al13"})).To(Succeed())
|
||||||
expireQueued(queueRepo, "al13")
|
expireQueued(queueRepo, "al13")
|
||||||
|
|
||||||
_, err := w.drain(ctx, 1)
|
_, err := w.drain(ctx, 1)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al13", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al13", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred(), "settling absent must create the row the failure is written to")
|
Expect(err).ToNot(HaveOccurred(), "settling absent must create the row the failure is written to")
|
||||||
Expect(ia.Hash).To(BeEmpty())
|
Expect(ia.Hash).To(BeEmpty())
|
||||||
Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty())
|
Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty())
|
||||||
|
|
@ -496,20 +445,20 @@ var _ = Describe("Worker", func() {
|
||||||
It("keeps the failure on the state row after the queue row is deleted", func() {
|
It("keeps the failure on the state row after the queue row is deleted", func() {
|
||||||
conf.Server.CoverArtPriority = "external"
|
conf.Server.CoverArtPriority = "external"
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al12", Name: "Album"}})
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al12", Name: "Album"}})
|
||||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "al12", ImageType: model.ImageTypePrimary,
|
ItemKind: "al", ItemID: "al12", ImageType: model.ImageTypePrimary,
|
||||||
Hash: "cafebabe", Source: "external:lastfm",
|
Hash: "cafebabe", Source: "external:lastfm",
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al12"})).To(Succeed())
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al12"})).To(Succeed())
|
||||||
expireQueued(queueRepo, "al12")
|
expireQueued(queueRepo, "al12")
|
||||||
|
|
||||||
_, err := w.drain(ctx, 1)
|
_, err := w.drain(ctx, 1)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
Expect(findQueued(queueRepo, "al", "al12")).To(BeNil())
|
Expect(findQueued(queueRepo, "al", "al12")).To(BeNil())
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al12", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al12", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty(),
|
Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty(),
|
||||||
"the queue row is gone, so this is the only remaining record of the failure")
|
"the queue row is gone, so this is the only remaining record of the failure")
|
||||||
|
|
@ -524,7 +473,7 @@ var _ = Describe("Worker", func() {
|
||||||
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||||
{ID: "mfX", LibraryID: 0, Path: "tests/fixtures/artist/an-album/gone.mp3", HasCoverArt: true},
|
{ID: "mfX", LibraryID: 0, Path: "tests/fixtures/artist/an-album/gone.mp3", HasCoverArt: true},
|
||||||
})
|
})
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mfX"})).To(Succeed())
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mfX"})).To(Succeed())
|
||||||
expireQueued(queueRepo, "mfX")
|
expireQueued(queueRepo, "mfX")
|
||||||
|
|
||||||
n, err := w.drain(ctx, 1)
|
n, err := w.drain(ctx, 1)
|
||||||
|
|
@ -532,7 +481,7 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(n).To(Equal(1))
|
Expect(n).To(Equal(1))
|
||||||
|
|
||||||
Expect(findQueued(queueRepo, "mf", "mfX")).To(BeNil(), "the row must stop retrying")
|
Expect(findQueued(queueRepo, "mf", "mfX")).To(BeNil(), "the row must stop retrying")
|
||||||
_, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mfX", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mfX", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound),
|
Expect(err).To(MatchError(model.ErrNotFound),
|
||||||
"no row leaves the track unresolved, so a later view can still recover it")
|
"no row leaves the track unresolved, so a later view can still recover it")
|
||||||
// Known gap: with no row and no absent settle, there is nowhere to keep the failure.
|
// Known gap: with no row and no absent settle, there is nowhere to keep the failure.
|
||||||
|
|
@ -547,14 +496,14 @@ var _ = Describe("Worker", func() {
|
||||||
tracks: &tests.MockPlaylistTrackRepo{},
|
tracks: &tests.MockPlaylistTrackRepo{},
|
||||||
}
|
}
|
||||||
w = NewWorker(vds, store, ag, ffm, broker, imgCache)
|
w = NewWorker(vds, store, ag, ffm, broker, imgCache)
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plPriv"})).To(Succeed())
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plPriv"})).To(Succeed())
|
||||||
|
|
||||||
n, err := w.drain(ctx, 1)
|
n, err := w.drain(ctx, 1)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(n).To(Equal(1))
|
Expect(n).To(Equal(1))
|
||||||
|
|
||||||
Expect(findQueued(queueRepo, "pl", "plPriv")).To(BeNil())
|
Expect(findQueued(queueRepo, "pl", "plPriv")).To(BeNil())
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindPlaylistArtwork, "plPriv", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "plPriv", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).To(BeEmpty())
|
Expect(ia.Hash).To(BeEmpty())
|
||||||
})
|
})
|
||||||
|
|
@ -574,9 +523,9 @@ var _ = Describe("Worker", func() {
|
||||||
{ID: "al1", Name: "Album 1", FolderIDs: []string{"f1"}},
|
{ID: "al1", Name: "Album 1", FolderIDs: []string{"f1"}},
|
||||||
{ID: "al2", Name: "Album 2", FolderIDs: []string{"f1"}},
|
{ID: "al2", Name: "Album 2", FolderIDs: []string{"f1"}},
|
||||||
})
|
})
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
Expect(queueRepo.Enqueue(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(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())
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
||||||
|
|
||||||
n, err := w.drain(ctx, 3)
|
n, err := w.drain(ctx, 3)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -623,7 +572,7 @@ var _ = Describe("Worker", func() {
|
||||||
conf.Server.CoverArtPriority = "cover.*" // local-only; no folder image → absent
|
conf.Server.CoverArtPriority = "cover.*" // local-only; no folder image → absent
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al3", Name: "Artless"}})
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al3", Name: "Artless"}})
|
||||||
folderRepo.result = nil
|
folderRepo.result = nil
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
||||||
|
|
||||||
n, err := w.drain(ctx, 2)
|
n, err := w.drain(ctx, 2)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -633,7 +582,7 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(evts).To(HaveLen(1), "a removed cover must live-refresh clients so they drop it")
|
Expect(evts).To(HaveLen(1), "a removed cover must live-refresh clients so they drop it")
|
||||||
Expect(evts[0].(*events.RefreshResource).Data(evts[0])).To(ContainSubstring("al3"))
|
Expect(evts[0].(*events.RefreshResource).Data(evts[0])).To(ContainSubstring("al3"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).To(BeEmpty(), "the outcome was absent, not found")
|
Expect(ia.Hash).To(BeEmpty(), "the outcome was absent, not found")
|
||||||
})
|
})
|
||||||
|
|
@ -642,7 +591,7 @@ var _ = Describe("Worker", func() {
|
||||||
conf.Server.CoverArtPriority = "external"
|
conf.Server.CoverArtPriority = "external"
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}})
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}})
|
||||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"})).To(Succeed())
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"})).To(Succeed())
|
||||||
|
|
||||||
n, err := w.drain(ctx, 2)
|
n, err := w.drain(ctx, 2)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -776,7 +725,7 @@ var _ = Describe("Worker", func() {
|
||||||
{ID: "alpc", Name: "Album", FolderIDs: []string{"f1"}},
|
{ID: "alpc", Name: "Album", FolderIDs: []string{"f1"}},
|
||||||
})
|
})
|
||||||
conf.Server.UICoverArtSize = 300
|
conf.Server.UICoverArtSize = 300
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||||
ItemKind: "al", ItemID: "alpc", Priority: model.ArtworkPriorityScan,
|
ItemKind: "al", ItemID: "alpc", Priority: model.ArtworkPriorityScan,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
})
|
})
|
||||||
|
|
@ -864,11 +813,11 @@ var _ = Describe("Worker", func() {
|
||||||
|
|
||||||
// Artists first, exactly as Backfill orders them.
|
// Artists first, exactly as Backfill orders them.
|
||||||
for _, a := range artists {
|
for _, a := range artists {
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||||
ItemKind: "ar", ItemID: a.ID, Priority: model.ArtworkPriorityBackfill,
|
ItemKind: "ar", ItemID: a.ID, Priority: model.ArtworkPriorityBackfill,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
}
|
}
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||||
ItemKind: "al", ItemID: "alx", Priority: model.ArtworkPriorityBackfill,
|
ItemKind: "al", ItemID: "alx", Priority: model.ArtworkPriorityBackfill,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -883,12 +832,12 @@ var _ = Describe("Worker", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
Eventually(func() bool {
|
Eventually(func() bool {
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alx", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alx", model.ImageTypePrimary)
|
||||||
return err == nil && ia.Hash != ""
|
return err == nil && ia.Hash != ""
|
||||||
}, 5*time.Second, 50*time.Millisecond).Should(BeTrue(),
|
}, 5*time.Second, 50*time.Millisecond).Should(BeTrue(),
|
||||||
"a blocked external pool must not hold up local artwork")
|
"a blocked external pool must not hold up local artwork")
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "arx0", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "arx0", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound), "artists are still blocked, as intended")
|
Expect(err).To(MatchError(model.ErrNotFound), "artists are still blocked, as intended")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -900,7 +849,7 @@ var _ = Describe("Worker", func() {
|
||||||
for i := range 8 {
|
for i := range 8 {
|
||||||
id := fmt.Sprintf("alc%d", i)
|
id := fmt.Sprintf("alc%d", i)
|
||||||
albums = append(albums, model.Album{ID: id, Name: "Album"})
|
albums = append(albums, model.Album{ID: id, Name: "Album"})
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||||
ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan,
|
ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
}
|
}
|
||||||
|
|
@ -917,38 +866,10 @@ var _ = Describe("Worker", func() {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
It("stops dispatching and leaves the rest queued when paused mid-batch", func() {
|
|
||||||
folderRepo.result = []model.Folder{{
|
|
||||||
Path: "tests/fixtures/artist/an-album",
|
|
||||||
ImageFiles: []string{"cover.jpg"},
|
|
||||||
}}
|
|
||||||
albums := model.Albums{}
|
|
||||||
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(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(ctx)
|
|
||||||
return n < 8
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := w.drain(ctx, 1)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
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")
|
|
||||||
})
|
|
||||||
|
|
||||||
It("dequeues past the worker pool so one drain covers many items", func() {
|
It("dequeues past the worker pool so one drain covers many items", func() {
|
||||||
for i := range 16 {
|
for i := range 16 {
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: fmt.Sprintf("alb%d", i), Name: "Album"}})
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: fmt.Sprintf("alb%d", i), Name: "Album"}})
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||||
ItemKind: "al", ItemID: fmt.Sprintf("alb%d", i), Priority: model.ArtworkPriorityScan,
|
ItemKind: "al", ItemID: fmt.Sprintf("alb%d", i), Priority: model.ArtworkPriorityScan,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
}
|
}
|
||||||
|
|
@ -975,30 +896,6 @@ var _ = Describe("Worker", func() {
|
||||||
Eventually(done, time.Second).Should(Receive(BeNil()))
|
Eventually(done, time.Second).Should(Receive(BeNil()))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("does not drain the queue while paused", func() {
|
|
||||||
folderRepo.result = []model.Folder{{
|
|
||||||
Path: "tests/fixtures/artist/an-album",
|
|
||||||
ImageFiles: []string{"cover.jpg"},
|
|
||||||
}}
|
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
|
||||||
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}},
|
|
||||||
})
|
|
||||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
|
||||||
ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan,
|
|
||||||
})).To(Succeed())
|
|
||||||
w.PauseWhile(func() bool { return true })
|
|
||||||
|
|
||||||
runCtx, cancel := context.WithCancel(ctx)
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() { done <- w.Run(runCtx) }()
|
|
||||||
DeferCleanup(func() {
|
|
||||||
cancel()
|
|
||||||
Eventually(done, 2*time.Second).Should(Receive(BeNil()))
|
|
||||||
})
|
|
||||||
|
|
||||||
Consistently(func() any { return findQueued(queueRepo, "al", "al1") }, 300*time.Millisecond).ShouldNot(BeNil())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("does not leak goroutines after Run exits", func() {
|
It("does not leak goroutines after Run exits", func() {
|
||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ func Init(ds model.DataStore) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadOrCreateSecret(ctx context.Context, ds model.DataStore, key string) string {
|
func loadOrCreateSecret(ctx context.Context, ds model.DataStore, key string) string {
|
||||||
secret, err := ds.Property().Get(ctx, key)
|
secret, err := ds.Property(ctx).Get(key)
|
||||||
if err != nil || secret == "" {
|
if err != nil || secret == "" {
|
||||||
log.Info(ctx, "Creating new JWT secret", "key", key)
|
log.Info(ctx, "Creating new JWT secret", "key", key)
|
||||||
return createNewSecret(ctx, ds, key)
|
return createNewSecret(ctx, ds, key)
|
||||||
|
|
@ -154,9 +154,9 @@ func CheckClaims(c Claims, usr model.User, audience string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
|
func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
|
||||||
u, err := ds.User().FindFirstAdmin(ctx)
|
u, err := ds.User(ctx).FindFirstAdmin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c, err := ds.User().CountAll(ctx)
|
c, err := ds.User(ctx).CountAll()
|
||||||
if c == 0 && err == nil {
|
if c == 0 && err == nil {
|
||||||
log.Debug(ctx, "No admin user yet!", err)
|
log.Debug(ctx, "No admin user yet!", err)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -176,7 +176,7 @@ func createNewSecret(ctx context.Context, ds model.DataStore, key string) string
|
||||||
log.Error(ctx, "Could not encrypt JWT secret", err)
|
log.Error(ctx, "Could not encrypt JWT secret", err)
|
||||||
return secret
|
return secret
|
||||||
}
|
}
|
||||||
if err := ds.Property().Put(ctx, key, encSecret); err != nil {
|
if err := ds.Property(ctx).Put(key, encSecret); err != nil {
|
||||||
log.Error(ctx, "Could not save JWT secret in DB", err)
|
log.Error(ctx, "Could not save JWT secret in DB", err)
|
||||||
}
|
}
|
||||||
return secret
|
return secret
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ func userName(ctx context.Context) string {
|
||||||
// BFR We should only access files through the `storage.Storage` interface. This will require changing how
|
// BFR We should only access files through the `storage.Storage` interface. This will require changing how
|
||||||
// TagLib and ffmpeg access files
|
// TagLib and ffmpeg access files
|
||||||
var AbsolutePath = func(ctx context.Context, ds model.DataStore, libId int, path string) string {
|
var AbsolutePath = func(ctx context.Context, ds model.DataStore, libId int, path string) string {
|
||||||
libPath, err := ds.Library().GetPath(ctx, libId)
|
libPath, err := ds.Library(ctx).GetPath(libId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
core/external/extdata_helper_test.go
vendored
18
core/external/extdata_helper_test.go
vendored
|
|
@ -31,7 +31,7 @@ func (m *mockArtistRepo) SetData(artists model.Artists) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get implements model.ArtistRepository.
|
// Get implements model.ArtistRepository.
|
||||||
func (m *mockArtistRepo) Get(_ context.Context, id string) (*model.Artist, error) {
|
func (m *mockArtistRepo) Get(id string) (*model.Artist, error) {
|
||||||
args := m.Called(id)
|
args := m.Called(id)
|
||||||
if args.Get(0) == nil {
|
if args.Get(0) == nil {
|
||||||
return nil, args.Error(1)
|
return nil, args.Error(1)
|
||||||
|
|
@ -40,7 +40,7 @@ func (m *mockArtistRepo) Get(_ context.Context, id string) (*model.Artist, error
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAll implements model.ArtistRepository.
|
// GetAll implements model.ArtistRepository.
|
||||||
func (m *mockArtistRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Artists, error) {
|
func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) {
|
||||||
argsSlice := make([]any, len(options))
|
argsSlice := make([]any, len(options))
|
||||||
for i, v := range options {
|
for i, v := range options {
|
||||||
argsSlice[i] = v
|
argsSlice[i] = v
|
||||||
|
|
@ -85,7 +85,7 @@ func (m *mockMediaFileRepo) SetData(mediaFiles model.MediaFiles) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get implements model.MediaFileRepository.
|
// Get implements model.MediaFileRepository.
|
||||||
func (m *mockMediaFileRepo) Get(ctx context.Context, id string) (*model.MediaFile, error) {
|
func (m *mockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
|
||||||
args := m.Called(id)
|
args := m.Called(id)
|
||||||
if args.Get(0) == nil {
|
if args.Get(0) == nil {
|
||||||
return nil, args.Error(1)
|
return nil, args.Error(1)
|
||||||
|
|
@ -94,12 +94,12 @@ func (m *mockMediaFileRepo) Get(ctx context.Context, id string) (*model.MediaFil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllByTags implements model.MediaFileRepository.
|
// GetAllByTags implements model.MediaFileRepository.
|
||||||
func (m *mockMediaFileRepo) GetAllByTags(ctx context.Context, _ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||||
return m.GetAll(ctx, options...)
|
return m.GetAll(options...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAll implements model.MediaFileRepository.
|
// GetAll implements model.MediaFileRepository.
|
||||||
func (m *mockMediaFileRepo) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) {
|
func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||||
argsSlice := make([]any, len(options))
|
argsSlice := make([]any, len(options))
|
||||||
for i, v := range options {
|
for i, v := range options {
|
||||||
argsSlice[i] = v
|
argsSlice[i] = v
|
||||||
|
|
@ -112,7 +112,7 @@ func (m *mockMediaFileRepo) GetAll(ctx context.Context, options ...model.QueryOp
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRandom implements model.MediaFileRepository.
|
// GetRandom implements model.MediaFileRepository.
|
||||||
func (m *mockMediaFileRepo) GetRandom(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) {
|
func (m *mockMediaFileRepo) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||||
argsSlice := make([]any, len(options))
|
argsSlice := make([]any, len(options))
|
||||||
for i, v := range options {
|
for i, v := range options {
|
||||||
argsSlice[i] = v
|
argsSlice[i] = v
|
||||||
|
|
@ -156,7 +156,7 @@ func newMockAlbumRepo() *mockAlbumRepo {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get implements model.AlbumRepository.
|
// Get implements model.AlbumRepository.
|
||||||
func (m *mockAlbumRepo) Get(_ context.Context, id string) (*model.Album, error) {
|
func (m *mockAlbumRepo) Get(id string) (*model.Album, error) {
|
||||||
args := m.Called(id)
|
args := m.Called(id)
|
||||||
if args.Get(0) == nil {
|
if args.Get(0) == nil {
|
||||||
return nil, args.Error(1)
|
return nil, args.Error(1)
|
||||||
|
|
@ -165,7 +165,7 @@ func (m *mockAlbumRepo) Get(_ context.Context, id string) (*model.Album, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAll implements model.AlbumRepository.
|
// GetAll implements model.AlbumRepository.
|
||||||
func (m *mockAlbumRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Albums, error) {
|
func (m *mockAlbumRepo) GetAll(options ...model.QueryOptions) (model.Albums, error) {
|
||||||
argsSlice := make([]any, len(options))
|
argsSlice := make([]any, len(options))
|
||||||
for i, v := range options {
|
for i, v := range options {
|
||||||
argsSlice[i] = v
|
argsSlice[i] = v
|
||||||
|
|
|
||||||
16
core/external/provider.go
vendored
16
core/external/provider.go
vendored
|
|
@ -182,7 +182,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = e.ds.Album().UpdateExternalInfo(ctx, &album.Album)
|
err = e.ds.Album(ctx).UpdateExternalInfo(&album.Album)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", albumName,
|
log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", albumName,
|
||||||
"elapsed", time.Since(start), err)
|
"elapsed", time.Since(start), err)
|
||||||
|
|
@ -285,7 +285,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
|
||||||
if !throttled {
|
if !throttled {
|
||||||
artist.ExternalInfoUpdatedAt = new(time.Now())
|
artist.ExternalInfoUpdatedAt = new(time.Now())
|
||||||
}
|
}
|
||||||
err := e.ds.Artist().UpdateExternalInfo(ctx, &artist.Artist)
|
err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName,
|
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName,
|
||||||
"elapsed", time.Since(start), err)
|
"elapsed", time.Since(start), err)
|
||||||
|
|
@ -548,7 +548,7 @@ func (e *provider) loadArtistsByID(ctx context.Context, similar []agents.Artist)
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return matches, nil
|
return matches, nil
|
||||||
}
|
}
|
||||||
res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
|
res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||||
Filters: squirrel.Eq{"artist.id": ids},
|
Filters: squirrel.Eq{"artist.id": ids},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -577,7 +577,7 @@ func (e *provider) loadArtistsByMBID(ctx context.Context, similar []agents.Artis
|
||||||
if len(mbids) == 0 {
|
if len(mbids) == 0 {
|
||||||
return matches, nil
|
return matches, nil
|
||||||
}
|
}
|
||||||
res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
|
res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||||
Filters: squirrel.Eq{"mbz_artist_id": mbids},
|
Filters: squirrel.Eq{"mbz_artist_id": mbids},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -612,7 +612,7 @@ func (e *provider) loadArtistsByName(ctx context.Context, similar []agents.Artis
|
||||||
clauses := slice.Map(names, func(name string) squirrel.Sqlizer {
|
clauses := slice.Map(names, func(name string) squirrel.Sqlizer {
|
||||||
return squirrel.Like{"artist.name": name}
|
return squirrel.Like{"artist.name": name}
|
||||||
})
|
})
|
||||||
res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
|
res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||||
Filters: squirrel.Or(clauses),
|
Filters: squirrel.Or(clauses),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -628,7 +628,7 @@ func (e *provider) loadArtistsByName(ctx context.Context, similar []agents.Artis
|
||||||
|
|
||||||
func (e *provider) findArtist(ctx context.Context, artistName, id string) (*auxArtist, error) {
|
func (e *provider) findArtist(ctx context.Context, artistName, id string) (*auxArtist, error) {
|
||||||
if id != "" {
|
if id != "" {
|
||||||
artist, err := e.ds.Artist().Get(ctx, id)
|
artist, err := e.ds.Artist(ctx).Get(id)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return &auxArtist{Artist: *artist}, nil
|
return &auxArtist{Artist: *artist}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -644,7 +644,7 @@ func (e *provider) findArtist(ctx context.Context, artistName, id string) (*auxA
|
||||||
return nil, model.ErrNotFound
|
return nil, model.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
artists, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
|
artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||||
Filters: squirrel.Like{"artist.name": artistName},
|
Filters: squirrel.Like{"artist.name": artistName},
|
||||||
Max: 1,
|
Max: 1,
|
||||||
})
|
})
|
||||||
|
|
@ -666,7 +666,7 @@ func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int
|
||||||
ids = append(ids, sa.ID)
|
ids = append(ids, sa.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
similar, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
|
similar, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||||
Filters: squirrel.Eq{"artist.id": ids},
|
Filters: squirrel.Eq{"artist.id": ids},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
8
core/external/provider_refreshinfo_test.go
vendored
8
core/external/provider_refreshinfo_test.go
vendored
|
|
@ -71,8 +71,8 @@ var _ = Describe("Provider - RefreshInfo", func() {
|
||||||
ag = new(mockAgents)
|
ag = new(mockAgents)
|
||||||
broker = &fakeBroker{}
|
broker = &fakeBroker{}
|
||||||
p = external.NewProvider(ds, ag, matcher.New(ds), broker)
|
p = external.NewProvider(ds, ag, matcher.New(ds), broker)
|
||||||
mockArtistRepo = ds.Artist().(*tests.MockArtistRepo)
|
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
|
||||||
mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo)
|
mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
||||||
})
|
})
|
||||||
|
|
||||||
It("repopulates an artist even when its info is fresh", func() {
|
It("repopulates an artist even when its info is fresh", func() {
|
||||||
|
|
@ -84,7 +84,7 @@ var _ = Describe("Provider - RefreshInfo", func() {
|
||||||
|
|
||||||
Expect(p.RefreshInfo(ctx, model.KindArtistArtwork, "ar-1")).To(Succeed())
|
Expect(p.RefreshInfo(ctx, model.KindArtistArtwork, "ar-1")).To(Succeed())
|
||||||
|
|
||||||
saved, err := mockArtistRepo.Get(ctx, "ar-1")
|
saved, err := mockArtistRepo.Get("ar-1")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(saved.Biography).To(Equal("Fresh Bio"))
|
Expect(saved.Biography).To(Equal("Fresh Bio"))
|
||||||
})
|
})
|
||||||
|
|
@ -99,7 +99,7 @@ var _ = Describe("Provider - RefreshInfo", func() {
|
||||||
|
|
||||||
Expect(p.RefreshInfo(ctx, model.KindAlbumArtwork, "al-1")).To(Succeed())
|
Expect(p.RefreshInfo(ctx, model.KindAlbumArtwork, "al-1")).To(Succeed())
|
||||||
|
|
||||||
saved, err := mockAlbumRepo.Get(ctx, "al-1")
|
saved, err := mockAlbumRepo.Get("al-1")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(saved.Description).To(Equal("Fresh Notes"))
|
Expect(saved.Description).To(Equal("Fresh Notes"))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
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