mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 10:27:08 +02:00
Compare commits
85 commits
dependabot
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52135913d4 |
||
|
|
caa2f8a0c0 |
||
|
|
95f67d2c4e |
||
|
|
758e64c999 |
||
|
|
0e1893530b |
||
|
|
e03ce87177 |
||
|
|
3a31f702b5 |
||
|
|
a62d1629f0 |
||
|
|
612c8290f9 |
||
|
|
ab5869123d |
||
|
|
4cdffd5633 |
||
|
|
ce484083bf |
||
|
|
46c432719f |
||
|
|
c22ce9ebb2 |
||
|
|
bb7d81a5ea |
||
|
|
4113d954ca |
||
|
|
6b05189190 | ||
|
|
b293b96256 |
||
|
|
83fff44c82 |
||
|
|
659d067aba |
||
|
|
336fa482f8 |
||
|
|
3f89baaec8 |
||
|
|
a0c4160376 | ||
|
|
69b496383d |
||
|
|
101145742f |
||
|
|
ee6dd1bc03 |
||
|
|
27483a46dc |
||
|
|
39028f65c8 |
||
|
|
a3f41fb422 |
||
|
|
961ee8c413 |
||
|
|
9e3deb4330 | ||
|
|
285dc4f391 |
||
|
|
cb7b042e36 |
||
|
|
07c756db3c |
||
|
|
00bb120288 |
||
|
|
6b3938b5b6 | ||
|
|
c3b9b4ecb3 |
||
|
|
cabfd16f9f |
||
|
|
9e8811e4c5 |
||
|
|
237276efcd |
||
|
|
0429fb3d40 |
||
|
|
8b4125267e |
||
|
|
8e784b6af7 |
||
|
|
a5334ee46b |
||
|
|
2913c13cdf |
||
|
|
49f626c00a |
||
|
|
672c0af580 | ||
|
|
fb45ad7b9c |
||
|
|
b76ae14286 |
||
|
|
be8ec15168 |
||
|
|
fed35e08de |
||
|
|
549dfa7f30 |
||
|
|
16567f147b |
||
|
|
ded4f47d93 |
||
|
|
5bd14da65c | ||
|
|
decac50f60 |
||
|
|
18205366c8 |
||
|
|
97270d44e1 |
||
|
|
3f4b6a642c |
||
|
|
dd71f1c57f | ||
|
|
d00c84716b | ||
|
|
1072e9f7eb |
||
|
|
276d767ce5 |
||
|
|
1a8463f7de |
||
|
|
4168377b65 |
||
|
|
2d09ebc676 |
||
|
|
c6732e1fdf |
||
|
|
2802c05a66 |
||
|
|
d9d38f842a |
||
|
|
d0d5403708 |
||
|
|
9e950cb63d |
||
|
|
2dc0983629 |
||
|
|
2aa7a5c466 |
||
|
|
4067e36a06 |
||
|
|
964d3c778b | ||
|
|
c14b598a01 |
||
|
|
25e7b5b20d |
||
|
|
bb386b13bf |
||
|
|
08eb46c8ad |
||
|
|
055fbde3cf | ||
|
|
72975a95fb |
||
|
|
e7b449b805 |
||
|
|
8d77a49b31 | ||
|
|
89026012ab | ||
|
|
404837799b |
643 changed files with 23636 additions and 9699 deletions
25
.github/workflows/pipeline.yml
vendored
25
.github/workflows/pipeline.yml
vendored
|
|
@ -92,8 +92,23 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
- name: Resolve OpenAPI tool versions
|
||||
id: api-tools
|
||||
run: echo "key=$(grep -E '^(VACUUM|OAPI_CODEGEN|OASDIFF)_VERSION' Makefile | tr -d ' \n')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache OpenAPI tools
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: bin
|
||||
key: api-tools-${{ runner.os }}-${{ steps.api-tools.outputs.key }}
|
||||
|
||||
- name: Lint OpenAPI spec
|
||||
run: make api-lint
|
||||
|
||||
- name: Run go generate
|
||||
run: go generate ./...
|
||||
run: |
|
||||
make api-gen
|
||||
go generate ./...
|
||||
- name: Verify no changes from go generate
|
||||
run: |
|
||||
git status --porcelain
|
||||
|
|
@ -102,6 +117,12 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check for breaking OpenAPI changes
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin ${{ github.event.pull_request.base.sha }}
|
||||
make api-diff API_DIFF_BASE=${{ github.event.pull_request.base.sha }}
|
||||
|
||||
validate-migrations:
|
||||
name: Validate DB migrations
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -549,7 +570,7 @@ jobs:
|
|||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
for artifact in $(gh api repos/${{ github.repository }}/actions/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
|
||||
for artifact in $(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
|
||||
gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
|
||||
done
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ linters:
|
|||
- asciicheck
|
||||
- bidichk
|
||||
- bodyclose
|
||||
- containedctx
|
||||
- copyloopvar
|
||||
- dogsled
|
||||
- durationcheck
|
||||
|
|
@ -57,6 +58,9 @@ linters:
|
|||
- gosec
|
||||
path: _test\.go
|
||||
text: "G703"
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- containedctx
|
||||
- path-except: 'db/migrations/'
|
||||
linters:
|
||||
- forbidigo
|
||||
|
|
|
|||
47
Makefile
47
Makefile
|
|
@ -20,7 +20,11 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin
|
|||
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
||||
DOCKER_TAG ?= deluan/navidrome:develop
|
||||
|
||||
GOLANGCI_LINT_VERSION ?= v2.13.2
|
||||
GOLANGCI_LINT_VERSION ?= v2.14.0
|
||||
VACUUM_VERSION ?= v0.30.6
|
||||
OAPI_CODEGEN_VERSION ?= v2.8.0
|
||||
OASDIFF_VERSION ?= v1.32.1
|
||||
API_DIFF_BASE ?= origin/master
|
||||
|
||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||
|
||||
|
|
@ -92,6 +96,45 @@ install-golangci-lint: ##@Development Install golangci-lint if not present
|
|||
fi
|
||||
.PHONY: install-golangci-lint
|
||||
|
||||
install-api-tools: ##@Development Install OpenAPI tools (vacuum, oapi-codegen, oasdiff) into ./bin
|
||||
@STAMP=bin/.api-tools-$(VACUUM_VERSION)-$(OAPI_CODEGEN_VERSION)-$(OASDIFF_VERSION); \
|
||||
if [ ! -f $$STAMP ] || [ ! -x bin/vacuum ] || [ ! -x bin/oapi-codegen ] || [ ! -x bin/oasdiff ]; then \
|
||||
echo "Installing OpenAPI tools..."; \
|
||||
GOBIN=$(CURDIR)/bin go install github.com/daveshanley/vacuum@$(VACUUM_VERSION) && \
|
||||
GOBIN=$(CURDIR)/bin go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@$(OAPI_CODEGEN_VERSION) && \
|
||||
GOBIN=$(CURDIR)/bin go install github.com/oasdiff/oasdiff@$(OASDIFF_VERSION) && \
|
||||
rm -f bin/.api-tools-* && touch $$STAMP; \
|
||||
fi
|
||||
.PHONY: install-api-tools
|
||||
|
||||
api-lint: install-api-tools ##@Development Lint the OpenAPI spec
|
||||
./bin/vacuum lint -r api/.vacuum.yaml -d -q -b --fail-severity error api/openapi/openapi.yaml
|
||||
.PHONY: api-lint
|
||||
|
||||
api-bundle: install-api-tools ##@Development Bundle the multi-file OpenAPI spec into api/bundled
|
||||
./bin/vacuum bundle -q --composed -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.yaml
|
||||
./bin/vacuum bundle -q --composed --format json -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.json
|
||||
.PHONY: api-bundle
|
||||
|
||||
api-gen: api-bundle ##@Development Generate the API v1 server code from the bundled spec
|
||||
./bin/oapi-codegen -config server/apiv1/oapi-codegen.yaml api/bundled/openapi.json
|
||||
.PHONY: api-gen
|
||||
|
||||
api-diff: api-bundle ##@Development Fail on breaking OpenAPI changes against the merge-base with $(API_DIFF_BASE)
|
||||
@git rev-parse --verify --quiet $(API_DIFF_BASE)^{commit} >/dev/null || { echo "Base ref $(API_DIFF_BASE) not found; set API_DIFF_BASE"; exit 1; }; \
|
||||
BASE="$$(git merge-base HEAD $(API_DIFF_BASE) 2>/dev/null)"; \
|
||||
if [ -z "$$BASE" ]; then \
|
||||
echo "No merge-base with $(API_DIFF_BASE); falling back to its tip"; \
|
||||
BASE=$(API_DIFF_BASE); \
|
||||
fi; \
|
||||
if git cat-file -e $$BASE:api/bundled/openapi.json 2>/dev/null; then \
|
||||
git show $$BASE:api/bundled/openapi.json > $(CURDIR)/bin/api-base.json && \
|
||||
./bin/oasdiff breaking $(CURDIR)/bin/api-base.json api/bundled/openapi.json --fail-on ERR --severity-levels api/.oasdiff-levels.txt; \
|
||||
else \
|
||||
echo "No bundled spec at $$BASE; skipping breaking-change check"; \
|
||||
fi
|
||||
.PHONY: api-diff
|
||||
|
||||
lint: install-golangci-lint ##@Development Lint Go code
|
||||
PATH=./bin:$$PATH golangci-lint run --timeout 5m
|
||||
.PHONY: lint
|
||||
|
|
@ -111,7 +154,7 @@ wire: check_go_env ##@Development Update Dependency Injection
|
|||
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
|
||||
.PHONY: wire
|
||||
|
||||
gen: check_go_env ##@Development Run go generate for code generation
|
||||
gen: check_go_env api-gen ##@Development Run go generate for code generation
|
||||
go generate ./...
|
||||
cd plugins/cmd/ndpgen && go run . -shared-types -input=../../types -output=../../pdk -go -rust
|
||||
cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host -shared=../../types
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ var _ = Describe("Extractor", func() {
|
|||
info.FileInfo = testFileInfo{FileInfo: fileInfo}
|
||||
|
||||
metadata := metadata.New(path, info)
|
||||
return new(metadata.ToMediaFile(1, "folderID"))
|
||||
return new(metadata.ToMediaFile(model.Library{ID: 1}, "folderID"))
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
|
|
|
|||
|
|
@ -241,6 +241,10 @@ func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, arti
|
|||
var (
|
||||
artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
|
||||
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) {
|
||||
|
|
@ -267,7 +271,9 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
|
|||
var res []agents.ExternalImage
|
||||
n := cascadia.Query(node, artistOpenGraphQuery)
|
||||
if n == nil {
|
||||
return res, nil
|
||||
// A real artist page always has og:image; its absence means a bot challenge or a redesign.
|
||||
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 {
|
||||
if attr.Key != "content" {
|
||||
|
|
|
|||
|
|
@ -357,7 +357,7 @@ var _ = Describe("lastfmAgent", func() {
|
|||
var httpClient *tests.FakeHttpClient
|
||||
var track *model.MediaFile
|
||||
BeforeEach(func() {
|
||||
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
||||
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
client := newClient("API_KEY", "SECRET", httpClient)
|
||||
agent = lastFMConstructor(ds)
|
||||
|
|
@ -648,18 +648,41 @@ var _ = Describe("lastfmAgent", func() {
|
|||
Expect(images).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns empty list if page has no meta tags", func() {
|
||||
It("errors when the page has no meta tags", 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.no_meta.html")
|
||||
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", "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(err).To(MatchError(errNoArtistPage))
|
||||
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() {
|
||||
apiClient.Err = errors.New("api error")
|
||||
_, 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 {
|
||||
sessionKey, err := s.client.getSession(ctx, token)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Could not fetch LastFM session key", "userId", uid, "token", token,
|
||||
log.Error(ctx, "Could not fetch LastFM session key", "userId", uid,
|
||||
"requestId", middleware.GetReqID(ctx), err)
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ var _ = Describe("auth_router", func() {
|
|||
})
|
||||
|
||||
storedSessionKey := func(userID string) string {
|
||||
key, _ := userProps.Get(userID, sessionKeyProperty)
|
||||
key, _ := userProps.Get(GinkgoT().Context(), userID, sessionKeyProperty)
|
||||
return key
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,12 @@ type client struct {
|
|||
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) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "album.getInfo")
|
||||
|
|
@ -66,7 +72,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) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "artist.getInfo")
|
||||
params.Add("artist", name)
|
||||
params.Add("artist", escapePlus(name))
|
||||
params.Add("lang", lang)
|
||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||
if err != nil {
|
||||
|
|
@ -78,7 +84,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) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "artist.getSimilar")
|
||||
params.Add("artist", name)
|
||||
params.Add("artist", escapePlus(name))
|
||||
params.Add("limit", strconv.Itoa(limit))
|
||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||
if err != nil {
|
||||
|
|
@ -90,7 +96,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) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "artist.getTopTracks")
|
||||
params.Add("artist", name)
|
||||
params.Add("artist", escapePlus(name))
|
||||
params.Add("limit", strconv.Itoa(limit))
|
||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||
if err != nil {
|
||||
|
|
@ -102,8 +108,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) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "track.getSimilar")
|
||||
params.Add("track", name)
|
||||
params.Add("artist", artist)
|
||||
params.Add("track", escapePlus(name))
|
||||
params.Add("artist", escapePlus(artist))
|
||||
params.Add("limit", strconv.Itoa(limit))
|
||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,15 @@ var _ = Describe("client", func() {
|
|||
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"))
|
||||
})
|
||||
|
||||
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() {
|
||||
|
|
@ -48,6 +57,15 @@ 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"))
|
||||
})
|
||||
|
||||
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() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`Internal Server Error`)),
|
||||
|
|
@ -107,6 +125,15 @@ var _ = Describe("client", func() {
|
|||
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"))
|
||||
})
|
||||
|
||||
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() {
|
||||
|
|
@ -119,6 +146,15 @@ var _ = Describe("client", func() {
|
|||
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"))
|
||||
})
|
||||
|
||||
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() {
|
||||
|
|
@ -135,6 +171,17 @@ 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"))
|
||||
})
|
||||
|
||||
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() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ var _ = Describe("listenBrainzAgent", func() {
|
|||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
ctx = context.Background()
|
||||
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
||||
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
agent = listenBrainzConstructor(ds)
|
||||
agent.client = newClient("http://localhost:8080", httpClient)
|
||||
|
|
|
|||
1
api/.oasdiff-levels.txt
Normal file
1
api/.oasdiff-levels.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
response-property-enum-value-added INFO
|
||||
155
api/.vacuum.yaml
Normal file
155
api/.vacuum.yaml
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
extends: [[spectral:oas, recommended]]
|
||||
rules:
|
||||
# vacuum's `enumeration` function mis-resolves hyphenated `then.field` names,
|
||||
# so the value check below targets `x-module` via `given` instead.
|
||||
nd-operation-x-module-required:
|
||||
description: Every operation belongs to exactly one capability module.
|
||||
severity: error
|
||||
given: $.paths[*][get,put,post,delete,patch]
|
||||
then:
|
||||
field: x-module
|
||||
function: truthy
|
||||
nd-operation-x-module:
|
||||
description: Every operation's capability module is one of the known values.
|
||||
severity: error
|
||||
given: $.paths[*][get,put,post,delete,patch]['x-module']
|
||||
then:
|
||||
function: enumeration
|
||||
functionOptions:
|
||||
values:
|
||||
- core
|
||||
- streaming
|
||||
- download
|
||||
- artwork
|
||||
- lyrics
|
||||
- transcoding
|
||||
- annotations
|
||||
- playback
|
||||
- queue
|
||||
- custom-tags
|
||||
- grouping
|
||||
- playlists
|
||||
- smart-playlists
|
||||
- sync
|
||||
- events
|
||||
- jukebox
|
||||
- sharing
|
||||
- radio
|
||||
- admin
|
||||
nd-operation-stability-level-required:
|
||||
description: Every operation declares its stability level, which the breaking-change gate relies on.
|
||||
severity: error
|
||||
given: $.paths[*][get,put,post,delete,patch]
|
||||
then:
|
||||
field: x-stability-level
|
||||
function: truthy
|
||||
nd-operation-stability-level:
|
||||
description: Every operation's stability level is alpha, beta, or stable.
|
||||
severity: error
|
||||
given: $.paths[*][get,put,post,delete,patch]['x-stability-level']
|
||||
then:
|
||||
function: enumeration
|
||||
functionOptions:
|
||||
values:
|
||||
- alpha
|
||||
- beta
|
||||
- stable
|
||||
nd-operation-required-fields:
|
||||
description: Operations need a stable operationId, summary, description and tags.
|
||||
severity: error
|
||||
given: $.paths[*][get,put,post,delete,patch]
|
||||
then:
|
||||
- field: operationId
|
||||
function: truthy
|
||||
- field: summary
|
||||
function: truthy
|
||||
- field: description
|
||||
function: truthy
|
||||
- field: tags
|
||||
function: truthy
|
||||
# Our schemas live in path/response files, not root components, so this
|
||||
# walks every resolved `properties` map in the document via `$..` instead.
|
||||
nd-schema-property-descriptions:
|
||||
description: Every schema property is documented.
|
||||
severity: error
|
||||
given: $..properties[*]
|
||||
then:
|
||||
field: description
|
||||
function: truthy
|
||||
# patternProperties covers the full 4xx/5xx range; needs an explicit
|
||||
# `properties` entry too, or `additionalProperties: false` rejects it.
|
||||
nd-error-responses-are-problems:
|
||||
description: 4xx and 5xx responses use application/problem+json.
|
||||
severity: error
|
||||
given: $.paths[*][*].responses
|
||||
then:
|
||||
function: schema
|
||||
functionOptions:
|
||||
forceValidationOnCurrentNode: true
|
||||
schema:
|
||||
type: object
|
||||
patternProperties:
|
||||
"^[45][0-9][0-9]$":
|
||||
type: object
|
||||
required: [content]
|
||||
properties:
|
||||
content:
|
||||
type: object
|
||||
properties:
|
||||
application/problem+json: {}
|
||||
required: [application/problem+json]
|
||||
additionalProperties: false
|
||||
# Same filter limitation applies here: "is this a list endpoint" is expressed
|
||||
# as a JSON Schema if/then on the operation object instead of a `given` filter.
|
||||
nd-list-endpoints-paginate:
|
||||
description: List endpoints declare the shared offset and limit parameters.
|
||||
severity: error
|
||||
given: $.paths[*].get
|
||||
then:
|
||||
function: schema
|
||||
functionOptions:
|
||||
forceValidationOnCurrentNode: true
|
||||
schema:
|
||||
type: object
|
||||
if:
|
||||
required: [responses]
|
||||
properties:
|
||||
responses:
|
||||
type: object
|
||||
required: ['200']
|
||||
properties:
|
||||
'200':
|
||||
type: object
|
||||
required: [content]
|
||||
properties:
|
||||
content:
|
||||
type: object
|
||||
required: [application/json]
|
||||
properties:
|
||||
application/json:
|
||||
type: object
|
||||
required: [schema]
|
||||
properties:
|
||||
schema:
|
||||
type: object
|
||||
required: [properties]
|
||||
properties:
|
||||
properties:
|
||||
type: object
|
||||
required: [items]
|
||||
then:
|
||||
required: [parameters]
|
||||
properties:
|
||||
parameters:
|
||||
type: array
|
||||
allOf:
|
||||
- contains:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
const: offset
|
||||
- contains:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
const: limit
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package ffmpeg
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
|
@ -9,9 +9,9 @@ import (
|
|||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestFFMpeg(t *testing.T) {
|
||||
tests.Init(t, true)
|
||||
func TestAPI(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "FFMpeg Suite")
|
||||
RunSpecs(t, "API Spec Suite")
|
||||
}
|
||||
262
api/bundled/openapi.json
Normal file
262
api/bundled/openapi.json
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "Navidrome API",
|
||||
"version": "1.0.0",
|
||||
"description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /server` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n",
|
||||
"license": {
|
||||
"name": "GPL-3.0",
|
||||
"url": "https://www.gnu.org/licenses/gpl-3.0.html"
|
||||
}
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "/api/v1"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{
|
||||
"name": "server",
|
||||
"description": "Server discovery and the published OpenAPI document."
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/server": {
|
||||
"get": {
|
||||
"operationId": "getServerInfo",
|
||||
"x-module": "core",
|
||||
"x-stability-level": "alpha",
|
||||
"tags": [
|
||||
"server"
|
||||
],
|
||||
"summary": "Describe the server",
|
||||
"description": "Returns the public server description. No authentication required.\nAuthenticated requests will additionally receive the implemented capability modules\nonce authentication is available.\n",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Server description.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServerInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/InternalError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/openapi.json": {
|
||||
"get": {
|
||||
"operationId": "getOpenAPISpecJSON",
|
||||
"x-module": "core",
|
||||
"x-stability-level": "alpha",
|
||||
"tags": [
|
||||
"server"
|
||||
],
|
||||
"summary": "Get the OpenAPI document (JSON)",
|
||||
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The OpenAPI document.",
|
||||
"headers": {
|
||||
"ETag": {
|
||||
"$ref": "#/components/headers/ETag"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"description": "OpenAPI 3.0 document."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"304": {
|
||||
"$ref": "#/components/responses/NotModified"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/openapi.yaml": {
|
||||
"get": {
|
||||
"operationId": "getOpenAPISpecYAML",
|
||||
"x-module": "core",
|
||||
"x-stability-level": "alpha",
|
||||
"tags": [
|
||||
"server"
|
||||
],
|
||||
"summary": "Get the OpenAPI document (YAML)",
|
||||
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The OpenAPI document.",
|
||||
"headers": {
|
||||
"ETag": {
|
||||
"$ref": "#/components/headers/ETag"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/yaml": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"description": "OpenAPI 3.0 document."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"304": {
|
||||
"$ref": "#/components/responses/NotModified"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT",
|
||||
"description": "Short-lived access token minted from a device grant. Not yet applied to any operation."
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"ServerInfo": {
|
||||
"type": "object",
|
||||
"description": "Public server description. Everything an add-server screen needs before login.",
|
||||
"required": [
|
||||
"name",
|
||||
"serverVersion",
|
||||
"specVersion",
|
||||
"setupRequired",
|
||||
"loginMethods"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Human-readable server product name."
|
||||
},
|
||||
"serverVersion": {
|
||||
"type": "string",
|
||||
"description": "Version of the running server build."
|
||||
},
|
||||
"specVersion": {
|
||||
"type": "string",
|
||||
"description": "Version of the OpenAPI document this server implements."
|
||||
},
|
||||
"setupRequired": {
|
||||
"type": "boolean",
|
||||
"description": "True until the first admin user has been created."
|
||||
},
|
||||
"loginMethods": {
|
||||
"type": "array",
|
||||
"description": "Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"password"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Problem": {
|
||||
"type": "object",
|
||||
"description": "RFC 9457 problem details, returned for every 4xx and 5xx response.",
|
||||
"required": [
|
||||
"title",
|
||||
"status",
|
||||
"code"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "URI reference identifying the problem type. Omitted while the problem carries no semantics\nbeyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their\nown semantics get their own URI; switch on `code` instead.\n"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Short human-readable summary, the same for all occurrences of this problem type."
|
||||
},
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"description": "HTTP status code of this response."
|
||||
},
|
||||
"detail": {
|
||||
"type": "string",
|
||||
"description": "Human-readable explanation specific to this occurrence. Omitted for internal errors."
|
||||
},
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": "Machine-readable error code, and the value clients switch on. New codes may be added.",
|
||||
"enum": [
|
||||
"validation",
|
||||
"unauthorized",
|
||||
"forbidden",
|
||||
"not_found",
|
||||
"method_not_allowed",
|
||||
"unavailable",
|
||||
"internal"
|
||||
]
|
||||
},
|
||||
"errors": {
|
||||
"type": "array",
|
||||
"description": "Per-field failures. Present only when `code` is `validation`.",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ValidationError": {
|
||||
"type": "object",
|
||||
"description": "One field-level validation failure.",
|
||||
"required": [
|
||||
"field",
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"field": {
|
||||
"type": "string",
|
||||
"description": "Name of the offending query parameter, path parameter, or body field (dotted for nested)."
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Why the value was rejected."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"InternalError": {
|
||||
"description": "Unexpected server failure. Details are in the server log.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Problem"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NotModified": {
|
||||
"description": "Not modified.",
|
||||
"headers": {
|
||||
"ETag": {
|
||||
"$ref": "#/components/headers/ETag"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"ETag": {
|
||||
"description": "Entity tag for `If-None-Match` revalidation.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
194
api/bundled/openapi.yaml
Normal file
194
api/bundled/openapi.yaml
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
openapi: 3.0.3
|
||||
info:
|
||||
title: Navidrome API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Navidrome API v1. Spec-first, additive within v1. Clients discover implemented
|
||||
capability modules through `GET /server` and never sniff versions.
|
||||
|
||||
Enums are open: new values may be added to any enum within v1. Clients must
|
||||
accept values they do not recognise instead of failing.
|
||||
|
||||
Every operation declares `x-stability-level`: `alpha` operations may change or
|
||||
disappear without notice, `beta` and `stable` operations only change additively.
|
||||
A level is only ever raised, never lowered.
|
||||
|
||||
`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods
|
||||
in its `Allow` header.
|
||||
license:
|
||||
name: GPL-3.0
|
||||
url: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
servers:
|
||||
- url: /api/v1
|
||||
tags:
|
||||
- name: server
|
||||
description: Server discovery and the published OpenAPI document.
|
||||
paths:
|
||||
/server:
|
||||
get:
|
||||
operationId: getServerInfo
|
||||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
summary: Describe the server
|
||||
description: |
|
||||
Returns the public server description. No authentication required.
|
||||
Authenticated requests will additionally receive the implemented capability modules
|
||||
once authentication is available.
|
||||
responses:
|
||||
'200':
|
||||
description: Server description.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServerInfo'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
/openapi.json:
|
||||
get:
|
||||
operationId: getOpenAPISpecJSON
|
||||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
summary: Get the OpenAPI document (JSON)
|
||||
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
||||
responses:
|
||||
'200':
|
||||
description: The OpenAPI document.
|
||||
headers:
|
||||
ETag:
|
||||
$ref: '#/components/headers/ETag'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: OpenAPI 3.0 document.
|
||||
'304':
|
||||
$ref: '#/components/responses/NotModified'
|
||||
/openapi.yaml:
|
||||
get:
|
||||
operationId: getOpenAPISpecYAML
|
||||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
summary: Get the OpenAPI document (YAML)
|
||||
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
||||
responses:
|
||||
'200':
|
||||
description: The OpenAPI document.
|
||||
headers:
|
||||
ETag:
|
||||
$ref: '#/components/headers/ETag'
|
||||
content:
|
||||
application/yaml:
|
||||
schema:
|
||||
type: object
|
||||
description: OpenAPI 3.0 document.
|
||||
'304':
|
||||
$ref: '#/components/responses/NotModified'
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: Short-lived access token minted from a device grant. Not yet applied to any operation.
|
||||
schemas:
|
||||
ServerInfo:
|
||||
type: object
|
||||
description: Public server description. Everything an add-server screen needs before login.
|
||||
required:
|
||||
- name
|
||||
- serverVersion
|
||||
- specVersion
|
||||
- setupRequired
|
||||
- loginMethods
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Human-readable server product name.
|
||||
serverVersion:
|
||||
type: string
|
||||
description: Version of the running server build.
|
||||
specVersion:
|
||||
type: string
|
||||
description: Version of the OpenAPI document this server implements.
|
||||
setupRequired:
|
||||
type: boolean
|
||||
description: True until the first admin user has been created.
|
||||
loginMethods:
|
||||
type: array
|
||||
description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- password
|
||||
Problem:
|
||||
type: object
|
||||
description: RFC 9457 problem details, returned for every 4xx and 5xx response.
|
||||
required:
|
||||
- title
|
||||
- status
|
||||
- code
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
description: |
|
||||
URI reference identifying the problem type. Omitted while the problem carries no semantics
|
||||
beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their
|
||||
own semantics get their own URI; switch on `code` instead.
|
||||
title:
|
||||
type: string
|
||||
description: Short human-readable summary, the same for all occurrences of this problem type.
|
||||
status:
|
||||
type: integer
|
||||
description: HTTP status code of this response.
|
||||
detail:
|
||||
type: string
|
||||
description: Human-readable explanation specific to this occurrence. Omitted for internal errors.
|
||||
code:
|
||||
type: string
|
||||
description: Machine-readable error code, and the value clients switch on. New codes may be added.
|
||||
enum:
|
||||
- validation
|
||||
- unauthorized
|
||||
- forbidden
|
||||
- not_found
|
||||
- method_not_allowed
|
||||
- unavailable
|
||||
- internal
|
||||
errors:
|
||||
type: array
|
||||
description: Per-field failures. Present only when `code` is `validation`.
|
||||
items:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
ValidationError:
|
||||
type: object
|
||||
description: One field-level validation failure.
|
||||
required:
|
||||
- field
|
||||
- message
|
||||
properties:
|
||||
field:
|
||||
type: string
|
||||
description: Name of the offending query parameter, path parameter, or body field (dotted for nested).
|
||||
message:
|
||||
type: string
|
||||
description: Why the value was rejected.
|
||||
responses:
|
||||
InternalError:
|
||||
description: Unexpected server failure. Details are in the server log.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Problem'
|
||||
NotModified:
|
||||
description: Not modified.
|
||||
headers:
|
||||
ETag:
|
||||
$ref: '#/components/headers/ETag'
|
||||
headers:
|
||||
ETag:
|
||||
description: Entity tag for `If-None-Match` revalidation.
|
||||
schema:
|
||||
type: string
|
||||
35
api/embed.go
Normal file
35
api/embed.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed bundled/openapi.json
|
||||
var specJSON []byte
|
||||
|
||||
//go:embed bundled/openapi.yaml
|
||||
var specYAML []byte
|
||||
|
||||
func SpecJSON() []byte {
|
||||
return specJSON
|
||||
}
|
||||
|
||||
func SpecYAML() []byte {
|
||||
return specYAML
|
||||
}
|
||||
|
||||
var specVersion = sync.OnceValue(func() string {
|
||||
var doc struct {
|
||||
Info struct {
|
||||
Version string `json:"version"`
|
||||
} `json:"info"`
|
||||
}
|
||||
_ = json.Unmarshal(SpecJSON(), &doc)
|
||||
return doc.Info.Version
|
||||
})
|
||||
|
||||
func SpecVersion() string {
|
||||
return specVersion()
|
||||
}
|
||||
37
api/embed_test.go
Normal file
37
api/embed_test.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package api_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/navidrome/navidrome/api"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var _ = Describe("Bundled spec", func() {
|
||||
It("embeds a valid OpenAPI 3 document", func() {
|
||||
doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(doc.Validate(GinkgoT().Context())).To(Succeed())
|
||||
Expect(doc.Paths.Find("/server")).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("embeds the YAML variant", func() {
|
||||
var doc map[string]any
|
||||
Expect(yaml.Unmarshal(api.SpecYAML(), &doc)).To(Succeed())
|
||||
Expect(doc).To(HaveKey("paths"))
|
||||
})
|
||||
|
||||
It("reports the version from the bundle, matching the source root document", func() {
|
||||
src, err := os.ReadFile("api/openapi/openapi.yaml")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
var root struct {
|
||||
Info struct{ Version string } `yaml:"info"`
|
||||
}
|
||||
Expect(yaml.Unmarshal(src, &root)).To(Succeed())
|
||||
Expect(api.SpecVersion()).To(Equal(root.Info.Version))
|
||||
Expect(api.SpecVersion()).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
3
api/openapi/components/headers/ETag.yaml
Normal file
3
api/openapi/components/headers/ETag.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
description: Entity tag for `If-None-Match` revalidation.
|
||||
schema:
|
||||
type: string
|
||||
9
api/openapi/components/parameters/limit.yaml
Normal file
9
api/openapi/components/parameters/limit.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
name: limit
|
||||
in: query
|
||||
description: Maximum number of items to return.
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 2000
|
||||
default: 100
|
||||
8
api/openapi/components/parameters/offset.yaml
Normal file
8
api/openapi/components/parameters/offset.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
name: offset
|
||||
in: query
|
||||
description: Zero-based index of the first item to return.
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
default: 0
|
||||
5
api/openapi/components/responses/BadRequest.yaml
Normal file
5
api/openapi/components/responses/BadRequest.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
description: The request is malformed or fails validation.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: ../schemas/Problem.yaml
|
||||
5
api/openapi/components/responses/Forbidden.yaml
Normal file
5
api/openapi/components/responses/Forbidden.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
description: The caller is authenticated but not allowed to do this.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: ../schemas/Problem.yaml
|
||||
5
api/openapi/components/responses/InternalError.yaml
Normal file
5
api/openapi/components/responses/InternalError.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
description: Unexpected server failure. Details are in the server log.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: ../schemas/Problem.yaml
|
||||
5
api/openapi/components/responses/NotFound.yaml
Normal file
5
api/openapi/components/responses/NotFound.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
description: No such resource or endpoint.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: ../schemas/Problem.yaml
|
||||
4
api/openapi/components/responses/NotModified.yaml
Normal file
4
api/openapi/components/responses/NotModified.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
description: Not modified.
|
||||
headers:
|
||||
ETag:
|
||||
$ref: ../headers/ETag.yaml
|
||||
5
api/openapi/components/responses/Unauthorized.yaml
Normal file
5
api/openapi/components/responses/Unauthorized.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
description: Missing, invalid, or expired credentials.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: ../schemas/Problem.yaml
|
||||
13
api/openapi/components/schemas/ListMeta.yaml
Normal file
13
api/openapi/components/schemas/ListMeta.yaml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
type: object
|
||||
description: Pagination metadata carried by every list response.
|
||||
required: [total, offset, limit]
|
||||
properties:
|
||||
total:
|
||||
type: integer
|
||||
description: Total number of items matching the request, ignoring pagination.
|
||||
offset:
|
||||
type: integer
|
||||
description: Zero-based index of the first returned item.
|
||||
limit:
|
||||
type: integer
|
||||
description: Maximum number of items in this page.
|
||||
35
api/openapi/components/schemas/Problem.yaml
Normal file
35
api/openapi/components/schemas/Problem.yaml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
type: object
|
||||
description: RFC 9457 problem details, returned for every 4xx and 5xx response.
|
||||
required: [title, status, code]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
description: |
|
||||
URI reference identifying the problem type. Omitted while the problem carries no semantics
|
||||
beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their
|
||||
own semantics get their own URI; switch on `code` instead.
|
||||
title:
|
||||
type: string
|
||||
description: Short human-readable summary, the same for all occurrences of this problem type.
|
||||
status:
|
||||
type: integer
|
||||
description: HTTP status code of this response.
|
||||
detail:
|
||||
type: string
|
||||
description: Human-readable explanation specific to this occurrence. Omitted for internal errors.
|
||||
code:
|
||||
type: string
|
||||
description: Machine-readable error code, and the value clients switch on. New codes may be added.
|
||||
enum:
|
||||
- validation
|
||||
- unauthorized
|
||||
- forbidden
|
||||
- not_found
|
||||
- method_not_allowed
|
||||
- unavailable
|
||||
- internal
|
||||
errors:
|
||||
type: array
|
||||
description: Per-field failures. Present only when `code` is `validation`.
|
||||
items:
|
||||
$ref: ./ValidationError.yaml
|
||||
22
api/openapi/components/schemas/ServerInfo.yaml
Normal file
22
api/openapi/components/schemas/ServerInfo.yaml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
type: object
|
||||
description: Public server description. Everything an add-server screen needs before login.
|
||||
required: [name, serverVersion, specVersion, setupRequired, loginMethods]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Human-readable server product name.
|
||||
serverVersion:
|
||||
type: string
|
||||
description: Version of the running server build.
|
||||
specVersion:
|
||||
type: string
|
||||
description: Version of the OpenAPI document this server implements.
|
||||
setupRequired:
|
||||
type: boolean
|
||||
description: True until the first admin user has been created.
|
||||
loginMethods:
|
||||
type: array
|
||||
description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.
|
||||
items:
|
||||
type: string
|
||||
enum: [password]
|
||||
10
api/openapi/components/schemas/ValidationError.yaml
Normal file
10
api/openapi/components/schemas/ValidationError.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
type: object
|
||||
description: One field-level validation failure.
|
||||
required: [field, message]
|
||||
properties:
|
||||
field:
|
||||
type: string
|
||||
description: Name of the offending query parameter, path parameter, or body field (dotted for nested).
|
||||
message:
|
||||
type: string
|
||||
description: Why the value was rejected.
|
||||
39
api/openapi/openapi.yaml
Normal file
39
api/openapi/openapi.yaml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
openapi: 3.0.3
|
||||
info:
|
||||
title: Navidrome API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Navidrome API v1. Spec-first, additive within v1. Clients discover implemented
|
||||
capability modules through `GET /server` and never sniff versions.
|
||||
|
||||
Enums are open: new values may be added to any enum within v1. Clients must
|
||||
accept values they do not recognise instead of failing.
|
||||
|
||||
Every operation declares `x-stability-level`: `alpha` operations may change or
|
||||
disappear without notice, `beta` and `stable` operations only change additively.
|
||||
A level is only ever raised, never lowered.
|
||||
|
||||
`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods
|
||||
in its `Allow` header.
|
||||
license:
|
||||
name: GPL-3.0
|
||||
url: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
servers:
|
||||
- url: /api/v1
|
||||
tags:
|
||||
- name: server
|
||||
description: Server discovery and the published OpenAPI document.
|
||||
paths:
|
||||
/server:
|
||||
$ref: ./paths/server.yaml
|
||||
/openapi.json:
|
||||
$ref: ./paths/openapi.yaml#/json
|
||||
/openapi.yaml:
|
||||
$ref: ./paths/openapi.yaml#/yaml
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: Short-lived access token minted from a device grant. Not yet applied to any operation.
|
||||
42
api/openapi/paths/openapi.yaml
Normal file
42
api/openapi/paths/openapi.yaml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
json:
|
||||
get:
|
||||
operationId: getOpenAPISpecJSON
|
||||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
summary: Get the OpenAPI document (JSON)
|
||||
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
||||
responses:
|
||||
'200':
|
||||
description: The OpenAPI document.
|
||||
headers:
|
||||
ETag:
|
||||
$ref: ../components/headers/ETag.yaml
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: OpenAPI 3.0 document.
|
||||
'304':
|
||||
$ref: ../components/responses/NotModified.yaml
|
||||
yaml:
|
||||
get:
|
||||
operationId: getOpenAPISpecYAML
|
||||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
summary: Get the OpenAPI document (YAML)
|
||||
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
||||
responses:
|
||||
'200':
|
||||
description: The OpenAPI document.
|
||||
headers:
|
||||
ETag:
|
||||
$ref: ../components/headers/ETag.yaml
|
||||
content:
|
||||
application/yaml:
|
||||
schema:
|
||||
type: object
|
||||
description: OpenAPI 3.0 document.
|
||||
'304':
|
||||
$ref: ../components/responses/NotModified.yaml
|
||||
19
api/openapi/paths/server.yaml
Normal file
19
api/openapi/paths/server.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
get:
|
||||
operationId: getServerInfo
|
||||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
summary: Describe the server
|
||||
description: |
|
||||
Returns the public server description. No authentication required.
|
||||
Authenticated requests will additionally receive the implemented capability modules
|
||||
once authentication is available.
|
||||
responses:
|
||||
'200':
|
||||
description: Server description.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas/ServerInfo.yaml
|
||||
'500':
|
||||
$ref: ../components/responses/InternalError.yaml
|
||||
|
|
@ -174,28 +174,28 @@ func queueTotal(stats []model.ArtworkQueueStat) int64 {
|
|||
}
|
||||
|
||||
func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error) {
|
||||
q := ds.ArtworkQueue(ctx)
|
||||
q := ds.ArtworkQueue()
|
||||
var rep statusReport
|
||||
var err error
|
||||
if rep.queue, err = q.CountQueued(nil, nil); err != nil {
|
||||
if rep.queue, err = q.CountQueued(ctx, nil, nil); err != nil {
|
||||
return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err)
|
||||
}
|
||||
|
||||
for _, k := range artwork.ReprocessKinds {
|
||||
sources, err := q.SourcesInUse(k)
|
||||
sources, err := q.SourcesInUse(ctx, k)
|
||||
if err != nil {
|
||||
return rep, fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
|
||||
}
|
||||
slices.Sort(sources)
|
||||
for _, s := range sources {
|
||||
n, err := q.CountBySource(k, []string{s})
|
||||
n, err := q.CountBySource(ctx, k, []string{s})
|
||||
if err != nil {
|
||||
return rep, fmt.Errorf("counting %s artwork resolved from %s: %w", k, displaySource(s), err)
|
||||
}
|
||||
rep.sources = append(rep.sources, sourceCount{kind: k, source: s, count: n})
|
||||
// An absent state is exactly a row with no source, so it needs no second query.
|
||||
if s == "" {
|
||||
failed, err := q.CountBySource(k, []string{model.ArtworkSourceFailed})
|
||||
failed, err := q.CountBySource(ctx, k, []string{model.ArtworkSourceFailed})
|
||||
if err != nil {
|
||||
return rep, fmt.Errorf("counting failed %s artwork: %w", k, err)
|
||||
}
|
||||
|
|
@ -205,7 +205,7 @@ func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error
|
|||
}
|
||||
|
||||
rep.current, rep.inputs = artwork.ConfigFingerprint(), artwork.FingerprintInputs()
|
||||
if rep.stored, err = ds.Property(ctx).DefaultGet(consts.ArtConfFingerprintPropertyKey, ""); err != nil {
|
||||
if rep.stored, err = ds.Property().DefaultGet(ctx, consts.ArtConfFingerprintPropertyKey, ""); err != nil {
|
||||
return rep, fmt.Errorf("reading the stored artwork fingerprint: %w", err)
|
||||
}
|
||||
return rep, nil
|
||||
|
|
@ -442,13 +442,13 @@ func promptConfirm(in io.Reader, verb string) confirmFunc {
|
|||
|
||||
// validateSources rejects a typo'd source: matching nothing silently reads as "nothing to do" when
|
||||
// it means the filter was wrong. Checked table-wide, so a filter is never a typo for one --kind only.
|
||||
func validateSources(q model.ArtworkQueueRepository, sources []string) error {
|
||||
func validateSources(ctx context.Context, q model.ArtworkQueueRepository, sources []string) error {
|
||||
if len(sources) == 0 {
|
||||
return nil
|
||||
}
|
||||
var inUse []string
|
||||
for _, k := range artwork.ReprocessKinds {
|
||||
found, err := q.SourcesInUse(k)
|
||||
found, err := q.SourcesInUse(ctx, k)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
|
||||
}
|
||||
|
|
@ -475,8 +475,8 @@ func validateSources(q model.ArtworkQueueRepository, sources []string) error {
|
|||
// actually inserted; the two differ because an already-queued row is left untouched.
|
||||
func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, sources []string,
|
||||
imageAgents artwork.ImageAgentCount, dryRun bool, confirm confirmFunc, out io.Writer) error {
|
||||
q := ds.ArtworkQueue(ctx)
|
||||
if err := validateSources(q, sources); err != nil {
|
||||
q := ds.ArtworkQueue()
|
||||
if err := validateSources(ctx, q, sources); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -495,7 +495,7 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin
|
|||
matched := make([]int64, len(kinds))
|
||||
var total, external int64
|
||||
for i, k := range kinds {
|
||||
n, err := q.CountBySource(k, sources)
|
||||
n, err := q.CountBySource(ctx, k, sources)
|
||||
if err != nil {
|
||||
return fmt.Errorf("counting %s artwork: %w", k, err)
|
||||
}
|
||||
|
|
@ -523,7 +523,7 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin
|
|||
if matched[i] == 0 {
|
||||
continue
|
||||
}
|
||||
n, err := q.EnqueueBySource(k, sources, model.ArtworkPriorityRecheck)
|
||||
n, err := q.EnqueueBySource(ctx, k, sources, model.ArtworkPriorityRecheck)
|
||||
if err != nil {
|
||||
return fmt.Errorf("queueing %s artwork: %w", k, err)
|
||||
}
|
||||
|
|
@ -590,8 +590,8 @@ func parseAll[T comparable](values []string, parse func(string) (T, error)) ([]T
|
|||
|
||||
func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, priorities []int,
|
||||
dryRun bool, confirm confirmFunc, out io.Writer) error {
|
||||
q := ds.ArtworkQueue(ctx)
|
||||
matched, err := q.CountQueued(kinds, priorities)
|
||||
q := ds.ArtworkQueue()
|
||||
matched, err := q.CountQueued(ctx, kinds, priorities)
|
||||
if err != nil {
|
||||
return fmt.Errorf("counting queued artwork: %w", err)
|
||||
}
|
||||
|
|
@ -612,7 +612,7 @@ func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind,
|
|||
return nil
|
||||
}
|
||||
|
||||
cancelled, err := q.PurgeQueued(kinds, priorities)
|
||||
cancelled, err := q.PurgeQueued(ctx, kinds, priorities)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cancelling queued artwork: %w", err)
|
||||
}
|
||||
|
|
@ -984,11 +984,11 @@ func runExplain(ctx context.Context, args []string) {
|
|||
}
|
||||
rep := explainReport{kind: kind, id: id, name: name}
|
||||
if artwork.KeepsState(kind) {
|
||||
rep.stored, err = ds.Artwork(ctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||
rep.stored, err = ds.Artwork().GetItemArtwork(ctx, kind, id, model.ImageTypePrimary)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
log.Fatal(ctx, "Failed to read artwork state", "kind", kind, "id", id, err)
|
||||
}
|
||||
rep.queued, err = ds.ArtworkQueue(ctx).Get(kind, id, model.ImageTypePrimary)
|
||||
rep.queued, err = ds.ArtworkQueue().Get(ctx, kind, id, model.ImageTypePrimary)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
log.Fatal(ctx, "Failed to read the artwork queue", "kind", kind, "id", id, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -491,17 +491,17 @@ var _ = Describe("explain/reprocess source round trip", func() {
|
|||
|
||||
It("names the absent state as reprocess --source accepts it", func() {
|
||||
ds := &tests.MockDataStore{}
|
||||
art := ds.Artwork(ctx).(*tests.MockArtworkRepo)
|
||||
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(),
|
||||
art := ds.Artwork().(*tests.MockArtworkRepo)
|
||||
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(),
|
||||
ItemID: "ar-1", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||
|
||||
shown := storedSource(formatExplain(explainReport{kind: model.KindArtistArtwork, id: "ar-1",
|
||||
stored: &model.ItemArtwork{AttemptedAt: time.Now()}}))
|
||||
|
||||
q := ds.ArtworkQueue(ctx)
|
||||
Expect(validateSources(q, repositorySources([]string{shown}))).To(Succeed(),
|
||||
q := ds.ArtworkQueue()
|
||||
Expect(validateSources(ctx, q, repositorySources([]string{shown}))).To(Succeed(),
|
||||
"explain's spelling of a source must be pasteable into --source")
|
||||
Expect(validateSources(q, repositorySources([]string{"(" + shown + ")"}))).ToNot(Succeed(),
|
||||
Expect(validateSources(ctx, q, repositorySources([]string{"(" + shown + ")"}))).ToNot(Succeed(),
|
||||
"a parenthesised name would be rejected, so explain must not print one")
|
||||
})
|
||||
})
|
||||
|
|
@ -573,7 +573,7 @@ var _ = Describe("reprocessArtwork", func() {
|
|||
decline := func(io.Writer, int64, int64) bool { return false }
|
||||
|
||||
put := func(kind model.Kind, id, source string) {
|
||||
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
|
||||
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
|
||||
ImageType: model.ImageTypePrimary, Hash: "h" + id, Source: source})).To(Succeed())
|
||||
}
|
||||
|
||||
|
|
@ -584,8 +584,8 @@ var _ = Describe("reprocessArtwork", func() {
|
|||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
imageAgents = artwork.ImageAgentCount{Artist: 1, Album: 1}
|
||||
ds = &tests.MockDataStore{}
|
||||
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
|
||||
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
||||
art = ds.Artwork().(*tests.MockArtworkRepo)
|
||||
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
|
||||
out.Reset()
|
||||
put(model.KindArtistArtwork, "ar-1", "external:deezer")
|
||||
put(model.KindArtistArtwork, "ar-2", "")
|
||||
|
|
@ -601,19 +601,19 @@ var _ = Describe("reprocessArtwork", func() {
|
|||
Expect(out.String()).To(ContainSubstring("album"))
|
||||
Expect(out.String()).To(ContainSubstring("TOTAL"))
|
||||
Expect(out.String()).To(ContainSubstring("Dry run"))
|
||||
Expect(queue.Count()).To(BeZero())
|
||||
Expect(queue.Count(ctx)).To(BeZero())
|
||||
})
|
||||
|
||||
It("queues nothing when the operator declines", func() {
|
||||
Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, decline, &out)).To(Succeed())
|
||||
|
||||
Expect(out.String()).To(ContainSubstring("Aborted"))
|
||||
Expect(queue.Count()).To(BeZero())
|
||||
Expect(queue.Count(ctx)).To(BeZero())
|
||||
})
|
||||
|
||||
DescribeTable("records the applied config only for a run that leaves nothing on the old one",
|
||||
func(selected []model.Kind, sources []string, dryRun, applied bool) {
|
||||
Expect(ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
||||
Expect(ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
||||
|
||||
Expect(reprocessArtwork(ctx, ds, selected, sources, imageAgents, dryRun, accept, &out)).To(Succeed())
|
||||
|
||||
|
|
@ -621,7 +621,7 @@ var _ = Describe("reprocessArtwork", func() {
|
|||
if applied {
|
||||
want = artwork.ConfigFingerprint()
|
||||
}
|
||||
Expect(ds.Property(ctx).Get(consts.ArtConfFingerprintPropertyKey)).To(Equal(want))
|
||||
Expect(ds.Property().Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal(want))
|
||||
},
|
||||
Entry("every kind, unfiltered", artwork.ReprocessKinds, nil, false, true),
|
||||
Entry("every kind, but nothing matched", artwork.ReprocessKinds, []string{}, false, true),
|
||||
|
|
@ -633,14 +633,14 @@ var _ = Describe("reprocessArtwork", func() {
|
|||
It("queues the matching items at recheck priority, leaving their artwork state alone", func() {
|
||||
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
|
||||
|
||||
Expect(queue.Count()).To(Equal(int64(2)))
|
||||
queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||
Expect(queue.Count(ctx)).To(Equal(int64(2)))
|
||||
queued, err := queue.Get(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(queued.Priority).To(Equal(model.ArtworkPriorityRecheck))
|
||||
_, err = queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
|
||||
_, err = queue.Get(ctx, model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound), "a non-matching source must not be queued")
|
||||
|
||||
stored, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||
stored, err := art.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(stored.Hash).To(Equal("hal-1"), "bulk reprocessing must not blank the current artwork")
|
||||
})
|
||||
|
|
@ -648,20 +648,20 @@ var _ = Describe("reprocessArtwork", func() {
|
|||
It("targets the absent state", func() {
|
||||
Expect(reprocessArtwork(ctx, ds, kinds, []string{""}, imageAgents, false, accept, &out)).To(Succeed())
|
||||
|
||||
Expect(queue.Count()).To(Equal(int64(1)))
|
||||
_, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
||||
Expect(queue.Count(ctx)).To(Equal(int64(1)))
|
||||
_, err := queue.Get(ctx, model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("reports matched and queued separately when part of the set is already queued", func() {
|
||||
Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1",
|
||||
Expect(queue.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1",
|
||||
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump})).To(Succeed())
|
||||
|
||||
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
|
||||
|
||||
Expect(out.String()).To(ContainSubstring("Queued 1 of 2 matched items"))
|
||||
Expect(out.String()).To(ContainSubstring("Already queued, left unchanged: 1"))
|
||||
queued, err := queue.Get(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
||||
queued, err := queue.Get(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump),
|
||||
"an already-queued row keeps its priority and backoff")
|
||||
|
|
@ -675,7 +675,7 @@ var _ = Describe("reprocessArtwork", func() {
|
|||
}, &out)).To(Succeed())
|
||||
|
||||
Expect(out.String()).To(ContainSubstring("Nothing"))
|
||||
Expect(queue.Count()).To(BeZero())
|
||||
Expect(queue.Count(ctx)).To(BeZero())
|
||||
})
|
||||
|
||||
It("reports an empty selection as a dry run when one was asked for", func() {
|
||||
|
|
@ -767,7 +767,7 @@ var _ = Describe("reprocessArtwork", func() {
|
|||
Expect(err.Error()).To(ContainSubstring("external:deezer"))
|
||||
Expect(err.Error()).To(ContainSubstring("folder"))
|
||||
Expect(err.Error()).To(ContainSubstring("absent"), "the empty source prints under its user-facing name")
|
||||
Expect(queue.Count()).To(BeZero())
|
||||
Expect(queue.Count(ctx)).To(BeZero())
|
||||
})
|
||||
|
||||
It("accepts the absent filter with nothing absent, still rejecting a typo", func() {
|
||||
|
|
@ -777,7 +777,7 @@ var _ = Describe("reprocessArtwork", func() {
|
|||
imageAgents, false, accept, &out)).To(Succeed(),
|
||||
"a reserved source must stay valid once the library has none of it")
|
||||
Expect(out.String()).To(ContainSubstring("Nothing matches"))
|
||||
Expect(queue.Count()).To(BeZero())
|
||||
Expect(queue.Count(ctx)).To(BeZero())
|
||||
|
||||
Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{"absnt"}),
|
||||
imageAgents, true, accept, &out)).ToNot(Succeed(), "a typo must still be rejected")
|
||||
|
|
@ -797,7 +797,7 @@ var _ = Describe("reprocessArtwork", func() {
|
|||
|
||||
Expect(out.String()).To(ContainSubstring("Nothing matches"),
|
||||
"a well-formed filter must not be reported as a typo because of the kinds selected")
|
||||
Expect(queue.Count()).To(BeZero())
|
||||
Expect(queue.Count(ctx)).To(BeZero())
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -816,19 +816,19 @@ var _ = Describe("collectStatus", func() {
|
|||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
|
||||
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
||||
art = ds.Artwork().(*tests.MockArtworkRepo)
|
||||
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
|
||||
put := func(kind model.Kind, id, source, hash string, attempted time.Time) {
|
||||
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
|
||||
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
|
||||
ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed())
|
||||
}
|
||||
put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now())
|
||||
put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-24*time.Hour))
|
||||
// ar-3 is absent because it gave up, so the two absent artists split across the columns.
|
||||
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "ar-3",
|
||||
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "ar-3",
|
||||
ImageType: model.ImageTypePrimary, LastFailure: "[]", AttemptedAt: time.Now()})).To(Succeed())
|
||||
put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now())
|
||||
Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9",
|
||||
Expect(queue.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9",
|
||||
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill})).To(Succeed())
|
||||
})
|
||||
|
||||
|
|
@ -848,7 +848,7 @@ var _ = Describe("collectStatus", func() {
|
|||
})
|
||||
|
||||
It("compares the stored fingerprint against the current one", func() {
|
||||
Expect(ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, "old-fingerprint")).To(Succeed())
|
||||
Expect(ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, "old-fingerprint")).To(Succeed())
|
||||
|
||||
rep, err := collectStatus(ctx, ds)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -860,7 +860,7 @@ var _ = Describe("collectStatus", func() {
|
|||
It("queues nothing", func() {
|
||||
_, err := collectStatus(ctx, ds)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(queue.Count()).To(Equal(int64(1)), "status must not enqueue anything")
|
||||
Expect(queue.Count(ctx)).To(Equal(int64(1)), "status must not enqueue anything")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -973,21 +973,21 @@ var _ = Describe("refreshItems", func() {
|
|||
albums := tests.CreateMockAlbumRepo()
|
||||
albums.SetData(model.Albums{{ID: "al-1"}, {ID: "al-3"}})
|
||||
ds = &tests.MockDataStore{MockedAlbum: albums}
|
||||
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
|
||||
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
||||
art = ds.Artwork().(*tests.MockArtworkRepo)
|
||||
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
|
||||
out.Reset()
|
||||
})
|
||||
|
||||
It("clears the stored state and queues each id at Bump priority", func() {
|
||||
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindAlbumArtwork.Prefix(),
|
||||
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: model.KindAlbumArtwork.Prefix(),
|
||||
ItemID: "al-1", ImageType: model.ImageTypePrimary, Hash: "abc123"})).To(Succeed())
|
||||
|
||||
Expect(refreshItems(ctx, ds, []model.ArtworkID{
|
||||
{Kind: model.KindAlbumArtwork, ID: "al-1"}, {Kind: model.KindAlbumArtwork, ID: "al-3"}}, &out)).To(BeZero())
|
||||
|
||||
_, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||
_, err := art.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||
queued, err := queue.Get(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump))
|
||||
Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n"))
|
||||
|
|
@ -996,7 +996,7 @@ var _ = Describe("refreshItems", func() {
|
|||
It("skips an id that does not exist instead of queuing it", func() {
|
||||
Expect(refreshItems(ctx, ds, []model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "al-2"}}, &out)).To(Equal(1))
|
||||
|
||||
_, err := queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
|
||||
_, err := queue.Get(ctx, model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound), "a typo must not leave an orphan queue row")
|
||||
Expect(out.String()).To(BeEmpty())
|
||||
})
|
||||
|
|
@ -1145,9 +1145,9 @@ var _ = Describe("cancelArtwork", func() {
|
|||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
||||
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
|
||||
out.Reset()
|
||||
Expect(queue.Enqueue(
|
||||
Expect(queue.Enqueue(ctx,
|
||||
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", ImageType: model.ImageTypePrimary,
|
||||
Priority: model.ArtworkPriorityBackfill},
|
||||
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-2", ImageType: model.ImageTypePrimary,
|
||||
|
|
@ -1164,28 +1164,28 @@ var _ = Describe("cancelArtwork", func() {
|
|||
Expect(out.String()).To(ContainSubstring("backfill"))
|
||||
Expect(out.String()).To(ContainSubstring("TOTAL"))
|
||||
Expect(out.String()).To(ContainSubstring("Dry run"))
|
||||
Expect(queue.Count()).To(BeNumerically("==", 3))
|
||||
Expect(queue.Count(ctx)).To(BeNumerically("==", 3))
|
||||
})
|
||||
|
||||
It("cancels nothing when the operator declines", func() {
|
||||
Expect(cancelArtwork(ctx, ds, nil, nil, false, decline, &out)).To(Succeed())
|
||||
|
||||
Expect(out.String()).To(ContainSubstring("Aborted"))
|
||||
Expect(queue.Count()).To(BeNumerically("==", 3))
|
||||
Expect(queue.Count(ctx)).To(BeNumerically("==", 3))
|
||||
})
|
||||
|
||||
It("deletes the selected rows and leaves the rest queued", func() {
|
||||
Expect(cancelArtwork(ctx, ds, nil, []int{model.ArtworkPriorityBackfill}, false, accept, &out)).To(Succeed())
|
||||
|
||||
Expect(queue.Count()).To(BeNumerically("==", 1))
|
||||
_, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
||||
Expect(queue.Count(ctx)).To(BeNumerically("==", 1))
|
||||
_, err := queue.Get(ctx, model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred(), "a non-matching priority must stay queued")
|
||||
Expect(out.String()).To(ContainSubstring("Cancelled 2 of 2 matched items."))
|
||||
})
|
||||
|
||||
It("cancels every kind and priority when neither filter is given", func() {
|
||||
Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(Succeed())
|
||||
Expect(queue.Count()).To(BeZero())
|
||||
Expect(queue.Count(ctx)).To(BeZero())
|
||||
})
|
||||
|
||||
It("stops at a selection that matches nothing instead of prompting", func() {
|
||||
|
|
@ -1196,7 +1196,7 @@ var _ = Describe("cancelArtwork", func() {
|
|||
Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, false, refuse, &out)).To(Succeed())
|
||||
|
||||
Expect(out.String()).To(ContainSubstring("Nothing matches this selection."))
|
||||
Expect(queue.Count()).To(BeNumerically("==", 3))
|
||||
Expect(queue.Count(ctx)).To(BeNumerically("==", 3))
|
||||
})
|
||||
|
||||
It("reports a queue read failure instead of reporting nothing to cancel", func() {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@ package cmd
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
|
|
@ -31,7 +29,7 @@ func init() {
|
|||
pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero")
|
||||
backupRoot.AddCommand(pruneCmd)
|
||||
|
||||
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "path of backup database to restore")
|
||||
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().BoolVarP(&force, "force", "f", false, "bypass restore warning")
|
||||
_ = restoreCommand.MarkFlagRequired("backup-file")
|
||||
backupRoot.AddCommand(restoreCommand)
|
||||
|
|
@ -78,24 +76,12 @@ func runBackup(ctx context.Context) {
|
|||
conf.Server.Backup.Path = conf.NewDir(backupDir)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
requireExistingDB()
|
||||
|
||||
start := time.Now()
|
||||
path, err := db.Backup(ctx)
|
||||
if err != nil {
|
||||
log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err)
|
||||
log.Fatal("Error backing up database", "backupPath", conf.Server.Backup.Path, err)
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
|
|
@ -111,36 +97,17 @@ func runPrune(ctx context.Context) {
|
|||
conf.Server.Backup.Count = backupCount
|
||||
}
|
||||
|
||||
if conf.Server.Backup.Count == 0 && !force {
|
||||
fmt.Println("Warning: pruning ALL backups")
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if conf.Server.Backup.Count == 0 && !force && !confirmYES("Warning: pruning ALL backups") {
|
||||
log.Warn("Prune cancelled")
|
||||
return
|
||||
}
|
||||
|
||||
requireExistingDB()
|
||||
|
||||
start := time.Now()
|
||||
count, err := db.Prune(ctx)
|
||||
if err != nil {
|
||||
log.Fatal("Error pruning up database", "backup path", conf.Server.BasePath, err)
|
||||
log.Fatal("Error pruning database", "backupPath", conf.Server.Backup.Path, err)
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
|
|
@ -149,36 +116,29 @@ func runPrune(ctx context.Context) {
|
|||
}
|
||||
|
||||
func runRestore(ctx context.Context) {
|
||||
idx := strings.LastIndex(conf.Server.DbPath, "?")
|
||||
var path string
|
||||
requireExistingDB()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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")
|
||||
// A relative --backup-file is resolved against Backup.Path, the same folder
|
||||
// `backup create` writes to. Without this, the value was treated as relative
|
||||
// to the working directory, where the file does not exist.
|
||||
if !filepath.IsAbs(restorePath) {
|
||||
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.") {
|
||||
log.Warn("Restore cancelled")
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
err := db.Restore(ctx, restorePath)
|
||||
if err != nil {
|
||||
log.Fatal("Error restoring database", "backup path", conf.Server.BasePath, err)
|
||||
log.Fatal("Error restoring database", "backupFile", restorePath, err)
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
|
|
|
|||
99
cmd/doctor.go
Normal file
99
cmd/doctor.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
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
|
||||
}
|
||||
124
cmd/doctor_test.go
Normal file
124
cmd/doctor_test.go
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
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,13 +1,17 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/spf13/cobra"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
|
@ -28,7 +32,7 @@ var inspectCmd = &cobra.Command{
|
|||
Long: "Show file tags as seen by Navidrome",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runInspector(args)
|
||||
runInspector(cmd.Context(), args)
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -55,18 +59,24 @@ func prettyMarshal(v any) ([]byte, error) {
|
|||
return []byte(res.String()), nil
|
||||
}
|
||||
|
||||
func runInspector(args []string) {
|
||||
func runInspector(ctx context.Context, args []string) {
|
||||
marshal := marshalers[format]
|
||||
if marshal == nil {
|
||||
log.Fatal("Invalid format", "format", format)
|
||||
}
|
||||
libs := loadLibraries(ctx)
|
||||
matcher := model.NewLibraryMatcher(libs)
|
||||
var out []core.InspectOutput
|
||||
for _, filePath := range args {
|
||||
if !model.IsAudioFile(filePath) {
|
||||
log.Warn("Not an audio file", "file", filePath)
|
||||
continue
|
||||
}
|
||||
output, err := core.Inspect(filePath, 1, "")
|
||||
lib, ok := libraryForFile(matcher, filePath)
|
||||
if !ok && len(libs) > 0 {
|
||||
log.Warn("File is not in any library, using the global PID config", "file", filePath)
|
||||
}
|
||||
output, err := core.Inspect(filePath, lib, "")
|
||||
if err != nil {
|
||||
log.Warn("Unable to process file", "file", filePath, "error", err)
|
||||
continue
|
||||
|
|
@ -77,3 +87,33 @@ func runInspector(args []string) {
|
|||
data, _ := marshal(out)
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
|
||||
// loadLibraries reads the libraries, so each file gets its library's PID config. It never creates a DB.
|
||||
func loadLibraries(ctx context.Context) model.Libraries {
|
||||
if dbFile, ok := existingDBFile(); !ok {
|
||||
log.Warn(ctx, "No database found, using the global PID config", "path", dbFile)
|
||||
return nil
|
||||
}
|
||||
defer db.Init(ctx)()
|
||||
libs, err := persistence.New(db.Db()).Library().GetAll(ctx)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not load libraries, using the global PID config", err)
|
||||
return nil
|
||||
}
|
||||
for i := range libs {
|
||||
if absPath, err := filepath.Abs(libs[i].Path); err == nil {
|
||||
libs[i].Path = absPath
|
||||
}
|
||||
}
|
||||
return libs
|
||||
}
|
||||
|
||||
// libraryForFile falls back to the default library with no overrides, which uses the global PID config.
|
||||
func libraryForFile(matcher *model.LibraryMatcher, filePath string) (model.Library, bool) {
|
||||
if absPath, err := filepath.Abs(filePath); err == nil {
|
||||
if lib, ok := matcher.FindLibrary(absPath); ok {
|
||||
return lib, true
|
||||
}
|
||||
}
|
||||
return model.Library{ID: model.DefaultLibraryID}, false
|
||||
}
|
||||
|
|
|
|||
61
cmd/inspect_test.go
Normal file
61
cmd/inspect_test.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("inspect", func() {
|
||||
Describe("libraryForFile", func() {
|
||||
var matcher *model.LibraryMatcher
|
||||
var root string
|
||||
|
||||
BeforeEach(func() {
|
||||
root = GinkgoT().TempDir()
|
||||
cwd, err := os.Getwd()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
matcher = model.NewLibraryMatcher(model.Libraries{
|
||||
{ID: 1, Path: filepath.Join(root, "music")},
|
||||
{ID: 2, Path: filepath.Join(cwd, "loose"), PIDAlbum: "folder"},
|
||||
})
|
||||
})
|
||||
|
||||
It("returns the library that contains an absolute path", func() {
|
||||
lib, ok := libraryForFile(matcher, filepath.Join(root, "music", "album", "track.mp3"))
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(lib.ID).To(Equal(1))
|
||||
})
|
||||
|
||||
It("resolves a relative path against the working directory", func() {
|
||||
lib, ok := libraryForFile(matcher, filepath.Join("loose", "track.mp3"))
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(lib.PIDAlbum).To(Equal("folder"))
|
||||
})
|
||||
|
||||
It("falls back to the default library without overrides", func() {
|
||||
lib, ok := libraryForFile(matcher, filepath.Join(root, "elsewhere", "track.mp3"))
|
||||
Expect(ok).To(BeFalse())
|
||||
Expect(lib).To(Equal(model.Library{ID: model.DefaultLibraryID}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("loadLibraries", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
|
||||
It("does not create a database when there is none", func() {
|
||||
dbFile := filepath.Join(GinkgoT().TempDir(), "navidrome.db")
|
||||
conf.Server.DbPath = dbFile + "?_journal_mode=WAL"
|
||||
|
||||
Expect(loadLibraries(GinkgoT().Context())).To(BeNil())
|
||||
Expect(dbFile).ToNot(BeAnExistingFile())
|
||||
})
|
||||
})
|
||||
})
|
||||
171
cmd/missing.go
Normal file
171
cmd/missing.go
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
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
|
||||
}
|
||||
81
cmd/missing_test.go
Normal file
81
cmd/missing_test.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
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}
|
||||
}
|
||||
pls, err := ds.Playlist(ctx).GetAll(options)
|
||||
pls, err := ds.Playlist().GetAll(ctx, options)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to retrieve playlists", err)
|
||||
}
|
||||
|
|
@ -117,17 +117,17 @@ func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.
|
|||
}
|
||||
|
||||
func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist {
|
||||
playlist, err := ds.Playlist(ctx).GetWithTracks(nameOrID, true, false)
|
||||
playlist, err := ds.Playlist().GetWithTracks(ctx, nameOrID, true, false)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||
}
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
|
||||
playlists, err := ds.Playlist().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
|
||||
if err != nil {
|
||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||
}
|
||||
if len(playlists) > 0 {
|
||||
playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false)
|
||||
playlist, err = ds.Playlist().GetWithTracks(ctx, playlists[0].ID, true, false)
|
||||
if err != nil {
|
||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||
}
|
||||
|
|
@ -194,7 +194,7 @@ func runExport(ctx context.Context) {
|
|||
|
||||
exported := 0
|
||||
for _, pls := range allPls {
|
||||
plsWithTracks, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false)
|
||||
plsWithTracks, err := ds.Playlist().GetWithTracks(ctx, pls.ID, true, false)
|
||||
if err != nil {
|
||||
log.Error("Error loading playlist tracks", "playlist", pls.Name, err)
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -243,7 +243,7 @@ func runPluginInfo(ctx context.Context, arg string) {
|
|||
}
|
||||
requirePluginsEnabled(ctx)
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
p, err := ds.Plugin(ctx).Get(arg)
|
||||
p, err := ds.Plugin().Get(ctx, arg)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
||||
}
|
||||
|
|
@ -264,7 +264,7 @@ func runPluginValidate(ctx context.Context, arg string) {
|
|||
}
|
||||
requirePluginsEnabled(ctx)
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
p, err := ds.Plugin(ctx).Get(arg)
|
||||
p, err := ds.Plugin().Get(ctx, arg)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
||||
}
|
||||
|
|
@ -329,7 +329,7 @@ func formatPluginList(list model.Plugins, format string) (string, error) {
|
|||
func runPluginList(ctx context.Context) {
|
||||
requirePluginsEnabled(ctx)
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
list, err := ds.Plugin(ctx).GetAll()
|
||||
list, err := ds.Plugin().GetAll(ctx)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to list plugins", err)
|
||||
}
|
||||
|
|
@ -372,7 +372,7 @@ var pluginEditCmd = &cobra.Command{
|
|||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requirePluginsEnabled(cmd.Context())
|
||||
ds, ctx := getAdminContext(cmd.Context())
|
||||
cur, err := ds.Plugin(ctx).Get(args[0])
|
||||
cur, err := ds.Plugin().Get(ctx, args[0])
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Plugin not found", "id", args[0], err)
|
||||
}
|
||||
|
|
|
|||
98
cmd/root.go
98
cmd/root.go
|
|
@ -44,7 +44,9 @@ Complete documentation is available at https://www.navidrome.org/docs`,
|
|||
preRun()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runNavidrome(cmd.Context())
|
||||
if err := runNavidrome(cmd.Context()); err != nil {
|
||||
log.Fatal("Fatal error in Navidrome. Aborting", err)
|
||||
}
|
||||
},
|
||||
PostRun: func(cmd *cobra.Command, args []string) {
|
||||
postRun()
|
||||
|
|
@ -76,16 +78,17 @@ func postRun() {
|
|||
}
|
||||
|
||||
// runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks.
|
||||
// If any of the services returns an error, it will log it and exit. If the process receives a signal to exit,
|
||||
// it will cancel the context and exit gracefully.
|
||||
func runNavidrome(ctx context.Context) {
|
||||
defer db.Init(ctx)()
|
||||
// If any of the services returns an error, it stops the others and returns that error, so the caller can
|
||||
// exit with a non-zero code. If the context is cancelled (a signal or a service stop), it returns nil.
|
||||
func runNavidrome(parentCtx context.Context) error {
|
||||
defer db.Init(parentCtx)()
|
||||
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g, ctx := errgroup.WithContext(parentCtx)
|
||||
g.Go(startServer(ctx))
|
||||
g.Go(startSignaller(ctx))
|
||||
g.Go(startScheduler(ctx))
|
||||
g.Go(startPlaybackServer(ctx))
|
||||
g.Go(startJellyfinDiscovery(ctx))
|
||||
g.Go(schedulePeriodicBackup(ctx))
|
||||
g.Go(startInsightsCollector(ctx))
|
||||
g.Go(scheduleDBAnalyzer(ctx))
|
||||
|
|
@ -101,9 +104,11 @@ func runNavidrome(ctx context.Context) {
|
|||
log.Warn(ctx, "Automatic Scanning is DISABLED")
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
log.Error("Fatal error in Navidrome. Aborting", err)
|
||||
// Errors caused by a normal shutdown are not failures
|
||||
if err := g.Wait(); err != nil && parentCtx.Err() == nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mainContext returns a context that is cancelled when the process receives a signal to exit.
|
||||
|
|
@ -132,6 +137,9 @@ func startServer(ctx context.Context) func() error {
|
|||
if conf.Server.Jellyfin.Enabled {
|
||||
a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx))
|
||||
}
|
||||
if conf.Server.DevAPIv1 {
|
||||
a.MountRouter("API v1", consts.URLPathAPIv1, CreateAPIv1Router(ctx))
|
||||
}
|
||||
if conf.Server.Prometheus.Enabled {
|
||||
p := CreatePrometheus()
|
||||
// blocking call because takes <100ms but useful if fails
|
||||
|
|
@ -182,46 +190,50 @@ func schedulePeriodicScan(ctx context.Context) func() error {
|
|||
}
|
||||
}
|
||||
|
||||
func pidHashChanged(ds model.DataStore) (bool, error) {
|
||||
pidAlbum, err := ds.Property(context.Background()).DefaultGet(consts.PIDAlbumKey, "")
|
||||
// librariesWithChangedPID returns the names of the libraries whose effective PID config differs from
|
||||
// the one used by their last finished scan
|
||||
func librariesWithChangedPID(ctx context.Context, ds model.DataStore) ([]string, error) {
|
||||
libs, err := ds.Library().GetAll(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return nil, err
|
||||
}
|
||||
pidTrack, err := ds.Property(context.Background()).DefaultGet(consts.PIDTrackKey, "")
|
||||
if err != nil {
|
||||
return false, err
|
||||
var names []string
|
||||
for _, lib := range libs {
|
||||
if lib.PIDChanged() {
|
||||
names = append(names, lib.Name)
|
||||
}
|
||||
}
|
||||
return !strings.EqualFold(pidAlbum, conf.Server.PID.Album) || !strings.EqualFold(pidTrack, conf.Server.PID.Track), nil
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// runInitialScan runs an initial scan of the music library if needed.
|
||||
func runInitialScan(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
ds := CreateDataStore()
|
||||
fullScanRequired, err := ds.Property(ctx).DefaultGet(consts.FullScanAfterMigrationFlagKey, "0")
|
||||
fullScanRequired, err := ds.Property().DefaultGet(ctx, consts.FullScanAfterMigrationFlagKey, "0")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inProgress, err := ds.Library(ctx).ScanInProgress()
|
||||
inProgress, err := ds.Library().ScanInProgress(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pidHasChanged, err := pidHashChanged(ds)
|
||||
pidChangedLibs, err := librariesWithChangedPID(ctx, ds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup
|
||||
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
|
||||
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || len(pidChangedLibs) > 0
|
||||
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
||||
if scanNeeded {
|
||||
s := CreateScanner(ctx)
|
||||
switch {
|
||||
case fullScanRequired == "1":
|
||||
log.Warn(ctx, "Full scan required after migration")
|
||||
_ = ds.Property(ctx).Delete(consts.FullScanAfterMigrationFlagKey)
|
||||
case pidHasChanged:
|
||||
log.Warn(ctx, "PID config changed, performing full scan")
|
||||
fullScanRequired = "1"
|
||||
_ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey)
|
||||
case len(pidChangedLibs) > 0:
|
||||
// Includes never-scanned libraries. The scanner rescans in full only the ones that need it
|
||||
log.Warn(ctx, "Libraries with a new or changed PID config, scanning", "libraries", pidChangedLibs)
|
||||
case inProgress:
|
||||
log.Warn(ctx, "Resuming interrupted scan")
|
||||
default:
|
||||
|
|
@ -343,6 +355,18 @@ 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.
|
||||
// It is responsible for the Jukebox functionality
|
||||
func startPlaybackServer(ctx context.Context) func() error {
|
||||
|
|
@ -362,10 +386,26 @@ func startPlaybackServer(ctx context.Context) func() error {
|
|||
func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() error {
|
||||
return func() error {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// reports an artwork config change without acting on it.
|
||||
func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error {
|
||||
|
|
@ -373,26 +413,20 @@ func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) fu
|
|||
schedulerInstance := scheduler.GetInstance()
|
||||
|
||||
if _, err := schedulerInstance.Add(consts.ArtworkEnqueueMissingSchedule, func() {
|
||||
if err := worker.EnqueueMissingAll(ctx); err != nil {
|
||||
log.Error(ctx, "Error enqueueing missing artwork rechecks", err)
|
||||
}
|
||||
outsideScan(ctx, "artwork missing-state recheck", worker.EnqueueMissingAll)
|
||||
}); err != nil {
|
||||
log.Error(ctx, "Error scheduling artwork missing-state recheck", err)
|
||||
}
|
||||
|
||||
if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() {
|
||||
if err := worker.RunPrune(ctx); err != nil {
|
||||
log.Error(ctx, "Error running artwork prune", err)
|
||||
}
|
||||
outsideScan(ctx, "artwork prune", worker.RunPrune)
|
||||
}); err != nil {
|
||||
log.Error(ctx, "Error scheduling artwork prune", err)
|
||||
}
|
||||
|
||||
// Also run the missing-row recheck once at startup so a never-scanned entity is picked up
|
||||
// immediately, not only on the next hourly tick (e.g. after enabling the feature).
|
||||
if err := worker.EnqueueMissingAll(ctx); err != nil {
|
||||
log.Error(ctx, "Error enqueueing missing artwork rechecks", err)
|
||||
}
|
||||
outsideScan(ctx, "artwork missing-state recheck", worker.EnqueueMissingAll)
|
||||
|
||||
if err := worker.ReconcileConfig(ctx); err != nil {
|
||||
log.Error(ctx, "Error checking the artwork config fingerprint", err)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path"
|
||||
|
|
@ -9,6 +10,8 @@ import (
|
|||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
|
@ -44,3 +47,30 @@ var _ = Describe("profilerHandler", func() {
|
|||
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"))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
55
cmd/search.go
Normal file
55
cmd/search.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
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 {
|
||||
ctx context.Context
|
||||
ctx context.Context //nolint:containedctx // service lifecycle ctx, cancelled by Stop
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
|
@ -53,8 +53,13 @@ func (p *svcControl) Start(service.Service) error {
|
|||
p.done = make(chan struct{})
|
||||
p.ctx, p.cancel = context.WithCancel(context.Background())
|
||||
go func() {
|
||||
runNavidrome(p.ctx)
|
||||
err := runNavidrome(p.ctx)
|
||||
close(p.done)
|
||||
// service.Run() only returns when it gets a stop request, so exit here to let the
|
||||
// service manager see the failure and restart the service
|
||||
if err != nil {
|
||||
log.Fatal("Fatal error in Navidrome. Aborting", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
|
@ -74,7 +79,7 @@ func (p *svcControl) Stop(service.Service) error {
|
|||
var svcInstance = sync.OnceValue(func() service.Service {
|
||||
options := make(service.KeyValue)
|
||||
options["Restart"] = "on-failure"
|
||||
options["SuccessExitStatus"] = "1 2 8 SIGKILL"
|
||||
options["SuccessExitStatus"] = "SIGKILL"
|
||||
options["UserService"] = false
|
||||
options["LogDirectory"] = conf.Server.DataFolder.String()
|
||||
options["SystemdScript"] = systemdScript
|
||||
|
|
|
|||
26
cmd/user.go
26
cmd/user.go
|
|
@ -183,7 +183,7 @@ func runCreateUser(ctx context.Context) {
|
|||
ds, ctx := getAdminContext(ctx)
|
||||
|
||||
err := ds.WithTx(func(tx model.DataStore) error {
|
||||
existingUser, err := tx.User(ctx).FindByUsername(userID)
|
||||
existingUser, err := tx.User().FindByUsername(ctx, userID)
|
||||
if existingUser != nil {
|
||||
return fmt.Errorf("existing user '%s'", userID)
|
||||
}
|
||||
|
|
@ -193,7 +193,7 @@ func runCreateUser(ctx context.Context) {
|
|||
}
|
||||
|
||||
if len(libraryIds) > 0 && !setAdmin {
|
||||
user.Libraries, err = tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
||||
user.Libraries, err = tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -202,13 +202,13 @@ func runCreateUser(ctx context.Context) {
|
|||
return libraryError(user.Libraries)
|
||||
}
|
||||
} else {
|
||||
user.Libraries, err = tx.Library(ctx).GetAll()
|
||||
user.Libraries, err = tx.Library().GetAll(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.User(ctx).Put(&user)
|
||||
err = tx.User().Put(ctx, &user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -218,7 +218,7 @@ func runCreateUser(ctx context.Context) {
|
|||
updatedIds[idx] = lib.ID
|
||||
}
|
||||
|
||||
err = tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
|
||||
err = tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
|
||||
return err
|
||||
})
|
||||
|
||||
|
|
@ -236,7 +236,7 @@ func runDeleteUser(ctx context.Context) {
|
|||
var user *model.User
|
||||
|
||||
err = ds.WithTx(func(tx model.DataStore) error {
|
||||
count, err := tx.User(ctx).CountAll()
|
||||
count, err := tx.User().CountAll(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -250,7 +250,7 @@ func runDeleteUser(ctx context.Context) {
|
|||
return err
|
||||
}
|
||||
|
||||
return tx.User(ctx).Delete(user.ID)
|
||||
return tx.User().Delete(ctx, user.ID)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
|
|
@ -276,7 +276,7 @@ func runUserEdit(ctx context.Context) {
|
|||
}
|
||||
|
||||
if len(libraryIds) > 0 && !setAdmin {
|
||||
libraries, err := tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
||||
libraries, err := tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -291,7 +291,7 @@ func runUserEdit(ctx context.Context) {
|
|||
}
|
||||
|
||||
if setAdmin && !user.IsAdmin {
|
||||
libraries, err := tx.Library(ctx).GetAll()
|
||||
libraries, err := tx.Library().GetAll(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -337,7 +337,7 @@ func runUserEdit(ctx context.Context) {
|
|||
return nil
|
||||
}
|
||||
|
||||
err := tx.User(ctx).Put(user)
|
||||
err := tx.User().Put(ctx, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -348,7 +348,7 @@ func runUserEdit(ctx context.Context) {
|
|||
updatedIds[idx] = lib.ID
|
||||
}
|
||||
|
||||
err := tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
|
||||
err := tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -393,13 +393,11 @@ func runUserList(ctx context.Context) {
|
|||
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
|
||||
users, err := ds.User(ctx).ReadAll()
|
||||
userList, err := ds.User().ReadAll(ctx)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to retrieve users", err)
|
||||
}
|
||||
|
||||
userList := users.(model.Users)
|
||||
|
||||
if outputFormat == "csv" {
|
||||
w := csv.NewWriter(os.Stdout)
|
||||
_ = w.Write([]string{
|
||||
|
|
|
|||
29
cmd/utils.go
29
cmd/utils.go
|
|
@ -5,8 +5,11 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
|
|
@ -15,6 +18,28 @@ import (
|
|||
"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.
|
||||
func newTabWriter(out io.Writer) *tabwriter.Writer {
|
||||
return tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||
|
|
@ -32,14 +57,14 @@ func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
|
|||
}
|
||||
|
||||
func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) {
|
||||
user, err := ds.User(ctx).FindByUsername(id)
|
||||
user, err := ds.User().FindByUsername(ctx, id)
|
||||
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return nil, fmt.Errorf("finding user by name: %w", err)
|
||||
}
|
||||
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
user, err = ds.User(ctx).Get(id)
|
||||
user, err = ds.User().Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("finding user by id: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/quickconnect"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/sonic"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
|
|
@ -30,6 +31,7 @@ import (
|
|||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/apiv1"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/jellyfin"
|
||||
"github.com/navidrome/navidrome/server/nativeapi"
|
||||
|
|
@ -70,7 +72,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
|||
insights := metrics.GetInstance(dataStore)
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
|
||||
|
|
@ -79,7 +81,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
|||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
||||
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider)
|
||||
quickConnect := quickconnect.GetInstance()
|
||||
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider, quickConnect)
|
||||
return router
|
||||
}
|
||||
|
||||
|
|
@ -92,8 +95,9 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
|||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
||||
transcodingCache := stream.GetTranscodingCache()
|
||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
||||
share := core.NewShare(dataStore)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork)
|
||||
players := core.NewPlayers(dataStore)
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
|
|
@ -103,11 +107,10 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
|||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
||||
uploader := artwork.NewUploader(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
||||
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
|
||||
return router
|
||||
|
|
@ -135,7 +138,15 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
|||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
||||
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
||||
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker)
|
||||
quickConnect := quickconnect.GetInstance()
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -148,9 +159,10 @@ func CreatePublicRouter() *public.Router {
|
|||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
||||
transcodingCache := stream.GetTranscodingCache()
|
||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
||||
share := core.NewShare(dataStore)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
|
||||
archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork)
|
||||
router := public.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, share, archiver)
|
||||
return router
|
||||
}
|
||||
|
||||
|
|
@ -168,6 +180,13 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
|||
return router
|
||||
}
|
||||
|
||||
func CreateJellyfinDiscovery() *jellyfin.Discovery {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
discovery := jellyfin.NewDiscovery(dataStore)
|
||||
return discovery
|
||||
}
|
||||
|
||||
func CreateInsights() metrics.Insights {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
|
|
@ -189,7 +208,7 @@ func CreateScanner(ctx context.Context) model.Scanner {
|
|||
uploader := artwork.NewUploader(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
return modelScanner
|
||||
}
|
||||
|
||||
|
|
@ -200,7 +219,7 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
|||
uploader := artwork.NewUploader(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||
return watcher
|
||||
}
|
||||
|
|
@ -249,7 +268,7 @@ func getPluginManager() *plugins.Manager {
|
|||
|
||||
// wire_injectors.go:
|
||||
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, jellyfin.NewDiscovery, apiv1.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
|
||||
|
||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||
manager := getPluginManager()
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import (
|
|||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/apiv1"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/jellyfin"
|
||||
"github.com/navidrome/navidrome/server/nativeapi"
|
||||
|
|
@ -36,13 +37,15 @@ var allProviders = wire.NewSet(
|
|||
server.New,
|
||||
subsonic.New,
|
||||
jellyfin.New,
|
||||
jellyfin.NewDiscovery,
|
||||
apiv1.New,
|
||||
nativeapi.New,
|
||||
public.New,
|
||||
persistence.New,
|
||||
lastfm.NewRouter,
|
||||
listenbrainz.NewRouter,
|
||||
events.GetBroker,
|
||||
scanner.New,
|
||||
scanner.GetInstance,
|
||||
scanner.GetWatcher,
|
||||
metrics.GetPrometheusInstance,
|
||||
db.Db,
|
||||
|
|
@ -90,6 +93,12 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
|||
))
|
||||
}
|
||||
|
||||
func CreateAPIv1Router(ctx context.Context) *apiv1.Router {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
||||
func CreatePublicRouter() *public.Router {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
|
|
@ -108,6 +117,12 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
|||
))
|
||||
}
|
||||
|
||||
func CreateJellyfinDiscovery() *jellyfin.Discovery {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
||||
func CreateInsights() metrics.Insights {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ type configOptions struct {
|
|||
DevExternalArtistFetchMultiplier float64
|
||||
DevPreserveUnicodeInExternalCalls bool
|
||||
DevEnableMediaFileProbe bool
|
||||
DevAPIv1 bool
|
||||
}
|
||||
|
||||
type scannerOptions struct {
|
||||
|
|
@ -235,6 +236,8 @@ type jellyfinOptions struct {
|
|||
// ExposedPublicUsers is a comma-separated list of usernames to advertise on the unauthenticated
|
||||
// GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users.
|
||||
ExposedPublicUsers string
|
||||
AutoDiscovery bool
|
||||
QuickConnect bool
|
||||
// MaxConcurrentStreams bounds how many collection responses can stream at once. Each holds a DB
|
||||
// cursor — and its pooled connection — for the whole client-paced response, so without a bound
|
||||
// enough slow clients would take the entire pool and stall the scanner, scrobbles and the UI.
|
||||
|
|
@ -407,7 +410,7 @@ func Load(noConfigDump bool) {
|
|||
if mkErr := os.MkdirAll(filepath.Dir(Server.LogFile), os.ModePerm); mkErr != nil {
|
||||
logFatal(fmt.Sprintf("Error creating log file directory: %s", mkErr.Error()))
|
||||
}
|
||||
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
|
||||
}
|
||||
|
|
@ -512,6 +515,11 @@ func Load(noConfigDump bool) {
|
|||
Server.UICoverArtSize = newValue
|
||||
}
|
||||
|
||||
if Server.Scanner.Extractor != consts.DefaultScannerExtractor {
|
||||
log.Warn("Invalid Scanner.Extractor, using default", "value", Server.Scanner.Extractor, "default", consts.DefaultScannerExtractor)
|
||||
Server.Scanner.Extractor = consts.DefaultScannerExtractor
|
||||
}
|
||||
|
||||
// Floor MaxImageSize at MaxImageUploadSize so accepted uploads can always be read back.
|
||||
imgSize, _ := humanize.ParseBytes(Server.MaxImageSize)
|
||||
uploadSize, _ := humanize.ParseBytes(Server.MaxImageUploadSize)
|
||||
|
|
@ -1087,6 +1095,8 @@ func setViperDefaults() {
|
|||
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
|
||||
viper.SetDefault("jellyfin.enabled", false)
|
||||
viper.SetDefault("jellyfin.servername", "")
|
||||
viper.SetDefault("jellyfin.autodiscovery", false)
|
||||
viper.SetDefault("jellyfin.quickconnect", true)
|
||||
viper.SetDefault("enablescrobblehistory", true)
|
||||
viper.SetDefault("httpheaders.frameoptions", "DENY")
|
||||
viper.SetDefault("backup.path", "")
|
||||
|
|
@ -1115,6 +1125,7 @@ func setViperDefaults() {
|
|||
viper.SetDefault("devshowartistpage", true)
|
||||
viper.SetDefault("devuishowconfig", true)
|
||||
viper.SetDefault("devneweventstream", true)
|
||||
viper.SetDefault("devapiv1", false)
|
||||
viper.SetDefault("devoffsetoptimize", 50000)
|
||||
// Half the pool: streams may take up to this many connections, leaving the rest for the scanner,
|
||||
// scrobbles and the UI. See MaxOpenConns.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -329,6 +330,21 @@ var _ = Describe("Configuration", func() {
|
|||
}).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() {
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("baseurl", "://invalid")
|
||||
|
|
@ -386,6 +402,27 @@ var _ = Describe("Configuration", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Describe("Scanner.Extractor", func() {
|
||||
BeforeEach(func() {
|
||||
viper.Reset()
|
||||
conf.SetViperDefaults()
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("loglevel", "error")
|
||||
conf.ResetConf()
|
||||
})
|
||||
|
||||
It("falls back to taglib for an unknown extractor", func() {
|
||||
viper.SetDefault("scanner.extractor", "ffmpeg")
|
||||
conf.Load(true)
|
||||
Expect(conf.Server.Scanner.Extractor).To(Equal("taglib"))
|
||||
})
|
||||
|
||||
It("keeps taglib", func() {
|
||||
conf.Load(true)
|
||||
Expect(conf.Server.Scanner.Extractor).To(Equal("taglib"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("EnforceNonRootUser", func() {
|
||||
It("defaults to false", func() {
|
||||
conf.Load(true)
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ const (
|
|||
DefaultEncryptionKey = "just for obfuscation"
|
||||
PasswordsEncryptedKey = "PasswordsEncryptedKey"
|
||||
PasswordAutogenPrefix = "__NAVIDROME_AUTOGEN__" //nolint:gosec
|
||||
APIKeyPrefix = "nds_"
|
||||
|
||||
DevInitialUserName = "admin"
|
||||
DevInitialName = "Dev Admin"
|
||||
|
|
@ -59,6 +60,7 @@ const (
|
|||
URLPathPublic = "/share"
|
||||
URLPathPublicImages = URLPathPublic + "/img"
|
||||
URLPathJellyfinAPI = "/jellyfin"
|
||||
URLPathAPIv1 = "/api/v1"
|
||||
|
||||
// JellyfinServerIDKey is the Property key for the stable, persisted server Id reported by the
|
||||
// Jellyfin API. Jellyfin clients cache this value, so it must survive process restarts.
|
||||
|
|
@ -154,8 +156,6 @@ const (
|
|||
//DefaultAlbumPID = "album_legacy"
|
||||
DefaultAlbumPID = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
|
||||
DefaultTrackPID = "musicbrainz_trackid|albumid,discnumber,tracknumber,title"
|
||||
PIDAlbumKey = "PIDAlbum"
|
||||
PIDTrackKey = "PIDTrack"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ RestrictNamespaces=yes
|
|||
RestrictRealtime=yes
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallFilter=~@privileged @resources
|
||||
SystemCallFilter=setrlimit
|
||||
SystemCallFilter=setrlimit mbind
|
||||
SystemCallArchitectures=native
|
||||
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) {
|
||||
top, err := p.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
top, err := p.ds.MediaFile().GetAll(ctx, model.QueryOptions{
|
||||
Sort: "playCount",
|
||||
Order: "desc",
|
||||
Max: count,
|
||||
|
|
@ -43,7 +43,7 @@ func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid
|
|||
}
|
||||
|
||||
func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
|
||||
seed, err := p.ds.MediaFile(ctx).Get(id)
|
||||
seed, err := p.ds.MediaFile().Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -53,7 +53,7 @@ func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artis
|
|||
return nil, nil
|
||||
}
|
||||
// Ask for extra so we can drop the seed itself and still fill the count.
|
||||
candidates, err := p.ds.MediaFile(ctx).GetRandom(model.QueryOptions{
|
||||
candidates, err := p.ds.MediaFile().GetRandom(ctx, model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
persistence.SongGenres.ByID(genreIDs),
|
||||
squirrel.Eq{"missing": false},
|
||||
|
|
|
|||
|
|
@ -13,13 +13,13 @@ type SessionKeys struct {
|
|||
}
|
||||
|
||||
func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error {
|
||||
return sk.DataStore.UserProps(ctx).Put(userId, sk.KeyName, sessionKey)
|
||||
return sk.DataStore.UserProps().Put(ctx, userId, sk.KeyName, sessionKey)
|
||||
}
|
||||
|
||||
func (sk *SessionKeys) Get(ctx context.Context, userId string) (string, error) {
|
||||
return sk.DataStore.UserProps(ctx).Get(userId, sk.KeyName)
|
||||
return sk.DataStore.UserProps().Get(ctx, userId, sk.KeyName)
|
||||
}
|
||||
|
||||
func (sk *SessionKeys) Delete(ctx context.Context, userId string) error {
|
||||
return sk.DataStore.UserProps(ctx).Delete(userId, sk.KeyName)
|
||||
return sk.DataStore.UserProps().Delete(ctx, userId, sk.KeyName)
|
||||
}
|
||||
|
|
|
|||
194
core/archiver.go
194
core/archiver.go
|
|
@ -2,23 +2,32 @@ package core
|
|||
|
||||
import (
|
||||
"archive/zip"
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
const archiveCoverArtSize = 500
|
||||
|
||||
type Archiver interface {
|
||||
ZipAlbum(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||
ZipArtist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||
|
|
@ -26,18 +35,20 @@ type Archiver interface {
|
|||
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||
}
|
||||
|
||||
func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share) Archiver {
|
||||
return &archiver{ds: ds, ms: ms, shares: shares}
|
||||
func NewArchiver(ms stream.MediaStreamer, decider stream.TranscodeDecider, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver {
|
||||
return &archiver{ds: ds, ms: ms, decider: decider, shares: shares, artwork: artwork}
|
||||
}
|
||||
|
||||
type archiver struct {
|
||||
ds model.DataStore
|
||||
ms stream.MediaStreamer
|
||||
shares Share
|
||||
ds model.DataStore
|
||||
ms stream.MediaStreamer
|
||||
decider stream.TranscodeDecider
|
||||
shares Share
|
||||
artwork artwork.Artwork
|
||||
}
|
||||
|
||||
func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||
return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_id": id})
|
||||
return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_id": id}, model.ArtworkID{})
|
||||
}
|
||||
|
||||
func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||
|
|
@ -47,28 +58,30 @@ func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitr
|
|||
persistence.ParticipantIDFilter("media_file", id, model.RoleAlbumArtist),
|
||||
squirrel.Eq{"missing": false},
|
||||
}
|
||||
return a.zipAlbums(ctx, id, format, bitrate, out, filter)
|
||||
return a.zipAlbums(ctx, id, format, bitrate, out, filter, model.Artist{ID: id}.CoverArtID())
|
||||
}
|
||||
|
||||
func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer) error {
|
||||
mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: filters, Sort: "album"})
|
||||
// rootArt, when set, is added to the archive root.
|
||||
func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer, rootArt model.ArtworkID) error {
|
||||
mfs, err := a.ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: filters, Sort: "album"})
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading mediafiles from artist", "id", id, err)
|
||||
return err
|
||||
}
|
||||
|
||||
z := createZipWriter(out, format, bitrate)
|
||||
albums := slice.Group(mfs, func(mf model.MediaFile) string {
|
||||
return mf.AlbumID
|
||||
})
|
||||
albums := slice.GroupOrdered(mfs, func(mf model.MediaFile) string { return mf.AlbumID })
|
||||
folders := albumFolders(albums)
|
||||
for _, album := range albums {
|
||||
discs := slice.Group(album, func(mf model.MediaFile) int { return mf.DiscNumber })
|
||||
isMultiDisc := len(discs) > 1
|
||||
log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist,
|
||||
folder := folders[album[0].AlbumID]
|
||||
log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist, "folder", folder,
|
||||
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
|
||||
for _, mf := range album {
|
||||
file := a.albumFilename(mf, format, isMultiDisc)
|
||||
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||
req := a.resolveRequest(ctx, &mf, format, bitrate)
|
||||
file := a.albumFilename(mf, req.Format, isMultiDisc, folder)
|
||||
if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||
// Stop iterating: continuing would just rack up more
|
||||
// rejections from the limiter. Close finalises whatever
|
||||
// tracks were already written; the rejected one is not
|
||||
|
|
@ -78,7 +91,10 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr
|
|||
return addErr
|
||||
}
|
||||
}
|
||||
// After the tracks, so a slow artwork lookup doesn't delay the first bytes.
|
||||
a.addCoverArtToZip(ctx, z, album[0].AlbumCoverArtID(), folder)
|
||||
}
|
||||
a.addCoverArtToZip(ctx, z, rootArt, "")
|
||||
err = z.Close()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error closing zip file", "id", id, err)
|
||||
|
|
@ -96,7 +112,61 @@ func createZipWriter(out io.Writer, format string, bitrate int) *zip.Writer {
|
|||
return z
|
||||
}
|
||||
|
||||
func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc bool) string {
|
||||
// Tried in order; the first one whose values are distinct across the clashing albums wins.
|
||||
// One album may have an empty value: it keeps the plain name, which the others can't clash with.
|
||||
var albumDisambiguators = []func(model.MediaFile) string{
|
||||
func(mf model.MediaFile) string { return mf.Tags.First(model.TagAlbumVersion) },
|
||||
func(mf model.MediaFile) string {
|
||||
// Reissues share Year (often the original's) but not ReleaseYear.
|
||||
if y := cmp.Or(mf.ReleaseYear, mf.Year); y != 0 {
|
||||
return strconv.Itoa(y)
|
||||
}
|
||||
return ""
|
||||
},
|
||||
func(mf model.MediaFile) string { return mf.MbzAlbumType },
|
||||
func(mf model.MediaFile) string { return mf.Tags.First(model.TagRecordLabel) },
|
||||
func(mf model.MediaFile) string { return mf.CatalogNum },
|
||||
func(mf model.MediaFile) string { return mf.AlbumID[:min(6, len(mf.AlbumID))] },
|
||||
func(mf model.MediaFile) string { return mf.AlbumID },
|
||||
}
|
||||
|
||||
// albumFolders maps each album id to its zip folder. Albums whose names sanitize to the
|
||||
// same folder get a " [suffix]" from the first disambiguator that tells them all apart.
|
||||
func albumFolders(albums [][]model.MediaFile) map[string]string {
|
||||
byName := map[string][]model.MediaFile{}
|
||||
for _, album := range albums {
|
||||
name := str.SanitizeFilename(album[0].FullAlbumName())
|
||||
byName[name] = append(byName[name], album[0])
|
||||
}
|
||||
folders := make(map[string]string, len(albums))
|
||||
for name, group := range byName {
|
||||
if len(group) == 1 {
|
||||
folders[group[0].AlbumID] = name
|
||||
continue
|
||||
}
|
||||
fields:
|
||||
for _, field := range albumDisambiguators {
|
||||
ids := make(map[string]string, len(group)) // suffix -> album id
|
||||
for _, mf := range group {
|
||||
s := str.SanitizeFilename(field(mf))
|
||||
if _, dup := ids[s]; dup {
|
||||
continue fields
|
||||
}
|
||||
ids[s] = mf.AlbumID
|
||||
}
|
||||
for s, id := range ids {
|
||||
folders[id] = name
|
||||
if s != "" {
|
||||
folders[id] = fmt.Sprintf("%s [%s]", name, s)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return folders
|
||||
}
|
||||
|
||||
func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc bool, folder string) string {
|
||||
_, file := filepath.Split(mf.Path)
|
||||
if format != "raw" {
|
||||
file = strings.TrimSuffix(file, mf.Suffix) + format
|
||||
|
|
@ -104,7 +174,7 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc
|
|||
if isMultiDisc {
|
||||
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
|
||||
}
|
||||
return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file)
|
||||
return fmt.Sprintf("%s/%s", folder, file)
|
||||
}
|
||||
|
||||
// ZipShare takes an already-loaded share: Share.Load records a visit, so
|
||||
|
|
@ -114,27 +184,31 @@ func (a *archiver) ZipShare(ctx context.Context, s *model.Share, out io.Writer)
|
|||
return model.ErrNotAuthorized
|
||||
}
|
||||
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
|
||||
return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false)
|
||||
// The share is the authorization (as in the public image handler): an anonymous lookup would
|
||||
// hide a private playlist. Only the cover read is elevated.
|
||||
coverCtx := request.WithUser(ctx, model.User{IsAdmin: true})
|
||||
return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, coverCtx, s.CoverArtID(), false)
|
||||
}
|
||||
|
||||
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||
pls, err := a.ds.Playlist(ctx).GetWithTracks(id, true, false)
|
||||
pls, err := a.ds.Playlist().GetWithTracks(ctx, id, true, false)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err)
|
||||
return err
|
||||
}
|
||||
mfs := pls.MediaFiles()
|
||||
log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs))
|
||||
return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, true)
|
||||
return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, ctx, pls.CoverArtID(), true)
|
||||
}
|
||||
|
||||
func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, addM3U bool) error {
|
||||
func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, coverCtx context.Context, coverArt model.ArtworkID, addM3U bool) error {
|
||||
z := createZipWriter(out, format, bitrate)
|
||||
|
||||
zippedMfs := make(model.MediaFiles, len(mfs))
|
||||
for idx, mf := range mfs {
|
||||
file := a.playlistFilename(mf, format, idx)
|
||||
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||
req := a.resolveRequest(ctx, &mf, format, bitrate)
|
||||
file := a.playlistFilename(mf, req.Format, idx)
|
||||
if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||
// Abort the whole archive: continuing would silently emit
|
||||
// empty zip entries since the headers are already written.
|
||||
_ = z.Close()
|
||||
|
|
@ -143,6 +217,7 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st
|
|||
mf.Path = file
|
||||
zippedMfs[idx] = mf
|
||||
}
|
||||
a.addCoverArtToZip(coverCtx, z, coverArt, "")
|
||||
|
||||
// Add M3U file if requested
|
||||
if addM3U && len(zippedMfs) > 0 {
|
||||
|
|
@ -179,7 +254,14 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int)
|
|||
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext)
|
||||
}
|
||||
|
||||
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {
|
||||
func (a *archiver) resolveRequest(ctx context.Context, mf *model.MediaFile, format string, bitrate int) stream.Request {
|
||||
if format == "" || format == "raw" {
|
||||
return stream.Request{Format: "raw"}
|
||||
}
|
||||
return a.decider.ResolveRequest(ctx, mf, format, bitrate, 0)
|
||||
}
|
||||
|
||||
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, req stream.Request, filename string) error {
|
||||
path := mf.AbsolutePath()
|
||||
|
||||
// Open the source before writing the zip entry header so a rejection
|
||||
|
|
@ -187,13 +269,13 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
|||
// archive.
|
||||
var r io.ReadCloser
|
||||
var err error
|
||||
if format != "raw" && format != "" {
|
||||
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
|
||||
if req.Format != "raw" {
|
||||
r, err = a.ms.NewStream(ctx, &mf, req)
|
||||
} else {
|
||||
r, err = os.Open(path)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
|
||||
log.Error(ctx, "Error opening file for zipping", "file", path, "format", req.Format, err)
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
|
|
@ -220,3 +302,61 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addCoverArtToZip adds the cover as dir/folder.<ext>. Errors are logged, never returned.
|
||||
func (a *archiver) addCoverArtToZip(ctx context.Context, z *zip.Writer, artID model.ArtworkID, dir string) {
|
||||
if artID.ID == "" {
|
||||
return
|
||||
}
|
||||
// Buffered so a failed read leaves no empty entry.
|
||||
data, err := a.readCoverArt(ctx, artID)
|
||||
if errors.Is(err, artwork.ErrUnavailable) || errors.Is(err, model.ErrNotFound) {
|
||||
log.Debug(ctx, "No cover art to add to zip", "artID", artID)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error reading cover art for zipping", "artID", artID, err)
|
||||
return
|
||||
}
|
||||
ext := coverArtExtension(data)
|
||||
if ext == "" {
|
||||
log.Warn(ctx, "Unknown cover art image type, not adding it to zip", "artID", artID)
|
||||
return
|
||||
}
|
||||
w, err := z.CreateHeader(&zip.FileHeader{
|
||||
Name: path.Join(dir, "folder."+ext),
|
||||
Modified: time.Now(),
|
||||
Method: zip.Store,
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error creating cover art zip entry", "artID", artID, err)
|
||||
return
|
||||
}
|
||||
if _, err = w.Write(data); err != nil {
|
||||
log.Warn(ctx, "Error zipping cover art", "artID", artID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *archiver) readCoverArt(ctx context.Context, artID model.ArtworkID) ([]byte, error) {
|
||||
img, err := a.artwork.Get(ctx, artID, archiveCoverArtSize, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer img.Close()
|
||||
return io.ReadAll(img)
|
||||
}
|
||||
|
||||
// Resizing may re-encode the image, so the type comes from its bytes.
|
||||
func coverArtExtension(data []byte) string {
|
||||
switch http.DetectContentType(data) {
|
||||
case "image/jpeg":
|
||||
return "jpg"
|
||||
case "image/png":
|
||||
return "png"
|
||||
case "image/webp":
|
||||
return "webp"
|
||||
case "image/gif":
|
||||
return "gif"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,18 @@ import (
|
|||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
|
@ -21,15 +26,19 @@ var _ = Describe("Archiver", func() {
|
|||
var (
|
||||
arch core.Archiver
|
||||
ms *mockMediaStreamer
|
||||
dc *fakeDecider
|
||||
ds *mockDataStore
|
||||
sh *mockShare
|
||||
ca *mockCoverArt
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ms = &mockMediaStreamer{}
|
||||
dc = &fakeDecider{}
|
||||
sh = &mockShare{}
|
||||
ds = &mockDataStore{}
|
||||
arch = core.NewArchiver(ms, ds, sh)
|
||||
ca = &mockCoverArt{images: map[string][]byte{}}
|
||||
arch = core.NewArchiver(ms, dc, ds, sh, ca)
|
||||
})
|
||||
|
||||
Context("ZipAlbum", func() {
|
||||
|
|
@ -45,7 +54,7 @@ var _ = Describe("Archiver", func() {
|
|||
Sort: "album",
|
||||
}}).Return(mfs, nil)
|
||||
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ds.On("MediaFile").Return(mfRepo)
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
|
|
@ -59,6 +68,23 @@ var _ = Describe("Archiver", func() {
|
|||
Expect(zr.File[0].Name).To(Equal("Album_Promo/01 - track1.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() {
|
||||
|
|
@ -77,7 +103,7 @@ var _ = Describe("Archiver", func() {
|
|||
Sort: "album",
|
||||
}}).Return(mfs, nil)
|
||||
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ds.On("MediaFile").Return(mfRepo)
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
|
|
@ -91,6 +117,140 @@ var _ = Describe("Archiver", func() {
|
|||
Expect(zr.File[0].Name).To(Equal("Album 1/01 - track1.mp3"))
|
||||
Expect(zr.File[1].Name).To(Equal("Album 1/02 - track2.mp3"))
|
||||
})
|
||||
|
||||
When("albums that share a name", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
|
||||
// zipArtistEntries zips the given tracks as artist "1" and returns the entry names in zip order.
|
||||
zipArtistEntries := func(mfs model.MediaFiles) []string {
|
||||
mfRepo := &mockMediaFileRepository{}
|
||||
mfRepo.On("GetAll", mock.Anything).Return(mfs, nil)
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||
Expect(err).To(BeNil())
|
||||
names := make([]string, len(zr.File))
|
||||
for i, f := range zr.File {
|
||||
names[i] = f.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
It("keeps the albums in query order", func() {
|
||||
names := zipArtistEntries(model.MediaFiles{
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Album C"},
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album A"},
|
||||
{Path: "a/02.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album A"},
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Album B"},
|
||||
})
|
||||
Expect(names).To(Equal([]string{"Album C/01.mp3", "Album A/01.mp3", "Album A/02.mp3", "Album B/01.mp3"}))
|
||||
})
|
||||
|
||||
It("suffixes the year when it tells the albums apart", func() {
|
||||
names := zipArtistEntries(model.MediaFiles{
|
||||
{Path: "a/01 - Intro.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
|
||||
{Path: "b/01 - Intro.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005},
|
||||
})
|
||||
Expect(names).To(Equal([]string{"Greatest Hits [2001]/01 - Intro.mp3", "Greatest Hits [2005]/01 - Intro.mp3"}))
|
||||
})
|
||||
|
||||
It("prefers the release year, so reissues of the same original are told apart", func() {
|
||||
names := zipArtistEntries(model.MediaFiles{
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 1996, ReleaseYear: 2001},
|
||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 1996, ReleaseYear: 2011},
|
||||
{Path: "c/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Greatest Hits", Year: 1996},
|
||||
})
|
||||
Expect(names).To(Equal([]string{"Greatest Hits [2001]/01.mp3", "Greatest Hits [2011]/01.mp3", "Greatest Hits [1996]/01.mp3"}))
|
||||
})
|
||||
|
||||
It("names the folder after the full album name", func() {
|
||||
names := zipArtistEntries(model.MediaFiles{
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001,
|
||||
Tags: model.Tags{model.TagAlbumVersion: {"Original"}}},
|
||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
|
||||
Tags: model.Tags{model.TagAlbumVersion: {"CD/Digital"}}},
|
||||
})
|
||||
Expect(names).To(Equal([]string{"Greatest Hits (Original)/01.mp3", "Greatest Hits (CD_Digital)/01.mp3"}))
|
||||
})
|
||||
|
||||
It("prefers the album version over the year when it is not part of the name", func() {
|
||||
conf.Server.Subsonic.AppendAlbumVersion = false
|
||||
names := zipArtistEntries(model.MediaFiles{
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001,
|
||||
Tags: model.Tags{model.TagAlbumVersion: {"Original"}}},
|
||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
|
||||
Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}},
|
||||
})
|
||||
Expect(names).To(Equal([]string{"Greatest Hits [Original]/01.mp3", "Greatest Hits [Deluxe Edition]/01.mp3"}))
|
||||
})
|
||||
|
||||
It("leaves the one album without the field unsuffixed", func() {
|
||||
conf.Server.Subsonic.AppendAlbumVersion = false
|
||||
names := zipArtistEntries(model.MediaFiles{
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
|
||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
|
||||
Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}},
|
||||
})
|
||||
Expect(names).To(Equal([]string{"Greatest Hits/01.mp3", "Greatest Hits [Deluxe Edition]/01.mp3"}))
|
||||
})
|
||||
|
||||
It("skips a field that is empty on more than one album", func() {
|
||||
conf.Server.Subsonic.AppendAlbumVersion = false
|
||||
names := zipArtistEntries(model.MediaFiles{
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
|
||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005},
|
||||
{Path: "c/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Greatest Hits", Year: 2010,
|
||||
Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}},
|
||||
})
|
||||
Expect(names).To(Equal([]string{"Greatest Hits [2001]/01.mp3", "Greatest Hits [2005]/01.mp3", "Greatest Hits [2010]/01.mp3"}))
|
||||
})
|
||||
|
||||
It("skips a field that is the same on every album", func() {
|
||||
names := zipArtistEntries(model.MediaFiles{
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Live", Year: 2001, MbzAlbumType: "album", CatalogNum: "CAT-1"},
|
||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Live", Year: 2001, MbzAlbumType: "album", CatalogNum: "CAT-2"},
|
||||
})
|
||||
Expect(names).To(Equal([]string{"Live [CAT-1]/01.mp3", "Live [CAT-2]/01.mp3"}))
|
||||
})
|
||||
|
||||
It("falls back to the album id when nothing differs", func() {
|
||||
names := zipArtistEntries(model.MediaFiles{
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "0123456789abcdef", Album: "Greatest Hits", Year: 2001},
|
||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "fedcba9876543210", Album: "Greatest Hits", Year: 2001},
|
||||
})
|
||||
Expect(names).To(Equal([]string{"Greatest Hits [012345]/01.mp3", "Greatest Hits [fedcba]/01.mp3"}))
|
||||
})
|
||||
|
||||
It("treats names that sanitize to the same folder as a clash", func() {
|
||||
names := zipArtistEntries(model.MediaFiles{
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "A/B", Year: 2001},
|
||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: `A\B`, Year: 2005},
|
||||
})
|
||||
Expect(names).To(Equal([]string{"A_B [2001]/01.mp3", "A_B [2005]/01.mp3"}))
|
||||
})
|
||||
|
||||
It("sanitizes the suffix", func() {
|
||||
conf.Server.Subsonic.AppendAlbumVersion = false
|
||||
names := zipArtistEntries(model.MediaFiles{
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Hits", Tags: model.Tags{model.TagAlbumVersion: {"Vinyl"}}},
|
||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Hits", Tags: model.Tags{model.TagAlbumVersion: {"CD/Digital"}}},
|
||||
})
|
||||
Expect(names).To(Equal([]string{"Hits [Vinyl]/01.mp3", "Hits [CD_Digital]/01.mp3"}))
|
||||
})
|
||||
|
||||
It("leaves the folder name alone when only one album has it", func() {
|
||||
names := zipArtistEntries(model.MediaFiles{
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
|
||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Other", Year: 2005},
|
||||
})
|
||||
Expect(names).To(Equal([]string{"Greatest Hits/01.mp3", "Other/01.mp3"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("when the transcode limiter rejects a file", func() {
|
||||
|
|
@ -105,7 +265,7 @@ var _ = Describe("Archiver", func() {
|
|||
Filters: squirrel.Eq{"album_id": "1"},
|
||||
Sort: "album",
|
||||
}}).Return(mfs, nil)
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ds.On("MediaFile").Return(mfRepo)
|
||||
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).
|
||||
Return(nil, stream.ErrTooManyTranscodes).Once()
|
||||
|
|
@ -155,6 +315,30 @@ var _ = Describe("Archiver", 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() {
|
||||
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"}},
|
||||
|
|
@ -169,7 +353,7 @@ var _ = Describe("Archiver", func() {
|
|||
|
||||
plRepo := &mockPlaylistRepository{}
|
||||
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
|
||||
ds.On("Playlist", mock.Anything).Return(plRepo)
|
||||
ds.On("Playlist").Return(plRepo)
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
|
|
@ -196,24 +380,195 @@ var _ = Describe("Archiver", func() {
|
|||
Expect(string(m3uContent)).To(Equal(expectedM3U))
|
||||
})
|
||||
})
|
||||
Context("cover art", func() {
|
||||
var (
|
||||
jpegData = []byte("\xff\xd8\xff\xe0 fake jpeg")
|
||||
pngData = []byte("\x89PNG\x0d\x0a\x1a\x0a fake png")
|
||||
)
|
||||
|
||||
mockAlbumTracks := func(filter squirrel.Sqlizer, mfs model.MediaFiles) {
|
||||
mfRepo := &mockMediaFileRepository{}
|
||||
mfRepo.On("GetAll", []model.QueryOptions{{Filters: filter, Sort: "album"}}).Return(mfs, nil)
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
|
||||
}
|
||||
|
||||
It("adds the album cover to the album folder", func() {
|
||||
ca.images["al-1"] = jpegData
|
||||
mockAlbumTracks(squirrel.Eq{"album_id": "1"}, model.MediaFiles{
|
||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1},
|
||||
})
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
Expect(arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
||||
|
||||
files := readZip(out)
|
||||
Expect(files).To(HaveLen(2))
|
||||
Expect(files).To(HaveKeyWithValue("Album_Promo/folder.jpg", jpegData))
|
||||
Expect(ca.requests).To(ConsistOf(coverRequest{id: "al-1", size: 500, square: false}))
|
||||
})
|
||||
|
||||
It("adds the artist image to the root and each album cover to its folder", func() {
|
||||
ca.images["ar-1"] = pngData
|
||||
ca.images["al-1"] = jpegData
|
||||
ca.images["al-2"] = jpegData
|
||||
mockAlbumTracks(squirrel.And{
|
||||
persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist),
|
||||
squirrel.Eq{"missing": false},
|
||||
}, model.MediaFiles{
|
||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1},
|
||||
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "2", Album: "Album 2", DiscNumber: 1},
|
||||
})
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
||||
|
||||
files := readZip(out)
|
||||
Expect(files).To(HaveLen(5))
|
||||
Expect(files).To(HaveKeyWithValue("folder.png", pngData))
|
||||
Expect(files).To(HaveKeyWithValue("Album 1/folder.jpg", jpegData))
|
||||
Expect(files).To(HaveKeyWithValue("Album 2/folder.jpg", jpegData))
|
||||
})
|
||||
|
||||
It("puts each same-named album's cover in that album's own folder", func() {
|
||||
ca.images["al-1"] = jpegData
|
||||
ca.images["al-2"] = pngData
|
||||
mockAlbumTracks(squirrel.And{
|
||||
persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist),
|
||||
squirrel.Eq{"missing": false},
|
||||
}, model.MediaFiles{
|
||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001, DiscNumber: 1},
|
||||
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005, DiscNumber: 1},
|
||||
})
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
||||
|
||||
files := readZip(out)
|
||||
Expect(files).To(HaveKeyWithValue("Greatest Hits [2001]/folder.jpg", jpegData))
|
||||
Expect(files).To(HaveKeyWithValue("Greatest Hits [2005]/folder.png", pngData))
|
||||
})
|
||||
|
||||
It("adds the playlist cover to the root", func() {
|
||||
ca.images["pl-1"] = jpegData
|
||||
plRepo := &mockPlaylistRepository{}
|
||||
plRepo.On("GetWithTracks", "1", true, false).Return(&model.Playlist{
|
||||
ID: "1",
|
||||
Name: "Test Playlist",
|
||||
Tracks: []model.PlaylistTrack{
|
||||
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Artist: "Artist 1", Title: "track1"}},
|
||||
},
|
||||
}, nil)
|
||||
ds.On("Playlist", mock.Anything).Return(plRepo)
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
Expect(arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
||||
|
||||
files := readZip(out)
|
||||
Expect(files).To(HaveLen(3))
|
||||
Expect(files).To(HaveKeyWithValue("folder.jpg", jpegData))
|
||||
Expect(files).To(HaveKey("Test Playlist.m3u"))
|
||||
})
|
||||
|
||||
It("adds the shared item's cover to the root, even for a private playlist", func() {
|
||||
ca.images["pl-10"] = jpegData
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
|
||||
share := &model.Share{
|
||||
ID: "1",
|
||||
Downloadable: true,
|
||||
Format: "mp3",
|
||||
MaxBitRate: 128,
|
||||
ResourceType: "playlist",
|
||||
ResourceIDs: "10",
|
||||
Tracks: model.MediaFiles{
|
||||
{ID: "1", Path: "test_data/01 - track1.mp3", Suffix: "mp3", Artist: "Artist 1", Title: "track1"},
|
||||
},
|
||||
}
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
Expect(arch.ZipShare(context.Background(), share, out)).To(Succeed())
|
||||
|
||||
files := readZip(out)
|
||||
Expect(files).To(HaveLen(2))
|
||||
Expect(files).To(HaveKeyWithValue("folder.jpg", jpegData))
|
||||
Expect(ca.requests).To(ConsistOf(coverRequest{id: "pl-10", size: 500, square: false, admin: true}))
|
||||
})
|
||||
|
||||
It("still builds the archive when the cover cannot be read", func() {
|
||||
ca.err = errors.New("boom")
|
||||
mockAlbumTracks(squirrel.Eq{"album_id": "1"}, model.MediaFiles{
|
||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
|
||||
})
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
Expect(arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
||||
|
||||
files := readZip(out)
|
||||
Expect(files).To(HaveLen(1))
|
||||
Expect(files).To(HaveKey("Album/01 - track1.mp3"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func readZip(out *bytes.Buffer) map[string][]byte {
|
||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
files := make(map[string][]byte, len(zr.File))
|
||||
for _, f := range zr.File {
|
||||
r, err := f.Open()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
data, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_ = r.Close()
|
||||
files[f.Name] = data
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
type coverRequest struct {
|
||||
id string
|
||||
size int
|
||||
square bool
|
||||
admin bool
|
||||
}
|
||||
|
||||
type mockCoverArt struct {
|
||||
artwork.Artwork
|
||||
images map[string][]byte
|
||||
err error
|
||||
requests []coverRequest
|
||||
}
|
||||
|
||||
func (m *mockCoverArt) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*artwork.Image, error) {
|
||||
user, _ := request.UserFrom(ctx)
|
||||
m.requests = append(m.requests, coverRequest{id: artID.String(), size: size, square: square, admin: user.IsAdmin})
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
data, ok := m.images[artID.String()]
|
||||
if !ok {
|
||||
return nil, artwork.ErrUnavailable
|
||||
}
|
||||
return &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader(data))}, nil
|
||||
}
|
||||
|
||||
type mockDataStore struct {
|
||||
mock.Mock
|
||||
model.DataStore
|
||||
}
|
||||
|
||||
func (m *mockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository {
|
||||
args := m.Called(ctx)
|
||||
func (m *mockDataStore) MediaFile() model.MediaFileRepository {
|
||||
args := m.Called()
|
||||
return args.Get(0).(model.MediaFileRepository)
|
||||
}
|
||||
|
||||
func (m *mockDataStore) Playlist(ctx context.Context) model.PlaylistRepository {
|
||||
args := m.Called(ctx)
|
||||
func (m *mockDataStore) Playlist() model.PlaylistRepository {
|
||||
args := m.Called()
|
||||
return args.Get(0).(model.PlaylistRepository)
|
||||
}
|
||||
|
||||
func (m *mockDataStore) Library(context.Context) model.LibraryRepository {
|
||||
func (m *mockDataStore) Library() model.LibraryRepository {
|
||||
return &mockLibraryRepository{}
|
||||
}
|
||||
|
||||
|
|
@ -222,7 +577,7 @@ type mockLibraryRepository struct {
|
|||
model.LibraryRepository
|
||||
}
|
||||
|
||||
func (m *mockLibraryRepository) GetPath(id int) (string, error) {
|
||||
func (m *mockLibraryRepository) GetPath(_ context.Context, id int) (string, error) {
|
||||
return "/music", nil
|
||||
}
|
||||
|
||||
|
|
@ -231,7 +586,7 @@ type mockMediaFileRepository struct {
|
|||
model.MediaFileRepository
|
||||
}
|
||||
|
||||
func (m *mockMediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
func (m *mockMediaFileRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
args := m.Called(options)
|
||||
return args.Get(0).(model.MediaFiles), args.Error(1)
|
||||
}
|
||||
|
|
@ -241,7 +596,7 @@ type mockPlaylistRepository struct {
|
|||
model.PlaylistRepository
|
||||
}
|
||||
|
||||
func (m *mockPlaylistRepository) GetWithTracks(id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) {
|
||||
func (m *mockPlaylistRepository) GetWithTracks(_ context.Context, id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) {
|
||||
args := m.Called(id, refreshSmartPlaylists, includeMissing)
|
||||
return args.Get(0).(*model.Playlist), args.Error(1)
|
||||
}
|
||||
|
|
@ -259,6 +614,19 @@ func (m *mockMediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile,
|
|||
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 {
|
||||
mock.Mock
|
||||
core.Share
|
||||
|
|
|
|||
|
|
@ -59,21 +59,21 @@ func entityExists(ctx context.Context, ds model.DataStore, artID model.ArtworkID
|
|||
var err error
|
||||
switch artID.Kind {
|
||||
case model.KindArtistArtwork:
|
||||
found, err = ds.Artist(ctx).Exists(artID.ID)
|
||||
found, err = ds.Artist().Exists(ctx, artID.ID)
|
||||
case model.KindAlbumArtwork:
|
||||
found, err = ds.Album(ctx).Exists(artID.ID)
|
||||
found, err = ds.Album().Exists(ctx, artID.ID)
|
||||
case model.KindMediaFileArtwork:
|
||||
found, err = ds.MediaFile(ctx).Exists(artID.ID)
|
||||
found, err = ds.MediaFile().Exists(ctx, artID.ID)
|
||||
case model.KindPlaylistArtwork:
|
||||
found, err = ds.Playlist(ctx).Exists(artID.ID)
|
||||
found, err = ds.Playlist().Exists(ctx, artID.ID)
|
||||
case model.KindRadioArtwork:
|
||||
found, err = ds.Radio(ctx).Exists(artID.ID)
|
||||
found, err = ds.Radio().Exists(ctx, artID.ID)
|
||||
case model.KindDiscArtwork:
|
||||
albumID, _, perr := model.ParseDiscArtworkID(artID.ID)
|
||||
if perr != nil {
|
||||
return false
|
||||
}
|
||||
found, err = ds.Album(ctx).Exists(albumID)
|
||||
found, err = ds.Album().Exists(ctx, albumID)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
|
@ -119,7 +119,7 @@ func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, squa
|
|||
}
|
||||
|
||||
func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
||||
ia, err := s.ds.Artwork(ctx).GetItemArtwork(artID.Kind, artID.ID, model.ImageTypePrimary)
|
||||
ia, err := s.ds.Artwork().GetItemArtwork(ctx, artID.Kind, artID.ID, model.ImageTypePrimary)
|
||||
switch {
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
return s.provisional(ctx, artID, size, square)
|
||||
|
|
@ -168,7 +168,12 @@ func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *mode
|
|||
if !entityExists(ctx, s.ds, artID) {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
art, err := s.ds.Artwork(ctx).GetImage(ia.Hash)
|
||||
// Checked here, not in openOriginal: a resize-cache hit never opens the source.
|
||||
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 errors.Is(err, model.ErrNotFound) {
|
||||
return s.dangling(ctx, artID)
|
||||
|
|
@ -259,13 +264,13 @@ func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, siz
|
|||
// The setting is not in the config fingerprint, so honor it at serve time: a direct mf- URL
|
||||
// must fall back to disc/album instead of serving stale persisted embedded art.
|
||||
if !conf.Server.EnableMediaFileCoverArt {
|
||||
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
|
||||
mf, err := s.ds.MediaFile().Get(ctx, artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
|
||||
}
|
||||
ia, err := s.ds.Artwork(ctx).GetItemArtwork(model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary)
|
||||
ia, err := s.ds.Artwork().GetItemArtwork(ctx, model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary)
|
||||
switch {
|
||||
case err == nil && ia.Hash != "":
|
||||
return s.serveHash(ctx, artID, ia, size, square)
|
||||
|
|
@ -278,7 +283,7 @@ func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, siz
|
|||
}
|
||||
noRow := errors.Is(err, model.ErrNotFound)
|
||||
|
||||
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
|
||||
mf, err := s.ds.MediaFile().Get(ctx, artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -337,7 +342,7 @@ func (s *service) dangling(ctx context.Context, artID model.ArtworkID) (*Image,
|
|||
}
|
||||
|
||||
func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) {
|
||||
err := s.ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
||||
err := s.ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{
|
||||
ItemKind: artID.Kind.Prefix(),
|
||||
ItemID: artID.ID,
|
||||
ImageType: model.ImageTypePrimary,
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/storage"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/httpclient"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"go.uber.org/goleak"
|
||||
|
|
@ -37,6 +41,14 @@ func TestArtwork(t *testing.T) {
|
|||
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.
|
||||
type osDirFS struct{ fs.FS }
|
||||
|
||||
|
|
@ -97,15 +109,15 @@ type fakeFolderRepo struct {
|
|||
otherAudioErr error
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) {
|
||||
func (f *fakeFolderRepo) GetAll(context.Context, ...model.QueryOptions) ([]model.Folder, error) {
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
|
||||
func (f *fakeFolderRepo) HasAudioOutsideFolders(context.Context, model.Folder, []string) (bool, error) {
|
||||
return f.hasOtherAudio, f.otherAudioErr
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) Get(string) (*model.Folder, error) {
|
||||
func (f *fakeFolderRepo) Get(context.Context, string) (*model.Folder, error) {
|
||||
f.getCallCount++
|
||||
if f.getErr != nil {
|
||||
return nil, f.getErr
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ var _ = Describe("Artwork", func() {
|
|||
hash, err := hashImage(bytes.NewReader(imgBytes))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(store.Write(hash, "image/jpeg", bytes.NewReader(imgBytes))).To(Succeed())
|
||||
Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed())
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed())
|
||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed())
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed())
|
||||
seedEntity(kind, id)
|
||||
return hash
|
||||
}
|
||||
|
|
@ -56,9 +56,9 @@ var _ = Describe("Artwork", func() {
|
|||
GinkgoHelper()
|
||||
switch kind {
|
||||
case "al":
|
||||
Expect(albumRepo.Put(&model.Album{ID: id, Name: "Album"})).To(Succeed())
|
||||
Expect(albumRepo.Put(ctx, &model.Album{ID: id, Name: "Album"})).To(Succeed())
|
||||
case "mf":
|
||||
Expect(mfRepo.Put(&model.MediaFile{ID: id})).To(Succeed())
|
||||
Expect(mfRepo.Put(ctx, &model.MediaFile{ID: id})).To(Succeed())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -147,9 +147,9 @@ var _ = Describe("Artwork", func() {
|
|||
imgPath := filepath.Join(dir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||
mtime := fileMtime(imgPath)
|
||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed())
|
||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed())
|
||||
seedEntity("al", "al2")
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al2", Hash: "aaaaaaaaaaaaaaaa",
|
||||
Source: "folder", SourcePath: imgPath, RefMtime: mtime,
|
||||
})).To(Succeed())
|
||||
|
|
@ -159,13 +159,60 @@ var _ = Describe("Artwork", func() {
|
|||
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() {
|
||||
dir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(dir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed())
|
||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed())
|
||||
seedEntity("al", "al3")
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al3", Hash: "bbbbbbbbbbbbbbbb",
|
||||
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
||||
})).To(Succeed())
|
||||
|
|
@ -173,7 +220,7 @@ var _ = Describe("Artwork", func() {
|
|||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al3"), 0, false)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(queueRepo.Data[primaryKey("al", "al3")].Priority).To(Equal(model.ArtworkPriorityScan))
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb"))
|
||||
})
|
||||
|
|
@ -182,9 +229,9 @@ var _ = Describe("Artwork", func() {
|
|||
dir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(dir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed())
|
||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed())
|
||||
seedEntity("al", "al3b")
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al3b", Hash: "cccccccccccccccc",
|
||||
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
||||
})).To(Succeed())
|
||||
|
|
@ -205,7 +252,7 @@ var _ = Describe("Artwork", func() {
|
|||
})
|
||||
|
||||
It("never re-enqueues an absent state on view, however old", func() {
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now().Add(-365 * 24 * time.Hour),
|
||||
})).To(Succeed())
|
||||
|
||||
|
|
@ -225,7 +272,7 @@ var _ = Describe("Artwork", func() {
|
|||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
|
||||
Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
||||
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
|
|
@ -236,7 +283,7 @@ var _ = Describe("Artwork", func() {
|
|||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al6"), 0, false)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(queueRepo.Data[primaryKey("al", "al6")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
||||
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
|
@ -263,7 +310,7 @@ var _ = Describe("Artwork", func() {
|
|||
|
||||
It("delegates to the album when the track's state is absent", func() {
|
||||
seedFoundStore("al", "albm", coverBytes)
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed())
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed())
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "mf2", AlbumID: "albm"}})
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf2"), 0, false)
|
||||
|
|
@ -296,7 +343,7 @@ var _ = Describe("Artwork", func() {
|
|||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
||||
Expect(queueRepo.Data[primaryKey("mf", "mf4")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary)
|
||||
_, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
|
|
@ -447,7 +494,7 @@ var _ = Describe("Artwork", func() {
|
|||
})
|
||||
|
||||
It("falls back to the artist placeholder for an absent artist", func() {
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed())
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed())
|
||||
|
||||
img, err := svc.GetOrPlaceholder(ctx, "ar-arph", 300, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -488,7 +535,7 @@ var _ = Describe("EntityExists", func() {
|
|||
artistRepo := tests.CreateMockArtistRepo()
|
||||
artistRepo.SetData(model.Artists{{ID: "ar1"}})
|
||||
radioRepo := tests.CreateMockedRadioRepo()
|
||||
Expect(radioRepo.Put(&model.Radio{ID: "ra1", Name: "R"})).To(Succeed())
|
||||
Expect(radioRepo.Put(ctx, &model.Radio{ID: "ra1", Name: "R"})).To(Succeed())
|
||||
ds = &tests.MockDataStore{MockedAlbum: albumRepo, MockedArtist: artistRepo, MockedRadio: radioRepo}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
|
|||
return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err)
|
||||
}
|
||||
|
||||
al, err := ds.Album(ctx).Get(albumID)
|
||||
al, err := ds.Album().Get(ctx, albumID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -61,7 +61,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
|
|||
}
|
||||
|
||||
// Query mediafiles for this album + disc to find folder associations and first track
|
||||
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{
|
||||
Sort: "track_number",
|
||||
Order: "ASC",
|
||||
Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber},
|
||||
|
|
@ -88,7 +88,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
|
|||
// Resolve folder IDs to library-relative paths
|
||||
discFoldersRel := make(map[string]bool)
|
||||
if len(folderIDs) > 0 {
|
||||
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{
|
||||
folders, err := ds.Folder().GetAll(ctx, model.QueryOptions{
|
||||
Filters: squirrel.Eq{"folder.id": folderIDs},
|
||||
})
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -44,20 +44,20 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
|
||||
itemFound := func(kind model.Kind, id string) func() bool {
|
||||
return func() bool {
|
||||
ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, kind, id, model.ImageTypePrimary)
|
||||
return err == nil && ia.Hash != ""
|
||||
}
|
||||
}
|
||||
itemAbsent := func(kind model.Kind, id string) func() bool {
|
||||
return func() bool {
|
||||
ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, kind, id, model.ImageTypePrimary)
|
||||
return err == nil && ia.Hash == ""
|
||||
}
|
||||
}
|
||||
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
|
||||
bump := func(kind, id string) {
|
||||
GinkgoHelper()
|
||||
Expect(ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
||||
Expect(ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{
|
||||
ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary,
|
||||
Priority: model.ArtworkPriorityBump,
|
||||
})).To(Succeed())
|
||||
|
|
@ -141,7 +141,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
bump("al", "al1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
|
||||
|
|
@ -158,7 +158,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
bump("ar", "ar1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindArtistArtwork, "ar1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("upload"))
|
||||
|
||||
|
|
@ -175,14 +175,14 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
bump("pl", "pl1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindPlaylistArtwork, "pl1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("generated"))
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("pl-pl1"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Hash).To(Equal(ia.Hash))
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(art.Mime).To(Equal("image/png"))
|
||||
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
||||
|
|
@ -194,7 +194,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
bump("ra", "ra1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("upload"))
|
||||
|
||||
|
|
@ -216,12 +216,12 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
provisionalBytes := readAll(provisional)
|
||||
Expect(len(provisionalBytes)).To(BeNumerically(">", 0))
|
||||
|
||||
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||
_, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound), "provisional serving must not write a state row")
|
||||
|
||||
// The provisional read enqueued a Bump; drain it.
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindMediaFileArtwork, "mf1"))
|
||||
ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("embedded"))
|
||||
Expect(ia.Hash).To(Equal(provisional.Hash))
|
||||
|
|
@ -237,9 +237,9 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
bump("al", "al1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(art.Mime).To(Equal("image/jpeg"))
|
||||
Expect(art.Width).To(BeNumerically(">", 0))
|
||||
|
|
@ -259,9 +259,9 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
bump("ra", "ra1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(art.Mime).To(Equal("image/gif"))
|
||||
Expect(art.Width).To(BeNumerically("==", 4))
|
||||
|
|
@ -279,9 +279,9 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
return itemFound(model.KindAlbumArtwork, "al1")() && itemFound(model.KindAlbumArtwork, "al2")()
|
||||
})
|
||||
|
||||
ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
ia1, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
|
||||
ia2, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia1.Hash).To(Equal(ia2.Hash), "identical bytes must share one content hash")
|
||||
Expect(readAll(mustGet(svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false)))).To(Equal(coverBytes))
|
||||
|
|
@ -293,7 +293,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
bump("ra", "ra1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
staleHash := ia.Hash
|
||||
|
||||
|
|
@ -308,7 +308,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
|
||||
// That failed read enqueued a re-resolution.
|
||||
runWorkerUntil(ctx, worker, func() bool {
|
||||
cur, gerr := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
cur, gerr := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
return gerr == nil && cur.Hash != "" && cur.Hash != staleHash
|
||||
})
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false)
|
||||
|
|
|
|||
|
|
@ -90,6 +90,28 @@ 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() {
|
||||
// Artist/
|
||||
// ├── artist.jpg ← wins (artist.* before album/artist.*)
|
||||
|
|
@ -179,7 +201,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
|||
uploaded := ar.ID + "_upload.jpg"
|
||||
writeUploadedImage(consts.EntityArtist, uploaded, pngBytes("artist-uploaded"))
|
||||
ar.UploadedImage = uploaded
|
||||
Expect(rds.Artist(rctx).Put(&ar)).To(Succeed())
|
||||
Expect(rds.Artist().Put(rctx, &ar)).To(Succeed())
|
||||
|
||||
ia := acquire(model.KindArtistArtwork, ar.ID)
|
||||
Expect(ia.Source).To(Equal("upload"))
|
||||
|
|
@ -257,7 +279,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
|||
|
||||
func soleArtist() model.Artist {
|
||||
GinkgoHelper()
|
||||
artists, err := rds.Artist(rctx).GetAll(model.QueryOptions{
|
||||
artists, err := rds.Artist().GetAll(rctx, model.QueryOptions{
|
||||
Filters: squirrel.Eq{"artist.name": "Artist"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
|
|||
|
|
@ -56,7 +56,8 @@ func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bo
|
|||
runCtx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- worker.Run(runCtx) }()
|
||||
Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue())
|
||||
// Long enough for one retry (3-7s backoff, 5s poll tick).
|
||||
Eventually(until, 15*time.Second, 10*time.Millisecond).Should(BeTrue())
|
||||
cancel()
|
||||
Eventually(done, 2*time.Second).Should(Receive(BeNil()))
|
||||
}
|
||||
|
|
@ -66,13 +67,17 @@ type fakeFolderRepo struct {
|
|||
result []model.Folder
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { return f.result, nil }
|
||||
func (f *fakeFolderRepo) GetAll(context.Context, ...model.QueryOptions) ([]model.Folder, error) {
|
||||
return f.result, nil
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
|
||||
func (f *fakeFolderRepo) HasAudioOutsideFolders(context.Context, model.Folder, []string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) Get(string) (*model.Folder, error) { return nil, model.ErrNotFound }
|
||||
func (f *fakeFolderRepo) Get(context.Context, string) (*model.Folder, error) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func writeUpload(entityType, name, srcFixture string) string {
|
||||
GinkgoHelper()
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ var _ = Describe("MediaFile artwork resolution", func() {
|
|||
|
||||
func mediafileOn(relPath string) model.MediaFile {
|
||||
GinkgoHelper()
|
||||
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{
|
||||
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{
|
||||
Filters: squirrel.Like{"media_file.path": relPath},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
|
|||
|
|
@ -142,13 +142,13 @@ var _ = Describe("Playlist artwork resolution", func() {
|
|||
})
|
||||
scan()
|
||||
|
||||
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{})
|
||||
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfs).To(HaveLen(2))
|
||||
|
||||
pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"}
|
||||
pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID})
|
||||
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
||||
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
|
||||
|
||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||
Expect(ia.Source).To(Equal("generated"))
|
||||
|
|
@ -180,14 +180,14 @@ var _ = Describe("Playlist artwork resolution", func() {
|
|||
setLayout(layout)
|
||||
scan()
|
||||
|
||||
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{})
|
||||
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfs).To(HaveLen(4))
|
||||
ids := slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID })
|
||||
|
||||
pl := model.Playlist{ID: "pl-8", Name: "Four", OwnerID: "admin-1"}
|
||||
pl.AddMediaFilesByID(ids)
|
||||
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
||||
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
|
||||
|
||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||
Expect(ia.Source).To(Equal("generated"))
|
||||
|
|
@ -208,6 +208,6 @@ func putPlaylist(pl model.Playlist) model.Playlist {
|
|||
if pl.OwnerID == "" {
|
||||
pl.OwnerID = "admin-1"
|
||||
}
|
||||
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
||||
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
|
||||
return pl
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ var _ = Describe("Radio artwork resolution", func() {
|
|||
It("returns the uploaded image bytes", func() {
|
||||
writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", pngBytes("radio-logo"))
|
||||
rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"}
|
||||
Expect(rds.Radio(rctx).Put(&rd)).To(Succeed())
|
||||
Expect(rds.Radio().Put(rctx, &rd)).To(Succeed())
|
||||
|
||||
ia := acquire(model.KindRadioArtwork, rd.ID)
|
||||
Expect(ia.Source).To(Equal("upload"))
|
||||
|
|
@ -35,7 +35,7 @@ var _ = Describe("Radio artwork resolution", func() {
|
|||
// (no files on disk — the resolver has no sources to fall back to)
|
||||
It("settles absent", func() {
|
||||
rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"}
|
||||
Expect(rds.Radio(rctx).Put(&rd)).To(Succeed())
|
||||
Expect(rds.Radio().Put(rctx, &rd)).To(Succeed())
|
||||
|
||||
ia := acquire(model.KindRadioArtwork, rd.ID)
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
|
|
|
|||
|
|
@ -99,11 +99,11 @@ func setupResolutionHarness() {
|
|||
rds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||
|
||||
adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"}
|
||||
Expect(rds.User(rctx).Put(&adminUser)).To(Succeed())
|
||||
Expect(rds.User().Put(rctx, &adminUser)).To(Succeed())
|
||||
|
||||
lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath}
|
||||
Expect(rds.Library(rctx).Put(&lib)).To(Succeed())
|
||||
Expect(rds.User(rctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())
|
||||
Expect(rds.Library().Put(rctx, &lib)).To(Succeed())
|
||||
Expect(rds.User().SetUserLibraries(rctx, adminUser.ID, []int{lib.ID})).To(Succeed())
|
||||
|
||||
loadEmbeddedFixture()
|
||||
|
||||
|
|
@ -140,13 +140,13 @@ func scan() {
|
|||
func acquire(kind model.Kind, id string) model.ItemArtwork {
|
||||
GinkgoHelper()
|
||||
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
|
||||
Expect(rds.ArtworkQueue(rctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
||||
Expect(rds.ArtworkQueue().EnqueuePreservingBackoff(rctx, model.ArtworkQueueItem{
|
||||
ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary,
|
||||
Priority: model.ArtworkPriorityBump,
|
||||
})).To(Succeed())
|
||||
var ia *model.ItemArtwork
|
||||
runResolutionWorkerUntil(func() bool {
|
||||
got, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||
got, err := rds.Artwork().GetItemArtwork(rctx, kind, id, model.ImageTypePrimary)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
|
@ -211,7 +211,7 @@ func expectAlbumFolderCover(al model.Album, suffix string) {
|
|||
// A drain settles every ready item, so byte-level folder assertions must precede any acquire.
|
||||
func requireNoStateRow(kind model.Kind, id string) {
|
||||
GinkgoHelper()
|
||||
_, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||
_, err := rds.Artwork().GetItemArtwork(rctx, kind, id, model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound),
|
||||
"assert %s %q before acquiring any other entity in this spec", kind, id)
|
||||
}
|
||||
|
|
@ -266,7 +266,7 @@ func gridQuadrants(data []byte) [4]color.RGBA {
|
|||
// Store-backed sources only (embedded/generated); file-backed ones assert on ia.SourcePath.
|
||||
func storedBytes(ia model.ItemArtwork) []byte {
|
||||
GinkgoHelper()
|
||||
art, err := rds.Artwork(rctx).GetImage(ia.Hash)
|
||||
art, err := rds.Artwork().GetImage(rctx, ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
r, err := rstore.Open(ia.Hash, art.Mime)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -345,7 +345,7 @@ func replaceWithRealMP3(relPath string) {
|
|||
|
||||
func firstAlbum() model.Album {
|
||||
GinkgoHelper()
|
||||
albums, err := rds.Album(rctx).GetAll(model.QueryOptions{})
|
||||
albums, err := rds.Album().GetAll(rctx, model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums))
|
||||
return albums[0]
|
||||
|
|
@ -353,7 +353,7 @@ func firstAlbum() model.Album {
|
|||
|
||||
func albumByName(name string) model.Album {
|
||||
GinkgoHelper()
|
||||
albums, err := rds.Album(rctx).GetAll(model.QueryOptions{})
|
||||
albums, err := rds.Album().GetAll(rctx, model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for _, al := range albums {
|
||||
if al.Name == name {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, album model.
|
|||
}
|
||||
|
||||
func loadFolders(ctx context.Context, ds model.DataStore, folderIDs []string) ([]model.Folder, error) {
|
||||
return ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}})
|
||||
return ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}})
|
||||
}
|
||||
|
||||
// folderImages collects the folders' image files, sorted so files without
|
||||
|
|
@ -79,7 +79,7 @@ func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Fo
|
|||
if len(folders) < 2 && anyFolderHasImages(folders) {
|
||||
return nil, nil
|
||||
}
|
||||
parent, err := ds.Folder(ctx).Get(commonParentID)
|
||||
parent, err := ds.Folder().Get(ctx, commonParentID)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "Artwork: Parent folder not found for album cover art lookup", "parentID", commonParentID)
|
||||
return nil, nil
|
||||
|
|
@ -91,7 +91,7 @@ func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Fo
|
|||
// The library root can never be an album root
|
||||
return nil, nil
|
||||
}
|
||||
hasOtherAudio, err := ds.Folder(ctx).HasAudioOutsideFolders(*parent, folderIDs)
|
||||
hasOtherAudio, err := ds.Folder().HasAudioOutsideFolders(ctx, *parent, folderIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
|
|
@ -169,14 +168,15 @@ func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albu
|
|||
folderPath = filepath.Dir(folderPath)
|
||||
}
|
||||
|
||||
// TODO: Hacky, but the easiest way to get the folder ID ATM
|
||||
libPath := core.AbsolutePath(ctx, ds, libID, "")
|
||||
// Cleaned like the album paths; Join keeps an empty path empty, Clean would return ".".
|
||||
libPath, _ := ds.Library().GetPath(ctx, libID)
|
||||
libPath = filepath.Join(libPath)
|
||||
folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath)
|
||||
|
||||
log.Trace(ctx, "Artwork: Calculating artist folder details", "folderPath", folderPath, "folderID", folderID,
|
||||
"libPath", libPath, "libID", libID, "albumPaths", paths)
|
||||
|
||||
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}})
|
||||
folders, err := ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}})
|
||||
if err != nil || len(folders) == 0 {
|
||||
log.Warn(ctx, "Artwork: Could not find folder for artist", "folderPath", folderPath, "id", folderID,
|
||||
"libPath", libPath, "libID", libID, err)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import (
|
|||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
|
|
@ -47,11 +46,11 @@ var _ = Describe("loadArtistFolder", func() {
|
|||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
DeferCleanup(stubCoreAbsolutePath())
|
||||
|
||||
updatedAt = time.Now().Truncate(time.Second).Add(5 * time.Minute)
|
||||
repo = &fakeFolderRepo{result: []model.Folder{{ImagesUpdatedAt: updatedAt}}}
|
||||
ds = &tests.MockDataStore{MockedFolder: repo}
|
||||
libRepo := &tests.MockLibraryRepo{}
|
||||
libRepo.SetData(model.Libraries{{ID: 1, Path: filepath.FromSlash("/music")}})
|
||||
ds = &tests.MockDataStore{MockedFolder: repo, MockedLibrary: libRepo}
|
||||
albums = model.Albums{{LibraryID: 1, ID: "album1", Name: "Album 1"}}
|
||||
})
|
||||
|
||||
|
|
@ -107,11 +106,3 @@ var _ = Describe("loadArtistFolder", func() {
|
|||
Expect(upd).To(BeZero())
|
||||
})
|
||||
})
|
||||
|
||||
func stubCoreAbsolutePath() func() {
|
||||
original := core.AbsolutePath
|
||||
core.AbsolutePath = func(context.Context, model.DataStore, int, string) string {
|
||||
return filepath.FromSlash("/music")
|
||||
}
|
||||
return func() { core.AbsolutePath = original }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ func ConfigFingerprint() string {
|
|||
// resolved under it. Nothing re-resolves on its own; applying a change is an explicit reprocess.
|
||||
func ReconcileConfigFingerprint(ctx context.Context, ds model.DataStore) error {
|
||||
current := ConfigFingerprint()
|
||||
stored, err := ds.Property(ctx).DefaultGet(consts.ArtConfFingerprintPropertyKey, "")
|
||||
stored, err := ds.Property().DefaultGet(ctx, consts.ArtConfFingerprintPropertyKey, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -89,14 +89,14 @@ func ReconcileConfigFingerprint(ctx context.Context, ds model.DataStore) error {
|
|||
|
||||
// MarkConfigApplied records the current fingerprint as the one the library is resolved under.
|
||||
func MarkConfigApplied(ctx context.Context, ds model.DataStore) error {
|
||||
return ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())
|
||||
return ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())
|
||||
}
|
||||
|
||||
// enqueueMissingAll is the safety net for entities a scan never enqueued (added between scans, or scanner off).
|
||||
func enqueueMissingAll(ctx context.Context, ds model.DataStore) error {
|
||||
queue := ds.ArtworkQueue(ctx)
|
||||
queue := ds.ArtworkQueue()
|
||||
for _, kind := range ReprocessKinds {
|
||||
if _, err := queue.EnqueueAllMissing(kind, model.ArtworkPriorityRecheck); err != nil {
|
||||
if _, err := queue.EnqueueAllMissing(ctx, kind, model.ArtworkPriorityRecheck); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -108,31 +108,31 @@ func enqueueMissingAll(ctx context.Context, ds model.DataStore) error {
|
|||
func ItemName(ctx context.Context, ds model.DataStore, kind model.Kind, id string) (string, error) {
|
||||
switch kind {
|
||||
case model.KindArtistArtwork:
|
||||
ar, err := ds.Artist(ctx).Get(id)
|
||||
ar, err := ds.Artist().Get(ctx, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ar.Name, nil
|
||||
case model.KindAlbumArtwork:
|
||||
al, err := ds.Album(ctx).Get(id)
|
||||
al, err := ds.Album().Get(ctx, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return al.Name, nil
|
||||
case model.KindPlaylistArtwork:
|
||||
pls, err := ds.Playlist(ctx).Get(id)
|
||||
pls, err := ds.Playlist().Get(ctx, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return pls.Name, nil
|
||||
case model.KindRadioArtwork:
|
||||
rd, err := ds.Radio(ctx).Get(id)
|
||||
rd, err := ds.Radio().Get(ctx, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rd.Name, nil
|
||||
case model.KindMediaFileArtwork:
|
||||
mf, err := ds.MediaFile(ctx).Get(id)
|
||||
mf, err := ds.MediaFile().Get(ctx, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
@ -148,7 +148,7 @@ func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string
|
|||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
al, err := ds.Album(ctx).Get(albumID)
|
||||
al, err := ds.Album().Get(ctx, albumID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
@ -162,11 +162,11 @@ func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string
|
|||
|
||||
// Refresh drops an item's resolved artwork state and re-queues it at Bump priority.
|
||||
func Refresh(ctx context.Context, ds model.DataStore, kind model.Kind, id string) error {
|
||||
if err := ds.Artwork(ctx).DeleteForItems(kind, []string{id}); err != nil {
|
||||
if err := ds.Artwork().DeleteForItems(ctx, kind, []string{id}); err != nil {
|
||||
return fmt.Errorf("clearing artwork state: %w", err)
|
||||
}
|
||||
item := model.ArtworkQueueItem{ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump}
|
||||
if err := ds.ArtworkQueue(ctx).Enqueue(item); err != nil {
|
||||
if err := ds.ArtworkQueue().Enqueue(ctx, item); err != nil {
|
||||
return fmt.Errorf("enqueuing artwork refresh: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -95,15 +95,15 @@ var _ = Describe("Housekeeping", func() {
|
|||
It("records the current fingerprint when none was ever stored", func() {
|
||||
Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed())
|
||||
|
||||
Expect(propRepo.Get(consts.ArtConfFingerprintPropertyKey)).To(Equal(ConfigFingerprint()))
|
||||
Expect(propRepo.Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal(ConfigFingerprint()))
|
||||
})
|
||||
|
||||
It("leaves a stale fingerprint stored, so the warning survives a restart", func() {
|
||||
Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
||||
Expect(propRepo.Put(ctx, consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
||||
|
||||
Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed())
|
||||
|
||||
Expect(propRepo.Get(consts.ArtConfFingerprintPropertyKey)).To(Equal("stale-fingerprint"))
|
||||
Expect(propRepo.Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal("stale-fingerprint"))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -153,7 +153,7 @@ var _ = Describe("ItemName", func() {
|
|||
{ID: "al-2", Name: "Sandinista!", Discs: model.Discs{2: "Side Three"}},
|
||||
})
|
||||
ds = &tests.MockDataStore{MockedAlbum: albumRepo}
|
||||
Expect(ds.Artist(ctx).(*tests.MockArtistRepo).Put(&model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed())
|
||||
Expect(ds.Artist().(*tests.MockArtistRepo).Put(ctx, &model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed())
|
||||
})
|
||||
|
||||
It("returns the album name", func() {
|
||||
|
|
|
|||
|
|
@ -98,6 +98,9 @@ func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error {
|
|||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmp.Name(), 0640); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), dst)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,17 @@ var _ = Describe("ImageStore", func() {
|
|||
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() {
|
||||
data := []byte("dup")
|
||||
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
|
||||
// library lookup.
|
||||
func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (libraryView, error) {
|
||||
lib, err := ds.Library(ctx).Get(libID)
|
||||
lib, err := ds.Library().Get(ctx, libID)
|
||||
if err != nil {
|
||||
return libraryView{}, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ var _ = Describe("loadLibraryView", Ordered, func() {
|
|||
})
|
||||
|
||||
It("returns a view for a library backed by registered storage", func() {
|
||||
Expect(ds.Library(ctx).Put(&model.Library{ID: 1, Path: "fake:///music"})).To(Succeed())
|
||||
Expect(ds.Library().Put(ctx, &model.Library{ID: 1, Path: "fake:///music"})).To(Succeed())
|
||||
|
||||
lib, err := loadLibraryView(ctx, ds, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -45,7 +45,7 @@ var _ = Describe("loadLibraryView", Ordered, func() {
|
|||
})
|
||||
|
||||
It("returns an error when the library path uses an unregistered scheme", func() {
|
||||
Expect(ds.Library(ctx).Put(&model.Library{ID: 2, Path: "unsupported:///music"})).To(Succeed())
|
||||
Expect(ds.Library().Put(ctx, &model.Library{ID: 2, Path: "unsupported:///music"})).To(Succeed())
|
||||
_, err := loadLibraryView(ctx, ds, 2)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ type processor struct {
|
|||
// acquire resolves one queue item end to end: find an image, hash/decode/
|
||||
// blurhash it, place its bytes, and persist the resulting state.
|
||||
func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) {
|
||||
repo := p.ds.Artwork(ctx)
|
||||
repo := p.ds.Artwork()
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
log.Debug(ctx, "Artwork: Acquisition finished", "kind", item.ItemKind, "id", item.ItemID,
|
||||
|
|
@ -137,7 +137,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
|
|||
log.Trace(ctx, "Artwork: Hashed image", "kind", item.ItemKind, "id", item.ItemID,
|
||||
"hash", hash, "bytes", len(data), "elapsed", time.Since(hashStart))
|
||||
|
||||
art, err := repo.GetImage(hash)
|
||||
art, err := repo.GetImage(ctx, hash)
|
||||
switch {
|
||||
case err == nil && art.Width > 0:
|
||||
log.Debug(ctx, "Artwork: Reusing a known image, skipping decode", "kind", item.ItemKind,
|
||||
|
|
@ -195,7 +195,7 @@ func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, i
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("writing image store: %w", err)
|
||||
}
|
||||
if err := repo.PutImage(art); err != nil {
|
||||
if err := repo.PutImage(ctx, art); err != nil {
|
||||
return nil, fmt.Errorf("persisting artwork image: %w", err)
|
||||
}
|
||||
ia := &model.ItemArtwork{
|
||||
|
|
@ -210,7 +210,7 @@ func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, i
|
|||
Trace: traceFrom(ctx).encode(sourcePath),
|
||||
}
|
||||
// PutItemArtwork stamps UpdatedAt on ia, so the returned struct matches the persisted row.
|
||||
if err := repo.PutItemArtwork(ia); err != nil {
|
||||
if err := repo.PutItemArtwork(ctx, ia); err != nil {
|
||||
return nil, fmt.Errorf("persisting item artwork state: %w", err)
|
||||
}
|
||||
return ia, nil
|
||||
|
|
@ -218,7 +218,7 @@ func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, i
|
|||
|
||||
// writeAbsent records a known-absent state: every source answered definitively "no".
|
||||
func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem) outcome {
|
||||
err := repo.PutItemArtwork(&model.ItemArtwork{
|
||||
err := repo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||
ItemKind: item.ItemKind,
|
||||
ItemID: item.ItemID,
|
||||
ImageType: item.ImageType,
|
||||
|
|
|
|||
|
|
@ -93,14 +93,14 @@ var _ = Describe("processor.acquire", func() {
|
|||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"})
|
||||
Expect(out).To(Equal(outcomeFound))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).ToNot(BeEmpty())
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg"))
|
||||
Expect(ia.RefMtime).To(BeNumerically(">", 0))
|
||||
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Every placeholder is derived from the one shared thumbnail, so all three land together.
|
||||
Expect(art.BlurHash).ToNot(BeEmpty())
|
||||
|
|
@ -156,12 +156,12 @@ var _ = Describe("processor.acquire", func() {
|
|||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"})
|
||||
Expect(out).To(Equal(outcomeFound))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("embedded"))
|
||||
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3"))
|
||||
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(art.BlurHash).ToNot(BeEmpty())
|
||||
|
||||
|
|
@ -179,7 +179,7 @@ var _ = Describe("processor.acquire", func() {
|
|||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"})
|
||||
Expect(out).To(Equal(outcomeAbsent))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
Expect(ia.Source).To(BeEmpty())
|
||||
|
|
@ -200,7 +200,7 @@ var _ = Describe("processor.acquire", func() {
|
|||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-io"})
|
||||
Expect(out).To(Equal(outcomeFailed))
|
||||
|
||||
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al-io", model.ImageTypePrimary)
|
||||
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-io", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound), "an I/O fault must not be recorded as absent")
|
||||
})
|
||||
|
||||
|
|
@ -225,7 +225,7 @@ var _ = Describe("processor.acquire", func() {
|
|||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-io"})
|
||||
Expect(out).To(Equal(outcomeFailed))
|
||||
|
||||
_, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra-io", model.ImageTypePrimary)
|
||||
_, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra-io", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound), "an unreadable upload must not be recorded as absent")
|
||||
})
|
||||
|
||||
|
|
@ -269,7 +269,7 @@ var _ = Describe("processor.acquire", func() {
|
|||
Expect(out).To(Equal(outcomeFailed))
|
||||
Expect(retryIn).To(BeZero(), "a plain failure asks for no particular delay")
|
||||
|
||||
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
|
||||
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
|
|
@ -299,7 +299,7 @@ var _ = Describe("processor.acquire", func() {
|
|||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})
|
||||
Expect(out).To(Equal(outcomeFoundStale))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).ToNot(BeEmpty())
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
|
|
@ -316,10 +316,10 @@ var _ = Describe("processor.acquire", func() {
|
|||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alU"})
|
||||
Expect(out).To(Equal(outcomeFound))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alU", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alU", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(art.Width).To(BeZero())
|
||||
Expect(art.BlurHash).To(BeEmpty())
|
||||
|
|
@ -336,7 +336,7 @@ var _ = Describe("processor.acquire", func() {
|
|||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"})
|
||||
Expect(out).To(Equal(outcomeFailed))
|
||||
|
||||
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alE", model.ImageTypePrimary)
|
||||
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alE", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
|
|
@ -354,7 +354,7 @@ var _ = Describe("processor.acquire", func() {
|
|||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"})
|
||||
Expect(out).To(Equal(outcomeFailed))
|
||||
|
||||
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alX", model.ImageTypePrimary)
|
||||
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alX", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
|
|
@ -373,12 +373,12 @@ var _ = Describe("processor.acquire", func() {
|
|||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"})
|
||||
Expect(out).To(Equal(outcomeFound))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alext", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alext", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("external:deezerFake"))
|
||||
Expect(ia.Hash).ToNot(BeEmpty())
|
||||
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc, err := store.Open(ia.Hash, art.Mime)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -397,7 +397,7 @@ var _ = Describe("processor.acquire", func() {
|
|||
|
||||
out1, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"})
|
||||
Expect(out1).To(Equal(outcomeFound))
|
||||
ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
||||
ia1, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// A re-decode instead of a hash dedup would overwrite this sentinel.
|
||||
|
|
@ -407,11 +407,11 @@ var _ = Describe("processor.acquire", func() {
|
|||
|
||||
out2, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"})
|
||||
Expect(out2).To(Equal(outcomeFound))
|
||||
ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
||||
ia2, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia2.Hash).To(Equal(ia1.Hash))
|
||||
|
||||
reused, err := artRepo.GetImage(ia1.Hash)
|
||||
reused, err := artRepo.GetImage(ctx, ia1.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reused.BlurHash).To(Equal("SENTINEL"))
|
||||
})
|
||||
|
|
@ -437,7 +437,7 @@ var _ = Describe("processor.acquire", func() {
|
|||
folderRepo.result = []model.Folder{{Path: "album-a", ImageFiles: []string{"cover.jpg"}}}
|
||||
outN, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"})
|
||||
Expect(outN).To(Equal(outcomeFound))
|
||||
iaA, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
|
||||
iaA, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(iaA.Source).To(Equal("folder"))
|
||||
Expect(filepath.ToSlash(iaA.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
|
||||
|
|
@ -451,19 +451,19 @@ var _ = Describe("processor.acquire", func() {
|
|||
folderRepo.result = []model.Folder{{Path: "album-b", ImageFiles: []string{"cover.jpg"}}}
|
||||
outN, _, _ = proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"})
|
||||
Expect(outN).To(Equal(outcomeFound))
|
||||
iaB, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alB", model.ImageTypePrimary)
|
||||
iaB, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alB", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(iaB.Hash).To(Equal(iaA.Hash))
|
||||
Expect(filepath.ToSlash(iaB.SourcePath)).To(HaveSuffix("album-b/cover.jpg"))
|
||||
Expect(iaB.RefMtime).To(Equal(time.Unix(2000, 0).UnixNano()))
|
||||
|
||||
iaAafter, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
|
||||
iaAafter, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(filepath.ToSlash(iaAafter.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
|
||||
Expect(iaAafter.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano()))
|
||||
|
||||
Expect(artRepo.Data).To(HaveLen(1))
|
||||
reused, err := artRepo.GetImage(iaA.Hash)
|
||||
reused, err := artRepo.GetImage(ctx, iaA.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reused.BlurHash).To(Equal("SENTINEL"))
|
||||
})
|
||||
|
|
@ -483,7 +483,7 @@ var _ = Describe("processor.acquire", func() {
|
|||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"})
|
||||
Expect(out).To(Equal(outcomeFailed))
|
||||
|
||||
_, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
_, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
|
|
@ -504,7 +504,7 @@ var _ = Describe("processor.acquire", func() {
|
|||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"})
|
||||
Expect(out).To(Equal(outcomeFailed))
|
||||
|
||||
_, err = artRepo.GetItemArtwork(model.KindRadioArtwork, "big", model.ImageTypePrimary)
|
||||
_, err = artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "big", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
|
|
@ -565,12 +565,12 @@ var _ = Describe("processor.acquire", func() {
|
|||
|
||||
hash, err := hashImage(bytes.NewReader(imgBytes))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "application/octet-stream"})).To(Succeed())
|
||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: hash, Mime: "application/octet-stream"})).To(Succeed())
|
||||
|
||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alM"})
|
||||
Expect(out).To(Equal(outcomeFound))
|
||||
|
||||
upgraded, err := artRepo.GetImage(hash)
|
||||
upgraded, err := artRepo.GetImage(ctx, hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(upgraded.Width).To(BeNumerically(">", 0))
|
||||
Expect(upgraded.BlurHash).ToNot(BeEmpty())
|
||||
|
|
@ -590,7 +590,7 @@ var _ = Describe("processor.acquire", func() {
|
|||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"})
|
||||
Expect(out).To(Equal(outcomeFailed))
|
||||
|
||||
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary)
|
||||
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al7", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ const pruneMinAge = time.Hour
|
|||
func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
|
||||
start := time.Now()
|
||||
defer func() { log.Debug(ctx, "Artwork: Prune finished", "elapsed", time.Since(start)) }()
|
||||
repo := ds.Artwork(ctx)
|
||||
repo := ds.Artwork()
|
||||
|
||||
purged, err := repo.PurgeDanglingItems()
|
||||
purged, err := repo.PurgeDanglingItems(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
|
|||
}
|
||||
|
||||
// Queue rows for deleted entities would otherwise retry forever (Get -> not found -> failed).
|
||||
queuePurged, err := ds.ArtworkQueue(ctx).PurgeDangling()
|
||||
queuePurged, err := ds.ArtworkQueue().PurgeDangling(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -35,7 +35,7 @@ func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
|
|||
|
||||
// Files younger than the grace window may belong to acquisitions whose rows aren't committed yet.
|
||||
cutoff := time.Now().Add(-pruneMinAge)
|
||||
orphans, err := repo.PurgeOrphans(cutoff)
|
||||
orphans, err := repo.PurgeOrphans(ctx, cutoff)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -44,7 +44,7 @@ func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
|
|||
}
|
||||
|
||||
// Read after the delete, so the sweep below reclaims the files of the rows just removed.
|
||||
mimes, err := repo.GetMimeByHash()
|
||||
mimes, err := repo.GetMimeByHash(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,18 +18,20 @@ type flakyGetArtworkRepo struct {
|
|||
*tests.MockArtworkRepo
|
||||
}
|
||||
|
||||
func (f *flakyGetArtworkRepo) GetMimeByHash() (map[string]string, error) {
|
||||
func (f *flakyGetArtworkRepo) GetMimeByHash(context.Context) (map[string]string, error) {
|
||||
return nil, errors.New("db locked")
|
||||
}
|
||||
|
||||
var _ = Describe("Prune", func() {
|
||||
var ctx context.Context
|
||||
var ds *tests.MockDataStore
|
||||
var store *ImageStore
|
||||
var awRepo *tests.MockArtworkRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
ds = &tests.MockDataStore{}
|
||||
awRepo = ds.Artwork(context.Background()).(*tests.MockArtworkRepo)
|
||||
awRepo = ds.Artwork().(*tests.MockArtworkRepo)
|
||||
store = NewImageStore(GinkgoT().TempDir())
|
||||
})
|
||||
|
||||
|
|
@ -41,9 +43,9 @@ var _ = Describe("Prune", func() {
|
|||
}
|
||||
|
||||
It("purges dangling item_artwork state for gone entities, summed across kinds", func() {
|
||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "gone-artist", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "live-artist", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||
Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||
Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "gone-artist", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||
Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "live-artist", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||
awRepo.ExistingIDs = map[string]map[string]bool{
|
||||
"al": {},
|
||||
"ar": {"live-artist": true},
|
||||
|
|
@ -51,17 +53,17 @@ var _ = Describe("Prune", func() {
|
|||
|
||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||
|
||||
_, err := awRepo.GetItemArtwork(model.KindAlbumArtwork, "gone-album", model.ImageTypePrimary)
|
||||
_, err := awRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "gone-album", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
_, err = awRepo.GetItemArtwork(model.KindArtistArtwork, "gone-artist", model.ImageTypePrimary)
|
||||
_, err = awRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "gone-artist", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
_, err = awRepo.GetItemArtwork(model.KindArtistArtwork, "live-artist", model.ImageTypePrimary)
|
||||
_, err = awRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "live-artist", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("purges dangling artwork_queue rows for gone entities", func() {
|
||||
queueRepo := tests.CreateMockArtworkQueueRepo()
|
||||
Expect(queueRepo.Enqueue(
|
||||
Expect(queueRepo.Enqueue(ctx,
|
||||
model.ArtworkQueueItem{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary},
|
||||
model.ArtworkQueueItem{ItemKind: "al", ItemID: "live-album", ImageType: model.ImageTypePrimary},
|
||||
)).To(Succeed())
|
||||
|
|
@ -80,17 +82,17 @@ var _ = Describe("Prune", func() {
|
|||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
|
||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||
ageArtwork(h, old)
|
||||
|
||||
kept := []byte("kept-bytes")
|
||||
hk, _ := hashImage(bytes.NewReader(kept))
|
||||
Expect(store.Write(hk, "image/jpeg", bytes.NewReader(kept))).To(Succeed())
|
||||
Expect(awRepo.PutImage(&model.Artwork{Hash: hk, Mime: "image/jpeg"})).To(Succeed())
|
||||
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: hk, Mime: "image/jpeg"})).To(Succeed())
|
||||
|
||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||
|
||||
_, err := awRepo.GetImage(h)
|
||||
_, err := awRepo.GetImage(ctx, h)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
_, err = store.Open(h, "image/jpeg")
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
|
|
@ -103,14 +105,14 @@ var _ = Describe("Prune", func() {
|
|||
data := []byte("reacquired-bytes")
|
||||
h, _ := hashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||
ageArtwork(h, time.Now().Add(-2*time.Hour))
|
||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "a1",
|
||||
Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "a1",
|
||||
ImageType: model.ImageTypePrimary, Hash: h, Source: "folder"})).To(Succeed())
|
||||
|
||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||
|
||||
_, err := awRepo.GetImage(h)
|
||||
_, err := awRepo.GetImage(ctx, h)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -122,11 +124,11 @@ var _ = Describe("Prune", func() {
|
|||
h, _ := hashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||
// Reacquisition refreshed created_at, so the row is unreferenced but too young to drop.
|
||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||
|
||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||
|
||||
_, err := awRepo.GetImage(h)
|
||||
_, err := awRepo.GetImage(ctx, h)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -137,7 +139,7 @@ var _ = Describe("Prune", func() {
|
|||
data := []byte("racing-bytes")
|
||||
h, _ := hashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||
ageArtwork(h, time.Now().Add(-2*time.Hour))
|
||||
// The row is orphaned, but a concurrent acquisition just touched the file's mtime.
|
||||
|
||||
|
|
@ -170,7 +172,7 @@ var _ = Describe("Prune", func() {
|
|||
Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed())
|
||||
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
|
||||
// The row records the current mime; the .png file is a superseded variant.
|
||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||
|
||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||
|
||||
|
|
@ -192,14 +194,14 @@ var _ = Describe("Prune", func() {
|
|||
hb, _ := hashImage(bytes.NewReader(blocked))
|
||||
Expect(store.Write(hb, "image/jpeg", bytes.NewReader(blocked))).To(Succeed())
|
||||
Expect(os.Chtimes(store.path(hb, "image/jpeg"), old, old)).To(Succeed())
|
||||
Expect(awRepo.PutImage(&model.Artwork{Hash: hb, Mime: "image/jpeg"})).To(Succeed())
|
||||
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: hb, Mime: "image/jpeg"})).To(Succeed())
|
||||
ageArtwork(hb, old)
|
||||
|
||||
good := []byte("good-bytes")
|
||||
hg, _ := hashImage(bytes.NewReader(good))
|
||||
Expect(store.Write(hg, "image/jpeg", bytes.NewReader(good))).To(Succeed())
|
||||
Expect(os.Chtimes(store.path(hg, "image/jpeg"), old, old)).To(Succeed())
|
||||
Expect(awRepo.PutImage(&model.Artwork{Hash: hg, Mime: "image/jpeg"})).To(Succeed())
|
||||
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: hg, Mime: "image/jpeg"})).To(Succeed())
|
||||
ageArtwork(hg, old)
|
||||
|
||||
// A read-only shard directory makes os.Remove fail (EACCES) for hb's file only.
|
||||
|
|
@ -210,13 +212,13 @@ var _ = Describe("Prune", func() {
|
|||
|
||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||
|
||||
_, err := awRepo.GetImage(hg)
|
||||
_, err := awRepo.GetImage(ctx, hg)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
_, err = store.Open(hg, "image/jpeg")
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
|
||||
// The row purge does not depend on file removal, so only the file survives.
|
||||
_, err = awRepo.GetImage(hb)
|
||||
_, err = awRepo.GetImage(ctx, hb)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
rc, err := store.Open(hb, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ func toFastScaleType(img image.Image) image.Image {
|
|||
}
|
||||
|
||||
func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) {
|
||||
original, format, err := image.Decode(bytes.NewReader(data))
|
||||
original, format, err := decodeCapped(data)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
|
|
|||
13
core/artwork/resize_test.go
Normal file
13
core/artwork/resize_test.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
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.
|
||||
func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution, error) {
|
||||
al, err := r.ds.Album(ctx).Get(albumID)
|
||||
al, err := r.ds.Album().Get(ctx, albumID)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
|
|
@ -243,7 +243,7 @@ func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution
|
|||
|
||||
// resolveArtist tries the uploaded image first, then walks conf.Server.ArtistArtPriority.
|
||||
func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resolution, error) {
|
||||
ar, err := r.ds.Artist(ctx).Get(artistID)
|
||||
ar, err := r.ds.Artist().Get(ctx, artistID)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
|
|
@ -259,7 +259,7 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti
|
|||
}
|
||||
|
||||
// Only consider albums where the artist is the sole album artist.
|
||||
als, err := r.ds.Album(ctx).GetAll(model.QueryOptions{Filters: persistence.SoleAlbumArtistFilter(artistID)})
|
||||
als, err := r.ds.Album().GetAll(ctx, model.QueryOptions{Filters: persistence.SoleAlbumArtistFilter(artistID)})
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
|
|
@ -328,7 +328,7 @@ const PlaylistGridSamples = 4
|
|||
|
||||
// resolvePlaylist tries the uploaded image, the sidecar and ExternalImageURL, then a generated grid.
|
||||
func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (resolution, error) {
|
||||
pl, err := r.ds.Playlist(ctx).Get(playlistID)
|
||||
pl, err := r.ds.Playlist().Get(ctx, playlistID)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
|
|
@ -374,8 +374,11 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
|
|||
}
|
||||
}
|
||||
|
||||
albumIDs, err := r.ds.Playlist(ctx).Tracks(pl.ID, false).
|
||||
GetAlbumIDs(model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
|
||||
tracks := r.ds.Playlist().Tracks(ctx, pl.ID, false)
|
||||
if tracks == nil {
|
||||
return resolution{}, fmt.Errorf("resolvePlaylist: could not load tracks for playlist %s", pl.ID)
|
||||
}
|
||||
albumIDs, err := tracks.GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
|
|
@ -428,7 +431,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
|
|||
|
||||
// resolveRadio serves only an uploaded image; there is no fallback.
|
||||
func (r *resolver) resolveRadio(ctx context.Context, radioID string) (resolution, error) {
|
||||
radio, err := r.ds.Radio(ctx).Get(radioID)
|
||||
radio, err := r.ds.Radio().Get(ctx, radioID)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
|
|
@ -439,7 +442,7 @@ func (r *resolver) resolveRadio(ctx context.Context, radioID string) (resolution
|
|||
// resolveMediaFile resolves a track's own embedded art only, so disabled or missing cover art
|
||||
// is a definitive absent.
|
||||
func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution, error) {
|
||||
mf, err := r.ds.MediaFile(ctx).Get(id)
|
||||
mf, err := r.ds.MediaFile().Get(ctx, id)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
|
|
@ -572,7 +575,7 @@ func resolveArtistFolderPattern(ctx context.Context, lib libraryView, artistFold
|
|||
// resolveLocalFile opens an absolute path directly. A missing path is "no source"; any other
|
||||
// open failure says nothing about whether the image exists.
|
||||
func resolveLocalFile(path, source string) (resolution, bool) {
|
||||
if path == "" {
|
||||
if path == "" || !model.IsImageFile(path) {
|
||||
return resolution{}, false
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
|
|
|
|||
|
|
@ -498,6 +498,23 @@ var _ = Describe("resolveItem", func() {
|
|||
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() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
folderRepo.result = nil // no grid tiles, so the external failure is what surfaces
|
||||
|
|
@ -690,6 +707,16 @@ var _ = Describe("resolveItem", func() {
|
|||
Expect(err).To(HaveOccurred())
|
||||
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,6 +18,7 @@ import (
|
|||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/httpclient"
|
||||
"github.com/navidrome/navidrome/utils/netguard"
|
||||
"go.senan.xyz/taglib"
|
||||
)
|
||||
|
||||
|
|
@ -37,7 +38,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)
|
||||
continue
|
||||
}
|
||||
if !match {
|
||||
if !match || !model.IsImageFile(name) {
|
||||
continue
|
||||
}
|
||||
f, err := libFS.Open(file)
|
||||
|
|
@ -150,10 +151,18 @@ type readCloser struct {
|
|||
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) {
|
||||
hc := httpclient.New(5 * time.Second)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
|
||||
resp, err := hc.Do(req) //nolint:gosec
|
||||
resp, err := remoteImageClient.Do(req)
|
||||
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 {
|
||||
return nil, "", err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,87 @@ import (
|
|||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/httpclient"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "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() {
|
||||
It("opens a matching file via the library FS", func() {
|
||||
fsys := fstest.MapFS{
|
||||
|
|
@ -48,6 +122,18 @@ var _ = Describe("fromExternalFile", func() {
|
|||
Expect(b).To(Equal([]byte("a")))
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -16,12 +16,14 @@ import (
|
|||
)
|
||||
|
||||
var _ = Describe("Uploader", func() {
|
||||
var ctx context.Context
|
||||
var svc Uploader
|
||||
var tmpDir string
|
||||
var artRepo *tests.MockArtworkRepo
|
||||
var queueRepo *tests.MockArtworkQueueRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tmpDir = GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
||||
|
|
@ -33,7 +35,6 @@ var _ = Describe("Uploader", func() {
|
|||
|
||||
Describe("SetImage", func() {
|
||||
It("creates directory and saves image file", func() {
|
||||
ctx := context.Background()
|
||||
reader := strings.NewReader("fake image data")
|
||||
filename, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", reader, ".jpg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -46,7 +47,6 @@ var _ = Describe("Uploader", func() {
|
|||
})
|
||||
|
||||
It("falls back to ID-only filename when name cleans to empty", func() {
|
||||
ctx := context.Background()
|
||||
reader := strings.NewReader("data")
|
||||
filename, err := svc.SetImage(ctx, consts.EntityPlaylist, "pl-1", "!!!", "", reader, ".png")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -54,7 +54,6 @@ var _ = Describe("Uploader", func() {
|
|||
})
|
||||
|
||||
It("removes old image when replacing", func() {
|
||||
ctx := context.Background()
|
||||
oldDir := filepath.Join(tmpDir, "artwork", "artist")
|
||||
Expect(os.MkdirAll(oldDir, 0755)).To(Succeed())
|
||||
oldFile := filepath.Join(oldDir, "ar-1_old.png")
|
||||
|
|
@ -70,15 +69,13 @@ var _ = Describe("Uploader", func() {
|
|||
})
|
||||
|
||||
It("ignores missing old file without error", func() {
|
||||
ctx := context.Background()
|
||||
reader := strings.NewReader("data")
|
||||
_, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Name", "/nonexistent/path.jpg", reader, ".jpg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("does not touch artwork state or the queue (that is EnqueueArtwork's job, post-Put)", func() {
|
||||
ctx := context.Background()
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||
ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external",
|
||||
})).To(Succeed())
|
||||
|
||||
|
|
@ -87,25 +84,24 @@ var _ = Describe("Uploader", func() {
|
|||
|
||||
// SetImage only writes the file; the state row survives and nothing is queued until
|
||||
// the caller has persisted the new filename and called EnqueueArtwork.
|
||||
_, err = artRepo.GetItemArtwork(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
||||
_, err = artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(queueRepo.DequeueBatch(1000)).To(BeEmpty())
|
||||
Expect(queueRepo.DequeueBatch(ctx, 1000)).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("EnqueueArtwork", func() {
|
||||
It("clears artwork state and enqueues a Bump", func() {
|
||||
ctx := context.Background()
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||
ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external",
|
||||
})).To(Succeed())
|
||||
|
||||
svc.EnqueueArtwork(ctx, consts.EntityArtist, "ar-1")
|
||||
|
||||
_, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
||||
_, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
|
||||
queued, err := queueRepo.DequeueBatch(1000)
|
||||
queued, err := queueRepo.DequeueBatch(ctx, 1000)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(queued).To(ContainElement(SatisfyAll(
|
||||
HaveField("ItemKind", "ar"),
|
||||
|
|
@ -116,13 +112,12 @@ var _ = Describe("Uploader", func() {
|
|||
|
||||
It("is a no-op for an unknown entity type", func() {
|
||||
svc.EnqueueArtwork(context.Background(), "unknown", "x-1")
|
||||
Expect(queueRepo.DequeueBatch(1000)).To(BeEmpty())
|
||||
Expect(queueRepo.DequeueBatch(ctx, 1000)).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RemoveImage", func() {
|
||||
It("removes the file at the given path", func() {
|
||||
ctx := context.Background()
|
||||
dir := filepath.Join(tmpDir, "artwork", "artist")
|
||||
Expect(os.MkdirAll(dir, 0755)).To(Succeed())
|
||||
path := filepath.Join(dir, "ar-1_test.jpg")
|
||||
|
|
@ -134,13 +129,11 @@ var _ = Describe("Uploader", func() {
|
|||
})
|
||||
|
||||
It("succeeds when file does not exist", func() {
|
||||
ctx := context.Background()
|
||||
err := svc.RemoveImage(ctx, "/nonexistent/file.jpg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("succeeds with empty path", func() {
|
||||
ctx := context.Background()
|
||||
err := svc.RemoveImage(ctx, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import (
|
|||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -45,7 +47,8 @@ type Worker struct {
|
|||
broker events.Broker
|
||||
pruneMu sync.RWMutex
|
||||
pools []*drainPool
|
||||
runCtx context.Context
|
||||
runCtx context.Context //nolint:containedctx // worker lifecycle ctx, set at Run
|
||||
paused func() bool
|
||||
|
||||
gatesMu sync.Mutex
|
||||
gates map[string]*extGate
|
||||
|
|
@ -59,6 +62,7 @@ func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg
|
|||
broker: broker,
|
||||
pools: newDrainPools(),
|
||||
runCtx: context.Background(),
|
||||
paused: func() bool { return false },
|
||||
gates: map[string]*extGate{},
|
||||
}
|
||||
w.proc.resolver = newResolver(ds, ag, ffmpeg, w.gate)
|
||||
|
|
@ -90,6 +94,11 @@ 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.
|
||||
func (w *Worker) Run(ctx context.Context) error {
|
||||
w.runCtx = ctx
|
||||
|
|
@ -143,9 +152,12 @@ func (w *Worker) EnqueueMissingAll(ctx context.Context) 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.
|
||||
// DequeueBatch does not mark rows taken, so this is one query per pass, not per slot.
|
||||
items, err := w.proc.ds.ArtworkQueue(ctx).DequeueBatch(max(16, 4*concurrency), kinds...)
|
||||
items, err := w.proc.ds.ArtworkQueue().DequeueBatch(ctx, max(16, 4*concurrency), kinds...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
|
@ -170,6 +182,9 @@ func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (i
|
|||
wg.Wait()
|
||||
return len(items), nil //nolint:nilerr // a cancelled drain is a clean stop, not an error
|
||||
}
|
||||
if w.paused() {
|
||||
break
|
||||
}
|
||||
wg.Go(func() {
|
||||
defer func() { <-sem }()
|
||||
out, got := w.process(ctx, item)
|
||||
|
|
@ -231,14 +246,14 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
|||
item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary)
|
||||
trace := &ChainTrace{}
|
||||
ctx = withTrace(ctx, trace)
|
||||
out, got, retryIn := w.proc.acquire(ctx, item)
|
||||
out, got, retryIn := w.safeAcquire(ctx, item)
|
||||
|
||||
queue := w.proc.ds.ArtworkQueue(ctx)
|
||||
queue := w.proc.ds.ArtworkQueue()
|
||||
switch out {
|
||||
case outcomeFound, outcomeAbsent:
|
||||
// A scan that re-enqueued this row mid-flight reset its retry_at, so the row survives
|
||||
// here and the next drain re-resolves it.
|
||||
if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
|
||||
if err := queue.DeleteIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
|
||||
log.Warn(ctx, "Artwork: Could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
}
|
||||
case outcomeFoundStale, outcomeFailed:
|
||||
|
|
@ -247,7 +262,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
|||
if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) {
|
||||
// A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its
|
||||
// fresh, immediate eligibility.
|
||||
if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt, encoded); err != nil {
|
||||
if err := queue.MarkFailedIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt, encoded); err != nil {
|
||||
log.Warn(ctx, "Artwork: Could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
}
|
||||
log.Debug(ctx, "Artwork: Rescheduled item", "kind", item.ItemKind, "id", item.ItemID,
|
||||
|
|
@ -258,7 +273,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
|||
// Art already being served is kept: exhaustion means unreachable, not removed.
|
||||
settled := "kept previous state"
|
||||
if out == outcomeFailed && settlesAbsentOnGiveUp(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) {
|
||||
writeAbsent(ctx, w.proc.ds.Artwork(ctx), item)
|
||||
writeAbsent(ctx, w.proc.ds.Artwork(), item)
|
||||
settled = "recorded absent"
|
||||
}
|
||||
// The queue row is about to go, taking the only record of the failure with it. This write is
|
||||
|
|
@ -266,13 +281,27 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
|||
w.recordGiveUp(ctx, item, encoded)
|
||||
log.Info(ctx, "Artwork: Retry budget exhausted, giving up", "kind", item.ItemKind, "id", item.ItemID,
|
||||
"outcome", out, "attempts", item.Attempts+1, "budget", giveUpAfter, "settled", settled)
|
||||
if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
|
||||
if err := queue.DeleteIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
|
||||
log.Warn(ctx, "Artwork: Could not remove exhausted queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
}
|
||||
}
|
||||
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
|
||||
// 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) {
|
||||
|
|
@ -280,7 +309,7 @@ func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem,
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := w.proc.ds.Artwork(ctx).PutLastFailure(kind, item.ItemID, item.ImageType, trace); err != nil {
|
||||
if err := w.proc.ds.Artwork().PutLastFailure(ctx, kind, item.ItemID, item.ImageType, trace); err != nil {
|
||||
log.Warn(ctx, "Artwork: Could not record the last failure", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -290,7 +319,7 @@ func (w *Worker) hasResolvedArtwork(ctx context.Context, item model.ArtworkQueue
|
|||
if !ok {
|
||||
return false
|
||||
}
|
||||
ia, err := w.proc.ds.Artwork(ctx).GetItemArtwork(kind, item.ItemID, item.ImageType)
|
||||
ia, err := w.proc.ds.Artwork().GetItemArtwork(ctx, kind, item.ItemID, item.ImageType)
|
||||
return err == nil && ia.Hash != ""
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ import (
|
|||
const soakCycles = 2200
|
||||
|
||||
var _ = Describe("Worker soak", func() {
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
})
|
||||
|
||||
It("does not leak goroutines, heap, or fds over many acquisition cycles", func() {
|
||||
if testing.Short() {
|
||||
Skip("skipping soak test in short mode")
|
||||
|
|
@ -100,9 +106,9 @@ var _ = Describe("Worker soak", func() {
|
|||
// Read-back exercises the surfaces a caller would use after acquisition.
|
||||
if out == outcomeFound {
|
||||
kind, _ := model.ParseKind(it.ItemKind)
|
||||
ia, err := artRepo.GetItemArtwork(kind, it.ItemID, model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, kind, it.ItemID, model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred(), "cycle %d: GetItemArtwork", i)
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred(), "cycle %d: GetImage", i)
|
||||
rc, err := store.Open(ia.Hash, art.Mime)
|
||||
switch {
|
||||
|
|
|
|||
|
|
@ -56,8 +56,8 @@ type reenqueueOnDequeue struct {
|
|||
done bool
|
||||
}
|
||||
|
||||
func (r *reenqueueOnDequeue) DequeueBatch(n int, kinds ...string) ([]model.ArtworkQueueItem, error) {
|
||||
items, err := r.MockArtworkQueueRepo.DequeueBatch(n, kinds...)
|
||||
func (r *reenqueueOnDequeue) DequeueBatch(ctx context.Context, n int, kinds ...string) ([]model.ArtworkQueueItem, error) {
|
||||
items, err := r.MockArtworkQueueRepo.DequeueBatch(ctx, n, kinds...)
|
||||
if !r.done && len(items) > 0 {
|
||||
r.done = true
|
||||
for k, it := range r.Data {
|
||||
|
|
@ -124,18 +124,39 @@ type visibilityPlaylistDS struct {
|
|||
tracks model.PlaylistTrackRepository
|
||||
}
|
||||
|
||||
func (v *visibilityPlaylistDS) Playlist(ctx context.Context) model.PlaylistRepository {
|
||||
func (v *visibilityPlaylistDS) Playlist() model.PlaylistRepository {
|
||||
repo := tests.CreateMockPlaylistRepo()
|
||||
repo.TracksRepo = v.tracks
|
||||
if u, ok := request.UserFrom(ctx); ok && u.IsAdmin {
|
||||
repo.SetData(model.Playlists{v.private})
|
||||
repo.SetData(model.Playlists{v.private})
|
||||
return &visibilityPlaylistRepo{MockPlaylistRepo: repo}
|
||||
}
|
||||
|
||||
type visibilityPlaylistRepo struct {
|
||||
*tests.MockPlaylistRepo
|
||||
}
|
||||
|
||||
func (v *visibilityPlaylistRepo) Get(ctx context.Context, id string) (*model.Playlist, error) {
|
||||
if u, ok := request.UserFrom(ctx); !ok || !u.IsAdmin {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return repo
|
||||
return v.MockPlaylistRepo.Get(ctx, id)
|
||||
}
|
||||
|
||||
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 {
|
||||
repo := tests.CreateMockUserRepo()
|
||||
Expect(repo.Put(&model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed())
|
||||
Expect(repo.Put(GinkgoT().Context(), &model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed())
|
||||
return repo
|
||||
}
|
||||
|
||||
|
|
@ -157,8 +178,8 @@ var _ = Describe("Worker", func() {
|
|||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = context.Background()
|
||||
var err error
|
||||
repoRoot, err = os.Getwd()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -200,7 +221,7 @@ var _ = Describe("Worker", func() {
|
|||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||
ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan,
|
||||
})).To(Succeed())
|
||||
|
||||
|
|
@ -208,11 +229,11 @@ var _ = Describe("Worker", func() {
|
|||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(1))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
|
||||
count, err := queueRepo.Count()
|
||||
count, err := queueRepo.Count(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeZero(), "a found item must be deleted from the queue")
|
||||
})
|
||||
|
|
@ -223,7 +244,7 @@ var _ = Describe("Worker", func() {
|
|||
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
{ID: "mf1", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true},
|
||||
})
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||
ItemKind: "mf", ItemID: "mf1", Priority: model.ArtworkPriorityBump,
|
||||
})).To(Succeed())
|
||||
|
||||
|
|
@ -231,12 +252,12 @@ var _ = Describe("Worker", func() {
|
|||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(1))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("embedded"))
|
||||
Expect(ia.Hash).ToNot(BeEmpty())
|
||||
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
r, err := store.Open(ia.Hash, art.Mime)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -245,7 +266,7 @@ var _ = Describe("Worker", func() {
|
|||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(data).ToNot(BeEmpty(), "embedded bytes must be written to the store")
|
||||
|
||||
count, err := queueRepo.Count()
|
||||
count, err := queueRepo.Count(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeZero())
|
||||
})
|
||||
|
|
@ -254,7 +275,7 @@ var _ = Describe("Worker", func() {
|
|||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al4", Name: "Album"}})
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})).To(Succeed())
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})).To(Succeed())
|
||||
|
||||
n, err := w.drain(ctx, 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -265,17 +286,47 @@ var _ = Describe("Worker", func() {
|
|||
Expect(it.Attempts).To(Equal(1))
|
||||
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
|
||||
|
||||
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
|
||||
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent")
|
||||
})
|
||||
|
||||
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() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
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.
|
||||
const askedFor = 90 * time.Minute
|
||||
imageAgents(&fakeImageAgent{name: "throttledAgent", err: &agents.RetryLaterError{RetryIn: askedFor}})
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
|
||||
|
||||
n, err := w.drain(ctx, 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -296,7 +347,7 @@ var _ = Describe("Worker", func() {
|
|||
{ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})).To(Succeed())
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})).To(Succeed())
|
||||
|
||||
n, err := w.drain(ctx, 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -307,7 +358,7 @@ var _ = Describe("Worker", func() {
|
|||
Expect(it.Attempts).To(Equal(1))
|
||||
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("folder"), "the fallback art is served meanwhile")
|
||||
|
||||
|
|
@ -327,7 +378,7 @@ var _ = Describe("Worker", func() {
|
|||
racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo}
|
||||
ds.MockedArtworkQueue = racing
|
||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||
ItemKind: "al", ItemID: "al7", Priority: model.ArtworkPriorityScan,
|
||||
})).To(Succeed())
|
||||
|
||||
|
|
@ -337,7 +388,7 @@ var _ = Describe("Worker", func() {
|
|||
|
||||
// The concurrent re-enqueue changed retry_at, so the found-path delete was a no-op.
|
||||
Expect(findQueued(queueRepo, "al", "al7")).ToNot(BeNil())
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al7", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
})
|
||||
|
|
@ -349,7 +400,7 @@ var _ = Describe("Worker", func() {
|
|||
racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo}
|
||||
ds.MockedArtworkQueue = racing
|
||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al8"})).To(Succeed())
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al8"})).To(Succeed())
|
||||
dequeued := findQueued(queueRepo, "al", "al8").RetryAt
|
||||
|
||||
n, err := w.drain(ctx, 1)
|
||||
|
|
@ -368,7 +419,7 @@ var _ = Describe("Worker", func() {
|
|||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
|
||||
// Age the row past the retry budget.
|
||||
expireQueued(queueRepo, "al9")
|
||||
|
||||
|
|
@ -377,7 +428,7 @@ var _ = Describe("Worker", func() {
|
|||
Expect(n).To(Equal(1))
|
||||
|
||||
Expect(findQueued(queueRepo, "al", "al9")).To(BeNil())
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al9", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al9", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
})
|
||||
|
|
@ -385,13 +436,13 @@ var _ = Describe("Worker", func() {
|
|||
It("keeps already-served art when the retry budget is exhausted", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al10", Name: "Album"}})
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al10", ImageType: model.ImageTypePrimary,
|
||||
Hash: "cafebabe", Source: "external:lastfm",
|
||||
})).To(Succeed())
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al10"})).To(Succeed())
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al10"})).To(Succeed())
|
||||
expireQueued(queueRepo, "al10")
|
||||
|
||||
n, err := w.drain(ctx, 1)
|
||||
|
|
@ -399,7 +450,7 @@ var _ = Describe("Worker", func() {
|
|||
Expect(n).To(Equal(1))
|
||||
|
||||
Expect(findQueued(queueRepo, "al", "al10")).To(BeNil())
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al10", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al10", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art")
|
||||
})
|
||||
|
|
@ -409,7 +460,7 @@ var _ = Describe("Worker", func() {
|
|||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al11", Name: "Album"}})
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al11"})).To(Succeed())
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al11"})).To(Succeed())
|
||||
|
||||
_, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -430,13 +481,13 @@ var _ = Describe("Worker", func() {
|
|||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al13", Name: "Album"}})
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al13"})).To(Succeed())
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al13"})).To(Succeed())
|
||||
expireQueued(queueRepo, "al13")
|
||||
|
||||
_, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al13", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al13", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred(), "settling absent must create the row the failure is written to")
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty())
|
||||
|
|
@ -445,20 +496,20 @@ var _ = Describe("Worker", func() {
|
|||
It("keeps the failure on the state row after the queue row is deleted", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al12", Name: "Album"}})
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al12", ImageType: model.ImageTypePrimary,
|
||||
Hash: "cafebabe", Source: "external:lastfm",
|
||||
})).To(Succeed())
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al12"})).To(Succeed())
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al12"})).To(Succeed())
|
||||
expireQueued(queueRepo, "al12")
|
||||
|
||||
_, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(findQueued(queueRepo, "al", "al12")).To(BeNil())
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al12", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al12", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty(),
|
||||
"the queue row is gone, so this is the only remaining record of the failure")
|
||||
|
|
@ -473,7 +524,7 @@ var _ = Describe("Worker", func() {
|
|||
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
{ID: "mfX", LibraryID: 0, Path: "tests/fixtures/artist/an-album/gone.mp3", HasCoverArt: true},
|
||||
})
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mfX"})).To(Succeed())
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mfX"})).To(Succeed())
|
||||
expireQueued(queueRepo, "mfX")
|
||||
|
||||
n, err := w.drain(ctx, 1)
|
||||
|
|
@ -481,7 +532,7 @@ var _ = Describe("Worker", func() {
|
|||
Expect(n).To(Equal(1))
|
||||
|
||||
Expect(findQueued(queueRepo, "mf", "mfX")).To(BeNil(), "the row must stop retrying")
|
||||
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mfX", model.ImageTypePrimary)
|
||||
_, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mfX", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound),
|
||||
"no row leaves the track unresolved, so a later view can still recover it")
|
||||
// Known gap: with no row and no absent settle, there is nowhere to keep the failure.
|
||||
|
|
@ -496,14 +547,14 @@ var _ = Describe("Worker", func() {
|
|||
tracks: &tests.MockPlaylistTrackRepo{},
|
||||
}
|
||||
w = NewWorker(vds, store, ag, ffm, broker, imgCache)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plPriv"})).To(Succeed())
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plPriv"})).To(Succeed())
|
||||
|
||||
n, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(1))
|
||||
|
||||
Expect(findQueued(queueRepo, "pl", "plPriv")).To(BeNil())
|
||||
ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "plPriv", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindPlaylistArtwork, "plPriv", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
})
|
||||
|
|
@ -523,9 +574,9 @@ var _ = Describe("Worker", func() {
|
|||
{ID: "al1", Name: "Album 1", FolderIDs: []string{"f1"}},
|
||||
{ID: "al2", Name: "Album 2", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
||||
|
||||
n, err := w.drain(ctx, 3)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -572,7 +623,7 @@ var _ = Describe("Worker", func() {
|
|||
conf.Server.CoverArtPriority = "cover.*" // local-only; no folder image → absent
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al3", Name: "Artless"}})
|
||||
folderRepo.result = nil
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
||||
|
||||
n, err := w.drain(ctx, 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -582,7 +633,7 @@ var _ = Describe("Worker", func() {
|
|||
Expect(evts).To(HaveLen(1), "a removed cover must live-refresh clients so they drop it")
|
||||
Expect(evts[0].(*events.RefreshResource).Data(evts[0])).To(ContainSubstring("al3"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).To(BeEmpty(), "the outcome was absent, not found")
|
||||
})
|
||||
|
|
@ -591,7 +642,7 @@ var _ = Describe("Worker", func() {
|
|||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}})
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"})).To(Succeed())
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"})).To(Succeed())
|
||||
|
||||
n, err := w.drain(ctx, 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -725,7 +776,7 @@ var _ = Describe("Worker", func() {
|
|||
{ID: "alpc", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
conf.Server.UICoverArtSize = 300
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||
ItemKind: "al", ItemID: "alpc", Priority: model.ArtworkPriorityScan,
|
||||
})).To(Succeed())
|
||||
})
|
||||
|
|
@ -813,11 +864,11 @@ var _ = Describe("Worker", func() {
|
|||
|
||||
// Artists first, exactly as Backfill orders them.
|
||||
for _, a := range artists {
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||
ItemKind: "ar", ItemID: a.ID, Priority: model.ArtworkPriorityBackfill,
|
||||
})).To(Succeed())
|
||||
}
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||
ItemKind: "al", ItemID: "alx", Priority: model.ArtworkPriorityBackfill,
|
||||
})).To(Succeed())
|
||||
|
||||
|
|
@ -832,12 +883,12 @@ var _ = Describe("Worker", func() {
|
|||
})
|
||||
|
||||
Eventually(func() bool {
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alx", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alx", model.ImageTypePrimary)
|
||||
return err == nil && ia.Hash != ""
|
||||
}, 5*time.Second, 50*time.Millisecond).Should(BeTrue(),
|
||||
"a blocked external pool must not hold up local artwork")
|
||||
|
||||
_, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "arx0", model.ImageTypePrimary)
|
||||
_, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "arx0", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound), "artists are still blocked, as intended")
|
||||
})
|
||||
})
|
||||
|
|
@ -849,7 +900,7 @@ var _ = Describe("Worker", func() {
|
|||
for i := range 8 {
|
||||
id := fmt.Sprintf("alc%d", i)
|
||||
albums = append(albums, model.Album{ID: id, Name: "Album"})
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||
ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan,
|
||||
})).To(Succeed())
|
||||
}
|
||||
|
|
@ -866,10 +917,38 @@ 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() {
|
||||
for i := range 16 {
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: fmt.Sprintf("alb%d", i), Name: "Album"}})
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||
ItemKind: "al", ItemID: fmt.Sprintf("alb%d", i), Priority: model.ArtworkPriorityScan,
|
||||
})).To(Succeed())
|
||||
}
|
||||
|
|
@ -896,6 +975,30 @@ var _ = Describe("Worker", func() {
|
|||
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() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ func Init(ds model.DataStore) {
|
|||
}
|
||||
|
||||
func loadOrCreateSecret(ctx context.Context, ds model.DataStore, key string) string {
|
||||
secret, err := ds.Property(ctx).Get(key)
|
||||
secret, err := ds.Property().Get(ctx, key)
|
||||
if err != nil || secret == "" {
|
||||
log.Info(ctx, "Creating new JWT secret", "key", key)
|
||||
return createNewSecret(ctx, ds, key)
|
||||
|
|
@ -154,9 +154,9 @@ func CheckClaims(c Claims, usr model.User, audience string) error {
|
|||
}
|
||||
|
||||
func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
|
||||
u, err := ds.User(ctx).FindFirstAdmin()
|
||||
u, err := ds.User().FindFirstAdmin(ctx)
|
||||
if err != nil {
|
||||
c, err := ds.User(ctx).CountAll()
|
||||
c, err := ds.User().CountAll(ctx)
|
||||
if c == 0 && err == nil {
|
||||
log.Debug(ctx, "No admin user yet!", err)
|
||||
} else {
|
||||
|
|
@ -176,7 +176,7 @@ func createNewSecret(ctx context.Context, ds model.DataStore, key string) string
|
|||
log.Error(ctx, "Could not encrypt JWT secret", err)
|
||||
return secret
|
||||
}
|
||||
if err := ds.Property(ctx).Put(key, encSecret); err != nil {
|
||||
if err := ds.Property().Put(ctx, key, encSecret); err != nil {
|
||||
log.Error(ctx, "Could not save JWT secret in DB", err)
|
||||
}
|
||||
return secret
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ func userName(ctx context.Context) string {
|
|||
// BFR We should only access files through the `storage.Storage` interface. This will require changing how
|
||||
// TagLib and ffmpeg access files
|
||||
var AbsolutePath = func(ctx context.Context, ds model.DataStore, libId int, path string) string {
|
||||
libPath, err := ds.Library(ctx).GetPath(libId)
|
||||
libPath, err := ds.Library().GetPath(ctx, libId)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
|
|
|
|||
18
core/external/extdata_helper_test.go
vendored
18
core/external/extdata_helper_test.go
vendored
|
|
@ -31,7 +31,7 @@ func (m *mockArtistRepo) SetData(artists model.Artists) {
|
|||
}
|
||||
|
||||
// Get implements model.ArtistRepository.
|
||||
func (m *mockArtistRepo) Get(id string) (*model.Artist, error) {
|
||||
func (m *mockArtistRepo) Get(_ context.Context, id string) (*model.Artist, error) {
|
||||
args := m.Called(id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
|
|
@ -40,7 +40,7 @@ func (m *mockArtistRepo) Get(id string) (*model.Artist, error) {
|
|||
}
|
||||
|
||||
// GetAll implements model.ArtistRepository.
|
||||
func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) {
|
||||
func (m *mockArtistRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Artists, error) {
|
||||
argsSlice := make([]any, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
|
|
@ -85,7 +85,7 @@ func (m *mockMediaFileRepo) SetData(mediaFiles model.MediaFiles) {
|
|||
}
|
||||
|
||||
// Get implements model.MediaFileRepository.
|
||||
func (m *mockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
|
||||
func (m *mockMediaFileRepo) Get(ctx context.Context, id string) (*model.MediaFile, error) {
|
||||
args := m.Called(id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
|
|
@ -94,12 +94,12 @@ func (m *mockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
|
|||
}
|
||||
|
||||
// GetAllByTags implements model.MediaFileRepository.
|
||||
func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
return m.GetAll(options...)
|
||||
func (m *mockMediaFileRepo) GetAllByTags(ctx context.Context, _ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
return m.GetAll(ctx, options...)
|
||||
}
|
||||
|
||||
// GetAll implements model.MediaFileRepository.
|
||||
func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
func (m *mockMediaFileRepo) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
argsSlice := make([]any, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
|
|
@ -112,7 +112,7 @@ func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFi
|
|||
}
|
||||
|
||||
// GetRandom implements model.MediaFileRepository.
|
||||
func (m *mockMediaFileRepo) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
func (m *mockMediaFileRepo) GetRandom(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
argsSlice := make([]any, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
|
|
@ -156,7 +156,7 @@ func newMockAlbumRepo() *mockAlbumRepo {
|
|||
}
|
||||
|
||||
// Get implements model.AlbumRepository.
|
||||
func (m *mockAlbumRepo) Get(id string) (*model.Album, error) {
|
||||
func (m *mockAlbumRepo) Get(_ context.Context, id string) (*model.Album, error) {
|
||||
args := m.Called(id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
|
|
@ -165,7 +165,7 @@ func (m *mockAlbumRepo) Get(id string) (*model.Album, error) {
|
|||
}
|
||||
|
||||
// GetAll implements model.AlbumRepository.
|
||||
func (m *mockAlbumRepo) GetAll(options ...model.QueryOptions) (model.Albums, error) {
|
||||
func (m *mockAlbumRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Albums, error) {
|
||||
argsSlice := make([]any, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
|
|
|
|||
16
core/external/provider.go
vendored
16
core/external/provider.go
vendored
|
|
@ -182,7 +182,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
|
|||
}
|
||||
}
|
||||
|
||||
err = e.ds.Album(ctx).UpdateExternalInfo(&album.Album)
|
||||
err = e.ds.Album().UpdateExternalInfo(ctx, &album.Album)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", albumName,
|
||||
"elapsed", time.Since(start), err)
|
||||
|
|
@ -285,7 +285,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
|
|||
if !throttled {
|
||||
artist.ExternalInfoUpdatedAt = new(time.Now())
|
||||
}
|
||||
err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist)
|
||||
err := e.ds.Artist().UpdateExternalInfo(ctx, &artist.Artist)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName,
|
||||
"elapsed", time.Since(start), err)
|
||||
|
|
@ -548,7 +548,7 @@ func (e *provider) loadArtistsByID(ctx context.Context, similar []agents.Artist)
|
|||
if len(ids) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
|
||||
Filters: squirrel.Eq{"artist.id": ids},
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -577,7 +577,7 @@ func (e *provider) loadArtistsByMBID(ctx context.Context, similar []agents.Artis
|
|||
if len(mbids) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
|
||||
Filters: squirrel.Eq{"mbz_artist_id": mbids},
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -612,7 +612,7 @@ func (e *provider) loadArtistsByName(ctx context.Context, similar []agents.Artis
|
|||
clauses := slice.Map(names, func(name string) squirrel.Sqlizer {
|
||||
return squirrel.Like{"artist.name": name}
|
||||
})
|
||||
res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
|
||||
Filters: squirrel.Or(clauses),
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -628,7 +628,7 @@ func (e *provider) loadArtistsByName(ctx context.Context, similar []agents.Artis
|
|||
|
||||
func (e *provider) findArtist(ctx context.Context, artistName, id string) (*auxArtist, error) {
|
||||
if id != "" {
|
||||
artist, err := e.ds.Artist(ctx).Get(id)
|
||||
artist, err := e.ds.Artist().Get(ctx, id)
|
||||
if err == nil {
|
||||
return &auxArtist{Artist: *artist}, nil
|
||||
}
|
||||
|
|
@ -644,7 +644,7 @@ func (e *provider) findArtist(ctx context.Context, artistName, id string) (*auxA
|
|||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
artists, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
|
||||
Filters: squirrel.Like{"artist.name": artistName},
|
||||
Max: 1,
|
||||
})
|
||||
|
|
@ -666,7 +666,7 @@ func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int
|
|||
ids = append(ids, sa.ID)
|
||||
}
|
||||
|
||||
similar, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
similar, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
|
||||
Filters: squirrel.Eq{"artist.id": ids},
|
||||
})
|
||||
if err != nil {
|
||||
|
|
|
|||
8
core/external/provider_refreshinfo_test.go
vendored
8
core/external/provider_refreshinfo_test.go
vendored
|
|
@ -71,8 +71,8 @@ var _ = Describe("Provider - RefreshInfo", func() {
|
|||
ag = new(mockAgents)
|
||||
broker = &fakeBroker{}
|
||||
p = external.NewProvider(ds, ag, matcher.New(ds), broker)
|
||||
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
|
||||
mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
||||
mockArtistRepo = ds.Artist().(*tests.MockArtistRepo)
|
||||
mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo)
|
||||
})
|
||||
|
||||
It("repopulates an artist even when its info is fresh", func() {
|
||||
|
|
@ -84,7 +84,7 @@ var _ = Describe("Provider - RefreshInfo", func() {
|
|||
|
||||
Expect(p.RefreshInfo(ctx, model.KindArtistArtwork, "ar-1")).To(Succeed())
|
||||
|
||||
saved, err := mockArtistRepo.Get("ar-1")
|
||||
saved, err := mockArtistRepo.Get(ctx, "ar-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(saved.Biography).To(Equal("Fresh Bio"))
|
||||
})
|
||||
|
|
@ -99,7 +99,7 @@ var _ = Describe("Provider - RefreshInfo", func() {
|
|||
|
||||
Expect(p.RefreshInfo(ctx, model.KindAlbumArtwork, "al-1")).To(Succeed())
|
||||
|
||||
saved, err := mockAlbumRepo.Get("al-1")
|
||||
saved, err := mockAlbumRepo.Get(ctx, "al-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(saved.Description).To(Equal("Fresh Notes"))
|
||||
})
|
||||
|
|
|
|||
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