mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Merge branch 'master' into fix-schema-inconsistencies
This commit is contained in:
commit
280d13c114
2
.github/workflows/pipeline.yml
vendored
2
.github/workflows/pipeline.yml
vendored
@ -338,7 +338,7 @@ jobs:
|
||||
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Create manifest list and push to Docker Hub
|
||||
uses: nick-fields/retry@v3
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 5
|
||||
max_attempts: 3
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -37,4 +37,5 @@ AGENTS.md
|
||||
*.wasm
|
||||
*.ndp
|
||||
openspec/
|
||||
go.work*
|
||||
go.work*
|
||||
.worktrees/
|
||||
@ -55,6 +55,7 @@ linters:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
- node_modules
|
||||
formatters:
|
||||
exclusions:
|
||||
generated: lax
|
||||
@ -62,3 +63,4 @@ formatters:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
- node_modules
|
||||
|
||||
37
Makefile
37
Makefile
@ -1,6 +1,8 @@
|
||||
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
||||
NODE_VERSION=$(shell cat .nvmrc)
|
||||
GO_BUILD_TAGS=netgo,sqlite_fts5
|
||||
|
||||
comma:=,
|
||||
GO_BUILD_TAGS=netgo,sqlite_fts5$(if $(EXTRA_BUILD_TAGS),$(comma)$(EXTRA_BUILD_TAGS))
|
||||
|
||||
# Set global environment variables, required for most targets
|
||||
export CGO_CFLAGS_ALLOW=--define-prefix
|
||||
@ -233,6 +235,39 @@ get-music: ##@Development Download some free music from Navidrome's demo instanc
|
||||
.PHONY: get-music
|
||||
|
||||
|
||||
##########################################
|
||||
#### Worktrees
|
||||
|
||||
WORKTREES_DIR := .worktrees
|
||||
|
||||
wt: check_go_env ##@Worktrees Create and setup a git worktree. Usage: make wt name=feature-name [go=1]
|
||||
@if [ -z "${name}" ]; then echo "Usage: make wt name=<branch-name> [go=1]"; exit 1; fi
|
||||
@mkdir -p $(WORKTREES_DIR)
|
||||
@echo "Creating worktree for branch '${name}'..."
|
||||
@git worktree add $(WORKTREES_DIR)/${name} -b ${name} 2>/dev/null || \
|
||||
git worktree add $(WORKTREES_DIR)/${name} ${name}
|
||||
@if [ -n "${go}" ]; then \
|
||||
./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name} --go-only; \
|
||||
else \
|
||||
./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name}; \
|
||||
fi
|
||||
@echo "\nWorktree ready at $(WORKTREES_DIR)/${name}"
|
||||
@echo " cd $(WORKTREES_DIR)/${name}"
|
||||
.PHONY: wt
|
||||
|
||||
rm-wt: ##@Worktrees Remove a git worktree. Usage: make rm-wt name=feature-name
|
||||
@if [ -z "${name}" ]; then echo "Usage: make rm-wt name=<branch-name>"; exit 1; fi
|
||||
@if [ ! -d "$(WORKTREES_DIR)/${name}" ]; then echo "Worktree '${name}' not found in $(WORKTREES_DIR)/"; exit 1; fi
|
||||
@echo "Removing worktree '${name}'..."
|
||||
@git worktree remove --force $(WORKTREES_DIR)/${name}
|
||||
@echo "Worktree '${name}' removed."
|
||||
@echo "Note: branch '${name}' still exists. Delete it with: git branch -D ${name}"
|
||||
.PHONY: rm-wt
|
||||
|
||||
ls-wt: ##@Worktrees List all active git worktrees
|
||||
@git worktree list
|
||||
.PHONY: ls-wt
|
||||
|
||||
##########################################
|
||||
#### Miscellaneous
|
||||
|
||||
|
||||
@ -58,7 +58,20 @@ func (e extractor) Version() string {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
||||
func (e extractor) extractMetadata(filePath string) (info *metadata.Info, err error) {
|
||||
// Recover from panics in the WASM runtime that can occur during any taglib
|
||||
// operation (opening, reading tags, or reading properties). This catches crashes
|
||||
// from malformed files or WASM runtime issues (e.g., wazero mmap failures on
|
||||
// hardened systems with MemoryDenyWriteExecute=true).
|
||||
debug.SetPanicOnFault(true)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error("gotaglib: WASM runtime panic reading file. Skipping", "filePath", filePath, "panic", r)
|
||||
debug.PrintStack()
|
||||
err = fmt.Errorf("WASM runtime panic: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
f, close, err := e.openFile(filePath)
|
||||
if err != nil {
|
||||
log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err)
|
||||
@ -112,16 +125,6 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
||||
// openFile opens the file at filePath using the extractor's filesystem.
|
||||
// It returns a TagLib File handle and a cleanup function to close resources.
|
||||
func (e extractor) openFile(filePath string) (f *taglib.File, closeFunc func(), err error) {
|
||||
// Recover from panics in the WASM runtime (e.g., wazero failing to mmap executable memory
|
||||
// on hardened systems like NixOS with MemoryDenyWriteExecute=true)
|
||||
debug.SetPanicOnFault(true)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error("WASM runtime panic: This may be caused by a hardened system that blocks executable memory mapping.", "file", filePath, "panic", r)
|
||||
err = fmt.Errorf("WASM runtime panic (hardened system?): %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
// Open the file from the filesystem
|
||||
file, err := e.fs.Open(filePath)
|
||||
if err != nil {
|
||||
|
||||
@ -1,116 +0,0 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
const apiBaseUrl = "https://api.spotify.com/v1/"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("spotify: not found")
|
||||
)
|
||||
|
||||
type httpDoer interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func newClient(id, secret string, hc httpDoer) *client {
|
||||
return &client{id, secret, hc}
|
||||
}
|
||||
|
||||
type client struct {
|
||||
id string
|
||||
secret string
|
||||
hc httpDoer
|
||||
}
|
||||
|
||||
func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
|
||||
token, err := c.authorize(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("type", "artist")
|
||||
params.Add("q", name)
|
||||
params.Add("offset", "0")
|
||||
params.Add("limit", strconv.Itoa(limit))
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", apiBaseUrl+"search", nil)
|
||||
req.URL.RawQuery = params.Encode()
|
||||
req.Header.Add("Authorization", "Bearer "+token)
|
||||
|
||||
var results SearchResults
|
||||
err = c.makeRequest(req, &results)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(results.Artists.Items) == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return results.Artists.Items, err
|
||||
}
|
||||
|
||||
func (c *client) authorize(ctx context.Context) (string, error) {
|
||||
payload := url.Values{}
|
||||
payload.Add("grant_type", "client_credentials")
|
||||
|
||||
encodePayload := payload.Encode()
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", "https://accounts.spotify.com/api/token", strings.NewReader(encodePayload))
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Add("Content-Length", strconv.Itoa(len(encodePayload)))
|
||||
auth := c.id + ":" + c.secret
|
||||
req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(auth)))
|
||||
|
||||
response := map[string]any{}
|
||||
err := c.makeRequest(req, &response)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if v, ok := response["access_token"]; ok {
|
||||
return v.(string), nil
|
||||
}
|
||||
log.Error(ctx, "Invalid spotify response", "resp", response)
|
||||
return "", errors.New("invalid response")
|
||||
}
|
||||
|
||||
func (c *client) makeRequest(req *http.Request, response any) error {
|
||||
log.Trace(req.Context(), fmt.Sprintf("Sending Spotify %s request", req.Method), "url", req.URL)
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return c.parseError(data)
|
||||
}
|
||||
|
||||
return json.Unmarshal(data, response)
|
||||
}
|
||||
|
||||
func (c *client) parseError(data []byte) error {
|
||||
var e Error
|
||||
err := json.Unmarshal(data, &e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("spotify error(%s): %s", e.Code, e.Message)
|
||||
}
|
||||
@ -1,131 +0,0 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("client", func() {
|
||||
var httpClient *fakeHttpClient
|
||||
var client *client
|
||||
|
||||
BeforeEach(func() {
|
||||
httpClient = &fakeHttpClient{}
|
||||
client = newClient("SPOTIFY_ID", "SPOTIFY_SECRET", httpClient)
|
||||
})
|
||||
|
||||
Describe("ArtistImages", func() {
|
||||
It("returns artist images from a successful request", func() {
|
||||
f, _ := os.Open("tests/fixtures/spotify.search.artist.json")
|
||||
httpClient.mock("https://api.spotify.com/v1/search", http.Response{Body: f, StatusCode: 200})
|
||||
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
|
||||
})
|
||||
|
||||
artists, err := client.searchArtists(context.TODO(), "U2", 10)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(artists).To(HaveLen(20))
|
||||
Expect(artists[0].Popularity).To(Equal(82))
|
||||
|
||||
images := artists[0].Images
|
||||
Expect(images).To(HaveLen(3))
|
||||
Expect(images[0].Width).To(Equal(640))
|
||||
Expect(images[1].Width).To(Equal(320))
|
||||
Expect(images[2].Width).To(Equal(160))
|
||||
})
|
||||
|
||||
It("fails if artist was not found", func() {
|
||||
httpClient.mock("https://api.spotify.com/v1/search", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{
|
||||
"artists" : {
|
||||
"href" : "https://api.spotify.com/v1/search?query=dasdasdas%2Cdna&type=artist&offset=0&limit=20",
|
||||
"items" : [ ], "limit" : 20, "next" : null, "offset" : 0, "previous" : null, "total" : 0
|
||||
}}`)),
|
||||
})
|
||||
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
|
||||
})
|
||||
|
||||
_, err := client.searchArtists(context.TODO(), "U2", 10)
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
})
|
||||
|
||||
It("fails if not able to authorize", func() {
|
||||
f, _ := os.Open("tests/fixtures/spotify.search.artist.json")
|
||||
httpClient.mock("https://api.spotify.com/v1/search", http.Response{Body: f, StatusCode: 200})
|
||||
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||
StatusCode: 400,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":"invalid_client","error_description":"Invalid client"}`)),
|
||||
})
|
||||
|
||||
_, err := client.searchArtists(context.TODO(), "U2", 10)
|
||||
Expect(err).To(MatchError("spotify error(invalid_client): Invalid client"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("authorize", func() {
|
||||
It("returns an access_token on successful authorization", func() {
|
||||
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
|
||||
})
|
||||
|
||||
token, err := client.authorize(context.TODO())
|
||||
Expect(err).To(BeNil())
|
||||
Expect(token).To(Equal("NEW_ACCESS_TOKEN"))
|
||||
auth := httpClient.lastRequest.Header.Get("Authorization")
|
||||
Expect(auth).To(Equal("Basic U1BPVElGWV9JRDpTUE9USUZZX1NFQ1JFVA=="))
|
||||
})
|
||||
|
||||
It("fails on unsuccessful authorization", func() {
|
||||
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||
StatusCode: 400,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":"invalid_client","error_description":"Invalid client"}`)),
|
||||
})
|
||||
|
||||
_, err := client.authorize(context.TODO())
|
||||
Expect(err).To(MatchError("spotify error(invalid_client): Invalid client"))
|
||||
})
|
||||
|
||||
It("fails on invalid JSON response", func() {
|
||||
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{NOT_VALID}`)),
|
||||
})
|
||||
|
||||
_, err := client.authorize(context.TODO())
|
||||
Expect(err).To(MatchError("invalid character 'N' looking for beginning of object key string"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type fakeHttpClient struct {
|
||||
responses map[string]*http.Response
|
||||
lastRequest *http.Request
|
||||
}
|
||||
|
||||
func (c *fakeHttpClient) mock(url string, response http.Response) {
|
||||
if c.responses == nil {
|
||||
c.responses = make(map[string]*http.Response)
|
||||
}
|
||||
c.responses[url] = &response
|
||||
}
|
||||
|
||||
func (c *fakeHttpClient) Do(req *http.Request) (*http.Response, error) {
|
||||
c.lastRequest = req
|
||||
u := req.URL
|
||||
u.RawQuery = ""
|
||||
if resp, ok := c.responses[u.String()]; ok {
|
||||
return resp, nil
|
||||
}
|
||||
panic("URL not mocked: " + u.String())
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
package spotify
|
||||
|
||||
type SearchResults struct {
|
||||
Artists ArtistsResult `json:"artists"`
|
||||
}
|
||||
|
||||
type ArtistsResult struct {
|
||||
HRef string `json:"href"`
|
||||
Items []Artist `json:"items"`
|
||||
}
|
||||
|
||||
type Artist struct {
|
||||
Genres []string `json:"genres"`
|
||||
HRef string `json:"href"`
|
||||
ID string `json:"id"`
|
||||
Popularity int `json:"popularity"`
|
||||
Images []Image `json:"images"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type Image struct {
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
type Error struct {
|
||||
Code string `json:"error"`
|
||||
Message string `json:"error_description"`
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Responses", func() {
|
||||
Describe("Search type=artist", func() {
|
||||
It("parses the artist search result correctly ", func() {
|
||||
var resp SearchResults
|
||||
body, _ := os.ReadFile("tests/fixtures/spotify.search.artist.json")
|
||||
err := json.Unmarshal(body, &resp)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(resp.Artists.Items).To(HaveLen(20))
|
||||
u2 := resp.Artists.Items[0]
|
||||
Expect(u2.Name).To(Equal("U2"))
|
||||
Expect(u2.Genres).To(ContainElements("irish rock", "permanent wave", "rock"))
|
||||
Expect(u2.ID).To(Equal("51Blml2LZPmy7TTiAg47vQ"))
|
||||
Expect(u2.HRef).To(Equal("https://api.spotify.com/v1/artists/51Blml2LZPmy7TTiAg47vQ"))
|
||||
Expect(u2.Images[0].URL).To(Equal("https://i.scdn.co/image/e22d5c0c8139b8439440a69854ed66efae91112d"))
|
||||
Expect(u2.Images[0].Width).To(Equal(640))
|
||||
Expect(u2.Images[0].Height).To(Equal(640))
|
||||
Expect(u2.Images[1].URL).To(Equal("https://i.scdn.co/image/40d6c5c14355cfc127b70da221233315497ec91d"))
|
||||
Expect(u2.Images[1].Width).To(Equal(320))
|
||||
Expect(u2.Images[1].Height).To(Equal(320))
|
||||
Expect(u2.Images[2].URL).To(Equal("https://i.scdn.co/image/7293d6752ae8a64e34adee5086858e408185b534"))
|
||||
Expect(u2.Images[2].Width).To(Equal(160))
|
||||
Expect(u2.Images[2].Height).To(Equal(160))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Error", func() {
|
||||
It("parses the error response correctly", func() {
|
||||
var errorResp Error
|
||||
body := []byte(`{"error":"invalid_client","error_description":"Invalid client"}`)
|
||||
err := json.Unmarshal(body, &errorResp)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(errorResp.Code).To(Equal("invalid_client"))
|
||||
Expect(errorResp.Message).To(Equal("Invalid client"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,96 +0,0 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/xrash/smetrics"
|
||||
)
|
||||
|
||||
const spotifyAgentName = "spotify"
|
||||
|
||||
type spotifyAgent struct {
|
||||
ds model.DataStore
|
||||
id string
|
||||
secret string
|
||||
client *client
|
||||
}
|
||||
|
||||
func spotifyConstructor(ds model.DataStore) agents.Interface {
|
||||
if conf.Server.Spotify.ID == "" || conf.Server.Spotify.Secret == "" {
|
||||
return nil
|
||||
}
|
||||
l := &spotifyAgent{
|
||||
ds: ds,
|
||||
id: conf.Server.Spotify.ID,
|
||||
secret: conf.Server.Spotify.Secret,
|
||||
}
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
l.client = newClient(l.id, l.secret, chc)
|
||||
return l
|
||||
}
|
||||
|
||||
func (s *spotifyAgent) AgentName() string {
|
||||
return spotifyAgentName
|
||||
}
|
||||
|
||||
func (s *spotifyAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
|
||||
a, err := s.searchArtist(ctx, name)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "Artist not found in Spotify", "artist", name)
|
||||
} else {
|
||||
log.Error(ctx, "Error calling Spotify", "artist", name, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var res []agents.ExternalImage
|
||||
for _, img := range a.Images {
|
||||
res = append(res, agents.ExternalImage{
|
||||
URL: img.URL,
|
||||
Size: img.Width,
|
||||
})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *spotifyAgent) searchArtist(ctx context.Context, name string) (*Artist, error) {
|
||||
artists, err := s.client.searchArtists(ctx, name, 40)
|
||||
if err != nil || len(artists) == 0 {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
name = strings.ToLower(name)
|
||||
|
||||
// Sort results, prioritizing artists with images, with similar names and with high popularity, in this order
|
||||
sort.Slice(artists, func(i, j int) bool {
|
||||
ai := fmt.Sprintf("%-5t-%03d-%04d", len(artists[i].Images) == 0, smetrics.WagnerFischer(name, strings.ToLower(artists[i].Name), 1, 1, 2), 1000-artists[i].Popularity)
|
||||
aj := fmt.Sprintf("%-5t-%03d-%04d", len(artists[j].Images) == 0, smetrics.WagnerFischer(name, strings.ToLower(artists[j].Name), 1, 1, 2), 1000-artists[j].Popularity)
|
||||
return ai < aj
|
||||
})
|
||||
|
||||
// If the first one has the same name, that's the one
|
||||
if strings.ToLower(artists[0].Name) != name {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return &artists[0], err
|
||||
}
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
agents.Register(spotifyAgentName, spotifyConstructor)
|
||||
})
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestSpotify(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Spotify Test Suite")
|
||||
}
|
||||
@ -27,7 +27,6 @@ import (
|
||||
_ "github.com/navidrome/navidrome/adapters/gotaglib"
|
||||
_ "github.com/navidrome/navidrome/adapters/lastfm"
|
||||
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
|
||||
_ "github.com/navidrome/navidrome/adapters/spotify"
|
||||
_ "github.com/navidrome/navidrome/adapters/taglib"
|
||||
)
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@ -74,7 +75,7 @@ func runScanner(ctx context.Context) {
|
||||
sqlDB := db.Db()
|
||||
defer db.Db().Close()
|
||||
ds := persistence.New(sqlDB)
|
||||
pls := playlists.NewPlaylists(ds)
|
||||
pls := playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
// Parse targets from command line or file
|
||||
var scanTargets []model.ScanTarget
|
||||
|
||||
@ -248,6 +248,7 @@ ExecStart={{.Path|cmdEscape}}{{range .Arguments}} {{.|cmd}}{{end}}
|
||||
TimeoutStopSec=20
|
||||
RestartSec=120
|
||||
EnvironmentFile=-/etc/sysconfig/{{.Name}}
|
||||
Environment="ND_SYSTEMD_PRIORITY_LOGGING=1"
|
||||
|
||||
DevicePolicy=closed
|
||||
NoNewPrivileges=yes
|
||||
|
||||
@ -39,7 +39,6 @@ import (
|
||||
_ "github.com/navidrome/navidrome/adapters/gotaglib"
|
||||
_ "github.com/navidrome/navidrome/adapters/lastfm"
|
||||
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
|
||||
_ "github.com/navidrome/navidrome/adapters/spotify"
|
||||
_ "github.com/navidrome/navidrome/adapters/taglib"
|
||||
)
|
||||
|
||||
@ -64,7 +63,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
share := core.NewShare(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore)
|
||||
imageUploadService := core.NewImageUploadService()
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||
insights := metrics.GetInstance(dataStore)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
@ -80,7 +80,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
|
||||
user := core.NewUser(dataStore, manager)
|
||||
maintenance := core.NewMaintenance(dataStore)
|
||||
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager)
|
||||
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, imageUploadService)
|
||||
return router
|
||||
}
|
||||
|
||||
@ -101,7 +101,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
players := core.NewPlayers(dataStore)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore)
|
||||
imageUploadService := core.NewImageUploadService()
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
@ -170,7 +171,8 @@ func CreateScanner(ctx context.Context) model.Scanner {
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore)
|
||||
imageUploadService := core.NewImageUploadService()
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
return modelScanner
|
||||
}
|
||||
@ -187,7 +189,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore)
|
||||
imageUploadService := core.NewImageUploadService()
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||
return watcher
|
||||
|
||||
@ -16,8 +16,8 @@ import (
|
||||
"github.com/kr/pretty"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/scheduler"
|
||||
"github.com/navidrome/navidrome/utils/run"
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
@ -69,14 +69,17 @@ type configOptions struct {
|
||||
MPVPath string
|
||||
MPVCmdTemplate string
|
||||
CoverArtPriority string
|
||||
CoverJpegQuality int
|
||||
CoverArtQuality int
|
||||
EnableWebPEncoding bool
|
||||
ArtistArtPriority string
|
||||
ArtistImageFolder string
|
||||
DiscArtPriority string
|
||||
LyricsPriority string
|
||||
EnableGravatar bool
|
||||
EnableFavourites bool
|
||||
EnableStarRating bool
|
||||
EnableUserEditing bool
|
||||
EnableCoverArtUpload bool
|
||||
EnableArtworkUpload bool
|
||||
EnableSharing bool
|
||||
ShareURL string
|
||||
DefaultShareExpiration time.Duration
|
||||
@ -85,6 +88,7 @@ type configOptions struct {
|
||||
DefaultLanguage string
|
||||
DefaultUIVolume int
|
||||
UISearchDebounceMs int
|
||||
UICoverArtSize int
|
||||
EnableReplayGain bool
|
||||
EnableCoverAnimation bool
|
||||
EnableNowPlaying bool
|
||||
@ -104,7 +108,6 @@ type configOptions struct {
|
||||
Inspect inspectOptions `json:",omitzero"`
|
||||
Subsonic subsonicOptions `json:",omitzero"`
|
||||
LastFM lastfmOptions `json:",omitzero"`
|
||||
Spotify spotifyOptions `json:",omitzero"`
|
||||
Deezer deezerOptions `json:",omitzero"`
|
||||
ListenBrainz listenBrainzOptions `json:",omitzero"`
|
||||
EnableScrobbleHistory bool
|
||||
@ -185,11 +188,6 @@ type lastfmOptions struct {
|
||||
Languages []string // Computed from Language, split by comma
|
||||
}
|
||||
|
||||
type spotifyOptions struct {
|
||||
ID string
|
||||
Secret string //nolint:gosec
|
||||
}
|
||||
|
||||
type deezerOptions struct {
|
||||
Enabled bool
|
||||
Language string
|
||||
@ -261,6 +259,13 @@ type searchOptions struct {
|
||||
FullString bool
|
||||
}
|
||||
|
||||
// logFatal prints a fatal error message to stderr and exits.
|
||||
// Overridden in tests to allow testing fatal paths.
|
||||
var logFatal = func(args ...any) {
|
||||
_, _ = fmt.Fprintln(os.Stderr, append([]any{"FATAL:"}, args...)...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var (
|
||||
Server = &configOptions{}
|
||||
hooks []func()
|
||||
@ -270,30 +275,29 @@ func LoadFromFile(confFile string) {
|
||||
viper.SetConfigFile(confFile)
|
||||
err := viper.ReadInConfig()
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error reading config file:", err)
|
||||
os.Exit(1)
|
||||
logFatal("Error reading config file:", err)
|
||||
}
|
||||
Load(true)
|
||||
}
|
||||
|
||||
func Load(noConfigDump bool) {
|
||||
parseIniFileConfiguration()
|
||||
remapEnvVarKeysFromConfig()
|
||||
|
||||
// Map deprecated options to their new names for backwards compatibility
|
||||
mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
||||
mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality")
|
||||
|
||||
err := viper.Unmarshal(&Server)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
|
||||
os.Exit(1)
|
||||
logFatal("Error parsing config:", err)
|
||||
}
|
||||
|
||||
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", err)
|
||||
os.Exit(1)
|
||||
logFatal("Error creating data path:", err)
|
||||
}
|
||||
|
||||
if Server.CacheFolder == "" {
|
||||
@ -301,14 +305,12 @@ func Load(noConfigDump bool) {
|
||||
}
|
||||
err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", err)
|
||||
os.Exit(1)
|
||||
logFatal("Error creating cache path:", err)
|
||||
}
|
||||
|
||||
err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating artwork path:", err)
|
||||
os.Exit(1)
|
||||
logFatal("Error creating artwork path:", err)
|
||||
}
|
||||
|
||||
if Server.Plugins.Enabled {
|
||||
@ -317,8 +319,7 @@ func Load(noConfigDump bool) {
|
||||
}
|
||||
err = os.MkdirAll(Server.Plugins.Folder, 0700)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating plugins path:", err)
|
||||
os.Exit(1)
|
||||
logFatal("Error creating plugins path:", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -330,8 +331,7 @@ func Load(noConfigDump bool) {
|
||||
if Server.Backup.Path != "" {
|
||||
err = os.MkdirAll(Server.Backup.Path, os.ModePerm)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating backup path:", err)
|
||||
os.Exit(1)
|
||||
logFatal("Error creating backup path:", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -339,10 +339,15 @@ func Load(noConfigDump bool) {
|
||||
if Server.LogFile != "" {
|
||||
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "FATAL: Error opening log file %s: %s\n", Server.LogFile, err.Error())
|
||||
os.Exit(1)
|
||||
logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
|
||||
}
|
||||
log.SetOutput(out)
|
||||
} else if os.Getenv("ND_SYSTEMD_PRIORITY_LOGGING") != "" && os.Getenv("JOURNAL_STREAM") != "" {
|
||||
// When running under systemd, prepend syslog priority prefixes so
|
||||
// journald assigns the correct severity to each log line.
|
||||
// Note that we have an additional environment variable, as JOURNAL_STREAM
|
||||
// can be present in a systemd environment even if not running as a systemd service
|
||||
log.EnableJournalFormat()
|
||||
}
|
||||
|
||||
log.SetLevelString(Server.LogLevel)
|
||||
@ -366,8 +371,7 @@ func Load(noConfigDump bool) {
|
||||
if Server.BaseURL != "" {
|
||||
u, err := url.Parse(Server.BaseURL)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Invalid BaseURL:", err)
|
||||
os.Exit(1)
|
||||
logFatal("Invalid BaseURL:", err)
|
||||
}
|
||||
Server.BasePath = u.Path
|
||||
u.Path = ""
|
||||
@ -408,6 +412,7 @@ func Load(noConfigDump bool) {
|
||||
// Parse Deezer.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
|
||||
Server.Deezer.Languages = parseLanguages(Server.Deezer.Language)
|
||||
|
||||
// Deprecated options
|
||||
logDeprecatedOptions("Scanner.GenreSeparators", "")
|
||||
logDeprecatedOptions("Scanner.GroupAlbumReleases", "")
|
||||
logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
|
||||
@ -415,6 +420,17 @@ func Load(noConfigDump bool) {
|
||||
logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
||||
logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality")
|
||||
|
||||
// Removed options
|
||||
logRemovedOptions("Spotify.ID", "Spotify.Secret")
|
||||
|
||||
// Validate other options
|
||||
if Server.UICoverArtSize < 200 || Server.UICoverArtSize > 1200 {
|
||||
newValue := max(200, min(1200, Server.UICoverArtSize))
|
||||
log.Warn("UICoverArtSize must be between 200 and 1200, clamping", "value", Server.UICoverArtSize, "newValue", newValue)
|
||||
Server.UICoverArtSize = newValue
|
||||
}
|
||||
|
||||
// Call init hooks
|
||||
for _, hook := range hooks {
|
||||
@ -440,6 +456,52 @@ func logDeprecatedOptions(oldName, newName string) {
|
||||
}
|
||||
}
|
||||
|
||||
// logRemovedOptions checks if the option is set, and if yes, outputs a warning message saying the option is
|
||||
// not available anymore
|
||||
func logRemovedOptions(options ...string) {
|
||||
for _, option := range options {
|
||||
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_"))
|
||||
logWarning := func(option string) {
|
||||
log.Warn(fmt.Sprintf("Option '%s' is not available anymore and will be ignored. Please remove it from your config", option))
|
||||
}
|
||||
if viper.InConfig(option) {
|
||||
logWarning(option)
|
||||
}
|
||||
if os.Getenv(envVar) != "" {
|
||||
logWarning(envVar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// remapEnvVarKeysFromConfig detects ND_-prefixed keys in the config file (users mistakenly
|
||||
// using environment variable names) and remaps them to canonical Viper keys with a warning.
|
||||
func remapEnvVarKeysFromConfig() {
|
||||
for _, key := range viper.AllKeys() {
|
||||
if !strings.HasPrefix(key, "nd_") || !viper.InConfig(key) {
|
||||
continue
|
||||
}
|
||||
stripped := strings.TrimPrefix(key, "nd_")
|
||||
canonicalKey := strings.ReplaceAll(stripped, "_", ".")
|
||||
displayNDKey := "ND_" + strings.ToUpper(stripped)
|
||||
displayCanonical := toPascalCase(canonicalKey)
|
||||
|
||||
if viper.InConfig(canonicalKey) {
|
||||
logFatal(fmt.Sprintf(
|
||||
"Config file contains both '%s' and '%s'. Remove the ND_-prefixed version. "+
|
||||
"The 'ND_' prefix is only needed for environment variables, not config file keys.",
|
||||
displayNDKey, displayCanonical,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
viper.Set(canonicalKey, viper.Get(key))
|
||||
_, _ = fmt.Fprintf(os.Stderr, "WARNING: Config key '%s' uses environment variable naming. Use '%s' instead. "+
|
||||
"The 'ND_' prefix is only needed for environment variables.\n",
|
||||
displayNDKey, displayCanonical,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// mapDeprecatedOption is used to provide backwards compatibility for deprecated options. It should be called after
|
||||
// the config has been read by viper, but before unmarshalling it into the Config struct.
|
||||
func mapDeprecatedOption(legacyName, newName string) {
|
||||
@ -457,18 +519,15 @@ func parseIniFileConfiguration() {
|
||||
var iniConfig map[string]any
|
||||
err := viper.Unmarshal(&iniConfig)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
|
||||
os.Exit(1)
|
||||
logFatal("Error parsing config:", err)
|
||||
}
|
||||
cfg, ok := iniConfig["default"].(map[string]any)
|
||||
if !ok {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config: missing [default] section:", iniConfig)
|
||||
os.Exit(1)
|
||||
logFatal("Error parsing config: missing [default] section:", iniConfig)
|
||||
}
|
||||
err = viper.MergeConfigMap(cfg)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
|
||||
os.Exit(1)
|
||||
logFatal("Error parsing config:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -478,7 +537,6 @@ func disableExternalServices() {
|
||||
Server.EnableInsightsCollector = false
|
||||
Server.EnableM3UExternalAlbumArt = false
|
||||
Server.LastFM.Enabled = false
|
||||
Server.Spotify.ID = ""
|
||||
Server.Deezer.Enabled = false
|
||||
Server.ListenBrainz.Enabled = false
|
||||
Server.Agents = ""
|
||||
@ -547,15 +605,9 @@ func validateBackupSchedule() error {
|
||||
}
|
||||
|
||||
func validateSchedule(schedule, field string) (string, error) {
|
||||
if _, err := time.ParseDuration(schedule); err == nil {
|
||||
schedule = "@every " + schedule
|
||||
}
|
||||
c := cron.New()
|
||||
id, err := c.AddFunc(schedule, func() {})
|
||||
_, err := scheduler.ParseCrontab(schedule)
|
||||
if err != nil {
|
||||
log.Error(fmt.Sprintf("Invalid %s. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", field), "schedule", schedule, err)
|
||||
} else {
|
||||
c.Remove(id)
|
||||
}
|
||||
return schedule, err
|
||||
}
|
||||
@ -598,6 +650,21 @@ func normalizeSearchBackend(value string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// toPascalCase converts a dotted lowercase config key to PascalCase for display.
|
||||
// Example: "scanner.schedule" → "Scanner.Schedule"
|
||||
func toPascalCase(key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(key, ".")
|
||||
for i, part := range parts {
|
||||
if len(part) > 0 {
|
||||
parts[i] = strings.ToUpper(part[:1]) + part[1:]
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
|
||||
// AddHook is used to register initialization code that should run as soon as the config is loaded
|
||||
func AddHook(hook func()) {
|
||||
hooks = append(hooks, hook)
|
||||
@ -653,10 +720,14 @@ func setViperDefaults() {
|
||||
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
|
||||
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
|
||||
viper.SetDefault("ffmpegpath", "")
|
||||
viper.SetDefault("mpvpath", "")
|
||||
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s")
|
||||
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
|
||||
viper.SetDefault("coverjpegquality", 75)
|
||||
viper.SetDefault("coverartquality", 75)
|
||||
viper.SetDefault("enablewebpencoding", false)
|
||||
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
||||
viper.SetDefault("artistimagefolder", "")
|
||||
viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded")
|
||||
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
|
||||
viper.SetDefault("enablegravatar", false)
|
||||
viper.SetDefault("enablefavourites", true)
|
||||
@ -666,10 +737,11 @@ func setViperDefaults() {
|
||||
viper.SetDefault("defaultlanguage", "")
|
||||
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
|
||||
viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs)
|
||||
viper.SetDefault("uicoverartsize", consts.DefaultUICoverArtSize)
|
||||
viper.SetDefault("enablereplaygain", true)
|
||||
viper.SetDefault("enablecoveranimation", true)
|
||||
viper.SetDefault("enablenowplaying", true)
|
||||
viper.SetDefault("enablecoverartupload", true)
|
||||
viper.SetDefault("enableartworkupload", true)
|
||||
viper.SetDefault("enablesharing", false)
|
||||
viper.SetDefault("shareurl", "")
|
||||
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
|
||||
@ -707,14 +779,12 @@ func setViperDefaults() {
|
||||
viper.SetDefault("subsonic.enableaveragerating", true)
|
||||
viper.SetDefault("subsonic.legacyclients", "DSub")
|
||||
viper.SetDefault("subsonic.minimalclients", "SubMusic")
|
||||
viper.SetDefault("agents", "lastfm,spotify,deezer")
|
||||
viper.SetDefault("agents", "deezer,lastfm,listenbrainz")
|
||||
viper.SetDefault("lastfm.enabled", true)
|
||||
viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage)
|
||||
viper.SetDefault("lastfm.apikey", "")
|
||||
viper.SetDefault("lastfm.secret", "")
|
||||
viper.SetDefault("lastfm.scrobblefirstartistonly", false)
|
||||
viper.SetDefault("spotify.id", "")
|
||||
viper.SetDefault("spotify.secret", "")
|
||||
viper.SetDefault("deezer.enabled", true)
|
||||
viper.SetDefault("deezer.language", consts.DefaultInfoLanguage)
|
||||
viper.SetDefault("listenbrainz.enabled", true)
|
||||
@ -736,6 +806,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("plugins.enabled", true)
|
||||
viper.SetDefault("plugins.cachesize", "200MB")
|
||||
viper.SetDefault("plugins.autoreload", false)
|
||||
viper.SetDefault("plugins.loglevel", "")
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
viper.SetDefault("devlogsourceline", false)
|
||||
@ -749,7 +820,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("devuishowconfig", true)
|
||||
viper.SetDefault("devneweventstream", true)
|
||||
viper.SetDefault("devoffsetoptimize", 50000)
|
||||
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/3))
|
||||
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2))
|
||||
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
||||
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
|
||||
@ -801,8 +872,7 @@ func InitConfig(cfgFile string, loadEnvVars bool) {
|
||||
|
||||
err := viper.ReadInConfig()
|
||||
if viper.ConfigFileUsed() != "" && err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Navidrome could not open config file: ", err)
|
||||
os.Exit(1)
|
||||
logFatal("Navidrome could not open config file:", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ package conf_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@ -24,6 +25,11 @@ var _ = Describe("Configuration", func() {
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("loglevel", "error")
|
||||
conf.ResetConf()
|
||||
|
||||
// Panic instead of exiting on fatal errors to allow testing error conditions
|
||||
DeferCleanup(conf.SetLogFatal(func(args ...any) {
|
||||
panic(fmt.Sprint(args...))
|
||||
}))
|
||||
})
|
||||
|
||||
Describe("ParseLanguages", func() {
|
||||
@ -108,6 +114,111 @@ var _ = Describe("Configuration", func() {
|
||||
Entry("falls back to 'fts' for empty string", "", "fts"),
|
||||
)
|
||||
|
||||
DescribeTable("ToPascalCase",
|
||||
func(input, expected string) {
|
||||
Expect(conf.ToPascalCase(input)).To(Equal(expected))
|
||||
},
|
||||
Entry("simple key", "address", "Address"),
|
||||
Entry("dotted key", "scanner.schedule", "Scanner.Schedule"),
|
||||
Entry("already capitalized", "Address", "Address"),
|
||||
Entry("multi-segment", "lastfm.enabled", "Lastfm.Enabled"),
|
||||
Entry("empty string", "", ""),
|
||||
)
|
||||
|
||||
Describe("remapEnvVarKeysFromConfig", func() {
|
||||
BeforeEach(func() {
|
||||
viper.Reset()
|
||||
conf.SetViperDefaults()
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("loglevel", "error")
|
||||
conf.ResetConf()
|
||||
})
|
||||
|
||||
It("remaps ND_-prefixed keys to canonical keys", func() {
|
||||
filename := filepath.Join("testdata", "cfg_nd_keys.toml")
|
||||
conf.InitConfig(filename, false)
|
||||
conf.Load(true)
|
||||
|
||||
Expect(conf.Server.Address).To(Equal("127.0.0.1"))
|
||||
Expect(conf.Server.Port).To(Equal(4531))
|
||||
Expect(conf.Server.Scanner.Schedule).To(Equal("@every 1h"))
|
||||
})
|
||||
|
||||
It("exits with fatal error when both ND_ and canonical key exist", func() {
|
||||
filename := filepath.Join("testdata", "cfg_nd_conflict.toml")
|
||||
conf.InitConfig(filename, false)
|
||||
|
||||
Expect(func() { conf.Load(true) }).To(PanicWith(And(
|
||||
ContainSubstring("ND_ADDRESS"),
|
||||
ContainSubstring("Address"),
|
||||
ContainSubstring("only needed for environment variables"),
|
||||
)))
|
||||
})
|
||||
|
||||
It("does nothing when no ND_ keys are present", func() {
|
||||
filename := filepath.Join("testdata", "cfg.toml")
|
||||
conf.InitConfig(filename, false)
|
||||
conf.Load(true)
|
||||
|
||||
// Verify normal config loading still works
|
||||
Expect(conf.Server.MusicFolder).To(Equal("/toml/music"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("logFatal", func() {
|
||||
var invalidPath string
|
||||
BeforeEach(func() {
|
||||
viper.Reset()
|
||||
conf.SetViperDefaults()
|
||||
viper.SetDefault("loglevel", "error")
|
||||
conf.ResetConf()
|
||||
|
||||
// Create a file so that any path under it is invalid on all OSes
|
||||
f, err := os.CreateTemp(GinkgoT().TempDir(), "blocker")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
f.Close()
|
||||
invalidPath = filepath.Join(f.Name(), "subdir")
|
||||
})
|
||||
|
||||
It("is called when LoadFromFile gets an invalid config file", func() {
|
||||
Expect(func() {
|
||||
conf.LoadFromFile(filepath.Join(invalidPath, "file.toml"))
|
||||
}).To(PanicWith(ContainSubstring("Error reading config file")))
|
||||
})
|
||||
|
||||
It("is called when DataFolder is not writable", func() {
|
||||
viper.SetDefault("datafolder", invalidPath)
|
||||
Expect(func() {
|
||||
conf.Load(true)
|
||||
}).To(PanicWith(ContainSubstring("Error creating data path")))
|
||||
})
|
||||
|
||||
It("is called when CacheFolder is not writable", func() {
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("cachefolder", invalidPath)
|
||||
Expect(func() {
|
||||
conf.Load(true)
|
||||
}).To(PanicWith(ContainSubstring("Error creating cache path")))
|
||||
})
|
||||
|
||||
It("is called when LogFile path is not writable", func() {
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("logfile", filepath.Join(invalidPath, "log.txt"))
|
||||
Expect(func() {
|
||||
conf.Load(true)
|
||||
}).To(PanicWith(ContainSubstring("Error opening log file")))
|
||||
})
|
||||
|
||||
It("is called when BaseURL is invalid", func() {
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("baseurl", "://invalid")
|
||||
Expect(func() {
|
||||
conf.Load(true)
|
||||
}).To(PanicWith(ContainSubstring("Invalid BaseURL")))
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
DescribeTable("should load configuration from",
|
||||
func(format string) {
|
||||
filename := filepath.Join("testdata", "cfg."+format)
|
||||
|
||||
@ -11,3 +11,11 @@ var ParseLanguages = parseLanguages
|
||||
var ValidateURL = validateURL
|
||||
|
||||
var NormalizeSearchBackend = normalizeSearchBackend
|
||||
|
||||
var ToPascalCase = toPascalCase
|
||||
|
||||
func SetLogFatal(f func(...any)) func() {
|
||||
old := logFatal
|
||||
logFatal = f
|
||||
return func() { logFatal = old }
|
||||
}
|
||||
|
||||
2
conf/testdata/cfg_nd_conflict.toml
vendored
Normal file
2
conf/testdata/cfg_nd_conflict.toml
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
ND_ADDRESS = "127.0.0.1"
|
||||
Address = "0.0.0.0"
|
||||
3
conf/testdata/cfg_nd_keys.toml
vendored
Normal file
3
conf/testdata/cfg_nd_keys.toml
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
ND_ADDRESS = "127.0.0.1"
|
||||
ND_PORT = 4531
|
||||
ND_SCANNER_SCHEDULE = "@every 1h"
|
||||
@ -70,7 +70,6 @@ const (
|
||||
PlaceholderArtistArt = "artist-placeholder.webp"
|
||||
PlaceholderAlbumArt = "album-placeholder.webp"
|
||||
PlaceholderAvatar = "logo-192x192.png"
|
||||
UICoverArtSize = 300
|
||||
DefaultUIVolume = 100
|
||||
DefaultUISearchDebounceMs = 200
|
||||
|
||||
@ -85,6 +84,10 @@ const (
|
||||
Zwsp = string('\u200b')
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultUICoverArtSize = 300
|
||||
)
|
||||
|
||||
// Prometheus options
|
||||
const (
|
||||
PrometheusDefaultPath = "/metrics"
|
||||
@ -103,6 +106,13 @@ const (
|
||||
DefaultCacheCleanUpInterval = 10 * time.Minute
|
||||
)
|
||||
|
||||
// Entity types
|
||||
const (
|
||||
EntityArtist = "artist"
|
||||
EntityPlaylist = "playlist"
|
||||
EntityRadio = "radio"
|
||||
)
|
||||
|
||||
const (
|
||||
AlbumPlayCountModeAbsolute = "absolute"
|
||||
AlbumPlayCountModeNormalized = "normalized"
|
||||
@ -153,7 +163,7 @@ var (
|
||||
Name: "aac audio",
|
||||
TargetFormat: "aac",
|
||||
DefaultBitRate: 256,
|
||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -",
|
||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
|
||||
},
|
||||
{
|
||||
Name: "flac audio",
|
||||
|
||||
@ -7,6 +7,6 @@ A new agent must comply with these simple implementation rules:
|
||||
2) Implement one or more of the `*Retriever()` interfaces. That's where the agent's logic resides.
|
||||
3) Register itself (in its `init()` function).
|
||||
|
||||
For an agent to be used it needs to be listed in the `Agents` config option (default is `"lastfm,spotify"`). The order dictates the priority of the agents
|
||||
For an agent to be used it needs to be listed in the `Agents` config option (default is `"deezer,lastfm"`). The order dictates the priority of the agents
|
||||
|
||||
For a simple Agent example, look at the [local_agent](local_agent.go) agent source code.
|
||||
|
||||
120
core/artwork/animation.go
Normal file
120
core/artwork/animation.go
Normal file
@ -0,0 +1,120 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
)
|
||||
|
||||
// isAnimatedGIF checks for multiple image descriptor blocks (0x2C) in a GIF file.
|
||||
// Animated GIFs use GIF89a and contain multiple image blocks.
|
||||
func isAnimatedGIF(data []byte) bool {
|
||||
// GIF header: "GIF87a" or "GIF89a"
|
||||
if !bytes.HasPrefix(data, []byte("GIF")) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Skip header (6 bytes) + logical screen descriptor (7 bytes)
|
||||
pos := 13
|
||||
if pos >= len(data) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Skip Global Color Table if present (bit 7 of packed byte at offset 10)
|
||||
if len(data) > 10 && data[10]&0x80 != 0 {
|
||||
// GCT size = 3 * 2^(N+1) where N = bits 0-2 of packed byte
|
||||
gctSize := 3 * (1 << ((data[10] & 0x07) + 1))
|
||||
pos += gctSize
|
||||
}
|
||||
|
||||
frameCount := 0
|
||||
for pos < len(data) {
|
||||
switch data[pos] {
|
||||
case 0x2C: // Image Descriptor - marks a frame
|
||||
frameCount++
|
||||
if frameCount > 1 {
|
||||
return true
|
||||
}
|
||||
pos++ // skip introducer
|
||||
if pos+8 >= len(data) {
|
||||
return false
|
||||
}
|
||||
pos += 8 // skip x, y, w, h (each 2 bytes)
|
||||
packed := data[pos]
|
||||
pos++ // skip packed byte
|
||||
// Skip Local Color Table if present
|
||||
if packed&0x80 != 0 {
|
||||
lctSize := 3 * (1 << ((packed & 0x07) + 1))
|
||||
pos += lctSize
|
||||
}
|
||||
// Skip LZW minimum code size
|
||||
pos++
|
||||
// Skip sub-blocks
|
||||
pos = skipGIFSubBlocks(data, pos)
|
||||
case 0x21: // Extension block
|
||||
pos++ // skip introducer
|
||||
if pos >= len(data) {
|
||||
return false
|
||||
}
|
||||
pos++ // skip extension label
|
||||
// Skip sub-blocks
|
||||
pos = skipGIFSubBlocks(data, pos)
|
||||
case 0x3B: // Trailer
|
||||
return false
|
||||
default:
|
||||
// Unknown block, bail
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// skipGIFSubBlocks advances past a sequence of GIF sub-blocks (terminated by a zero-length block).
|
||||
func skipGIFSubBlocks(data []byte, pos int) int {
|
||||
for pos < len(data) {
|
||||
blockSize := int(data[pos])
|
||||
pos++ // skip size byte
|
||||
if blockSize == 0 {
|
||||
break
|
||||
}
|
||||
pos += blockSize
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
// isAnimatedWebP checks for ANMF (animation frame) chunks in a WebP RIFF container.
|
||||
func isAnimatedWebP(data []byte) bool {
|
||||
// WebP header: "RIFF" + 4 bytes size + "WEBP"
|
||||
if !bytes.HasPrefix(data, []byte("RIFF")) || len(data) < 12 {
|
||||
return false
|
||||
}
|
||||
if !bytes.Equal(data[8:12], []byte("WEBP")) {
|
||||
return false
|
||||
}
|
||||
// Scan for ANMF chunk identifier
|
||||
return bytes.Contains(data[12:], []byte("ANMF"))
|
||||
}
|
||||
|
||||
// isAnimatedPNG checks for the acTL (animation control) chunk in a PNG file.
|
||||
// APNG files contain an acTL chunk that is not present in static PNGs.
|
||||
func isAnimatedPNG(data []byte) bool {
|
||||
// PNG signature: 8 bytes
|
||||
pngSig := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
|
||||
if !bytes.HasPrefix(data, pngSig) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Scan chunks for "acTL" (animation control)
|
||||
pos := uint64(8)
|
||||
dataLen := uint64(len(data))
|
||||
for pos+8 <= dataLen {
|
||||
chunkLen := uint64(binary.BigEndian.Uint32(data[pos : pos+4]))
|
||||
chunkType := string(data[pos+4 : pos+8])
|
||||
|
||||
if chunkType == "acTL" {
|
||||
return true
|
||||
}
|
||||
// Move to next chunk: 4 (length) + 4 (type) + chunkLen (data) + 4 (CRC)
|
||||
pos += 12 + chunkLen
|
||||
}
|
||||
return false
|
||||
}
|
||||
161
core/artwork/animation_test.go
Normal file
161
core/artwork/animation_test.go
Normal file
@ -0,0 +1,161 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/gif"
|
||||
"image/png"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Animation detection", func() {
|
||||
Describe("isAnimatedGIF", func() {
|
||||
It("detects an animated GIF with multiple frames", func() {
|
||||
Expect(isAnimatedGIF(createAnimatedGIF(2))).To(BeTrue())
|
||||
})
|
||||
|
||||
It("detects an animated GIF with many frames", func() {
|
||||
Expect(isAnimatedGIF(createAnimatedGIF(5))).To(BeTrue())
|
||||
})
|
||||
|
||||
It("does not flag a static GIF (single frame)", func() {
|
||||
Expect(isAnimatedGIF(createAnimatedGIF(1))).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false for non-GIF data", func() {
|
||||
Expect(isAnimatedGIF(nil)).To(BeFalse())
|
||||
Expect(isAnimatedGIF([]byte{0xFF, 0xD8})).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("isAnimatedWebP", func() {
|
||||
It("detects an animated WebP with ANMF chunk", func() {
|
||||
Expect(isAnimatedWebP(createAnimatedWebPBytes())).To(BeTrue())
|
||||
})
|
||||
|
||||
It("does not flag a static WebP (no ANMF chunk)", func() {
|
||||
Expect(isAnimatedWebP(createStaticWebPBytes())).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false for non-WebP data", func() {
|
||||
Expect(isAnimatedWebP(nil)).To(BeFalse())
|
||||
Expect(isAnimatedWebP([]byte{0xFF, 0xD8})).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("isAnimatedPNG", func() {
|
||||
It("detects an APNG with acTL chunk", func() {
|
||||
Expect(isAnimatedPNG(createAPNGBytes())).To(BeTrue())
|
||||
})
|
||||
|
||||
It("does not flag a static PNG (no acTL chunk)", func() {
|
||||
Expect(isAnimatedPNG(createStaticPNGBytes())).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false for non-PNG data", func() {
|
||||
Expect(isAnimatedPNG(nil)).To(BeFalse())
|
||||
Expect(isAnimatedPNG([]byte{0xFF, 0xD8})).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// createAnimatedGIF creates a minimal animated GIF with the given number of frames.
|
||||
func createAnimatedGIF(frames int) []byte {
|
||||
g := &gif.GIF{
|
||||
LoopCount: 0,
|
||||
}
|
||||
for range frames {
|
||||
img := image.NewPaletted(image.Rect(0, 0, 2, 2), color.Palette{color.Black, color.White})
|
||||
g.Image = append(g.Image, img)
|
||||
g.Delay = append(g.Delay, 10)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
err := gif.EncodeAll(&buf, g)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// writeUint32LE appends a little-endian uint32 to the buffer.
|
||||
func writeUint32LE(buf *bytes.Buffer, v uint32) {
|
||||
b := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(b, v)
|
||||
buf.Write(b)
|
||||
}
|
||||
|
||||
// writeUint32BE appends a big-endian uint32 to the buffer.
|
||||
func writeUint32BE(buf *bytes.Buffer, v uint32) {
|
||||
b := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(b, v)
|
||||
buf.Write(b)
|
||||
}
|
||||
|
||||
// createAnimatedWebPBytes creates a minimal RIFF/WEBP container with an ANMF chunk.
|
||||
func createAnimatedWebPBytes() []byte {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("RIFF")
|
||||
writeUint32LE(&buf, 100) // file size placeholder
|
||||
buf.WriteString("WEBP")
|
||||
// VP8X chunk (extended format, required for animation)
|
||||
buf.WriteString("VP8X")
|
||||
writeUint32LE(&buf, 10)
|
||||
buf.Write(make([]byte, 10))
|
||||
// ANIM chunk (animation parameters)
|
||||
buf.WriteString("ANIM")
|
||||
writeUint32LE(&buf, 6)
|
||||
buf.Write(make([]byte, 6))
|
||||
// ANMF chunk (animation frame)
|
||||
buf.WriteString("ANMF")
|
||||
writeUint32LE(&buf, 16)
|
||||
buf.Write(make([]byte, 16))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// createStaticWebPBytes creates a minimal RIFF/WEBP container without ANMF chunks.
|
||||
func createStaticWebPBytes() []byte {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("RIFF")
|
||||
writeUint32LE(&buf, 20) // file size
|
||||
buf.WriteString("WEBP")
|
||||
// VP8 chunk (simple lossy format)
|
||||
buf.WriteString("VP8 ")
|
||||
writeUint32LE(&buf, 4)
|
||||
buf.Write(make([]byte, 4))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// createAPNGBytes creates a minimal PNG with an acTL chunk (making it APNG).
|
||||
func createAPNGBytes() []byte {
|
||||
// Start with a real PNG
|
||||
staticPNG := createStaticPNGBytes()
|
||||
|
||||
// Insert an acTL chunk after the IHDR chunk.
|
||||
// PNG structure: signature (8) + IHDR chunk (4 len + 4 type + 13 data + 4 crc = 25)
|
||||
ihdrEnd := 8 + 25
|
||||
var buf bytes.Buffer
|
||||
buf.Write(staticPNG[:ihdrEnd])
|
||||
// Write acTL chunk: length=8, type="acTL", data=num_frames(4)+num_plays(4), CRC=4
|
||||
writeUint32BE(&buf, 8) // chunk data length
|
||||
buf.WriteString("acTL")
|
||||
writeUint32BE(&buf, 2) // num_frames
|
||||
writeUint32BE(&buf, 0) // num_plays (0 = infinite)
|
||||
writeUint32BE(&buf, 0) // CRC placeholder
|
||||
buf.Write(staticPNG[ihdrEnd:])
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// createStaticPNGBytes creates a minimal valid static PNG.
|
||||
func createStaticPNGBytes() []byte {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
||||
var buf bytes.Buffer
|
||||
err := png.Encode(&buf, img)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
@ -122,6 +122,10 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
|
||||
artReader, err = newMediafileArtworkReader(ctx, a, artID)
|
||||
case model.KindPlaylistArtwork:
|
||||
artReader, err = newPlaylistArtworkReader(ctx, a, artID)
|
||||
case model.KindDiscArtwork:
|
||||
artReader, err = newDiscArtworkReader(ctx, a, artID)
|
||||
case model.KindRadioArtwork:
|
||||
artReader, err = newRadioArtworkReader(ctx, a, artID)
|
||||
default:
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
|
||||
@ -7,9 +7,12 @@ import (
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "github.com/gen2brain/webp"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@ -25,7 +28,7 @@ var _ = Describe("Artwork", func() {
|
||||
var ffmpeg *tests.MockFFmpeg
|
||||
var folderRepo *fakeFolderRepo
|
||||
ctx := log.NewContext(context.TODO())
|
||||
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers model.Album
|
||||
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers, alSingleDisc model.Album
|
||||
var arMultipleCovers model.Artist
|
||||
var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile
|
||||
|
||||
@ -41,8 +44,9 @@ var _ = Describe("Artwork", func() {
|
||||
}
|
||||
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}
|
||||
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}}
|
||||
alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}}
|
||||
alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "", 2: ""}}
|
||||
alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}}
|
||||
alSingleDisc = model.Album{ID: "888", Name: "Single disc", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}}
|
||||
arMultipleCovers = model.Artist{ID: "777", Name: "All options"}
|
||||
alMultipleCovers = model.Album{
|
||||
ID: "666",
|
||||
@ -190,6 +194,7 @@ var _ = Describe("Artwork", func() {
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alOnlyEmbed,
|
||||
alOnlyExternal,
|
||||
alSingleDisc,
|
||||
})
|
||||
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
mfWithEmbed,
|
||||
@ -233,6 +238,28 @@ var _ = Describe("Artwork", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("al-444_0"))
|
||||
})
|
||||
It("falls back to disc cover art when media file has a disc number on a multi-disc album", func() {
|
||||
mfWithDisc := model.MediaFile{ID: "46", Path: "tests/fixtures/test.ogg", AlbumID: "444", DiscNumber: 2}
|
||||
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfWithDisc)).To(Succeed())
|
||||
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithDisc.ID))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should fall back to disc art, which itself falls back to album art
|
||||
Expect(path).To(Equal("dc-444:2_0"))
|
||||
})
|
||||
It("falls back to album cover art for single-disc albums even with a disc number", func() {
|
||||
mfOnSingleDisc := model.MediaFile{ID: "47", Path: "tests/fixtures/test.ogg", AlbumID: "888", DiscNumber: 1}
|
||||
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfOnSingleDisc)).To(Succeed())
|
||||
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfOnSingleDisc.ID))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Single-disc album should skip disc art and go straight to album art
|
||||
Expect(path).To(Equal("al-888_0"))
|
||||
})
|
||||
})
|
||||
})
|
||||
Describe("playlistArtworkReader", func() {
|
||||
@ -353,7 +380,7 @@ var _ = Describe("Artwork", func() {
|
||||
})
|
||||
})
|
||||
When("Square is false", func() {
|
||||
It("returns a PNG if original image is a PNG", func() {
|
||||
It("returns PNG if original image is a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@ -364,7 +391,7 @@ var _ = Describe("Artwork", func() {
|
||||
Expect(img.Bounds().Size().X).To(Equal(15))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||
})
|
||||
It("returns a JPEG if original image is not a PNG", func() {
|
||||
It("returns JPEG if original image is not a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@ -380,9 +407,9 @@ var _ = Describe("Artwork", func() {
|
||||
var alCover model.Album
|
||||
|
||||
DescribeTable("resize",
|
||||
func(format string, landscape bool, size int) {
|
||||
coverFileName := "cover." + format
|
||||
dirName := createImage(format, landscape, size)
|
||||
func(srcFormat string, expectedFormat string, landscape bool, size int) {
|
||||
coverFileName := "cover." + srcFormat
|
||||
dirName := createImage(srcFormat, landscape, size)
|
||||
alCover = model.Album{
|
||||
ID: "444",
|
||||
Name: "Only external",
|
||||
@ -399,16 +426,97 @@ var _ = Describe("Artwork", func() {
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(format).To(Equal(expectedFormat))
|
||||
Expect(img.Bounds().Size().X).To(Equal(size))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(size))
|
||||
},
|
||||
Entry("portrait png image", "png", false, 200),
|
||||
Entry("landscape png image", "png", true, 200),
|
||||
Entry("portrait jpg image", "jpg", false, 200),
|
||||
Entry("landscape jpg image", "jpg", true, 200),
|
||||
Entry("portrait png image", "png", "png", false, 200),
|
||||
Entry("landscape png image", "png", "png", true, 200),
|
||||
Entry("portrait jpg image", "jpg", "png", false, 200),
|
||||
Entry("landscape jpg image", "jpg", "png", true, 200),
|
||||
)
|
||||
})
|
||||
When("EnableWebPEncoding is true and square is false", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.EnableWebPEncoding = true
|
||||
})
|
||||
It("returns WebP even if original image is a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("webp"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(15))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||
})
|
||||
It("returns WebP if original image is not a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(format).To(Equal("webp"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
})
|
||||
When("EnableWebPEncoding is false and square is false", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.EnableWebPEncoding = false
|
||||
})
|
||||
It("returns PNG if original image is a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(15))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||
})
|
||||
It("returns JPEG if original image is a JPG", func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
})
|
||||
When("EnableWebPEncoding is false and square is true", func() {
|
||||
var alCover model.Album
|
||||
|
||||
BeforeEach(func() {
|
||||
conf.Server.EnableWebPEncoding = false
|
||||
})
|
||||
It("returns PNG for square mode", func() {
|
||||
dirName := createImage("png", false, 200)
|
||||
alCover = model.Album{
|
||||
ID: "444",
|
||||
Name: "Only external",
|
||||
FolderIDs: []string{"tmp"},
|
||||
}
|
||||
folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{"cover.png"}}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{alCover})
|
||||
|
||||
conf.Server.CoverArtPriority = "cover.png"
|
||||
r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), 200, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
})
|
||||
When("Requested size is larger than original", func() {
|
||||
It("clamps size to original dimensions", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
|
||||
37
core/artwork/benchmark_decode_test.go
Normal file
37
core/artwork/benchmark_decode_test.go
Normal file
@ -0,0 +1,37 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func BenchmarkImageDecode(b *testing.B) {
|
||||
sizes := []int{300, 1000, 3000}
|
||||
formats := []struct {
|
||||
name string
|
||||
gen func(tb testing.TB, w, h int) []byte
|
||||
}{
|
||||
{"jpeg", func(tb testing.TB, w, h int) []byte { return generateJPEG(tb, w, h, 75) }},
|
||||
{"png", func(tb testing.TB, w, h int) []byte { return generatePNG(tb, w, h) }},
|
||||
}
|
||||
|
||||
for _, format := range formats {
|
||||
for _, size := range sizes {
|
||||
data := format.gen(b, size, size)
|
||||
b.Run(fmt.Sprintf("%s/%dx%d", format.name, size, size), func(b *testing.B) {
|
||||
b.SetBytes(int64(len(data)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
189
core/artwork/benchmark_e2e_test.go
Normal file
189
core/artwork/benchmark_e2e_test.go
Normal file
@ -0,0 +1,189 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
)
|
||||
|
||||
// setupE2EBenchmark creates an artwork instance with a real album cover image on disk,
|
||||
// backed by either a real file cache or disabled cache depending on cacheSize.
|
||||
// Note: This benchmarks artwork.Get() directly (not the full HTTP handler), which covers
|
||||
// the critical path (source selection, decode, resize, encode, cache). This is a deliberate
|
||||
// spec deviation — the full HTTP round-trip benchmark requires significant infrastructure
|
||||
// (DB, scanner, fake filesystem) and can be added later if HTTP overhead proves significant.
|
||||
//
|
||||
// Depends on fakeFolderRepo defined in reader_artist_test.go (same package, compiled together).
|
||||
func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID, func()) {
|
||||
b.Helper()
|
||||
cleanup := configtest.SetupConfig()
|
||||
b.Cleanup(cleanup)
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "artwork-bench-*")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a realistic cover image on disk
|
||||
coverPath := filepath.Join(tmpDir, "cover.jpg")
|
||||
coverImg := generateGradientImage(1000, 1000)
|
||||
f, err := os.Create(coverPath)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if err := jpeg.Encode(f, coverImg, &jpeg.Options{Quality: 90}); err != nil {
|
||||
f.Close()
|
||||
b.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
// Configure cache
|
||||
conf.Server.ImageCacheSize = cacheSize
|
||||
conf.Server.CacheFolder = tmpDir
|
||||
conf.Server.CoverArtQuality = 75
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
|
||||
// Set up mock data store with album pointing to our cover.
|
||||
// Set UpdatedAt so CoverArtID().LastUpdate is consistent across calls.
|
||||
album := model.Album{
|
||||
ID: "bench-album-1",
|
||||
Name: "Benchmark Album",
|
||||
FolderIDs: []string{"f1"},
|
||||
UpdatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
folderRepo := &fakeFolderRepo{
|
||||
result: []model.Folder{{
|
||||
Path: tmpDir,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}},
|
||||
}
|
||||
ds := &tests.MockDataStore{
|
||||
MockedTranscoding: &tests.MockTranscodingRepo{},
|
||||
MockedFolder: folderRepo,
|
||||
}
|
||||
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{album})
|
||||
|
||||
artID := album.CoverArtID()
|
||||
|
||||
imgCache := cache.NewFileCache("BenchImage", cacheSize, "bench-images", 0,
|
||||
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
|
||||
r, _, err := arg.(artworkReader).Reader(ctx)
|
||||
return r, err
|
||||
})
|
||||
|
||||
// Wait for cache init if enabled
|
||||
if cacheSize != "0" {
|
||||
for !imgCache.Available(context.Background()) && !imgCache.Disabled(context.Background()) {
|
||||
runtime.Gosched() // Yield to allow background init goroutine to run
|
||||
}
|
||||
}
|
||||
|
||||
ffmpeg := tests.NewMockFFmpeg("fallback content")
|
||||
aw := NewArtwork(ds, imgCache, ffmpeg, nil)
|
||||
|
||||
cleanupAll := func() {
|
||||
os.RemoveAll(tmpDir)
|
||||
}
|
||||
return aw, artID, cleanupAll
|
||||
}
|
||||
|
||||
func BenchmarkArtworkGetE2E(b *testing.B) {
|
||||
cacheConfigs := []struct {
|
||||
name string
|
||||
cacheSize string
|
||||
}{
|
||||
{"no_cache", "0"},
|
||||
{"with_cache", "100MB"},
|
||||
}
|
||||
sizes := []int{0, 300}
|
||||
|
||||
for _, cc := range cacheConfigs {
|
||||
for _, size := range sizes {
|
||||
b.Run(fmt.Sprintf("%s/size_%d", cc.name, size), func(b *testing.B) {
|
||||
aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize)
|
||||
defer cleanup()
|
||||
|
||||
// Warm the cache on first call if cache is enabled
|
||||
if cc.cacheSize != "0" {
|
||||
r, _, err := aw.Get(context.Background(), artID, size, size > 0)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
_, _ = io.ReadAll(r)
|
||||
r.Close()
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
r, _, err := aw.Get(context.Background(), artID, size, size > 0)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
_, _ = io.ReadAll(r)
|
||||
r.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkArtworkGetE2EConcurrent(b *testing.B) {
|
||||
cacheConfigs := []struct {
|
||||
name string
|
||||
cacheSize string
|
||||
}{
|
||||
{"no_cache", "0"},
|
||||
{"with_cache", "100MB"},
|
||||
}
|
||||
concurrencyLevels := []int{10, 50}
|
||||
|
||||
for _, cc := range cacheConfigs {
|
||||
for _, n := range concurrencyLevels {
|
||||
b.Run(fmt.Sprintf("%s/goroutines_%d", cc.name, n), func(b *testing.B) {
|
||||
aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize)
|
||||
defer cleanup()
|
||||
|
||||
// Warm cache
|
||||
if cc.cacheSize != "0" {
|
||||
r, _, _ := aw.Get(context.Background(), artID, 300, true)
|
||||
if r != nil {
|
||||
_, _ = io.ReadAll(r)
|
||||
r.Close()
|
||||
}
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(n)
|
||||
for g := 0; g < n; g++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
r, _, err := aw.Get(context.Background(), artID, 300, true)
|
||||
if err != nil {
|
||||
b.Error(err)
|
||||
return
|
||||
}
|
||||
_, _ = io.ReadAll(r)
|
||||
r.Close()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
40
core/artwork/benchmark_encode_test.go
Normal file
40
core/artwork/benchmark_encode_test.go
Normal file
@ -0,0 +1,40 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func BenchmarkImageEncode(b *testing.B) {
|
||||
img := generateGradientImage(300, 300)
|
||||
|
||||
jpegQualities := []int{60, 75, 90}
|
||||
for _, q := range jpegQualities {
|
||||
b.Run(fmt.Sprintf("jpeg/q%d/300x300", q), func(b *testing.B) {
|
||||
var buf bytes.Buffer
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
buf.Reset()
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: q}); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
b.ReportMetric(float64(buf.Len()), "bytes")
|
||||
})
|
||||
}
|
||||
|
||||
b.Run("png/300x300", func(b *testing.B) {
|
||||
var buf bytes.Buffer
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
buf.Reset()
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
b.ReportMetric(float64(buf.Len()), "bytes")
|
||||
})
|
||||
}
|
||||
47
core/artwork/benchmark_helpers_test.go
Normal file
47
core/artwork/benchmark_helpers_test.go
Normal file
@ -0,0 +1,47 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// generateJPEG creates a JPEG image of the given dimensions with a gradient pattern.
|
||||
// The gradient ensures the image has realistic entropy (not trivially compressible).
|
||||
func generateJPEG(t testing.TB, width, height, quality int) []byte {
|
||||
t.Helper()
|
||||
img := generateGradientImage(width, height)
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// generatePNG creates a PNG image of the given dimensions with a gradient pattern.
|
||||
func generatePNG(t testing.TB, width, height int) []byte {
|
||||
t.Helper()
|
||||
img := generateGradientImage(width, height)
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// generateGradientImage creates an RGBA image with a diagonal gradient pattern.
|
||||
func generateGradientImage(width, height int) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
r := uint8((x * 255) / width)
|
||||
g := uint8((y * 255) / height)
|
||||
b := uint8(((x + y) * 255) / (width + height))
|
||||
img.Set(x, y, color.RGBA{R: r, G: g, B: b, A: 255})
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
50
core/artwork/benchmark_pipeline_test.go
Normal file
50
core/artwork/benchmark_pipeline_test.go
Normal file
@ -0,0 +1,50 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
)
|
||||
|
||||
func BenchmarkResizeFullPipeline(b *testing.B) {
|
||||
cleanup := configtest.SetupConfig()
|
||||
b.Cleanup(cleanup)
|
||||
conf.Server.CoverArtQuality = 75
|
||||
|
||||
sourceSizes := []int{1000, 3000}
|
||||
targetSize := 300
|
||||
|
||||
for _, srcSize := range sourceSizes {
|
||||
jpegData := generateJPEG(b, srcSize, srcSize, 90)
|
||||
|
||||
b.Run(fmt.Sprintf("jpeg/%dx%d_to_%d", srcSize, srcSize, targetSize), func(b *testing.B) {
|
||||
b.SetBytes(int64(len(jpegData)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
result, _, err := resizeStaticImage(jpegData, targetSize, false)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if result == nil {
|
||||
b.Fatal("expected non-nil resized image")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
b.Run(fmt.Sprintf("jpeg/%dx%d_to_%d_square", srcSize, srcSize, targetSize), func(b *testing.B) {
|
||||
b.SetBytes(int64(len(jpegData)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
result, _, err := resizeStaticImage(jpegData, targetSize, true)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if result == nil {
|
||||
b.Fatal("expected non-nil resized image")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
38
core/artwork/benchmark_tag_test.go
Normal file
38
core/artwork/benchmark_tag_test.go
Normal file
@ -0,0 +1,38 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"go.senan.xyz/taglib"
|
||||
)
|
||||
|
||||
func BenchmarkTagExtraction(b *testing.B) {
|
||||
// Ensure working directory is the project root (tests.Init not called with -run='^$')
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
b.Fatal("runtime.Caller failed")
|
||||
}
|
||||
appPath, _ := filepath.Abs(filepath.Join(filepath.Dir(file), "..", ".."))
|
||||
|
||||
// Use existing test fixture with embedded artwork
|
||||
testFile := filepath.Join(appPath, "tests/fixtures/artist/an-album/test.mp3")
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
f, err := taglib.OpenReadOnly(testFile, taglib.WithReadStyle(taglib.ReadStyleFast))
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
images := f.Properties().Images
|
||||
if len(images) == 0 {
|
||||
b.Fatal("no images found in test file")
|
||||
}
|
||||
data, err := f.Image(0)
|
||||
if err != nil || len(data) == 0 {
|
||||
b.Fatal("failed to extract image data")
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
@ -10,7 +10,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@ -24,7 +23,7 @@ type CacheWarmer interface {
|
||||
|
||||
// NewCacheWarmer creates a new CacheWarmer instance. The CacheWarmer will pre-cache Artwork images in the background
|
||||
// to speed up the response time when the image is requested by the UI. The cache is pre-populated with the original
|
||||
// image size, as well as the size defined in the UICoverArtSize constant.
|
||||
// image size, as well as the size defined by the UICoverArtSize config option.
|
||||
func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
||||
// If image cache is disabled, return a NOOP implementation
|
||||
if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache {
|
||||
@ -38,10 +37,11 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
||||
}
|
||||
|
||||
a := &cacheWarmer{
|
||||
artwork: artwork,
|
||||
cache: cache,
|
||||
buffer: make(map[model.ArtworkID]struct{}),
|
||||
wakeSignal: make(chan struct{}, 1),
|
||||
artwork: artwork,
|
||||
cache: cache,
|
||||
buffer: make(map[model.ArtworkID]struct{}),
|
||||
wakeSignal: make(chan struct{}, 1),
|
||||
coverArtSize: conf.Server.UICoverArtSize,
|
||||
}
|
||||
|
||||
// Create a context with a fake admin user, to be able to pre-cache Playlist CoverArts
|
||||
@ -51,11 +51,12 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
||||
}
|
||||
|
||||
type cacheWarmer struct {
|
||||
artwork Artwork
|
||||
buffer map[model.ArtworkID]struct{}
|
||||
mutex sync.Mutex
|
||||
cache cache.FileCache
|
||||
wakeSignal chan struct{}
|
||||
artwork Artwork
|
||||
buffer map[model.ArtworkID]struct{}
|
||||
mutex sync.Mutex
|
||||
cache cache.FileCache
|
||||
wakeSignal chan struct{}
|
||||
coverArtSize int
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) PreCache(artID model.ArtworkID) {
|
||||
@ -132,7 +133,7 @@ func (a *cacheWarmer) waitSignal(ctx context.Context, timeout time.Duration) {
|
||||
func (a *cacheWarmer) processBatch(ctx context.Context, batch []model.ArtworkID) {
|
||||
log.Trace(ctx, "PreCaching a new batch of artwork", "batchSize", len(batch))
|
||||
input := pl.FromSlice(ctx, batch)
|
||||
errs := pl.Sink(ctx, 2, input, a.doCacheImage)
|
||||
errs := pl.Sink(ctx, 4, input, a.doCacheImage)
|
||||
for err := range errs {
|
||||
log.Debug(ctx, "Error warming cache", err)
|
||||
}
|
||||
@ -142,16 +143,14 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize, true)
|
||||
size := a.coverArtSize
|
||||
r, _, err := a.artwork.Get(ctx, id, size, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("caching id='%s': %w", id, err)
|
||||
return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err)
|
||||
}
|
||||
defer r.Close()
|
||||
_, err = io.Copy(io.Discard, r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
r.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
func NoopCacheWarmer() CacheWarmer {
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@ -173,20 +174,42 @@ var _ = Describe("CacheWarmer", func() {
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("pre-caches UICoverArtSize", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
|
||||
Eventually(func() []int {
|
||||
return aw.getCachedSizes()
|
||||
}).Should(ContainElements(conf.Server.UICoverArtSize))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockArtwork struct {
|
||||
err error
|
||||
err error
|
||||
mu sync.Mutex
|
||||
cachedSizes []int
|
||||
}
|
||||
|
||||
func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) {
|
||||
if m.err != nil {
|
||||
return nil, time.Time{}, m.err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.cachedSizes = append(m.cachedSizes, size)
|
||||
m.mu.Unlock()
|
||||
return io.NopCloser(strings.NewReader("test")), time.Now(), nil
|
||||
}
|
||||
|
||||
func (m *mockArtwork) getCachedSizes() []int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
result := make([]int, len(m.cachedSizes))
|
||||
copy(result, m.cachedSizes)
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) {
|
||||
return m.Get(ctx, model.ArtworkID{}, size, square)
|
||||
}
|
||||
|
||||
@ -13,13 +13,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/maruel/natural"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/natural"
|
||||
)
|
||||
|
||||
type albumArtworkReader struct {
|
||||
@ -59,10 +59,11 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar
|
||||
}
|
||||
|
||||
func (a *albumArtworkReader) Key() string {
|
||||
var hash [16]byte
|
||||
hashInput := conf.Server.CoverArtPriority
|
||||
if conf.Server.EnableExternalServices {
|
||||
hash = md5.Sum([]byte(conf.Server.Agents + conf.Server.CoverArtPriority))
|
||||
hashInput = conf.Server.Agents + hashInput
|
||||
}
|
||||
hash := md5.Sum([]byte(hashInput))
|
||||
return fmt.Sprintf(
|
||||
"%s.%x.%t",
|
||||
a.cacheKey.Key(),
|
||||
|
||||
@ -29,11 +29,12 @@ const (
|
||||
|
||||
type artistReader struct {
|
||||
cacheKey
|
||||
a *artwork
|
||||
provider external.Provider
|
||||
artist model.Artist
|
||||
artistFolder string
|
||||
imgFiles []string
|
||||
a *artwork
|
||||
provider external.Provider
|
||||
artist model.Artist
|
||||
artistFolder string
|
||||
imgFiles []string
|
||||
imgFolderImgPath string // cached path from ArtistImageFolder lookup
|
||||
}
|
||||
|
||||
func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
|
||||
@ -71,15 +72,26 @@ func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.A
|
||||
//a.cacheKey.lastUpdate = ar.ExternalInfoUpdatedAt
|
||||
|
||||
a.cacheKey.lastUpdate = *imagesUpdatedAt
|
||||
if ar.UpdatedAt != nil && ar.UpdatedAt.After(a.cacheKey.lastUpdate) {
|
||||
a.cacheKey.lastUpdate = *ar.UpdatedAt
|
||||
}
|
||||
if artistFolderLastUpdate.After(a.cacheKey.lastUpdate) {
|
||||
a.cacheKey.lastUpdate = artistFolderLastUpdate
|
||||
}
|
||||
if conf.Server.ArtistImageFolder != "" && strings.Contains(strings.ToLower(conf.Server.ArtistArtPriority), "image-folder") {
|
||||
a.imgFolderImgPath = findImageInArtistFolder(conf.Server.ArtistImageFolder, ar.MbzArtistID, ar.Name)
|
||||
if a.imgFolderImgPath != "" {
|
||||
if info, err := os.Stat(a.imgFolderImgPath); err == nil && info.ModTime().After(a.cacheKey.lastUpdate) {
|
||||
a.cacheKey.lastUpdate = info.ModTime()
|
||||
}
|
||||
}
|
||||
}
|
||||
a.cacheKey.artID = artID
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *artistReader) Key() string {
|
||||
hash := md5.Sum([]byte(conf.Server.Agents + conf.Server.Spotify.ID))
|
||||
hash := md5.Sum([]byte(conf.Server.Agents))
|
||||
return fmt.Sprintf(
|
||||
"%s.%t.%x",
|
||||
a.cacheKey.Key(),
|
||||
@ -93,10 +105,15 @@ func (a *artistReader) LastUpdated() time.Time {
|
||||
}
|
||||
|
||||
func (a *artistReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
var ff = a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority)
|
||||
ff := []sourceFunc{a.fromArtistUploadedImage()}
|
||||
ff = append(ff, a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority)...)
|
||||
return selectImageReader(ctx, a.artID, ff...)
|
||||
}
|
||||
|
||||
func (a *artistReader) fromArtistUploadedImage() sourceFunc {
|
||||
return fromLocalFile(a.artist.UploadedImagePath())
|
||||
}
|
||||
|
||||
func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority string) []sourceFunc {
|
||||
var ff []sourceFunc
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
|
||||
@ -104,6 +121,8 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin
|
||||
switch {
|
||||
case pattern == "external":
|
||||
ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.provider))
|
||||
case pattern == "image-folder":
|
||||
ff = append(ff, a.fromArtistImageFolder(ctx))
|
||||
case strings.HasPrefix(pattern, "album/"):
|
||||
ff = append(ff, fromExternalFile(ctx, a.imgFiles, strings.TrimPrefix(pattern, "album/")))
|
||||
default:
|
||||
@ -196,3 +215,51 @@ func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albu
|
||||
}
|
||||
return folderPath, folders[0].ImagesUpdatedAt, nil
|
||||
}
|
||||
|
||||
func (a *artistReader) fromArtistImageFolder(ctx context.Context) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
folder := conf.Server.ArtistImageFolder
|
||||
if folder == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
// Use cached path from newArtistArtworkReader if available,
|
||||
// avoiding a second directory scan.
|
||||
path := a.imgFolderImgPath
|
||||
if path == "" {
|
||||
path = findImageInArtistFolder(folder, a.artist.MbzArtistID, a.artist.Name)
|
||||
}
|
||||
if path == "" {
|
||||
return nil, "", fmt.Errorf("no image found for artist %q in %s", a.artist.Name, folder)
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return f, path, nil
|
||||
}
|
||||
}
|
||||
|
||||
// findImageInArtistFolder scans a folder for an image file matching the artist's MBID or name
|
||||
// (case-insensitive). Returns the full path, or empty string if not found.
|
||||
func findImageInArtistFolder(folder, mbzArtistID, artistName string) string {
|
||||
entries, err := os.ReadDir(folder)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, candidate := range []string{mbzArtistID, artistName} {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
base := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
if strings.EqualFold(base, candidate) && model.IsImageFile(name) {
|
||||
return filepath.Join(folder, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -8,6 +8,8 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@ -413,6 +415,257 @@ var _ = Describe("artistArtworkReader", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fromArtistUploadedImage", func() {
|
||||
var (
|
||||
tempDir string
|
||||
reader *artistReader
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tempDir = GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = tempDir
|
||||
|
||||
// Create the artwork/artist directory
|
||||
Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "artist"), 0755)).To(Succeed())
|
||||
|
||||
reader = &artistReader{}
|
||||
})
|
||||
|
||||
When("artist has an uploaded image", func() {
|
||||
It("returns the uploaded image", func() {
|
||||
imgPath := filepath.Join(tempDir, "artwork", "artist", "ar-1_test.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("uploaded artist image"), 0600)).To(Succeed())
|
||||
|
||||
reader.artist = model.Artist{ID: "ar-1", UploadedImage: "ar-1_test.jpg"}
|
||||
sf := reader.fromArtistUploadedImage()
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("uploaded artist image"))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("artist has no uploaded image", func() {
|
||||
It("returns nil reader (falls through)", func() {
|
||||
reader.artist = model.Artist{ID: "ar-1"}
|
||||
sf := reader.fromArtistUploadedImage()
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fromArtistImageFolder", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
tempDir string
|
||||
ar *artistReader
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tempDir = GinkgoT().TempDir()
|
||||
ar = &artistReader{}
|
||||
})
|
||||
|
||||
When("ArtistImageFolder is not configured", func() {
|
||||
It("returns nil (skips)", func() {
|
||||
conf.Server.ArtistImageFolder = ""
|
||||
ar.artist = model.Artist{Name: "Test Artist"}
|
||||
sf := ar.fromArtistImageFolder(ctx)
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
When("image exists matching MBID", func() {
|
||||
It("finds the image by MBID", func() {
|
||||
conf.Server.ArtistImageFolder = tempDir
|
||||
mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e"
|
||||
imgPath := filepath.Join(tempDir, mbid+".jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("mbid image"), 0600)).To(Succeed())
|
||||
|
||||
ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid}
|
||||
sf := ar.fromArtistImageFolder(ctx)
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("mbid image"))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("MBID match is case-insensitive", func() {
|
||||
It("finds the image regardless of case", func() {
|
||||
conf.Server.ArtistImageFolder = tempDir
|
||||
mbid := "F27EC8DB-AF05-4F36-916E-3D57F91ECF5E"
|
||||
imgPath := filepath.Join(tempDir, "f27ec8db-af05-4f36-916e-3d57f91ecf5e.png")
|
||||
Expect(os.WriteFile(imgPath, []byte("mbid case image"), 0600)).To(Succeed())
|
||||
|
||||
ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid}
|
||||
sf := ar.fromArtistImageFolder(ctx)
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("no MBID file exists but artist name file does", func() {
|
||||
It("falls back to artist name match", func() {
|
||||
conf.Server.ArtistImageFolder = tempDir
|
||||
imgPath := filepath.Join(tempDir, "Test Artist.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("name image"), 0600)).To(Succeed())
|
||||
|
||||
ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: "nonexistent-mbid"}
|
||||
sf := ar.fromArtistImageFolder(ctx)
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("name image"))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("artist name match is case-insensitive", func() {
|
||||
It("matches regardless of case", func() {
|
||||
conf.Server.ArtistImageFolder = tempDir
|
||||
imgPath := filepath.Join(tempDir, "test artist.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("case insensitive"), 0600)).To(Succeed())
|
||||
|
||||
ar.artist = model.Artist{Name: "Test Artist"}
|
||||
sf := ar.fromArtistImageFolder(ctx)
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("both MBID and name files exist", func() {
|
||||
It("prefers MBID over name match", func() {
|
||||
conf.Server.ArtistImageFolder = tempDir
|
||||
mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e"
|
||||
mbidPath := filepath.Join(tempDir, mbid+".jpg")
|
||||
namePath := filepath.Join(tempDir, "Test Artist.jpg")
|
||||
Expect(os.WriteFile(mbidPath, []byte("mbid image"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(namePath, []byte("name image"), 0600)).To(Succeed())
|
||||
|
||||
ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid}
|
||||
sf := ar.fromArtistImageFolder(ctx)
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(mbidPath))
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("mbid image"))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("no matching image found", func() {
|
||||
It("returns an error", func() {
|
||||
conf.Server.ArtistImageFolder = tempDir
|
||||
// Create an unrelated file
|
||||
Expect(os.WriteFile(filepath.Join(tempDir, "other.jpg"), []byte("other"), 0600)).To(Succeed())
|
||||
|
||||
ar.artist = model.Artist{Name: "Test Artist"}
|
||||
sf := ar.fromArtistImageFolder(ctx)
|
||||
r, _, err := sf()
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
Expect(err.Error()).To(ContainSubstring("no image found"))
|
||||
})
|
||||
})
|
||||
|
||||
When("cached imgFolderImgPath is set", func() {
|
||||
It("uses cached path instead of scanning", func() {
|
||||
conf.Server.ArtistImageFolder = tempDir
|
||||
imgPath := filepath.Join(tempDir, "cached.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("cached image"), 0600)).To(Succeed())
|
||||
|
||||
ar.artist = model.Artist{Name: "Test Artist"}
|
||||
ar.imgFolderImgPath = imgPath
|
||||
sf := ar.fromArtistImageFolder(ctx)
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("cached image"))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("findImageInArtistFolder", func() {
|
||||
var tempDir string
|
||||
|
||||
BeforeEach(func() {
|
||||
tempDir = GinkgoT().TempDir()
|
||||
})
|
||||
|
||||
When("matching file exists by MBID", func() {
|
||||
It("returns the file path", func() {
|
||||
mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e"
|
||||
imgPath := filepath.Join(tempDir, mbid+".jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed())
|
||||
|
||||
path := findImageInArtistFolder(tempDir, mbid, "Test")
|
||||
Expect(path).To(Equal(imgPath))
|
||||
})
|
||||
})
|
||||
|
||||
When("matching file exists by name", func() {
|
||||
It("returns the file path", func() {
|
||||
imgPath := filepath.Join(tempDir, "Test Artist.png")
|
||||
Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed())
|
||||
|
||||
path := findImageInArtistFolder(tempDir, "", "Test Artist")
|
||||
Expect(path).To(Equal(imgPath))
|
||||
})
|
||||
})
|
||||
|
||||
When("no matching file exists", func() {
|
||||
It("returns empty string", func() {
|
||||
path := findImageInArtistFolder(tempDir, "", "Unknown Artist")
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
When("folder does not exist", func() {
|
||||
It("returns empty string", func() {
|
||||
path := findImageInArtistFolder("/nonexistent/path", "", "Test")
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type fakeFolderRepo struct {
|
||||
|
||||
268
core/artwork/reader_disc.go
Normal file
268
core/artwork/reader_disc.go
Normal file
@ -0,0 +1,268 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type discArtworkReader struct {
|
||||
cacheKey
|
||||
a *artwork
|
||||
album model.Album
|
||||
discNumber int
|
||||
imgFiles []string
|
||||
discFolders map[string]bool
|
||||
isMultiFolder bool
|
||||
firstTrackPath string
|
||||
updatedAt *time.Time
|
||||
}
|
||||
|
||||
func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID) (*discArtworkReader, error) {
|
||||
albumID, discNumber, err := model.ParseDiscArtworkID(artID.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err)
|
||||
}
|
||||
|
||||
al, err := a.ds.Album(ctx).Get(albumID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, a.ds, *al)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Query mediafiles for this album + disc to find folder associations and first track
|
||||
mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Sort: "track_number",
|
||||
Order: "ASC",
|
||||
Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build disc folder set and find first track
|
||||
discFolders := make(map[string]bool)
|
||||
var firstTrackPath string
|
||||
allFolderIDs := make(map[string]bool)
|
||||
for _, mf := range mfs {
|
||||
allFolderIDs[mf.FolderID] = true
|
||||
if firstTrackPath == "" {
|
||||
firstTrackPath = mf.Path
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve folder IDs to absolute paths
|
||||
if len(allFolderIDs) > 0 {
|
||||
folderIDs := make([]string, 0, len(allFolderIDs))
|
||||
for id := range allFolderIDs {
|
||||
folderIDs = append(folderIDs, id)
|
||||
}
|
||||
folders, err := a.ds.Folder(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"folder.id": folderIDs},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, f := range folders {
|
||||
discFolders[f.AbsolutePath()] = true
|
||||
}
|
||||
}
|
||||
|
||||
isMultiFolder := len(al.FolderIDs) > 1
|
||||
|
||||
r := &discArtworkReader{
|
||||
a: a,
|
||||
album: *al,
|
||||
discNumber: discNumber,
|
||||
imgFiles: imgFiles,
|
||||
discFolders: discFolders,
|
||||
isMultiFolder: isMultiFolder,
|
||||
firstTrackPath: core.AbsolutePath(ctx, a.ds, al.LibraryID, firstTrackPath),
|
||||
updatedAt: imagesUpdatedAt,
|
||||
}
|
||||
r.cacheKey.artID = artID
|
||||
if r.updatedAt != nil && r.updatedAt.After(al.UpdatedAt) {
|
||||
r.cacheKey.lastUpdate = *r.updatedAt
|
||||
} else {
|
||||
r.cacheKey.lastUpdate = al.UpdatedAt
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (d *discArtworkReader) Key() string {
|
||||
hash := md5.Sum([]byte(conf.Server.DiscArtPriority))
|
||||
return fmt.Sprintf(
|
||||
"%s.%x",
|
||||
d.cacheKey.Key(),
|
||||
hash,
|
||||
)
|
||||
}
|
||||
|
||||
func (d *discArtworkReader) LastUpdated() time.Time {
|
||||
return d.album.UpdatedAt
|
||||
}
|
||||
|
||||
func (d *discArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
var ff = d.fromDiscArtPriority(ctx, d.a.ffmpeg, conf.Server.DiscArtPriority)
|
||||
// Fallback to album cover art
|
||||
albumArtID := model.NewArtworkID(model.KindAlbumArtwork, d.album.ID, &d.album.UpdatedAt)
|
||||
ff = append(ff, fromAlbum(ctx, d.a, albumArtID))
|
||||
return selectImageReader(ctx, d.cacheKey.artID, ff...)
|
||||
}
|
||||
|
||||
func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc {
|
||||
var ff []sourceFunc
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "embedded":
|
||||
ff = append(ff, fromTag(ctx, d.firstTrackPath), fromFFmpegTag(ctx, ffmpeg, d.firstTrackPath))
|
||||
case pattern == "external":
|
||||
// Not supported for disc art, silently ignore
|
||||
case pattern == "discsubtitle":
|
||||
if subtitle := strings.TrimSpace(d.album.Discs[d.discNumber]); subtitle != "" {
|
||||
ff = append(ff, d.fromDiscSubtitle(ctx, subtitle))
|
||||
}
|
||||
case len(d.imgFiles) > 0:
|
||||
ff = append(ff, d.fromExternalFile(ctx, pattern))
|
||||
}
|
||||
}
|
||||
return ff
|
||||
}
|
||||
|
||||
// fromDiscSubtitle returns a sourceFunc that matches image files whose stem
|
||||
// (filename without extension) equals the disc subtitle (case-insensitive).
|
||||
func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
for _, file := range d.imgFiles {
|
||||
_, name := filepath.Split(file)
|
||||
stem := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
if !strings.EqualFold(stem, subtitle) {
|
||||
continue
|
||||
}
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not open disc art file", "file", file, err)
|
||||
continue
|
||||
}
|
||||
return f, file, nil
|
||||
}
|
||||
return nil, "", fmt.Errorf("disc %d: no image file matching subtitle %q", d.discNumber, subtitle)
|
||||
}
|
||||
}
|
||||
|
||||
// extractDiscNumber extracts a disc number from a filename based on a glob pattern.
|
||||
// It finds the portion of the filename that the wildcard matched and parses leading
|
||||
// digits as the disc number. Returns (0, false) if the pattern doesn't match or
|
||||
// no leading digits are found in the wildcard portion.
|
||||
func extractDiscNumber(pattern, filename string) (int, bool) {
|
||||
filename = strings.ToLower(filename)
|
||||
pattern = strings.ToLower(pattern)
|
||||
|
||||
matched, err := filepath.Match(pattern, filename)
|
||||
if err != nil || !matched {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Find the prefix before the first '*' in the pattern
|
||||
starIdx := strings.IndexByte(pattern, '*')
|
||||
if starIdx < 0 {
|
||||
return 0, false
|
||||
}
|
||||
prefix := pattern[:starIdx]
|
||||
|
||||
// Strip the prefix from the filename to get the wildcard-matched portion
|
||||
if !strings.HasPrefix(filename, prefix) {
|
||||
return 0, false
|
||||
}
|
||||
remainder := filename[len(prefix):]
|
||||
|
||||
// Extract leading ASCII digits from the remainder
|
||||
var digits []byte
|
||||
for _, r := range remainder {
|
||||
if r >= '0' && r <= '9' {
|
||||
digits = append(digits, byte(r))
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(digits) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
num, err := strconv.Atoi(string(digits))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return num, true
|
||||
}
|
||||
|
||||
// fromExternalFile returns a sourceFunc that matches image files against a glob
|
||||
// pattern with disc-number-aware filtering.
|
||||
//
|
||||
// Matching rules:
|
||||
// - If a disc number can be extracted from the filename, the file matches only if
|
||||
// the number equals the target disc number.
|
||||
// - If no number is found and this is a multi-folder album, the file matches if
|
||||
// it's in a folder containing tracks for this disc.
|
||||
// - If no number is found and this is a single-folder album, the file is skipped
|
||||
// (ambiguous).
|
||||
func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
for _, file := range d.imgFiles {
|
||||
_, name := filepath.Split(file)
|
||||
match, err := filepath.Match(pattern, strings.ToLower(name))
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error matching disc art file to pattern", "pattern", pattern, "file", file)
|
||||
continue
|
||||
}
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to extract disc number from filename
|
||||
num, hasNum := extractDiscNumber(pattern, name)
|
||||
if hasNum {
|
||||
// File has a disc number — must match target disc
|
||||
if num != d.discNumber {
|
||||
continue
|
||||
}
|
||||
} else if d.isMultiFolder {
|
||||
// No number, multi-folder: match by folder association
|
||||
dir := filepath.Dir(file)
|
||||
if !d.discFolders[dir] {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// No number, single-folder: ambiguous, skip
|
||||
continue
|
||||
}
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not open disc art file", "file", file, err)
|
||||
continue
|
||||
}
|
||||
return f, file, nil
|
||||
}
|
||||
return nil, "", fmt.Errorf("disc %d: pattern '%s' not matched by files", d.discNumber, pattern)
|
||||
}
|
||||
}
|
||||
285
core/artwork/reader_disc_test.go
Normal file
285
core/artwork/reader_disc_test.go
Normal file
@ -0,0 +1,285 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Disc Artwork Reader", func() {
|
||||
Describe("extractDiscNumber", func() {
|
||||
DescribeTable("extracts disc number from filename based on glob pattern",
|
||||
func(pattern, filename string, expectedNum int, expectedOk bool) {
|
||||
num, ok := extractDiscNumber(pattern, filename)
|
||||
Expect(ok).To(Equal(expectedOk))
|
||||
if expectedOk {
|
||||
Expect(num).To(Equal(expectedNum))
|
||||
}
|
||||
},
|
||||
// Standard disc patterns
|
||||
Entry("disc1.jpg", "disc*.*", "disc1.jpg", 1, true),
|
||||
Entry("disc2.png", "disc*.*", "disc2.png", 2, true),
|
||||
Entry("disc01.jpg", "disc*.*", "disc01.jpg", 1, true),
|
||||
Entry("disc02.png", "disc*.*", "disc02.png", 2, true),
|
||||
Entry("disc10.jpg", "disc*.*", "disc10.jpg", 10, true),
|
||||
|
||||
// CD patterns
|
||||
Entry("cd1.jpg", "cd*.*", "cd1.jpg", 1, true),
|
||||
Entry("cd02.png", "cd*.*", "cd02.png", 2, true),
|
||||
|
||||
// No number in filename
|
||||
Entry("disc.jpg has no number", "disc*.*", "disc.jpg", 0, false),
|
||||
Entry("cd.jpg has no number", "cd*.*", "cd.jpg", 0, false),
|
||||
|
||||
// Extra text after number
|
||||
Entry("disc2-bonus.jpg", "disc*.*", "disc2-bonus.jpg", 2, true),
|
||||
Entry("disc01_front.png", "disc*.*", "disc01_front.png", 1, true),
|
||||
|
||||
// Case insensitive (filename already lowered by caller)
|
||||
Entry("Disc1.jpg lowered", "disc*.*", "disc1.jpg", 1, true),
|
||||
|
||||
// Pattern doesn't match
|
||||
Entry("cover.jpg doesn't match disc*.*", "disc*.*", "cover.jpg", 0, false),
|
||||
|
||||
// Pattern with no wildcard before dot
|
||||
Entry("front1.jpg with front*.*", "front*.*", "front1.jpg", 1, true),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("fromExternalFile", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
tmpDir string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
tmpDir = GinkgoT().TempDir()
|
||||
})
|
||||
|
||||
createFile := func(path string) string {
|
||||
fullPath := filepath.Join(tmpDir, filepath.FromSlash(path))
|
||||
Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed())
|
||||
Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed())
|
||||
return fullPath
|
||||
}
|
||||
|
||||
It("matches file with disc number in single-folder album", func() {
|
||||
f1 := createFile("album/disc1.jpg")
|
||||
f2 := createFile("album/disc2.jpg")
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1, f2},
|
||||
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, "disc*.*")
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(f1))
|
||||
})
|
||||
|
||||
It("skips file without number in single-folder album", func() {
|
||||
f1 := createFile("album/disc.jpg")
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1},
|
||||
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, "disc*.*")
|
||||
r, _, _ := sf()
|
||||
Expect(r).To(BeNil())
|
||||
})
|
||||
|
||||
It("matches file without number in multi-folder album by folder", func() {
|
||||
f1 := createFile("album/cd1/disc.jpg")
|
||||
f2 := createFile("album/cd2/disc.jpg")
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1, f2},
|
||||
discFolders: map[string]bool{filepath.Join(tmpDir, "album", "cd1"): true},
|
||||
isMultiFolder: true,
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, "disc*.*")
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(f1))
|
||||
})
|
||||
|
||||
It("prefers disc number over folder when number is present", func() {
|
||||
// disc2.jpg in cd1 folder should match disc 2, not disc 1
|
||||
f1 := createFile("album/cd1/disc2.jpg")
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 2,
|
||||
imgFiles: []string{f1},
|
||||
discFolders: map[string]bool{filepath.Join(tmpDir, "album", "cd1"): true},
|
||||
isMultiFolder: true,
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, "disc*.*")
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(f1))
|
||||
})
|
||||
|
||||
It("does not match disc2.jpg when looking for disc 1", func() {
|
||||
f1 := createFile("album/disc2.jpg")
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1},
|
||||
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
|
||||
}
|
||||
|
||||
sf := reader.fromExternalFile(ctx, "disc*.*")
|
||||
r, _, _ := sf()
|
||||
Expect(r).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fromDiscSubtitle", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
tmpDir string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
tmpDir = GinkgoT().TempDir()
|
||||
})
|
||||
|
||||
createFile := func(path string) string {
|
||||
fullPath := filepath.Join(tmpDir, filepath.FromSlash(path))
|
||||
Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed())
|
||||
Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed())
|
||||
return fullPath
|
||||
}
|
||||
|
||||
It("matches image file whose stem equals the disc subtitle (case-insensitive)", func() {
|
||||
f1 := createFile("album/The Blue Disc.jpg")
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1},
|
||||
}
|
||||
|
||||
sf := reader.fromDiscSubtitle(ctx, "The Blue Disc")
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(f1))
|
||||
})
|
||||
|
||||
It("matches case-insensitively", func() {
|
||||
f1 := createFile("album/bonus tracks.png")
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 2,
|
||||
imgFiles: []string{f1},
|
||||
}
|
||||
|
||||
sf := reader.fromDiscSubtitle(ctx, "Bonus Tracks")
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(f1))
|
||||
})
|
||||
|
||||
It("returns error when no matching file found", func() {
|
||||
f1 := createFile("album/cover.jpg")
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1},
|
||||
}
|
||||
|
||||
sf := reader.fromDiscSubtitle(ctx, "The Blue Disc")
|
||||
_, _, err := sf()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("matches first file when multiple extensions exist", func() {
|
||||
f1 := createFile("album/The Blue Disc.jpg")
|
||||
f2 := createFile("album/The Blue Disc.png")
|
||||
reader := &discArtworkReader{
|
||||
discNumber: 1,
|
||||
imgFiles: []string{f1, f2},
|
||||
}
|
||||
|
||||
sf := reader.fromDiscSubtitle(ctx, "The Blue Disc")
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
Expect(path).To(Equal(f1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("discArtworkReader", func() {
|
||||
Describe("fromDiscArtPriority", func() {
|
||||
var reader *discArtworkReader
|
||||
|
||||
BeforeEach(func() {
|
||||
reader = &discArtworkReader{
|
||||
discNumber: 2,
|
||||
isMultiFolder: true,
|
||||
discFolders: map[string]bool{"/music/album/cd2": true},
|
||||
imgFiles: []string{
|
||||
"/music/album/cd1/disc.jpg",
|
||||
"/music/album/cd2/disc.jpg",
|
||||
"/music/album/cd2/disc2.jpg",
|
||||
},
|
||||
firstTrackPath: "/music/album/cd2/track1.flac",
|
||||
}
|
||||
})
|
||||
|
||||
It("returns source funcs for glob patterns", func() {
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*")
|
||||
Expect(ff).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("returns source funcs for embedded pattern", func() {
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "embedded")
|
||||
Expect(ff).To(HaveLen(2)) // fromTag + fromFFmpegTag
|
||||
})
|
||||
|
||||
It("handles multiple comma-separated patterns", func() {
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*, cd*.*, embedded")
|
||||
Expect(ff).To(HaveLen(4)) // disc*.* + cd*.* + fromTag + fromFFmpegTag
|
||||
})
|
||||
|
||||
It("ignores 'external' pattern silently", func() {
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "external")
|
||||
Expect(ff).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("returns no source funcs when imgFiles is empty and pattern is not embedded", func() {
|
||||
reader.imgFiles = nil
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*")
|
||||
Expect(ff).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("returns source func for discsubtitle pattern", func() {
|
||||
reader.album = model.Album{Discs: model.Discs{2: "Bonus Tracks"}}
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle")
|
||||
Expect(ff).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("returns no source func for discsubtitle when disc has no subtitle", func() {
|
||||
reader.album = model.Album{Discs: model.Discs{2: ""}}
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle")
|
||||
Expect(ff).To(HaveLen(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -26,16 +26,22 @@ func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID mode
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, _, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, *al)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &mediafileArtworkReader{
|
||||
a: artwork,
|
||||
mediafile: *mf,
|
||||
album: *al,
|
||||
}
|
||||
a.cacheKey.artID = artID
|
||||
if al.UpdatedAt.After(mf.UpdatedAt) {
|
||||
a.cacheKey.lastUpdate = mf.UpdatedAt
|
||||
if al.UpdatedAt.After(a.cacheKey.lastUpdate) {
|
||||
a.cacheKey.lastUpdate = al.UpdatedAt
|
||||
} else {
|
||||
a.cacheKey.lastUpdate = mf.UpdatedAt
|
||||
}
|
||||
if imagesUpdatedAt != nil && imagesUpdatedAt.After(a.cacheKey.lastUpdate) {
|
||||
a.cacheKey.lastUpdate = *imagesUpdatedAt
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
@ -60,6 +66,12 @@ func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, str
|
||||
fromFFmpegTag(ctx, a.a.ffmpeg, path),
|
||||
}
|
||||
}
|
||||
ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.AlbumCoverArtID()))
|
||||
// For multi-disc albums, fall back to disc artwork first; for single-disc albums,
|
||||
// skip disc resolution (it would just fall through to album art anyway).
|
||||
if len(a.album.Discs) > 1 {
|
||||
ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.DiscCoverArtID()))
|
||||
} else {
|
||||
ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.AlbumCoverArtID()))
|
||||
}
|
||||
return selectImageReader(ctx, a.artID, ff...)
|
||||
}
|
||||
|
||||
@ -14,11 +14,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
type playlistArtworkReader struct {
|
||||
@ -200,7 +200,7 @@ func (a *playlistArtworkReader) createTile(_ context.Context, r io.ReadCloser) (
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return imaging.Fill(img, tileSize/2, tileSize/2, imaging.Center, imaging.Lanczos), nil
|
||||
return fillCenter(img, tileSize/2, tileSize/2), nil
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) createTiledImage(_ context.Context, tiles []image.Image) (io.ReadCloser, error) {
|
||||
@ -238,3 +238,32 @@ func rect(pos int) image.Rectangle {
|
||||
r.Max.Y = r.Min.Y + tileSize/2
|
||||
return r
|
||||
}
|
||||
|
||||
// fillCenter crops the source image from the center and scales it to fill dstW x dstH exactly,
|
||||
// equivalent to imaging.Fill with Center anchor.
|
||||
func fillCenter(src image.Image, dstW, dstH int) image.Image {
|
||||
srcBounds := src.Bounds()
|
||||
srcW := srcBounds.Dx()
|
||||
srcH := srcBounds.Dy()
|
||||
|
||||
// Calculate crop rectangle (center crop to match destination aspect ratio)
|
||||
srcAspect := float64(srcW) / float64(srcH)
|
||||
dstAspect := float64(dstW) / float64(dstH)
|
||||
|
||||
var cropRect image.Rectangle
|
||||
if srcAspect > dstAspect {
|
||||
// Source is wider — crop horizontally
|
||||
cropW := int(float64(srcH) * dstAspect)
|
||||
cropX := (srcW - cropW) / 2
|
||||
cropRect = image.Rect(srcBounds.Min.X+cropX, srcBounds.Min.Y, srcBounds.Min.X+cropX+cropW, srcBounds.Max.Y)
|
||||
} else {
|
||||
// Source is taller — crop vertically
|
||||
cropH := int(float64(srcW) / dstAspect)
|
||||
cropY := (srcH - cropH) / 2
|
||||
cropRect = image.Rect(srcBounds.Min.X, srcBounds.Min.Y+cropY, srcBounds.Max.X, srcBounds.Min.Y+cropY+cropH)
|
||||
}
|
||||
|
||||
dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, cropRect, draw.Src, nil)
|
||||
return dst
|
||||
}
|
||||
|
||||
40
core/artwork/reader_radio.go
Normal file
40
core/artwork/reader_radio.go
Normal file
@ -0,0 +1,40 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type radioArtworkReader struct {
|
||||
cacheKey
|
||||
a *artwork
|
||||
radio model.Radio
|
||||
}
|
||||
|
||||
func newRadioArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*radioArtworkReader, error) {
|
||||
r, err := artwork.ds.Radio(ctx).Get(artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &radioArtworkReader{a: artwork, radio: *r}
|
||||
a.cacheKey.artID = artID
|
||||
a.cacheKey.lastUpdate = r.UpdatedAt
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *radioArtworkReader) LastUpdated() time.Time {
|
||||
return a.lastUpdate
|
||||
}
|
||||
|
||||
func (a *radioArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
return selectImageReader(ctx, a.artID,
|
||||
a.fromRadioUploadedImage(),
|
||||
)
|
||||
}
|
||||
|
||||
func (a *radioArtworkReader) fromRadioUploadedImage() sourceFunc {
|
||||
return fromLocalFile(a.radio.UploadedImagePath())
|
||||
}
|
||||
84
core/artwork/reader_radio_test.go
Normal file
84
core/artwork/reader_radio_test.go
Normal file
@ -0,0 +1,84 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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("radioArtworkReader", func() {
|
||||
var (
|
||||
tempDir string
|
||||
reader *radioArtworkReader
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tempDir = GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = tempDir
|
||||
|
||||
Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "radio"), 0755)).To(Succeed())
|
||||
|
||||
reader = &radioArtworkReader{}
|
||||
})
|
||||
|
||||
Describe("fromRadioUploadedImage", func() {
|
||||
When("radio has an uploaded image", func() {
|
||||
It("returns the uploaded image", func() {
|
||||
imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed())
|
||||
|
||||
reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"}
|
||||
sf := reader.fromRadioUploadedImage()
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("radio has no uploaded image", func() {
|
||||
It("returns nil reader (falls through)", func() {
|
||||
reader.radio = model.Radio{ID: "rd-1"}
|
||||
sf := reader.fromRadioUploadedImage()
|
||||
r, path, err := sf()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Reader", func() {
|
||||
When("radio has an uploaded image", func() {
|
||||
It("returns the image reader", func() {
|
||||
imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed())
|
||||
|
||||
reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"}
|
||||
reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"}
|
||||
r, _, err := reader.Reader(context.Background())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("radio has no uploaded image", func() {
|
||||
It("returns ErrUnavailable", func() {
|
||||
reader.radio = model.Radio{ID: "rd-1"}
|
||||
reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"}
|
||||
r, _, err := reader.Reader(context.Background())
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(r).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -5,17 +5,36 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/gen2brain/webp"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
if err := webp.Dynamic(); err != nil {
|
||||
log.Debug("Using WASM WebP encoder/decoder", "reason", err)
|
||||
} else {
|
||||
log.Debug("Using native libwebp for WebP encoding/decoding")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var bufPool = sync.Pool{
|
||||
New: func() any {
|
||||
return new(bytes.Buffer)
|
||||
},
|
||||
}
|
||||
|
||||
type resizedArtworkReader struct {
|
||||
artID model.ArtworkID
|
||||
cacheKey string
|
||||
@ -46,7 +65,7 @@ func (a *resizedArtworkReader) Key() string {
|
||||
if a.square {
|
||||
return baseKey + ".square"
|
||||
}
|
||||
return fmt.Sprintf("%s.%d", baseKey, conf.Server.CoverJpegQuality)
|
||||
return fmt.Sprintf("%s.%d", baseKey, conf.Server.CoverArtQuality)
|
||||
}
|
||||
|
||||
func (a *resizedArtworkReader) LastUpdated() time.Time {
|
||||
@ -61,7 +80,7 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin
|
||||
}
|
||||
defer orig.Close()
|
||||
|
||||
resized, origSize, err := resizeImage(orig, a.size, a.square)
|
||||
resized, origSize, err := a.resizeImage(ctx, orig)
|
||||
if resized == nil {
|
||||
log.Trace(ctx, "Image smaller than requested size", "artID", a.artID, "original", origSize, "resized", a.size, "square", a.square)
|
||||
} else {
|
||||
@ -75,11 +94,40 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin
|
||||
orig, _, err = a.a.Get(ctx, a.artID, 0, false)
|
||||
return orig, "", err
|
||||
}
|
||||
// Preserve ReadCloser semantics if the resized reader already supports Close
|
||||
// (e.g., ffmpeg pipe), otherwise wrap with NopCloser
|
||||
if rc, ok := resized.(io.ReadCloser); ok {
|
||||
return rc, fmt.Sprintf("%s@%d", a.artID, a.size), nil
|
||||
}
|
||||
return io.NopCloser(resized), fmt.Sprintf("%s@%d", a.artID, a.size), nil
|
||||
}
|
||||
|
||||
func resizeImage(reader io.Reader, size int, square bool) (io.Reader, int, error) {
|
||||
original, format, err := image.Decode(reader)
|
||||
func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader) (io.Reader, int, error) {
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("reading image data: %w", err)
|
||||
}
|
||||
|
||||
// Preserve animation for animated images
|
||||
if isAnimatedGIF(data) {
|
||||
if a.a.ffmpeg.IsAvailable() {
|
||||
// Animated GIF: convert to animated WebP via ffmpeg (with optional resize)
|
||||
r, err := a.a.ffmpeg.ConvertAnimatedImage(ctx, bytes.NewReader(data), a.size, conf.Server.CoverArtQuality)
|
||||
if err == nil {
|
||||
return r, 0, nil
|
||||
}
|
||||
log.Warn(ctx, "Could not convert animated GIF, falling back to static", err)
|
||||
}
|
||||
} else if isAnimatedWebP(data) || isAnimatedPNG(data) {
|
||||
// Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these)
|
||||
return bytes.NewReader(data), 0, nil
|
||||
}
|
||||
|
||||
return resizeStaticImage(data, a.size, a.square)
|
||||
}
|
||||
|
||||
func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) {
|
||||
original, format, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@ -96,26 +144,43 @@ func resizeImage(reader io.Reader, size int, square bool) (io.Reader, int, error
|
||||
return nil, originalSize, nil
|
||||
}
|
||||
|
||||
var resized image.Image
|
||||
if originalSize >= size {
|
||||
resized = imaging.Fit(original, size, size, imaging.Lanczos)
|
||||
} else {
|
||||
if bounds.Max.Y < bounds.Max.X {
|
||||
resized = imaging.Resize(original, size, 0, imaging.Lanczos)
|
||||
} else {
|
||||
resized = imaging.Resize(original, 0, size, imaging.Lanczos)
|
||||
}
|
||||
}
|
||||
if square {
|
||||
bg := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
resized = imaging.OverlayCenter(bg, resized, 1)
|
||||
}
|
||||
// Calculate aspect-fit dimensions
|
||||
srcW, srcH := bounds.Dx(), bounds.Dy()
|
||||
scale := float64(size) / float64(max(srcW, srcH))
|
||||
dstW := int(float64(srcW) * scale)
|
||||
dstH := int(float64(srcH) * scale)
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
if format == "png" || square {
|
||||
err = png.Encode(buf, resized)
|
||||
var dst *image.NRGBA
|
||||
var dstRect image.Rectangle
|
||||
if square {
|
||||
// Square canvas with image centered (transparent padding via zero-initialized NRGBA)
|
||||
dst = image.NewNRGBA(image.Rect(0, 0, size, size))
|
||||
offsetX := (size - dstW) / 2
|
||||
offsetY := (size - dstH) / 2
|
||||
dstRect = image.Rect(offsetX, offsetY, offsetX+dstW, offsetY+dstH)
|
||||
} else {
|
||||
err = jpeg.Encode(buf, resized, &jpeg.Options{Quality: conf.Server.CoverJpegQuality})
|
||||
// Tight-fit canvas
|
||||
dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
dstRect = dst.Bounds()
|
||||
}
|
||||
return buf, originalSize, err
|
||||
xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil)
|
||||
|
||||
buf := bufPool.Get().(*bytes.Buffer)
|
||||
buf.Reset()
|
||||
if conf.Server.EnableWebPEncoding {
|
||||
err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality})
|
||||
} else if format == "png" || square {
|
||||
err = png.Encode(buf, dst)
|
||||
} else {
|
||||
err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality})
|
||||
}
|
||||
if err != nil {
|
||||
bufPool.Put(buf)
|
||||
return nil, originalSize, err
|
||||
}
|
||||
// Copy bytes before returning buffer to pool (pool may reuse the buffer)
|
||||
encoded := make([]byte, buf.Len())
|
||||
copy(encoded, buf.Bytes())
|
||||
bufPool.Put(buf)
|
||||
return bytes.NewReader(encoded), originalSize, nil
|
||||
}
|
||||
|
||||
176
core/artwork/reader_resized_test.go
Normal file
176
core/artwork/reader_resized_test.go
Normal file
@ -0,0 +1,176 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("resizeImage", func() {
|
||||
var mockFF *tests.MockFFmpeg
|
||||
var r *resizedArtworkReader
|
||||
|
||||
BeforeEach(func() {
|
||||
mockFF = tests.NewMockFFmpeg("converted-animated-data")
|
||||
r = &resizedArtworkReader{
|
||||
size: 300,
|
||||
square: false,
|
||||
a: &artwork{ffmpeg: mockFF},
|
||||
}
|
||||
})
|
||||
|
||||
Describe("animated GIF handling", func() {
|
||||
It("converts animated GIF via ffmpeg when available", func() {
|
||||
data := createAnimatedGIF(3)
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
// Should have been processed by ffmpeg (mock returns "converted-animated-data")
|
||||
output, err := io.ReadAll(result)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output).To(Equal(data)) // MockFFmpeg echoes input back
|
||||
})
|
||||
|
||||
It("falls back to static resize when ffmpeg fails for animated GIF", func() {
|
||||
mockFF.Error = errors.New("ffmpeg failed")
|
||||
// Use size smaller than image so static resize actually produces output
|
||||
r.size = 1
|
||||
data := createAnimatedGIF(3)
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
// Should fall through to static resize successfully (no ffmpeg error propagated)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
// Verify it's a static image (WebP encoded), not the ffmpeg error
|
||||
output, err := io.ReadAll(result)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(output)).To(BeNumerically(">", 0))
|
||||
})
|
||||
|
||||
It("preserves animation for square thumbnails with animated GIF", func() {
|
||||
r.square = true
|
||||
data := createAnimatedGIF(3)
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
// Should have been processed by ffmpeg (mock returns input data)
|
||||
output, err := io.ReadAll(result)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output).To(Equal(data))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("animated WebP handling", func() {
|
||||
It("returns animated WebP data as-is when not square", func() {
|
||||
data := createAnimatedWebPBytes()
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
// Should return original data unchanged
|
||||
output, err := io.ReadAll(result)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output).To(Equal(data))
|
||||
})
|
||||
|
||||
It("preserves animated WebP for square thumbnails", func() {
|
||||
r.square = true
|
||||
data := createAnimatedWebPBytes()
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
// Should return original data unchanged
|
||||
output, err := io.ReadAll(result)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output).To(Equal(data))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("animated PNG handling", func() {
|
||||
It("returns animated PNG data as-is when not square", func() {
|
||||
data := createAPNGBytes()
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
// Should return original data unchanged
|
||||
output, err := io.ReadAll(result)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output).To(Equal(data))
|
||||
})
|
||||
|
||||
It("preserves animated PNG for square thumbnails", func() {
|
||||
r.square = true
|
||||
data := createAPNGBytes()
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
// Should return original data unchanged
|
||||
output, err := io.ReadAll(result)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output).To(Equal(data))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("static image handling", func() {
|
||||
It("resizes a static PNG normally", func() {
|
||||
data := createStaticPNGBytes()
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
// Static PNG is 2x2, size 300 is larger, so should return nil (no upscale)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ReadCloser preservation", func() {
|
||||
It("preserves Close semantics from ffmpeg ReadCloser", func() {
|
||||
// Create a trackable ReadCloser
|
||||
tracker := &closeTracker{Reader: bytes.NewReader([]byte("test data"))}
|
||||
mockFF2 := &mockFFmpegWithCloser{tracker: tracker}
|
||||
r.a = &artwork{ffmpeg: mockFF2}
|
||||
|
||||
data := createAnimatedGIF(3)
|
||||
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// The result should be an io.ReadCloser (the tracker)
|
||||
rc, ok := result.(io.ReadCloser)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(rc.Close()).ToNot(HaveOccurred())
|
||||
Expect(tracker.closed).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// closeTracker is an io.ReadCloser that tracks whether Close was called.
|
||||
type closeTracker struct {
|
||||
io.Reader
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (c *closeTracker) Close() error {
|
||||
c.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// mockFFmpegWithCloser is a minimal FFmpeg mock that returns a specific ReadCloser
|
||||
// for ConvertAnimatedImage, allowing us to verify Close propagation.
|
||||
type mockFFmpegWithCloser struct {
|
||||
ffmpeg.FFmpeg
|
||||
tracker *closeTracker
|
||||
}
|
||||
|
||||
func (m *mockFFmpegWithCloser) IsAvailable() bool { return true }
|
||||
func (m *mockFFmpegWithCloser) ConvertAnimatedImage(_ context.Context, _ io.Reader, _ int, _ int) (io.ReadCloser, error) {
|
||||
return m.tracker, nil
|
||||
}
|
||||
@ -130,10 +130,25 @@ func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourc
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return r, path, nil
|
||||
// Validate that the stream actually contains image data by reading the first byte.
|
||||
// ffmpeg.ExtractImage returns a pipe reader that may fail asynchronously if the
|
||||
// file has no video/image stream (e.g., an MP3 without embedded art).
|
||||
buf := make([]byte, 1)
|
||||
n, err := r.Read(buf)
|
||||
if n == 0 || err != nil {
|
||||
r.Close()
|
||||
return nil, "", fmt.Errorf("ffmpeg produced no image data for %s: %w", path, err)
|
||||
}
|
||||
return readCloser{Reader: io.MultiReader(bytes.NewReader(buf[:n]), r), Closer: r}, path, nil
|
||||
}
|
||||
}
|
||||
|
||||
// readCloser combines a Reader and a Closer into an io.ReadCloser.
|
||||
type readCloser struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}
|
||||
|
||||
func fromAlbum(ctx context.Context, a *artwork, id model.ArtworkID) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
r, _, err := a.Get(ctx, id, 0, false)
|
||||
|
||||
22
core/external/provider.go
vendored
22
core/external/provider.go
vendored
@ -374,13 +374,25 @@ func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e.callGetImage(ctx, e.ag, &artist)
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "ArtistImage call canceled", ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
imageUrl := artist.ArtistImageUrl()
|
||||
if imageUrl == "" {
|
||||
// No cached URL — must fetch from external source synchronously
|
||||
e.callGetImage(ctx, e.ag, &artist)
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "ArtistImage call canceled", ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
imageUrl = artist.ArtistImageUrl()
|
||||
} else {
|
||||
// If cached info is expired, enqueue a background refresh so that config changes
|
||||
// (e.g. disabling an agent) take effect without waiting for a full artist info refresh.
|
||||
updatedAt := V(artist.ExternalInfoUpdatedAt)
|
||||
if !updatedAt.IsZero() && time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
|
||||
log.Debug(ctx, "Artist image info expired, enqueuing background refresh", "artist", artist.Name(), "updatedAt", updatedAt)
|
||||
e.artistQueue.enqueue(&artist)
|
||||
}
|
||||
}
|
||||
|
||||
imageUrl := artist.ArtistImageUrl()
|
||||
if imageUrl == "" {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
65
core/external/provider_artistimage_test.go
vendored
65
core/external/provider_artistimage_test.go
vendored
@ -1,14 +1,17 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@ -266,6 +269,68 @@ var _ = Describe("Provider - ArtistImage", func() {
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("returns cached URL and does not call agent when info is not expired", func() {
|
||||
// Arrange: artist has a cached image URL with recent ExternalInfoUpdatedAt
|
||||
recentTime := time.Now().Add(-1 * time.Minute)
|
||||
cachedArtist := &model.Artist{
|
||||
ID: "artist-cached",
|
||||
Name: "Cached Artist",
|
||||
LargeImageUrl: "http://example.com/cached-large.jpg",
|
||||
ExternalInfoUpdatedAt: &recentTime,
|
||||
}
|
||||
mockArtistRepo.On("Get", "artist-cached").Return(cachedArtist, nil).Maybe()
|
||||
expectedURL, _ := url.Parse("http://example.com/cached-large.jpg")
|
||||
|
||||
// Capture log output
|
||||
var logBuf bytes.Buffer
|
||||
log.SetOutput(&logBuf)
|
||||
defer log.SetOutput(GinkgoWriter)
|
||||
log.SetLevel(log.LevelDebug)
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-cached")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-cached", mock.Anything, mock.Anything)
|
||||
|
||||
// Assert: background refresh was NOT enqueued
|
||||
Expect(logBuf.String()).ToNot(ContainSubstring("Artist image info expired, enqueuing background refresh"))
|
||||
|
||||
})
|
||||
|
||||
It("returns stale URL and enqueues refresh when info is expired", func() {
|
||||
// Arrange
|
||||
conf.Server.DevArtistInfoTimeToLive = 1 * time.Nanosecond
|
||||
expiredTime := time.Now().Add(-1 * time.Hour)
|
||||
staleArtist := &model.Artist{
|
||||
ID: "artist-expired",
|
||||
Name: "Expired Artist",
|
||||
LargeImageUrl: "http://example.com/expired-large.jpg",
|
||||
ExternalInfoUpdatedAt: &expiredTime,
|
||||
}
|
||||
mockArtistRepo.On("Get", "artist-expired").Return(staleArtist, nil).Maybe()
|
||||
expectedURL, _ := url.Parse("http://example.com/expired-large.jpg")
|
||||
|
||||
// Capture log output
|
||||
var logBuf bytes.Buffer
|
||||
log.SetOutput(&logBuf)
|
||||
defer log.SetOutput(GinkgoWriter)
|
||||
log.SetLevel(log.LevelDebug)
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-expired")
|
||||
|
||||
// Assert: returns stale URL immediately, no agent call
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-expired", mock.Anything, mock.Anything)
|
||||
|
||||
// Assert: background refresh was enqueued
|
||||
Expect(logBuf.String()).To(ContainSubstring("Artist image info expired, enqueuing background refresh"))
|
||||
})
|
||||
|
||||
Context("Unicode handling in artist names", func() {
|
||||
var artistWithEnDash *model.Artist
|
||||
var expectedURL *url.URL
|
||||
|
||||
1
core/external/provider_topsongs_test.go
vendored
1
core/external/provider_topsongs_test.go
vendored
@ -6,7 +6,6 @@ import (
|
||||
|
||||
_ "github.com/navidrome/navidrome/adapters/lastfm"
|
||||
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
|
||||
_ "github.com/navidrome/navidrome/adapters/spotify"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@ -43,6 +44,7 @@ type AudioProbeResult struct {
|
||||
type FFmpeg interface {
|
||||
Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error)
|
||||
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
|
||||
ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error)
|
||||
Probe(ctx context.Context, files []string) (string, error)
|
||||
ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error)
|
||||
CmdPath() (string, error)
|
||||
@ -78,6 +80,23 @@ func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadC
|
||||
return e.start(ctx, args)
|
||||
}
|
||||
|
||||
func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) {
|
||||
cmdPath, err := ffmpegCmd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
args := []string{cmdPath, "-i", "pipe:0"}
|
||||
if maxSize > 0 {
|
||||
vf := fmt.Sprintf("scale='min(%d,iw)':'min(%d,ih)':force_original_aspect_ratio=decrease", maxSize, maxSize)
|
||||
args = append(args, "-vf", vf)
|
||||
}
|
||||
args = append(args, "-loop", "0", "-c:v", "libwebp_anim",
|
||||
"-quality", strconv.Itoa(quality), "-f", "webp", "-")
|
||||
|
||||
return e.start(ctx, args, reader)
|
||||
}
|
||||
|
||||
func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) {
|
||||
if _, err := ffmpegCmd(); err != nil {
|
||||
return nil, err
|
||||
@ -223,9 +242,12 @@ func (e *ffmpeg) Version() string {
|
||||
return parts[2]
|
||||
}
|
||||
|
||||
func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error) {
|
||||
func (e *ffmpeg) start(ctx context.Context, args []string, input ...io.Reader) (io.ReadCloser, error) {
|
||||
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
|
||||
j := &ffCmd{args: args}
|
||||
if len(input) > 0 {
|
||||
j.input = input[0]
|
||||
}
|
||||
j.PipeReader, j.out = io.Pipe()
|
||||
err := j.start(ctx)
|
||||
if err != nil {
|
||||
@ -237,18 +259,25 @@ func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error
|
||||
|
||||
type ffCmd struct {
|
||||
*io.PipeReader
|
||||
out *io.PipeWriter
|
||||
args []string
|
||||
cmd *exec.Cmd
|
||||
out *io.PipeWriter
|
||||
args []string
|
||||
cmd *exec.Cmd
|
||||
input io.Reader // optional stdin source
|
||||
stderr *bytes.Buffer
|
||||
}
|
||||
|
||||
func (j *ffCmd) start(ctx context.Context) error {
|
||||
cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
|
||||
cmd.Stdout = j.out
|
||||
if j.input != nil {
|
||||
cmd.Stdin = j.input
|
||||
}
|
||||
j.stderr = &bytes.Buffer{}
|
||||
stderrWriter := &limitedWriter{buf: j.stderr, limit: 4096}
|
||||
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stderr = io.MultiWriter(os.Stderr, stderrWriter)
|
||||
} else {
|
||||
cmd.Stderr = io.Discard
|
||||
cmd.Stderr = stderrWriter
|
||||
}
|
||||
j.cmd = cmd
|
||||
|
||||
@ -262,7 +291,11 @@ func (j *ffCmd) wait() {
|
||||
if err := j.cmd.Wait(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
_ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()))
|
||||
errMsg := fmt.Sprintf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode())
|
||||
if stderrOutput := strings.TrimSpace(j.stderr.String()); stderrOutput != "" {
|
||||
errMsg += ": " + stderrOutput
|
||||
}
|
||||
_ = j.out.CloseWithError(errors.New(errMsg))
|
||||
} else {
|
||||
_ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err))
|
||||
}
|
||||
@ -271,6 +304,26 @@ func (j *ffCmd) wait() {
|
||||
_ = j.out.Close()
|
||||
}
|
||||
|
||||
// limitedWriter wraps a bytes.Buffer and stops writing once the limit is reached.
|
||||
// Writes that would exceed the limit are silently discarded to prevent unbounded memory usage.
|
||||
type limitedWriter struct {
|
||||
buf *bytes.Buffer
|
||||
limit int
|
||||
}
|
||||
|
||||
func (w *limitedWriter) Write(p []byte) (int, error) {
|
||||
n := len(p)
|
||||
remaining := w.limit - w.buf.Len()
|
||||
if remaining <= 0 {
|
||||
return n, nil // Discard but report success to avoid breaking the writer
|
||||
}
|
||||
if len(p) > remaining {
|
||||
p = p[:remaining]
|
||||
}
|
||||
w.buf.Write(p)
|
||||
return n, nil // Always report full write to avoid ErrShortWrite from io.MultiWriter
|
||||
}
|
||||
|
||||
// formatCodecMap maps target format to ffmpeg codec flag.
|
||||
var formatCodecMap = map[string]string{
|
||||
"mp3": "libmp3lame",
|
||||
@ -283,7 +336,7 @@ var formatCodecMap = map[string]string{
|
||||
var formatOutputMap = map[string]string{
|
||||
"mp3": "mp3",
|
||||
"opus": "opus",
|
||||
"aac": "ipod",
|
||||
"aac": "adts",
|
||||
"flac": "flac",
|
||||
}
|
||||
|
||||
@ -339,11 +392,6 @@ func buildDynamicArgs(opts TranscodeOptions) []string {
|
||||
args = append(args, "-f", outputFmt)
|
||||
}
|
||||
|
||||
// For AAC in MP4 container, enable fragmented MP4 for pipe-safe streaming
|
||||
if opts.Format == "aac" {
|
||||
args = append(args, "-movflags", "frag_keyframe+empty_moov")
|
||||
}
|
||||
|
||||
args = append(args, "-")
|
||||
return args
|
||||
}
|
||||
|
||||
@ -86,7 +86,7 @@ var _ = Describe("ffmpeg", func() {
|
||||
Expect(isDefaultCommand("opus", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue())
|
||||
})
|
||||
It("returns true for known default aac command", func() {
|
||||
Expect(isDefaultCommand("aac", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -")).To(BeTrue())
|
||||
Expect(isDefaultCommand("aac", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue())
|
||||
})
|
||||
It("returns true for known default flac command", func() {
|
||||
Expect(isDefaultCommand("flac", "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue())
|
||||
@ -174,7 +174,7 @@ var _ = Describe("ffmpeg", func() {
|
||||
}))
|
||||
})
|
||||
|
||||
It("builds aac args with fragmented MP4 container", func() {
|
||||
It("builds aac args with ADTS output", func() {
|
||||
args := buildDynamicArgs(TranscodeOptions{
|
||||
Format: "aac",
|
||||
FilePath: "/music/file.flac",
|
||||
@ -186,8 +186,7 @@ var _ = Describe("ffmpeg", func() {
|
||||
"-c:a", "aac",
|
||||
"-b:a", "256k",
|
||||
"-v", "0",
|
||||
"-f", "ipod",
|
||||
"-movflags", "frag_keyframe+empty_moov",
|
||||
"-f", "adts",
|
||||
"-",
|
||||
}))
|
||||
})
|
||||
@ -585,9 +584,12 @@ var _ = Describe("ffmpeg", func() {
|
||||
// Cancel the context
|
||||
cancel()
|
||||
|
||||
// Next read should fail due to cancelled context
|
||||
_, err = stream.Read(buf)
|
||||
Expect(err).To(HaveOccurred())
|
||||
// Subsequent reads should eventually fail due to cancelled context.
|
||||
// There may be buffered data in the pipe, so we drain until an error occurs.
|
||||
Eventually(func() error {
|
||||
_, err = stream.Read(buf)
|
||||
return err
|
||||
}).WithTimeout(5 * time.Second).WithPolling(10 * time.Millisecond).Should(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should handle immediate context cancellation", func() {
|
||||
@ -605,6 +607,46 @@ var _ = Describe("ffmpeg", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Context("stderr capture", func() {
|
||||
BeforeEach(func() {
|
||||
if runtime.GOOS == "windows" {
|
||||
Skip("stderr capture tests use /bin/sh, skipping on Windows")
|
||||
}
|
||||
})
|
||||
|
||||
It("should include stderr in error when process fails", func() {
|
||||
ff := &ffmpeg{}
|
||||
ctx := GinkgoT().Context()
|
||||
|
||||
// Directly call start() with a bash command that writes to stderr and fails
|
||||
args := []string{"/bin/sh", "-c", "echo 'codec not found: libopus' >&2; exit 1"}
|
||||
stream, err := ff.start(ctx, args)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer stream.Close()
|
||||
|
||||
buf := make([]byte, 1024)
|
||||
_, err = stream.Read(buf)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("codec not found: libopus"))
|
||||
})
|
||||
|
||||
It("should not include stderr in error when process succeeds", func() {
|
||||
ff := &ffmpeg{}
|
||||
ctx := GinkgoT().Context()
|
||||
|
||||
// Command that writes to stderr but exits successfully
|
||||
args := []string{"/bin/sh", "-c", "echo 'warning: something' >&2; printf 'output'"}
|
||||
stream, err := ff.start(ctx, args)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer stream.Close()
|
||||
|
||||
buf := make([]byte, 1024)
|
||||
n, err := stream.Read(buf)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(buf[:n])).To(Equal("output"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with mock process behavior", func() {
|
||||
var longRunningCmd string
|
||||
BeforeEach(func() {
|
||||
|
||||
71
core/image_upload.go
Normal file
71
core/image_upload.go
Normal file
@ -0,0 +1,71 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
type ImageUploadService interface {
|
||||
SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error)
|
||||
RemoveImage(ctx context.Context, path string) error
|
||||
}
|
||||
|
||||
type imageUploadService struct{}
|
||||
|
||||
func NewImageUploadService() ImageUploadService {
|
||||
return &imageUploadService{}
|
||||
}
|
||||
|
||||
func (s *imageUploadService) SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (string, error) {
|
||||
filename := imageFilename(entityID, name, ext)
|
||||
absPath := model.UploadedImagePath(entityType, filename)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
|
||||
return "", fmt.Errorf("creating image directory: %w", err)
|
||||
}
|
||||
|
||||
// Remove old image if it exists
|
||||
if oldPath != "" {
|
||||
if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) {
|
||||
log.Warn(ctx, "Failed to remove old image", "path", oldPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save new image
|
||||
f, err := os.Create(absPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating image file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := io.Copy(f, reader); err != nil {
|
||||
return "", fmt.Errorf("writing image file: %w", err)
|
||||
}
|
||||
|
||||
return filename, nil
|
||||
}
|
||||
|
||||
func (s *imageUploadService) RemoveImage(ctx context.Context, path string) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("removing image %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func imageFilename(id, name, ext string) string {
|
||||
clean := utils.CleanFileName(name)
|
||||
if clean == "" {
|
||||
return id + ext
|
||||
}
|
||||
return id + "_" + clean + ext
|
||||
}
|
||||
99
core/image_upload_test.go
Normal file
99
core/image_upload_test.go
Normal file
@ -0,0 +1,99 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ImageUploadService", func() {
|
||||
var svc core.ImageUploadService
|
||||
var tmpDir string
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tmpDir = GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = tmpDir
|
||||
svc = core.NewImageUploadService()
|
||||
})
|
||||
|
||||
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())
|
||||
Expect(filename).To(Equal("ar-1_pink_floyd.jpg"))
|
||||
|
||||
absPath := filepath.Join(tmpDir, "artwork", "artist", "ar-1_pink_floyd.jpg")
|
||||
data, err := os.ReadFile(absPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("fake image data"))
|
||||
})
|
||||
|
||||
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())
|
||||
Expect(filename).To(Equal("pl-1.png"))
|
||||
})
|
||||
|
||||
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")
|
||||
Expect(os.WriteFile(oldFile, []byte("old"), 0600)).To(Succeed())
|
||||
|
||||
reader := strings.NewReader("new image")
|
||||
_, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "New Name", oldFile, reader, ".jpg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(oldFile).ToNot(BeAnExistingFile())
|
||||
|
||||
newPath := filepath.Join(oldDir, "ar-1_new_name.jpg")
|
||||
Expect(newPath).To(BeAnExistingFile())
|
||||
})
|
||||
|
||||
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())
|
||||
})
|
||||
})
|
||||
|
||||
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")
|
||||
Expect(os.WriteFile(path, []byte("img"), 0600)).To(Succeed())
|
||||
|
||||
err := svc.RemoveImage(ctx, path)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).ToNot(BeAnExistingFile())
|
||||
})
|
||||
|
||||
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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -193,13 +193,16 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
data.Config.TLSConfigured = conf.Server.TLSCert != "" && conf.Server.TLSKey != ""
|
||||
data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL
|
||||
data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache
|
||||
data.Config.EnableArtworkUpload = conf.Server.EnableArtworkUpload
|
||||
data.Config.CoverArtQuality = conf.Server.CoverArtQuality
|
||||
data.Config.EnableWebPEncoding = conf.Server.EnableWebPEncoding
|
||||
data.Config.UICoverArtSize = conf.Server.UICoverArtSize
|
||||
data.Config.EnableCoverAnimation = conf.Server.EnableCoverAnimation
|
||||
data.Config.EnableNowPlaying = conf.Server.EnableNowPlaying
|
||||
data.Config.EnableDownloads = conf.Server.EnableDownloads
|
||||
data.Config.EnableSharing = conf.Server.EnableSharing
|
||||
data.Config.EnableStarRating = conf.Server.EnableStarRating
|
||||
data.Config.EnableLastFM = conf.Server.LastFM.Enabled && conf.Server.LastFM.ApiKey != "" && conf.Server.LastFM.Secret != ""
|
||||
data.Config.EnableSpotify = conf.Server.Spotify.ID != "" && conf.Server.Spotify.Secret != ""
|
||||
data.Config.EnableListenBrainz = conf.Server.ListenBrainz.Enabled
|
||||
data.Config.EnableDeezer = conf.Server.Deezer.Enabled
|
||||
data.Config.EnableMediaFileCoverArt = conf.Server.EnableMediaFileCoverArt
|
||||
|
||||
@ -61,9 +61,12 @@ type Data struct {
|
||||
EnableListenBrainz bool `json:"enableListenBrainz,omitempty"`
|
||||
EnableDeezer bool `json:"enableDeezer,omitempty"`
|
||||
EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"`
|
||||
EnableSpotify bool `json:"enableSpotify,omitempty"`
|
||||
EnableJukebox bool `json:"enableJukebox,omitempty"`
|
||||
EnablePrometheus bool `json:"enablePrometheus,omitempty"`
|
||||
EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"`
|
||||
CoverArtQuality int `json:"coverArtQuality,omitempty"`
|
||||
EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"`
|
||||
UICoverArtSize int `json:"uiCoverArtSize,omitempty"`
|
||||
EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"`
|
||||
EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
|
||||
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
|
||||
|
||||
@ -10,9 +10,9 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/kballard/go-shellquote"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils/shellquote"
|
||||
)
|
||||
|
||||
func start(ctx context.Context, args []string) (Executor, error) {
|
||||
|
||||
@ -188,7 +188,7 @@ var _ = Describe("MPV", func() {
|
||||
|
||||
It("returns empty slice for empty template", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{}))
|
||||
Expect(args).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
@ -42,7 +43,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
var folder *model.Folder
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
ds.MockedMediaFile = &mockedMediaFileRepo{}
|
||||
libPath, _ := os.Getwd()
|
||||
// Set up library with the actual library path that matches the folder
|
||||
@ -117,7 +118,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
@ -135,7 +136,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
@ -154,7 +155,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
@ -173,7 +174,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
@ -190,7 +191,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
@ -207,7 +208,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
@ -224,7 +225,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
@ -242,7 +243,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
@ -256,7 +257,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
m3u := "#EXTALBUMARTURL:https://example.com/new-cover.jpg\ntest.mp3\n"
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
@ -283,7 +284,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
m3u := "test.mp3\n"
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
@ -358,7 +359,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
// Create the playlist file on disk with the filesystem's normalization form
|
||||
plsFile := tmpDir + "/" + filesystemName + ".m3u"
|
||||
@ -418,7 +419,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
"def.mp3", // This is playlists/def.mp3 relative to plsDir
|
||||
},
|
||||
}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
})
|
||||
|
||||
It("handles relative paths that reference files in other libraries", func() {
|
||||
@ -574,7 +575,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
},
|
||||
}
|
||||
// Recreate playlists service to pick up new mock
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
|
||||
// Create playlist in music library that references both tracks
|
||||
plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3"
|
||||
@ -617,7 +618,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
BeforeEach(func() {
|
||||
repo = &mockedMediaFileFromListRepo{}
|
||||
ds.MockedMediaFile = repo
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
})
|
||||
|
||||
@ -31,8 +31,8 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
||||
filteredLines := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "#PLAYLIST:") {
|
||||
pls.Name = line[len("#PLAYLIST:"):]
|
||||
if after, ok := strings.CutPrefix(line, "#PLAYLIST:"); ok {
|
||||
pls.Name = after
|
||||
continue
|
||||
}
|
||||
if after, ok := strings.CutPrefix(line, "#EXTALBUMARTURL:"); ok {
|
||||
|
||||
@ -9,10 +9,10 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/RaveNoX/go-jsoncommentstrip"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/utils/jsoncommentstrip"
|
||||
)
|
||||
|
||||
func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
|
||||
|
||||
@ -2,7 +2,6 @@ package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@ -12,6 +11,7 @@ import (
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@ -50,12 +50,20 @@ type Playlists interface {
|
||||
TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository
|
||||
}
|
||||
|
||||
type playlists struct {
|
||||
ds model.DataStore
|
||||
// ImageUploadService is a local interface satisfied by core.ImageUploadService.
|
||||
// Defined here to avoid an import cycle between core and core/playlists.
|
||||
type ImageUploadService interface {
|
||||
SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error)
|
||||
RemoveImage(ctx context.Context, path string) error
|
||||
}
|
||||
|
||||
func NewPlaylists(ds model.DataStore) Playlists {
|
||||
return &playlists{ds: ds}
|
||||
type playlists struct {
|
||||
ds model.DataStore
|
||||
imgUpload ImageUploadService
|
||||
}
|
||||
|
||||
func NewPlaylists(ds model.DataStore, imgUpload ImageUploadService) Playlists {
|
||||
return &playlists{ds: ds, imgUpload: imgUpload}
|
||||
}
|
||||
|
||||
func InPath(folder model.Folder) bool {
|
||||
@ -288,33 +296,13 @@ func (s *playlists) SetImage(ctx context.Context, playlistID string, reader io.R
|
||||
return err
|
||||
}
|
||||
|
||||
filename := pls.ImageFilename(ext)
|
||||
oldPath := pls.UploadedImagePath()
|
||||
pls.UploadedImage = filename
|
||||
absPath := pls.UploadedImagePath()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
|
||||
return fmt.Errorf("creating playlist images directory: %w", err)
|
||||
}
|
||||
|
||||
// Remove old image if it exists
|
||||
if oldPath != "" {
|
||||
if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) {
|
||||
log.Warn(ctx, "Failed to remove old playlist image", "path", oldPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save new image
|
||||
f, err := os.Create(absPath)
|
||||
filename, err := s.imgUpload.SetImage(ctx, consts.EntityPlaylist, pls.ID, pls.Name, oldPath, reader, ext)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating playlist image file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := io.Copy(f, reader); err != nil {
|
||||
return fmt.Errorf("writing playlist image file: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
pls.UploadedImage = filename
|
||||
return s.ds.Playlist(ctx).Put(pls)
|
||||
}
|
||||
|
||||
@ -324,10 +312,8 @@ func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if path := pls.UploadedImagePath(); path != "" {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
log.Warn(ctx, "Failed to remove playlist image", "path", path, err)
|
||||
}
|
||||
if err := s.imgUpload.RemoveImage(ctx, pls.UploadedImagePath()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pls.UploadedImage = ""
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
@ -41,7 +42,7 @@ var _ = Describe("Playlists", func() {
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
})
|
||||
|
||||
It("allows owner to delete their playlist", func() {
|
||||
@ -80,7 +81,7 @@ var _ = Describe("Playlists", func() {
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
})
|
||||
|
||||
It("creates a new playlist with owner set from context", func() {
|
||||
@ -138,7 +139,7 @@ var _ = Describe("Playlists", func() {
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
})
|
||||
|
||||
It("allows owner to update their playlist", func() {
|
||||
@ -201,7 +202,7 @@ var _ = Describe("Playlists", func() {
|
||||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
})
|
||||
|
||||
It("allows owner to add tracks", func() {
|
||||
@ -249,7 +250,7 @@ var _ = Describe("Playlists", func() {
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
})
|
||||
|
||||
It("allows owner to remove tracks", func() {
|
||||
@ -283,7 +284,7 @@ var _ = Describe("Playlists", func() {
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
})
|
||||
|
||||
It("allows owner to reorder", func() {
|
||||
@ -312,7 +313,7 @@ var _ = Describe("Playlists", func() {
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
||||
}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
})
|
||||
|
||||
It("saves image file and updates UploadedImage", func() {
|
||||
@ -382,7 +383,7 @@ var _ = Describe("Playlists", func() {
|
||||
"pls-empty": {ID: "pls-empty", Name: "No Cover", OwnerID: "user-1"},
|
||||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
||||
}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
})
|
||||
|
||||
It("removes file and clears UploadedImage", func() {
|
||||
|
||||
@ -97,5 +97,7 @@ func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity
|
||||
if entity.OwnerID != "" {
|
||||
current.OwnerID = entity.OwnerID
|
||||
}
|
||||
// Apply smart playlist rules update
|
||||
current.Rules = entity.Rules
|
||||
return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public)
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
@ -36,7 +37,7 @@ var _ = Describe("REST Adapter", func() {
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
})
|
||||
|
||||
Describe("Save", func() {
|
||||
@ -125,6 +126,22 @@ var _ = Describe("REST Adapter", func() {
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
|
||||
It("updates smart playlist rules", func() {
|
||||
mockPlsRepo.Data["smart-1"] = &model.Playlist{
|
||||
ID: "smart-1",
|
||||
Name: "Smart Playlist",
|
||||
OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "old"}},
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
newRules := &criteria.Criteria{Expression: criteria.Contains{"title": "new"}}
|
||||
pls := &model.Playlist{Name: "Smart Playlist", Rules: newRules}
|
||||
err := repo.Update("smart-1", pls)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Rules).To(Equal(newRules))
|
||||
})
|
||||
|
||||
It("returns rest.ErrNotFound when playlist doesn't exist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
|
||||
@ -45,6 +45,9 @@ func PublicURL(req *http.Request, u string, params url.Values) string {
|
||||
}
|
||||
buildUrl.Scheme = shareUrl.Scheme
|
||||
buildUrl.Host = shareUrl.Host
|
||||
if basePath := strings.TrimRight(shareUrl.Path, "/"); basePath != "" {
|
||||
buildUrl.Path = path.Join(basePath, buildUrl.Path)
|
||||
}
|
||||
if len(params) > 0 {
|
||||
buildUrl.RawQuery = params.Encode()
|
||||
}
|
||||
|
||||
@ -56,6 +56,31 @@ var _ = Describe("Public URL Utilities", func() {
|
||||
})
|
||||
})
|
||||
|
||||
When("ShareURL includes a path", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ShareURL = "https://example.com/navi"
|
||||
})
|
||||
|
||||
It("prepends the ShareURL path to the resource", func() {
|
||||
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||
result := publicurl.PublicURL(r, "/share/img/hash", nil)
|
||||
Expect(result).To(Equal("https://example.com/navi/share/img/hash"))
|
||||
})
|
||||
|
||||
It("prepends the ShareURL path and includes query parameters", func() {
|
||||
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||
params := url.Values{"size": []string{"600"}}
|
||||
result := publicurl.PublicURL(r, "/share/img/hash", params)
|
||||
Expect(result).To(Equal("https://example.com/navi/share/img/hash?size=600"))
|
||||
})
|
||||
|
||||
It("handles trailing slash in ShareURL path", func() {
|
||||
conf.Server.ShareURL = "https://example.com/navi/"
|
||||
result := publicurl.PublicURL(nil, "/share/img/hash", nil)
|
||||
Expect(result).To(Equal("https://example.com/navi/share/img/hash"))
|
||||
})
|
||||
})
|
||||
|
||||
When("ShareURL is not set", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ShareURL = ""
|
||||
|
||||
@ -7,11 +7,11 @@ import (
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/nanoid"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
@ -73,7 +73,7 @@ type shareRepositoryWrapper struct {
|
||||
|
||||
func (r *shareRepositoryWrapper) newId() (string, error) {
|
||||
for {
|
||||
id, err := gonanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 10)
|
||||
id, err := nanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 10)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@ -17,8 +17,8 @@ func (s *localStorage) Start(ctx context.Context) (<-chan string, error) {
|
||||
if !s.watching.CompareAndSwap(false, true) {
|
||||
return nil, errors.New("watcher already started")
|
||||
}
|
||||
input := make(chan notify.EventInfo, 1)
|
||||
output := make(chan string, 1)
|
||||
input := make(chan notify.EventInfo, 500)
|
||||
output := make(chan string, 500)
|
||||
|
||||
started := make(chan struct{})
|
||||
go func() {
|
||||
|
||||
@ -80,6 +80,11 @@ func matchesCodec(codec string, codecs []string) bool {
|
||||
return matchesWithAliases(codec, codecs, codecAliasGroups)
|
||||
}
|
||||
|
||||
// IsAACCodec returns true if the given codec or container name resolves to AAC.
|
||||
func IsAACCodec(name string) bool {
|
||||
return matchesCodec(name, []string{"aac"}) || matchesContainer(name, []string{"aac"})
|
||||
}
|
||||
|
||||
func containsIgnoreCase(slice []string, s string) bool {
|
||||
return slices.ContainsFunc(slice, func(item string) bool {
|
||||
return strings.EqualFold(item, s)
|
||||
|
||||
30
core/stream/aliases_test.go
Normal file
30
core/stream/aliases_test.go
Normal file
@ -0,0 +1,30 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Aliases", func() {
|
||||
Describe("IsAACCodec", func() {
|
||||
It("returns true for AAC and its aliases", func() {
|
||||
Expect(IsAACCodec("aac")).To(BeTrue())
|
||||
Expect(IsAACCodec("AAC")).To(BeTrue())
|
||||
Expect(IsAACCodec("adts")).To(BeTrue())
|
||||
Expect(IsAACCodec("m4a")).To(BeTrue())
|
||||
Expect(IsAACCodec("mp4")).To(BeTrue())
|
||||
Expect(IsAACCodec("m4b")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false for non-AAC formats", func() {
|
||||
Expect(IsAACCodec("mp3")).To(BeFalse())
|
||||
Expect(IsAACCodec("opus")).To(BeFalse())
|
||||
Expect(IsAACCodec("flac")).To(BeFalse())
|
||||
Expect(IsAACCodec("ogg")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false for empty string", func() {
|
||||
Expect(IsAACCodec("")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -86,7 +86,16 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile
|
||||
return req
|
||||
}
|
||||
|
||||
// No compatible profile — fallback to raw
|
||||
// No compatible profile for the requested format — retry with DefaultDownsamplingFormat
|
||||
// TODO: validate DefaultDownsamplingFormat at startup to warn about unsupported values
|
||||
fallbackFormat := conf.Server.DefaultDownsamplingFormat
|
||||
if reqFormat != "" && fallbackFormat != "" && !strings.EqualFold(reqFormat, fallbackFormat) {
|
||||
log.Warn(ctx, "Requested format not available, falling back to default downsampling format",
|
||||
"requestedFormat", reqFormat, "fallbackFormat", fallbackFormat, "id", mf.ID)
|
||||
return s.ResolveRequest(ctx, mf, fallbackFormat, reqBitRate, offset)
|
||||
}
|
||||
|
||||
// Ultimate fallback — raw
|
||||
req.Format = "raw"
|
||||
return req
|
||||
}
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -96,3 +100,141 @@ var _ = Describe("buildLegacyClientInfo", func() {
|
||||
Expect(ci.MaxAudioBitrate).To(BeZero())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("ResolveRequest", func() {
|
||||
var (
|
||||
svc TranscodeDecider
|
||||
ctx context.Context
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
ds := &tests.MockDataStore{
|
||||
MockedProperty: &tests.MockedPropertyRepo{},
|
||||
MockedTranscoding: &tests.MockTranscodingRepo{},
|
||||
}
|
||||
ff := tests.NewMockFFmpeg("")
|
||||
auth.Init(ds)
|
||||
svc = NewTranscodeDecider(ds, ff)
|
||||
})
|
||||
|
||||
It("returns raw when format is 'raw'", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
|
||||
|
||||
decider := svc.(*deciderService)
|
||||
req := decider.ResolveRequest(ctx, mf, "raw", 0, 0)
|
||||
|
||||
Expect(req.Format).To(Equal("raw"))
|
||||
})
|
||||
|
||||
It("returns raw (direct play) when no format or bitrate specified", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
|
||||
|
||||
decider := svc.(*deciderService)
|
||||
req := decider.ResolveRequest(ctx, mf, "", 0, 0)
|
||||
|
||||
Expect(req.Format).To(Equal("raw"))
|
||||
})
|
||||
|
||||
It("transcodes to requested format", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
||||
|
||||
decider := svc.(*deciderService)
|
||||
req := decider.ResolveRequest(ctx, mf, "opus", 0, 0)
|
||||
|
||||
Expect(req.Format).To(Equal("opus"))
|
||||
})
|
||||
|
||||
It("transcodes to requested format with bitrate limit", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
||||
|
||||
decider := svc.(*deciderService)
|
||||
req := decider.ResolveRequest(ctx, mf, "mp3", 128, 0)
|
||||
|
||||
Expect(req.Format).To(Equal("mp3"))
|
||||
Expect(req.BitRate).To(Equal(128))
|
||||
})
|
||||
|
||||
It("returns raw when requested format matches source and no bitrate reduction", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
|
||||
|
||||
decider := svc.(*deciderService)
|
||||
req := decider.ResolveRequest(ctx, mf, "mp3", 320, 0)
|
||||
|
||||
Expect(req.Format).To(Equal("raw"))
|
||||
})
|
||||
|
||||
It("downsamples when only bitrate is specified below source", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultDownsamplingFormat = "opus"
|
||||
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
||||
|
||||
decider := svc.(*deciderService)
|
||||
req := decider.ResolveRequest(ctx, mf, "", 128, 0)
|
||||
|
||||
Expect(req.Format).To(Equal("opus"))
|
||||
Expect(req.BitRate).To(Equal(128))
|
||||
})
|
||||
|
||||
It("passes offset through", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
||||
|
||||
decider := svc.(*deciderService)
|
||||
req := decider.ResolveRequest(ctx, mf, "opus", 128, 30)
|
||||
|
||||
Expect(req.Format).To(Equal("opus"))
|
||||
Expect(req.Offset).To(Equal(30))
|
||||
})
|
||||
|
||||
Context("fallback for unknown format", func() {
|
||||
It("falls back to DefaultDownsamplingFormat", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultDownsamplingFormat = "opus"
|
||||
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
|
||||
|
||||
decider := svc.(*deciderService)
|
||||
req := decider.ResolveRequest(ctx, mf, "xyz", 0, 0)
|
||||
|
||||
Expect(req.Format).To(Equal("opus"))
|
||||
})
|
||||
|
||||
It("falls back to raw when DefaultDownsamplingFormat is empty", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultDownsamplingFormat = ""
|
||||
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
|
||||
|
||||
decider := svc.(*deciderService)
|
||||
req := decider.ResolveRequest(ctx, mf, "xyz", 0, 0)
|
||||
|
||||
Expect(req.Format).To(Equal("raw"))
|
||||
})
|
||||
|
||||
It("falls back to raw when DefaultDownsamplingFormat is also invalid", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultDownsamplingFormat = "xyz"
|
||||
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
|
||||
|
||||
decider := svc.(*deciderService)
|
||||
req := decider.ResolveRequest(ctx, mf, "xyz", 0, 0)
|
||||
|
||||
Expect(req.Format).To(Equal("raw"))
|
||||
})
|
||||
|
||||
It("preserves bitrate when falling back to DefaultDownsamplingFormat", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultDownsamplingFormat = "opus"
|
||||
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
||||
|
||||
decider := svc.(*deciderService)
|
||||
req := decider.ResolveRequest(ctx, mf, "xyz", 128, 0)
|
||||
|
||||
Expect(req.Format).To(Equal("opus"))
|
||||
Expect(req.BitRate).To(Equal(128))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -5,8 +5,9 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@ -17,6 +18,7 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
type MediaStreamer interface {
|
||||
@ -51,6 +53,9 @@ func (j *streamJob) Key() string {
|
||||
return fmt.Sprintf("%s.%s.%d.%d.%d.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.sampleRate, j.bitDepth, j.channels, j.format, j.offset)
|
||||
}
|
||||
|
||||
// NewStream creates a Stream for the given MediaFile and Request. It handles both raw streaming (no transcoding)
|
||||
// and transcoded streaming based on the requested format and bitrate. It also logs detailed information about
|
||||
// the streaming request and whether the transcoding result was served from cache or not.
|
||||
func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req Request) (*Stream, error) {
|
||||
var format string
|
||||
var bitRate int
|
||||
@ -133,14 +138,59 @@ func (s *Stream) EstimatedContentLength() int {
|
||||
return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
|
||||
}
|
||||
|
||||
// NewTestStream creates a Stream for testing purposes.
|
||||
func NewTestStream(mf *model.MediaFile, format string, bitRate int) *Stream {
|
||||
// Serve writes the stream to the HTTP response. For seekable streams it uses http.ServeContent
|
||||
// (supporting range requests). For non-seekable streams it writes directly and logs any errors.
|
||||
// Returns the number of bytes written and an error only when io.Copy fails with 0 bytes written
|
||||
// (meaning the HTTP 200 status has not been flushed yet and the caller can still send an error response).
|
||||
// Empty output (0 bytes, no error) is logged but not treated as an error.
|
||||
func (s *Stream) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request) (int64, error) {
|
||||
if s.Seekable() {
|
||||
http.ServeContent(w, r, s.Name(), s.ModTime(), s)
|
||||
return -1, nil
|
||||
}
|
||||
|
||||
w.Header().Set("Accept-Ranges", "none")
|
||||
w.Header().Set("Content-Type", s.ContentType())
|
||||
|
||||
if req.Params(r).BoolOr("estimateContentLength", false) {
|
||||
length := strconv.Itoa(s.EstimatedContentLength())
|
||||
log.Trace(ctx, "Estimated content-length", "contentLength", length)
|
||||
w.Header().Set("Content-Length", length)
|
||||
}
|
||||
|
||||
if r.Method == http.MethodHead {
|
||||
go func() { _, _ = io.Copy(io.Discard, s) }()
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
id := s.mf.ID
|
||||
c, err := io.Copy(w, s)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error sending transcoded file", "id", id, err)
|
||||
if c == 0 {
|
||||
w.Header().Del("Content-Length")
|
||||
return 0, fmt.Errorf("sending transcoded file: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
if c == 0 {
|
||||
log.Error(ctx, "Transcoding returned empty output, ffmpeg may have failed. "+
|
||||
"Check that ffmpeg supports the requested codec. Enable Trace logging for ffmpeg stderr details",
|
||||
"id", id, "format", s.ContentType())
|
||||
} else {
|
||||
log.Trace(ctx, "Success sending transcoded file", "id", id, "size", c)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// NewStream creates a non-seekable Stream from the given components.
|
||||
func NewStream(mf *model.MediaFile, format string, bitRate int, r io.ReadCloser) *Stream {
|
||||
return &Stream{
|
||||
ctx: context.Background(),
|
||||
mf: mf,
|
||||
format: format,
|
||||
bitRate: bitRate,
|
||||
ReadCloser: io.NopCloser(strings.NewReader("")),
|
||||
ReadCloser: r,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -23,6 +23,8 @@ var Set = wire.NewSet(
|
||||
NewLibrary,
|
||||
NewUser,
|
||||
NewMaintenance,
|
||||
NewImageUploadService,
|
||||
wire.Bind(new(playlists.ImageUploadService), new(ImageUploadService)),
|
||||
stream.NewTranscodeDecider,
|
||||
agents.GetAgents,
|
||||
external.NewProvider,
|
||||
|
||||
@ -17,9 +17,12 @@ func upEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
|
||||
// Older installations may be missing default transcodings that were added
|
||||
// after the initial seeding (e.g., aac was added later than mp3/opus).
|
||||
// Insert any missing defaults without touching user-customized entries.
|
||||
// Check both target_format and name since both have UNIQUE constraints,
|
||||
// and older entries may have a different target_format (e.g., 'oga' vs 'opus')
|
||||
// but the same name.
|
||||
for _, t := range consts.DefaultTranscodings {
|
||||
var count int
|
||||
err := tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = ?", t.TargetFormat).Scan(&count)
|
||||
err := tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = ? OR name = ?", t.TargetFormat, t.Name).Scan(&count)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
30
db/migrations/20260310113858_fix_aac_transcode_command.go
Normal file
30
db/migrations/20260310113858_fix_aac_transcode_command.go
Normal file
@ -0,0 +1,30 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(upFixAacTranscodeCommand, downFixAacTranscodeCommand)
|
||||
}
|
||||
|
||||
func upFixAacTranscodeCommand(_ context.Context, tx *sql.Tx) error {
|
||||
// The old AAC command used `-f ipod -movflags frag_keyframe+empty_moov` which produces
|
||||
// corrupt/silent audio when ffmpeg pipes to stdout (confirmed in ffmpeg 8.0+).
|
||||
// Switch to `-f adts` (raw AAC framing) which works reliably via pipe.
|
||||
// Only update rows that still have the old default command.
|
||||
const oldCommand = "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -"
|
||||
const newCommand = "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -"
|
||||
_, err := tx.Exec(
|
||||
"UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?",
|
||||
newCommand, oldCommand,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func downFixAacTranscodeCommand(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
22
db/migrations/20260315233131_add_artist_uploaded_image.go
Normal file
22
db/migrations/20260315233131_add_artist_uploaded_image.go
Normal file
@ -0,0 +1,22 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(upAddArtistUploadedImage, downAddArtistUploadedImage)
|
||||
}
|
||||
|
||||
func upAddArtistUploadedImage(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `ALTER TABLE artist ADD COLUMN uploaded_image VARCHAR(255) DEFAULT ''`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddArtistUploadedImage(ctx context.Context, tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
74
db/migrations/20260316000000_normalize_timestamps.sql
Normal file
74
db/migrations/20260316000000_normalize_timestamps.sql
Normal file
@ -0,0 +1,74 @@
|
||||
-- +goose Up
|
||||
|
||||
-- Normalize T-format timestamps (RFC3339Nano with 'T' separator) to SQLite-compatible format.
|
||||
-- SQLite uses string comparison for ORDER BY on TEXT columns, so 'T' (ASCII 84) > ' ' (ASCII 32)
|
||||
-- causes T-format timestamps to sort after space-format ones, breaking "Recently Added" ordering.
|
||||
|
||||
UPDATE album SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
|
||||
UPDATE album SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
|
||||
UPDATE album SET imported_at = replace(replace(imported_at, 'T', ' '), 'Z', '+00:00') WHERE imported_at LIKE '%T%';
|
||||
UPDATE album SET external_info_updated_at = replace(replace(external_info_updated_at, 'T', ' '), 'Z', '+00:00') WHERE external_info_updated_at LIKE '%T%';
|
||||
|
||||
UPDATE media_file SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
|
||||
UPDATE media_file SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
|
||||
UPDATE media_file SET birth_time = replace(replace(birth_time, 'T', ' '), 'Z', '+00:00') WHERE birth_time LIKE '%T%';
|
||||
|
||||
UPDATE artist SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
|
||||
UPDATE artist SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
|
||||
UPDATE artist SET external_info_updated_at = replace(replace(external_info_updated_at, 'T', ' '), 'Z', '+00:00') WHERE external_info_updated_at LIKE '%T%';
|
||||
|
||||
UPDATE annotation SET play_date = replace(replace(play_date, 'T', ' '), 'Z', '+00:00') WHERE play_date LIKE '%T%';
|
||||
UPDATE annotation SET starred_at = replace(replace(starred_at, 'T', ' '), 'Z', '+00:00') WHERE starred_at LIKE '%T%';
|
||||
UPDATE annotation SET rated_at = replace(replace(rated_at, 'T', ' '), 'Z', '+00:00') WHERE rated_at LIKE '%T%';
|
||||
|
||||
UPDATE playlist SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
|
||||
UPDATE playlist SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
|
||||
UPDATE playlist SET evaluated_at = replace(replace(evaluated_at, 'T', ' '), 'Z', '+00:00') WHERE evaluated_at LIKE '%T%';
|
||||
|
||||
UPDATE user SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
|
||||
UPDATE user SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
|
||||
UPDATE user SET last_login_at = replace(replace(last_login_at, 'T', ' '), 'Z', '+00:00') WHERE last_login_at LIKE '%T%';
|
||||
UPDATE user SET last_access_at = replace(replace(last_access_at, 'T', ' '), 'Z', '+00:00') WHERE last_access_at LIKE '%T%';
|
||||
|
||||
UPDATE player SET last_seen = replace(replace(last_seen, 'T', ' '), 'Z', '+00:00') WHERE last_seen LIKE '%T%';
|
||||
|
||||
UPDATE playqueue SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
|
||||
UPDATE playqueue SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
|
||||
|
||||
UPDATE bookmark SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
|
||||
UPDATE bookmark SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
|
||||
|
||||
UPDATE share SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
|
||||
UPDATE share SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
|
||||
UPDATE share SET expires_at = replace(replace(expires_at, 'T', ' '), 'Z', '+00:00') WHERE expires_at LIKE '%T%';
|
||||
UPDATE share SET last_visited_at = replace(replace(last_visited_at, 'T', ' '), 'Z', '+00:00') WHERE last_visited_at LIKE '%T%';
|
||||
|
||||
UPDATE radio SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
|
||||
UPDATE radio SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
|
||||
|
||||
UPDATE folder SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
|
||||
UPDATE folder SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
|
||||
UPDATE folder SET images_updated_at = replace(replace(images_updated_at, 'T', ' '), 'Z', '+00:00') WHERE images_updated_at LIKE '%T%';
|
||||
|
||||
UPDATE library SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
|
||||
UPDATE library SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
|
||||
UPDATE library SET last_scan_at = replace(replace(last_scan_at, 'T', ' '), 'Z', '+00:00') WHERE last_scan_at LIKE '%T%';
|
||||
UPDATE library SET last_scan_started_at = replace(replace(last_scan_started_at, 'T', ' '), 'Z', '+00:00') WHERE last_scan_started_at LIKE '%T%';
|
||||
|
||||
UPDATE scrobble_buffer SET play_time = replace(replace(play_time, 'T', ' '), 'Z', '+00:00') WHERE play_time LIKE '%T%';
|
||||
UPDATE scrobble_buffer SET enqueue_time = replace(replace(enqueue_time, 'T', ' '), 'Z', '+00:00') WHERE enqueue_time LIKE '%T%';
|
||||
|
||||
UPDATE plugin SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
|
||||
UPDATE plugin SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
|
||||
|
||||
-- Replace plain indexes with expression indexes for datetime()-based sorting
|
||||
DROP INDEX IF EXISTS album_created_at;
|
||||
CREATE INDEX album_created_at ON album(datetime(created_at));
|
||||
DROP INDEX IF EXISTS album_updated_at;
|
||||
CREATE INDEX album_updated_at ON album(datetime(updated_at));
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX IF EXISTS album_created_at;
|
||||
CREATE INDEX album_created_at ON album(created_at);
|
||||
DROP INDEX IF EXISTS album_updated_at;
|
||||
CREATE INDEX album_updated_at ON album(updated_at);
|
||||
22
db/migrations/20260318182414_add_radio_uploaded_image.go
Normal file
22
db/migrations/20260318182414_add_radio_uploaded_image.go
Normal file
@ -0,0 +1,22 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(upAddRadioUploadedImage, downAddRadioUploadedImage)
|
||||
}
|
||||
|
||||
func upAddRadioUploadedImage(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `ALTER TABLE radio ADD COLUMN uploaded_image VARCHAR(255) NOT NULL DEFAULT ''`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddRadioUploadedImage(ctx context.Context, tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
33
go.mod
33
go.mod
@ -7,14 +7,12 @@ replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260307161927
|
||||
|
||||
require (
|
||||
github.com/Masterminds/squirrel v1.5.4
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0
|
||||
github.com/andybalholm/cascadia v1.3.3
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0
|
||||
github.com/bradleyjkemp/cupaloy/v2 v2.8.0
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
|
||||
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
|
||||
github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/djherbis/atime v1.1.0
|
||||
github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4
|
||||
github.com/djherbis/stream v1.4.0
|
||||
@ -22,6 +20,7 @@ require (
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/extism/go-sdk v1.7.1
|
||||
github.com/fatih/structs v1.1.0
|
||||
github.com/gen2brain/webp v0.5.5
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/go-chi/httprate v0.15.0
|
||||
@ -35,17 +34,14 @@ require (
|
||||
github.com/hashicorp/go-multierror v1.1.1
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0
|
||||
github.com/kardianos/service v1.2.4
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
||||
github.com/kr/pretty v0.3.1
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.13
|
||||
github.com/maruel/natural v1.3.0
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||
github.com/mattn/go-sqlite3 v1.14.34
|
||||
github.com/mattn/go-sqlite3 v1.14.38
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/onsi/ginkgo/v2 v2.28.1
|
||||
github.com/onsi/gomega v1.39.1
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
github.com/pelletier/go-toml/v2 v2.3.0
|
||||
github.com/pocketbase/dbx v1.12.0
|
||||
github.com/pressly/goose/v3 v3.27.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
@ -62,12 +58,12 @@ require (
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
|
||||
go.senan.xyz/taglib v0.11.1
|
||||
go.uber.org/goleak v1.3.0
|
||||
golang.org/x/image v0.36.0
|
||||
golang.org/x/net v0.51.0
|
||||
golang.org/x/image v0.38.0
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/term v0.40.0
|
||||
golang.org/x/text v0.34.0
|
||||
golang.org/x/term v0.41.0
|
||||
golang.org/x/text v0.35.0
|
||||
golang.org/x/time v0.15.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
@ -84,12 +80,13 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
|
||||
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
|
||||
@ -98,6 +95,7 @@ require (
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||
@ -106,8 +104,9 @@ require (
|
||||
github.com/lestrrat-go/dsig v1.0.0 // indirect
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.4 // indirect
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
|
||||
github.com/maruel/natural v1.3.0 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
@ -135,10 +134,10 @@ require (
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/ini.v1 v1.67.1 // indirect
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||
|
||||
59
go.sum
59
go.sum
@ -6,8 +6,6 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
|
||||
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0 h1:t527LHHE3HmiHrq74QMpNPZpGCIJzTx+apLkMKt4HC0=
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY=
|
||||
@ -44,8 +42,6 @@ github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6n
|
||||
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E=
|
||||
github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 h1:r4hxcT6GBIA/j8Ox4OXI5MNgMKfR+9plcAWYi1OnmOg=
|
||||
github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g=
|
||||
github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE=
|
||||
github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 h1:wdZllsLrDJtYfHiAKogB4PNHSDeO+v+5S3eqSWHGDlc=
|
||||
@ -60,6 +56,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY=
|
||||
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q=
|
||||
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw=
|
||||
github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA=
|
||||
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||
@ -69,6 +67,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gen2brain/webp v0.5.5 h1:MvQR75yIPU/9nSqYT5h13k4URaJK3gf9tgz/ksRbyEg=
|
||||
github.com/gen2brain/webp v0.5.5/go.mod h1:xOSMzp4aROt2KFW++9qcK/RBTOVC2S9tJG66ip/9Oc0=
|
||||
github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
|
||||
github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
|
||||
github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
|
||||
@ -96,8 +96,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg=
|
||||
@ -167,20 +167,18 @@ github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.4 h1:pXyH2ppK8GYYggygxJ3TvxpCZnbEUWc9qSwRTTApaLA=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.4/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
|
||||
github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
|
||||
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4=
|
||||
github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
|
||||
@ -201,8 +199,8 @@ github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI
|
||||
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
|
||||
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
|
||||
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@ -321,20 +319,19 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
|
||||
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@ -346,8 +343,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@ -375,8 +372,8 @@ golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0=
|
||||
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@ -385,8 +382,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@ -397,8 +394,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@ -408,8 +405,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
|
||||
41
log/journal.go
Normal file
41
log/journal.go
Normal file
@ -0,0 +1,41 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// journalFormatter wraps a logrus.Formatter and prepends a syslog priority
|
||||
// prefix (<N>) to each log line. When stderr is captured by systemd-journald,
|
||||
// this prefix tells journald the correct severity for each message.
|
||||
//
|
||||
// See https://www.freedesktop.org/software/systemd/man/sd-daemon.html
|
||||
type journalFormatter struct {
|
||||
inner logrus.Formatter
|
||||
}
|
||||
|
||||
// levelToPriority maps logrus levels to syslog priority values.
|
||||
// The mapping follows RFC 5424 severity levels.
|
||||
var levelToPriority = map[logrus.Level]int{
|
||||
logrus.PanicLevel: 0, // emerg
|
||||
logrus.FatalLevel: 2, // crit
|
||||
logrus.ErrorLevel: 3, // err
|
||||
logrus.WarnLevel: 4, // warning
|
||||
logrus.InfoLevel: 6, // info
|
||||
logrus.DebugLevel: 7, // debug
|
||||
logrus.TraceLevel: 7, // debug
|
||||
}
|
||||
|
||||
func (f *journalFormatter) Format(entry *logrus.Entry) ([]byte, error) {
|
||||
formatted, err := f.inner.Format(entry)
|
||||
if err != nil {
|
||||
return formatted, err
|
||||
}
|
||||
priority, ok := levelToPriority[entry.Level]
|
||||
if !ok {
|
||||
priority = 6 // default to info for unknown levels
|
||||
}
|
||||
prefix := []byte(fmt.Sprintf("<%d>", priority))
|
||||
return append(prefix, formatted...), nil
|
||||
}
|
||||
41
log/journal_test.go
Normal file
41
log/journal_test.go
Normal file
@ -0,0 +1,41 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var _ = Describe("journalFormatter", func() {
|
||||
var formatter *journalFormatter
|
||||
|
||||
BeforeEach(func() {
|
||||
inner := &logrus.TextFormatter{
|
||||
DisableTimestamp: true,
|
||||
DisableColors: true,
|
||||
}
|
||||
formatter = &journalFormatter{inner: inner}
|
||||
})
|
||||
|
||||
DescribeTable("prefixes log lines with syslog priority",
|
||||
func(level logrus.Level, expectedPrefix string) {
|
||||
entry := &logrus.Entry{
|
||||
Logger: logrus.New(),
|
||||
Level: level,
|
||||
Message: "test message",
|
||||
Data: logrus.Fields{},
|
||||
}
|
||||
out, err := formatter.Format(entry)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(out)).To(HavePrefix(expectedPrefix))
|
||||
},
|
||||
Entry("error", logrus.ErrorLevel, "<3>"),
|
||||
Entry("warning", logrus.WarnLevel, "<4>"),
|
||||
Entry("info", logrus.InfoLevel, "<6>"),
|
||||
Entry("debug", logrus.DebugLevel, "<7>"),
|
||||
Entry("trace", logrus.TraceLevel, "<7>"),
|
||||
Entry("fatal", logrus.FatalLevel, "<2>"),
|
||||
Entry("panic", logrus.PanicLevel, "<0>"),
|
||||
Entry("unknown level defaults to info", logrus.Level(99), "<6>"),
|
||||
)
|
||||
})
|
||||
10
log/log.go
10
log/log.go
@ -27,7 +27,6 @@ var redacted = &Hook{
|
||||
// Keys from the config
|
||||
"(ApiKey:\")[\\w]*",
|
||||
"(Secret:\")[\\w]*",
|
||||
"(Spotify.*ID:\")[\\w]*",
|
||||
"(PasswordEncryptionKey:[\\s]*\")[^\"]*",
|
||||
"(UserHeader:[\\s]*\")[^\"]*",
|
||||
"(TrustedSources:[\\s]*\")[^\"]*",
|
||||
@ -146,6 +145,15 @@ func SetOutput(w io.Writer) {
|
||||
defaultLogger.SetOutput(w)
|
||||
}
|
||||
|
||||
// EnableJournalFormat wraps the current logger formatter with syslog
|
||||
// priority prefixes for systemd-journald. Only call this when output
|
||||
// goes to stderr and JOURNAL_STREAM is set.
|
||||
func EnableJournalFormat() {
|
||||
loggerMu.Lock()
|
||||
defer loggerMu.Unlock()
|
||||
defaultLogger.Formatter = &journalFormatter{inner: defaultLogger.Formatter}
|
||||
}
|
||||
|
||||
// Redact applies redaction to a single string
|
||||
func Redact(msg string) string {
|
||||
r, _ := redacted.redact(msg)
|
||||
|
||||
@ -4,6 +4,8 @@ import (
|
||||
"maps"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
)
|
||||
|
||||
type Artist struct {
|
||||
@ -34,6 +36,8 @@ type Artist struct {
|
||||
|
||||
Missing bool `structs:"missing" json:"missing"`
|
||||
|
||||
UploadedImage string `structs:"uploaded_image" json:"uploadedImage,omitempty"`
|
||||
|
||||
CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"`
|
||||
UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"`
|
||||
}
|
||||
@ -58,6 +62,10 @@ func (a Artist) CoverArtID() ArtworkID {
|
||||
return artworkIDFromArtist(a)
|
||||
}
|
||||
|
||||
func (a Artist) UploadedImagePath() string {
|
||||
return UploadedImagePath(consts.EntityArtist, a.UploadedImage)
|
||||
}
|
||||
|
||||
// Roles returns the roles this artist has participated in., based on the Stats field
|
||||
func (a Artist) Roles() []Role {
|
||||
return slices.Collect(maps.Keys(a.Stats))
|
||||
|
||||
30
model/artist_test.go
Normal file
30
model/artist_test.go
Normal file
@ -0,0 +1,30 @@
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"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("Artist", func() {
|
||||
Describe("UploadedImagePath", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DataFolder = "/data"
|
||||
})
|
||||
|
||||
It("returns empty string when no image uploaded", func() {
|
||||
a := model.Artist{ID: "ar-1"}
|
||||
Expect(a.UploadedImagePath()).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns full path when image is set", func() {
|
||||
a := model.Artist{ID: "ar-1", UploadedImage: "ar-1_test.jpg"}
|
||||
Expect(a.UploadedImagePath()).To(Equal(filepath.Join("/data", "artwork", "artist", "ar-1_test.jpg")))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -22,6 +22,8 @@ var (
|
||||
KindArtistArtwork = Kind{"ar", "artist"}
|
||||
KindAlbumArtwork = Kind{"al", "album"}
|
||||
KindPlaylistArtwork = Kind{"pl", "playlist"}
|
||||
KindDiscArtwork = Kind{"dc", "disc"}
|
||||
KindRadioArtwork = Kind{"ra", "radio"}
|
||||
)
|
||||
|
||||
var artworkKindMap = map[string]Kind{
|
||||
@ -29,6 +31,8 @@ var artworkKindMap = map[string]Kind{
|
||||
KindArtistArtwork.prefix: KindArtistArtwork,
|
||||
KindAlbumArtwork.prefix: KindAlbumArtwork,
|
||||
KindPlaylistArtwork.prefix: KindPlaylistArtwork,
|
||||
KindDiscArtwork.prefix: KindDiscArtwork,
|
||||
KindRadioArtwork.prefix: KindRadioArtwork,
|
||||
}
|
||||
|
||||
type ArtworkID struct {
|
||||
@ -91,6 +95,22 @@ func MustParseArtworkID(id string) ArtworkID {
|
||||
return artID
|
||||
}
|
||||
|
||||
func DiscArtworkID(albumID string, discNumber int) string {
|
||||
return fmt.Sprintf("%s:%d", albumID, discNumber)
|
||||
}
|
||||
|
||||
func ParseDiscArtworkID(id string) (albumID string, discNumber int, err error) {
|
||||
parts := strings.SplitN(id, ":", 2)
|
||||
if len(parts) != 2 || parts[1] == "" {
|
||||
return "", 0, errors.New("invalid disc artwork id")
|
||||
}
|
||||
num, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("invalid disc number in artwork id: %w", err)
|
||||
}
|
||||
return parts[0], num, nil
|
||||
}
|
||||
|
||||
func artworkIDFromAlbum(al Album) ArtworkID {
|
||||
return ArtworkID{
|
||||
Kind: KindAlbumArtwork,
|
||||
@ -121,3 +141,11 @@ func artworkIDFromArtist(ar Artist) ArtworkID {
|
||||
ID: ar.ID,
|
||||
}
|
||||
}
|
||||
|
||||
func artworkIDFromRadio(r Radio) ArtworkID {
|
||||
return ArtworkID{
|
||||
Kind: KindRadioArtwork,
|
||||
ID: r.ID,
|
||||
LastUpdate: r.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,6 +28,40 @@ var _ = Describe("ArtworkID", func() {
|
||||
Expect(parsedId.LastUpdate.Unix()).To(Equal(id.LastUpdate.Unix()))
|
||||
})
|
||||
})
|
||||
Describe("ParseArtworkID - disc kind", func() {
|
||||
It("parses a disc artwork ID with dc prefix", func() {
|
||||
now := time.Now()
|
||||
id := model.NewArtworkID(model.KindDiscArtwork, "albumid123:2", &now)
|
||||
parsedId, err := model.ParseArtworkID(id.String())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(parsedId.Kind).To(Equal(model.KindDiscArtwork))
|
||||
Expect(parsedId.ID).To(Equal("albumid123:2"))
|
||||
Expect(parsedId.LastUpdate.Unix()).To(Equal(now.Unix()))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ParseDiscArtworkID", func() {
|
||||
DescribeTable("parses composite disc artwork IDs",
|
||||
func(id string, expectedAlbum string, expectedDisc int, expectErr bool) {
|
||||
albumID, discNumber, err := model.ParseDiscArtworkID(id)
|
||||
if expectErr {
|
||||
Expect(err).To(HaveOccurred())
|
||||
} else {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(albumID).To(Equal(expectedAlbum))
|
||||
Expect(discNumber).To(Equal(expectedDisc))
|
||||
}
|
||||
},
|
||||
Entry("valid id", "albumid123:2", "albumid123", 2, false),
|
||||
Entry("disc number 1", "abc:1", "abc", 1, false),
|
||||
Entry("large disc number", "abc:10", "abc", 10, false),
|
||||
Entry("missing colon", "albumid123", "", 0, true),
|
||||
Entry("missing disc number", "albumid123:", "", 0, true),
|
||||
Entry("non-numeric disc", "albumid123:abc", "", 0, true),
|
||||
Entry("empty string", "", "", 0, true),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("ParseArtworkID()", func() {
|
||||
It("parses album artwork ids", func() {
|
||||
id, err := model.ParseArtworkID("al-1234")
|
||||
|
||||
@ -35,6 +35,7 @@ var fieldMap = map[string]*mappedField{
|
||||
"releasedate": {field: "media_file.release_date"},
|
||||
"size": {field: "media_file.size"},
|
||||
"compilation": {field: "media_file.compilation"},
|
||||
"missing": {field: "media_file.missing"},
|
||||
"explicitstatus": {field: "media_file.explicit_status"},
|
||||
"dateadded": {field: "media_file.created_at"},
|
||||
"datemodified": {field: "media_file.updated_at"},
|
||||
@ -49,9 +50,11 @@ var fieldMap = map[string]*mappedField{
|
||||
"catalognumber": {field: "media_file.catalog_num"},
|
||||
"filepath": {field: "media_file.path"},
|
||||
"filetype": {field: "media_file.suffix"},
|
||||
"codec": {field: "media_file.codec"},
|
||||
"duration": {field: "media_file.duration"},
|
||||
"bitrate": {field: "media_file.bit_rate"},
|
||||
"bitdepth": {field: "media_file.bit_depth"},
|
||||
"samplerate": {field: "media_file.sample_rate"},
|
||||
"bpm": {field: "media_file.bpm"},
|
||||
"channels": {field: "media_file.channels"},
|
||||
"loved": {field: "COALESCE(annotation.starred, false)"},
|
||||
|
||||
@ -22,5 +22,9 @@ func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) {
|
||||
if err == nil {
|
||||
return mf, nil
|
||||
}
|
||||
r, err := ds.Radio(ctx).Get(id)
|
||||
if err == nil {
|
||||
return r, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -6,12 +6,12 @@ import (
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils/nanoid"
|
||||
)
|
||||
|
||||
func NewRandom() string {
|
||||
id, err := gonanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 22)
|
||||
id, err := nanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 22)
|
||||
if err != nil {
|
||||
log.Error("Could not generate new ID", err)
|
||||
}
|
||||
|
||||
17
model/image.go
Normal file
17
model/image.go
Normal file
@ -0,0 +1,17 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
)
|
||||
|
||||
// UploadedImagePath returns the absolute filesystem path for a manually uploaded
|
||||
// entity cover image. Returns empty string if filename is empty.
|
||||
func UploadedImagePath(entityType, filename string) string {
|
||||
if filename == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(conf.Server.DataFolder, consts.ArtworkFolder, entityType, filename)
|
||||
}
|
||||
@ -119,7 +119,16 @@ func (mf MediaFile) CoverArtID() ArtworkID {
|
||||
if mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt {
|
||||
return artworkIDFromMediaFile(mf)
|
||||
}
|
||||
// if it does not have a coverArt, fallback to the album cover
|
||||
// Otherwise fallback to disc (if available) or album cover
|
||||
return mf.DiscCoverArtID()
|
||||
}
|
||||
|
||||
// DiscCoverArtID returns the disc artwork ID when the media file has a disc number,
|
||||
// otherwise it returns the album artwork ID.
|
||||
func (mf MediaFile) DiscCoverArtID() ArtworkID {
|
||||
if mf.DiscNumber > 0 {
|
||||
return NewArtworkID(KindDiscArtwork, DiscArtworkID(mf.AlbumID, mf.DiscNumber), nil)
|
||||
}
|
||||
return mf.AlbumCoverArtID()
|
||||
}
|
||||
|
||||
|
||||
@ -504,13 +504,26 @@ var _ = Describe("MediaFile", func() {
|
||||
Expect(id.Kind).To(Equal(KindMediaFileArtwork))
|
||||
Expect(id.ID).To(Equal(mf.ID))
|
||||
})
|
||||
It("returns its album id if HasCoverArt is false", func() {
|
||||
It("returns disc art id if HasCoverArt is false and DiscNumber > 0", func() {
|
||||
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: false, DiscNumber: 2}
|
||||
id := mf.CoverArtID()
|
||||
Expect(id.Kind).To(Equal(KindDiscArtwork))
|
||||
Expect(id.ID).To(Equal("1:2"))
|
||||
})
|
||||
It("returns its album id if HasCoverArt is false and DiscNumber is 0", func() {
|
||||
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: false}
|
||||
id := mf.CoverArtID()
|
||||
Expect(id.Kind).To(Equal(KindAlbumArtwork))
|
||||
Expect(id.ID).To(Equal(mf.AlbumID))
|
||||
})
|
||||
It("returns its album id if EnableMediaFileCoverArt is disabled", func() {
|
||||
It("returns disc art id if EnableMediaFileCoverArt is disabled and DiscNumber > 0", func() {
|
||||
conf.Server.EnableMediaFileCoverArt = false
|
||||
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true, DiscNumber: 3}
|
||||
id := mf.CoverArtID()
|
||||
Expect(id.Kind).To(Equal(KindDiscArtwork))
|
||||
Expect(id.ID).To(Equal("1:3"))
|
||||
})
|
||||
It("returns its album id if EnableMediaFileCoverArt is disabled and DiscNumber is 0", func() {
|
||||
conf.Server.EnableMediaFileCoverArt = false
|
||||
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
|
||||
id := mf.CoverArtID()
|
||||
|
||||
@ -65,7 +65,7 @@ var _ = Describe("getPID", func() {
|
||||
Context("calculated attributes", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.PID.Album = "musicbrainz_albumid|albumartistid,album,version,releasedate"
|
||||
conf.Server.PID.Album = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
|
||||
})
|
||||
When("field is title", func() {
|
||||
It("should return the pid", func() {
|
||||
@ -88,13 +88,13 @@ var _ = Describe("getPID", func() {
|
||||
It("should return the pid", func() {
|
||||
spec := "albumid|title"
|
||||
md.tags = map[model.TagName][]string{
|
||||
"title": {"title"},
|
||||
"album": {"album name"},
|
||||
"version": {"version"},
|
||||
"releasedate": {"2021-01-01"},
|
||||
"title": {"title"},
|
||||
"album": {"album name"},
|
||||
"albumversion": {"deluxe edition"},
|
||||
"releasedate": {"2021-01-01"},
|
||||
}
|
||||
mf.AlbumArtist = "Album Artist"
|
||||
Expect(getPID(mf, md, spec, false)).To(Equal("(((album artist)\\album name\\version\\2021-01-01))"))
|
||||
Expect(getPID(mf, md, spec, false)).To(Equal("(((album artist)\\album name\\deluxe edition\\2021-01-01))"))
|
||||
})
|
||||
})
|
||||
When("field is albumartistid", func() {
|
||||
|
||||
@ -1,15 +1,12 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
type Playlist struct {
|
||||
@ -108,16 +105,6 @@ func (pls *Playlist) AddMediaFiles(mfs MediaFiles) {
|
||||
pls.refreshStats()
|
||||
}
|
||||
|
||||
// ImageFilename returns a human-friendly filename for an uploaded playlist cover image.
|
||||
// Format: <ID>_<clean_name><ext>, falling back to <ID><ext> if the name cleans to empty.
|
||||
func (pls Playlist) ImageFilename(ext string) string {
|
||||
clean := utils.CleanFileName(pls.Name)
|
||||
if clean == "" {
|
||||
return pls.ID + ext
|
||||
}
|
||||
return pls.ID + "_" + clean + ext
|
||||
}
|
||||
|
||||
func (pls Playlist) CoverArtID() ArtworkID {
|
||||
return artworkIDFromPlaylist(pls)
|
||||
}
|
||||
@ -127,10 +114,7 @@ func (pls Playlist) CoverArtID() ArtworkID {
|
||||
// This does NOT cover sidecar images or external URLs — those are resolved
|
||||
// by the artwork reader's fallback chain.
|
||||
func (pls Playlist) UploadedImagePath() string {
|
||||
if pls.UploadedImage == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(conf.Server.DataFolder, consts.ArtworkFolder, "playlist", pls.UploadedImage)
|
||||
return UploadedImagePath(consts.EntityPlaylist, pls.UploadedImage)
|
||||
}
|
||||
|
||||
type Playlists []Playlist
|
||||
|
||||
@ -7,28 +7,6 @@ import (
|
||||
)
|
||||
|
||||
var _ = Describe("Playlist", func() {
|
||||
Describe("ImageFilename", func() {
|
||||
It("returns ID_cleanname.ext for a normal name", func() {
|
||||
pls := model.Playlist{ID: "abc123", Name: "My Cool Playlist"}
|
||||
Expect(pls.ImageFilename(".jpg")).To(Equal("abc123_my_cool_playlist.jpg"))
|
||||
})
|
||||
|
||||
It("falls back to ID.ext when name cleans to empty", func() {
|
||||
pls := model.Playlist{ID: "abc123", Name: "!!!"}
|
||||
Expect(pls.ImageFilename(".png")).To(Equal("abc123.png"))
|
||||
})
|
||||
|
||||
It("falls back to ID.ext for empty name", func() {
|
||||
pls := model.Playlist{ID: "abc123", Name: ""}
|
||||
Expect(pls.ImageFilename(".jpg")).To(Equal("abc123.jpg"))
|
||||
})
|
||||
|
||||
It("handles names with special characters", func() {
|
||||
pls := model.Playlist{ID: "x1", Name: "Rock & Roll! (2024)"}
|
||||
Expect(pls.ImageFilename(".webp")).To(Equal("x1_rock__roll_2024.webp"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ToM3U8()", func() {
|
||||
var pls model.Playlist
|
||||
BeforeEach(func() {
|
||||
|
||||
@ -1,14 +1,27 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
)
|
||||
|
||||
type Radio struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
StreamUrl string `structs:"stream_url" json:"streamUrl"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
HomePageUrl string `structs:"home_page_url" json:"homePageUrl"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
ID string `structs:"id" json:"id"`
|
||||
StreamUrl string `structs:"stream_url" json:"streamUrl"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
HomePageUrl string `structs:"home_page_url" json:"homePageUrl"`
|
||||
UploadedImage string `structs:"uploaded_image" json:"uploadedImage,omitempty"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (r Radio) CoverArtID() ArtworkID {
|
||||
return artworkIDFromRadio(r)
|
||||
}
|
||||
|
||||
func (r Radio) UploadedImagePath() string {
|
||||
return UploadedImagePath(consts.EntityRadio, r.UploadedImage)
|
||||
}
|
||||
|
||||
type Radios []Radio
|
||||
@ -19,5 +32,5 @@ type RadioRepository interface {
|
||||
Delete(id string) error
|
||||
Get(id string) (*Radio, error)
|
||||
GetAll(options ...QueryOptions) (Radios, error)
|
||||
Put(u *Radio) error
|
||||
Put(u *Radio, colsToUpdate ...string) error
|
||||
}
|
||||
|
||||
42
model/radio_test.go
Normal file
42
model/radio_test.go
Normal file
@ -0,0 +1,42 @@
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"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("Radio", func() {
|
||||
Describe("CoverArtID", func() {
|
||||
It("returns a radio artwork ID", func() {
|
||||
now := time.Now()
|
||||
r := model.Radio{ID: "rd-1", UpdatedAt: now}
|
||||
artID := r.CoverArtID()
|
||||
Expect(artID.Kind).To(Equal(model.KindRadioArtwork))
|
||||
Expect(artID.ID).To(Equal("rd-1"))
|
||||
Expect(artID.LastUpdate).To(Equal(now))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("UploadedImagePath", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DataFolder = "/data"
|
||||
})
|
||||
|
||||
It("returns empty string when no image uploaded", func() {
|
||||
r := model.Radio{ID: "rd-1"}
|
||||
Expect(r.UploadedImagePath()).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns full path when image is set", func() {
|
||||
r := model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"}
|
||||
Expect(r.UploadedImagePath()).To(Equal(filepath.Join("/data", "artwork", "radio", "rd-1_test.jpg")))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -143,9 +143,9 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc {
|
||||
|
||||
func recentlyAddedSort() string {
|
||||
if conf.Server.RecentlyAddedByModTime {
|
||||
return "updated_at"
|
||||
return "datetime(album.updated_at)"
|
||||
}
|
||||
return "created_at"
|
||||
return "datetime(album.created_at)"
|
||||
}
|
||||
|
||||
func recentlyPlayedFilter(string, any) Sqlizer {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user