Compare commits

..

1 Commits

356 changed files with 4437 additions and 22287 deletions

View File

@ -221,7 +221,7 @@ jobs:
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Build Binaries
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
@ -244,7 +244,7 @@ jobs:
- name: Build and push image by digest
id: push-image
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
@ -338,7 +338,7 @@ jobs:
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Create manifest list and push to Docker Hub
uses: nick-fields/retry@v4
uses: nick-fields/retry@v3
with:
timeout_minutes: 5
max_attempts: 3

3
.gitignore vendored
View File

@ -37,5 +37,4 @@ AGENTS.md
*.wasm
*.ndp
openspec/
go.work*
.worktrees/
go.work*

View File

@ -41,11 +41,6 @@ linters:
enable:
- nilness
exclusions:
rules:
- linters:
- gosec
path: _test\.go
text: "G703"
generated: lax
presets:
- comments

View File

@ -20,8 +20,8 @@ PLATFORMS ?= $(SUPPORTED_PLATFORMS)
DOCKER_TAG ?= deluan/navidrome:develop
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
CROSS_TAGLIB_VERSION ?= 2.2.1-1
GOLANGCI_LINT_VERSION ?= v2.11.1
CROSS_TAGLIB_VERSION ?= 2.2.0-1
GOLANGCI_LINT_VERSION ?= v2.10.0
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
@ -109,7 +109,7 @@ format: ##@Development Format code
.PHONY: format
wire: check_go_env ##@Development Update Dependency Injection
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
go tool wire gen -tags=$(GO_BUILD_TAGS) ./...
.PHONY: wire
gen: check_go_env ##@Development Run go generate for code generation
@ -233,39 +233,6 @@ 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

View File

@ -44,34 +44,10 @@ func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
}
func (e extractor) Version() string {
bi, ok := debug.ReadBuildInfo()
if ok {
for _, dep := range bi.Deps {
if dep.Path == "go.senan.xyz/taglib" {
if dep.Replace != nil {
return dep.Replace.Version
}
return dep.Version
}
}
}
return "unknown"
return "2.2 WASM"
}
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)
}
}()
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
f, close, err := e.openFile(filePath)
if err != nil {
log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err)
@ -90,7 +66,6 @@ func (e extractor) extractMetadata(filePath string) (info *metadata.Info, err er
Channels: int(props.Channels),
SampleRate: int(props.SampleRate),
BitDepth: int(props.BitsPerSample),
Codec: props.Codec,
}
// Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys)
@ -125,6 +100,16 @@ func (e extractor) extractMetadata(filePath string) (info *metadata.Info, err er
// 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 {

116
adapters/spotify/client.go Normal file
View File

@ -0,0 +1,116 @@
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)
}

View File

@ -0,0 +1,131 @@
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())
}

View File

@ -0,0 +1,30 @@
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"`
}

View File

@ -0,0 +1,48 @@
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"))
})
})
})

View File

@ -0,0 +1,96 @@
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)
})
}

View File

@ -1,4 +1,4 @@
package stream
package spotify
import (
"testing"
@ -9,9 +9,9 @@ import (
. "github.com/onsi/gomega"
)
func TestStream(t *testing.T) {
func TestSpotify(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Stream Suite")
RunSpecs(t, "Spotify Test Suite")
}

View File

@ -27,6 +27,7 @@ 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"
)

View File

@ -8,7 +8,6 @@ import (
"os"
"strings"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
@ -75,7 +74,7 @@ func runScanner(ctx context.Context) {
sqlDB := db.Db()
defer db.Db().Close()
ds := persistence.New(sqlDB)
pls := playlists.NewPlaylists(ds, core.NewImageUploadService())
pls := playlists.NewPlaylists(ds)
// Parse targets from command line or file
var scanTargets []model.ScanTarget

View File

@ -248,7 +248,6 @@ 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

View File

@ -1,6 +1,6 @@
// Code generated by Wire. DO NOT EDIT.
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo sqlite_fts5"
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo"
//go:build !wireinject
// +build !wireinject
@ -16,12 +16,10 @@ import (
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
@ -39,6 +37,7 @@ 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"
)
@ -63,8 +62,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
share := core.NewShare(dataStore)
imageUploadService := core.NewImageUploadService()
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
playlistsPlaylists := playlists.NewPlaylists(dataStore)
insights := metrics.GetInstance(dataStore)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
@ -80,7 +78,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, imageUploadService)
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager)
return router
}
@ -95,20 +93,17 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
transcodingCache := stream.GetTranscodingCache()
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
transcodingCache := core.GetTranscodingCache()
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
share := core.NewShare(dataStore)
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
players := core.NewPlayers(dataStore)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
imageUploadService := core.NewImageUploadService()
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
playlistsPlaylists := playlists.NewPlaylists(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
lyricsLyrics := lyrics.NewLyrics(manager)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics)
return router
}
@ -123,8 +118,8 @@ func CreatePublicRouter() *public.Router {
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
transcodingCache := stream.GetTranscodingCache()
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
transcodingCache := core.GetTranscodingCache()
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
share := core.NewShare(dataStore)
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
@ -171,8 +166,7 @@ func CreateScanner(ctx context.Context) model.Scanner {
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
imageUploadService := core.NewImageUploadService()
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
playlistsPlaylists := playlists.NewPlaylists(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
return modelScanner
}
@ -189,8 +183,7 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
imageUploadService := core.NewImageUploadService()
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
playlistsPlaylists := playlists.NewPlaylists(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, modelScanner)
return watcher
@ -214,7 +207,7 @@ func getPluginManager() *plugins.Manager {
// wire_injectors.go:
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
func GetPluginManager(ctx context.Context) *plugins.Manager {
manager := getPluginManager()

View File

@ -11,7 +11,6 @@ import (
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
@ -45,7 +44,6 @@ var allProviders = wire.NewSet(
plugins.GetManager,
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)),
wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)),

View File

@ -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,16 +69,14 @@ type configOptions struct {
MPVPath string
MPVCmdTemplate string
CoverArtPriority string
CoverArtQuality int
CoverJpegQuality int
ArtistArtPriority string
ArtistImageFolder string
DiscArtPriority string
LyricsPriority string
EnableGravatar bool
EnableFavourites bool
EnableStarRating bool
EnableUserEditing bool
EnableArtworkUpload bool
EnableCoverArtUpload bool
EnableSharing bool
ShareURL string
DefaultShareExpiration time.Duration
@ -106,6 +104,7 @@ 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
@ -133,6 +132,7 @@ type configOptions struct {
DevExternalScanner bool
DevScannerThreads uint
DevSelectiveWatcher bool
DevLegacyEmbedImage bool
DevInsightsInitialDelay time.Duration
DevEnablePlayerInsights bool
DevEnablePluginsInsights bool
@ -140,8 +140,6 @@ type configOptions struct {
DevExternalArtistFetchMultiplier float64
DevOptimizeDB bool
DevPreserveUnicodeInExternalCalls bool
DevEnableMediaFileProbe bool
DevJpegCoverArt bool
}
type scannerOptions struct {
@ -187,6 +185,11 @@ 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
@ -258,13 +261,6 @@ 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()
@ -274,29 +270,30 @@ func LoadFromFile(confFile string) {
viper.SetConfigFile(confFile)
err := viper.ReadInConfig()
if err != nil {
logFatal("Error reading config file:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error reading config file:", err)
os.Exit(1)
}
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 {
logFatal("Error parsing config:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
os.Exit(1)
}
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
if err != nil {
logFatal("Error creating data path:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", err)
os.Exit(1)
}
if Server.CacheFolder == "" {
@ -304,12 +301,14 @@ func Load(noConfigDump bool) {
}
err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
if err != nil {
logFatal("Error creating cache path:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", err)
os.Exit(1)
}
err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm)
if err != nil {
logFatal("Error creating artwork path:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating artwork path:", err)
os.Exit(1)
}
if Server.Plugins.Enabled {
@ -318,7 +317,8 @@ func Load(noConfigDump bool) {
}
err = os.MkdirAll(Server.Plugins.Folder, 0700)
if err != nil {
logFatal("Error creating plugins path:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating plugins path:", err)
os.Exit(1)
}
}
@ -330,7 +330,8 @@ func Load(noConfigDump bool) {
if Server.Backup.Path != "" {
err = os.MkdirAll(Server.Backup.Path, os.ModePerm)
if err != nil {
logFatal("Error creating backup path:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating backup path:", err)
os.Exit(1)
}
}
@ -338,15 +339,10 @@ 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 {
logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
_, _ = fmt.Fprintf(os.Stderr, "FATAL: Error opening log file %s: %s\n", Server.LogFile, err.Error())
os.Exit(1)
}
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)
@ -370,7 +366,8 @@ func Load(noConfigDump bool) {
if Server.BaseURL != "" {
u, err := url.Parse(Server.BaseURL)
if err != nil {
logFatal("Invalid BaseURL:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Invalid BaseURL:", err)
os.Exit(1)
}
Server.BasePath = u.Path
u.Path = ""
@ -411,7 +408,6 @@ 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
@ -419,10 +415,6 @@ 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")
// Call init hooks
for _, hook := range hooks {
@ -448,52 +440,6 @@ 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) {
@ -511,15 +457,18 @@ func parseIniFileConfiguration() {
var iniConfig map[string]any
err := viper.Unmarshal(&iniConfig)
if err != nil {
logFatal("Error parsing config:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
os.Exit(1)
}
cfg, ok := iniConfig["default"].(map[string]any)
if !ok {
logFatal("Error parsing config: missing [default] section:", iniConfig)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config: missing [default] section:", iniConfig)
os.Exit(1)
}
err = viper.MergeConfigMap(cfg)
if err != nil {
logFatal("Error parsing config:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
os.Exit(1)
}
}
}
@ -529,6 +478,7 @@ 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 = ""
@ -597,9 +547,15 @@ func validateBackupSchedule() error {
}
func validateSchedule(schedule, field string) (string, error) {
_, err := scheduler.ParseCrontab(schedule)
if _, err := time.ParseDuration(schedule); err == nil {
schedule = "@every " + schedule
}
c := cron.New()
id, err := c.AddFunc(schedule, func() {})
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
}
@ -642,21 +598,6 @@ 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)
@ -714,9 +655,8 @@ func setViperDefaults() {
viper.SetDefault("ffmpegpath", "")
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("coverartquality", 75)
viper.SetDefault("coverjpegquality", 75)
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded")
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
viper.SetDefault("enablegravatar", false)
viper.SetDefault("enablefavourites", true)
@ -729,7 +669,7 @@ func setViperDefaults() {
viper.SetDefault("enablereplaygain", true)
viper.SetDefault("enablecoveranimation", true)
viper.SetDefault("enablenowplaying", true)
viper.SetDefault("enableartworkupload", true)
viper.SetDefault("enablecoverartupload", true)
viper.SetDefault("enablesharing", false)
viper.SetDefault("shareurl", "")
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
@ -767,12 +707,14 @@ func setViperDefaults() {
viper.SetDefault("subsonic.enableaveragerating", true)
viper.SetDefault("subsonic.legacyclients", "DSub")
viper.SetDefault("subsonic.minimalclients", "SubMusic")
viper.SetDefault("agents", "deezer,lastfm,listenbrainz")
viper.SetDefault("agents", "lastfm,spotify,deezer")
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)
@ -807,7 +749,7 @@ func setViperDefaults() {
viper.SetDefault("devuishowconfig", true)
viper.SetDefault("devneweventstream", true)
viper.SetDefault("devoffsetoptimize", 50000)
viper.SetDefault("devartworkmaxrequests", max(4, runtime.NumCPU()))
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/3))
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
@ -822,8 +764,6 @@ func setViperDefaults() {
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
viper.SetDefault("devoptimizedb", true)
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
viper.SetDefault("devenablemediafileprobe", true)
viper.SetDefault("devjpegcoverart", false)
}
func init() {
@ -860,7 +800,8 @@ func InitConfig(cfgFile string, loadEnvVars bool) {
err := viper.ReadInConfig()
if viper.ConfigFileUsed() != "" && err != nil {
logFatal("Navidrome could not open config file:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Navidrome could not open config file: ", err)
os.Exit(1)
}
}

View File

@ -2,7 +2,6 @@ package conf_test
import (
"fmt"
"os"
"path/filepath"
"testing"
@ -25,11 +24,6 @@ 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() {
@ -114,111 +108,6 @@ 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)

View File

@ -11,11 +11,3 @@ 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 }
}

View File

@ -1,2 +0,0 @@
ND_ADDRESS = "127.0.0.1"
Address = "0.0.0.0"

View File

@ -1,3 +0,0 @@
ND_ADDRESS = "127.0.0.1"
ND_PORT = 4531
ND_SCANNER_SCHEDULE = "@every 1h"

View File

@ -70,6 +70,7 @@ const (
PlaceholderArtistArt = "artist-placeholder.webp"
PlaceholderAlbumArt = "album-placeholder.webp"
PlaceholderAvatar = "logo-192x192.png"
UICoverArtSize = 300
DefaultUIVolume = 100
DefaultUISearchDebounceMs = 200
@ -84,12 +85,6 @@ const (
Zwsp = string('\u200b')
)
const (
UICoverArtSize = 600
)
var CacheWarmerImageSizes = []int{UICoverArtSize}
// Prometheus options
const (
PrometheusDefaultPath = "/metrics"
@ -108,13 +103,6 @@ const (
DefaultCacheCleanUpInterval = 10 * time.Minute
)
// Entity types
const (
EntityArtist = "artist"
EntityPlaylist = "playlist"
EntityRadio = "radio"
)
const (
AlbumPlayCountModeAbsolute = "absolute"
AlbumPlayCountModeNormalized = "normalized"
@ -167,12 +155,6 @@ var (
DefaultBitRate: 256,
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
},
{
Name: "flac audio",
TargetFormat: "flac",
DefaultBitRate: 0,
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -",
},
}
)

View File

@ -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 `"deezer,lastfm"`). 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 `"lastfm,spotify"`). The order dictates the priority of the agents
For a simple Agent example, look at the [local_agent](local_agent.go) agent source code.

View File

@ -10,7 +10,6 @@ import (
"strings"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
@ -23,13 +22,13 @@ type Archiver interface {
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
}
func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share) Archiver {
func NewArchiver(ms MediaStreamer, ds model.DataStore, shares Share) Archiver {
return &archiver{ds: ds, ms: ms, shares: shares}
}
type archiver struct {
ds model.DataStore
ms stream.MediaStreamer
ms MediaStreamer
shares Share
}
@ -177,7 +176,7 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
var r io.ReadCloser
if format != "raw" && format != "" {
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
r, err = a.ms.DoStream(ctx, &mf, format, bitrate, 0)
} else {
r, err = os.Open(path)
}

View File

@ -9,7 +9,6 @@ import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -45,7 +44,7 @@ var _ = Describe("Archiver", func() {
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
out := new(bytes.Buffer)
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
@ -74,7 +73,7 @@ var _ = Describe("Archiver", func() {
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out)
@ -105,7 +104,7 @@ var _ = Describe("Archiver", func() {
}
sh.On("Load", mock.Anything, "1").Return(share, nil)
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipShare(context.Background(), "1", out)
@ -137,7 +136,7 @@ var _ = Describe("Archiver", func() {
plRepo := &mockPlaylistRepository{}
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
ds.On("Playlist", mock.Anything).Return(plRepo)
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)
@ -215,15 +214,15 @@ func (m *mockPlaylistRepository) GetWithTracks(id string, refreshSmartPlaylists,
type mockMediaStreamer struct {
mock.Mock
stream.MediaStreamer
core.MediaStreamer
}
func (m *mockMediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) {
args := m.Called(ctx, mf, req)
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*core.Stream, error) {
args := m.Called(ctx, mf, reqFormat, reqBitRate, reqOffset)
if args.Error(1) != nil {
return nil, args.Error(1)
}
return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
return &core.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
}
type mockShare struct {

View File

@ -1,120 +0,0 @@
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
}

View File

@ -1,161 +0,0 @@
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()
}

View File

@ -122,10 +122,6 @@ 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
}

View File

@ -7,12 +7,9 @@ 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"
@ -28,7 +25,7 @@ var _ = Describe("Artwork", func() {
var ffmpeg *tests.MockFFmpeg
var folderRepo *fakeFolderRepo
ctx := log.NewContext(context.TODO())
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers, alSingleDisc model.Album
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers model.Album
var arMultipleCovers model.Artist
var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile
@ -44,9 +41,8 @@ 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"}, Discs: model.Discs{1: "", 2: ""}}
alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}}
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",
@ -194,7 +190,6 @@ var _ = Describe("Artwork", func() {
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alOnlyEmbed,
alOnlyExternal,
alSingleDisc,
})
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
mfWithEmbed,
@ -238,28 +233,6 @@ 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() {
@ -380,24 +353,24 @@ var _ = Describe("Artwork", func() {
})
})
When("Square is false", func() {
It("returns WebP even if original image is a PNG", func() {
It("returns a 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("webp"))
Expect(format).To(Equal("png"))
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() {
It("returns a 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())
img, format, err := image.Decode(r)
Expect(format).To(Equal("webp"))
Expect(format).To(Equal("jpeg"))
Expect(err).ToNot(HaveOccurred())
Expect(img.Bounds().Size().X).To(Equal(200))
Expect(img.Bounds().Size().Y).To(Equal(200))
@ -407,9 +380,9 @@ var _ = Describe("Artwork", func() {
var alCover model.Album
DescribeTable("resize",
func(srcFormat string, expectedFormat string, landscape bool, size int) {
coverFileName := "cover." + srcFormat
dirName := createImage(srcFormat, landscape, size)
func(format string, landscape bool, size int) {
coverFileName := "cover." + format
dirName := createImage(format, landscape, size)
alCover = model.Album{
ID: "444",
Name: "Only external",
@ -426,70 +399,16 @@ var _ = Describe("Artwork", func() {
img, format, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
Expect(format).To(Equal(expectedFormat))
Expect(format).To(Equal("png"))
Expect(img.Bounds().Size().X).To(Equal(size))
Expect(img.Bounds().Size().Y).To(Equal(size))
},
Entry("portrait png image", "png", "webp", false, 200),
Entry("landscape png image", "png", "webp", true, 200),
Entry("portrait jpg image", "jpg", "webp", false, 200),
Entry("landscape jpg image", "jpg", "webp", true, 200),
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),
)
})
When("DevJpegCoverArt is true and square is false", func() {
BeforeEach(func() {
conf.Server.DevJpegCoverArt = true
})
It("returns JPEG 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("jpeg"))
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("DevJpegCoverArt is true and square is true", func() {
var alCover model.Album
BeforeEach(func() {
conf.Server.DevJpegCoverArt = true
})
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"

View File

@ -1,37 +0,0 @@
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)
}
}
})
}
}
}

View File

@ -1,189 +0,0 @@
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()
}
})
}
}
}

View File

@ -1,40 +0,0 @@
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")
})
}

View File

@ -1,47 +0,0 @@
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
}

View File

@ -1,50 +0,0 @@
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")
}
}
})
}
}

View File

@ -1,38 +0,0 @@
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()
}
}

View File

@ -132,7 +132,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, 4, input, a.doCacheImage)
errs := pl.Sink(ctx, 2, input, a.doCacheImage)
for err := range errs {
log.Debug(ctx, "Error warming cache", err)
}
@ -142,13 +142,13 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
for _, size := range consts.CacheWarmerImageSizes {
r, _, err := a.artwork.Get(ctx, id, size, true)
if err != nil {
return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err)
}
_, err = io.Copy(io.Discard, r)
r.Close()
r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize, true)
if err != nil {
return fmt.Errorf("caching id='%s': %w", id, err)
}
defer r.Close()
_, err = io.Copy(io.Discard, r)
if err != nil {
return err
}
return nil

View File

@ -6,13 +6,11 @@ import (
"fmt"
"io"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache"
. "github.com/onsi/ginkgo/v2"
@ -175,42 +173,20 @@ 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(consts.UICoverArtSize))
})
})
})
type mockArtwork struct {
err error
mu sync.Mutex
cachedSizes []int
err error
}
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)
}

View File

@ -4,7 +4,6 @@ import (
"cmp"
"context"
"crypto/md5"
"errors"
"fmt"
"io"
"path/filepath"
@ -13,13 +12,12 @@ 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,11 +57,10 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar
}
func (a *albumArtworkReader) Key() string {
hashInput := conf.Server.CoverArtPriority
var hash [16]byte
if conf.Server.EnableExternalServices {
hashInput += conf.Server.Agents
hash = md5.Sum([]byte(conf.Server.Agents + conf.Server.CoverArtPriority))
}
hash := md5.Sum([]byte(hashInput))
return fmt.Sprintf(
"%s.%x.%t",
a.cacheKey.Key(),
@ -106,28 +103,6 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
if err != nil {
return nil, nil, nil, err
}
folderIDSet := make(map[string]bool, len(folderIDs))
for _, id := range folderIDs {
folderIDSet[id] = true
}
// For multi-disc albums (2+ folders), check if all folders share a common parent
// that is not already included. This finds cover art in the album root folder
// (e.g., "Artist/Album/cover.jpg" when tracks are in "Artist/Album/CD1/" and "Artist/Album/CD2/").
// We skip single-folder albums to avoid pulling images from the artist folder.
if commonParentID := commonParentFolder(folders, folderIDSet); commonParentID != "" {
parentFolder, err := ds.Folder(ctx).Get(commonParentID)
if errors.Is(err, model.ErrNotFound) {
log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID)
} else if err != nil {
return nil, nil, nil, err
}
if parentFolder != nil {
folders = append(folders, *parentFolder)
}
}
var paths []string
var imgFiles []string
var updatedAt time.Time
@ -150,24 +125,6 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
return paths, imgFiles, &updatedAt, nil
}
// commonParentFolder returns the shared parent folder ID when all folders have the
// same parent and that parent is not already in folderIDSet. Returns "" otherwise.
func commonParentFolder(folders []model.Folder, folderIDSet map[string]bool) string {
if len(folders) < 2 {
return ""
}
parentID := folders[0].ParentID
if parentID == "" || folderIDSet[parentID] {
return ""
}
for _, f := range folders[1:] {
if f.ParentID != parentID {
return ""
}
}
return parentID
}
// compareImageFiles compares two image file paths for sorting.
// It extracts the base filename (without extension) and compares case-insensitively.
// This ensures that "cover.jpg" sorts before "cover.1.jpg" since "cover" < "cover.1".

View File

@ -2,7 +2,6 @@ package artwork
import (
"context"
"errors"
"path/filepath"
"time"
@ -117,181 +116,5 @@ var _ = Describe("Album Artwork Reader", func() {
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Folder.jpg")))
})
It("includes images from parent folder for multi-disc albums", func() {
// Simulates: Artist/Album/cover.jpg with tracks in Artist/Album/CD1/ and Artist/Album/CD2/
repo.result = []model.Folder{
{
ID: "folder1",
Path: "Artist/Album",
Name: "CD1",
ParentID: "parentFolder",
ImagesUpdatedAt: now,
ImageFiles: []string{},
},
{
ID: "folder2",
Path: "Artist/Album",
Name: "CD2",
ParentID: "parentFolder",
ImagesUpdatedAt: now,
ImageFiles: []string{},
},
}
repo.parentResult = &model.Folder{
ID: "parentFolder",
Path: "Artist",
Name: "Album",
ImagesUpdatedAt: expectedAt,
ImageFiles: []string{"cover.jpg", "back.jpg"},
}
_, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
Expect(err).ToNot(HaveOccurred())
Expect(*imagesUpdatedAt).To(Equal(expectedAt))
Expect(imgFiles).To(HaveLen(2))
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/back.jpg")))
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
})
It("does not query parent when parent ID is already in album folders", func() {
// When the parent folder is already one of the album's folders, skip it
repo.result = []model.Folder{
{
ID: "folder1",
Path: "Artist",
Name: "Album",
ParentID: "folder2",
ImagesUpdatedAt: now,
ImageFiles: []string{"cover.jpg"},
},
{
ID: "folder2",
Path: "",
Name: "Artist",
ImagesUpdatedAt: now,
ImageFiles: []string{},
},
}
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
Expect(err).ToNot(HaveOccurred())
Expect(imgFiles).To(HaveLen(1))
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
// Get should not have been called (parent already in folder set)
Expect(repo.getCallCount).To(Equal(0))
})
It("does not query parent when folders have different parents", func() {
// When album folders span different parents, don't search any parent
repo.result = []model.Folder{
{
ID: "folder1",
Path: "Artist1/Album",
Name: "part1",
ParentID: "parentA",
ImagesUpdatedAt: now,
ImageFiles: []string{"cover.jpg"},
},
{
ID: "folder2",
Path: "Artist2/Album",
Name: "part2",
ParentID: "parentB",
ImagesUpdatedAt: now,
ImageFiles: []string{},
},
}
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
Expect(err).ToNot(HaveOccurred())
Expect(imgFiles).To(HaveLen(1))
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist1/Album/part1/cover.jpg")))
// Get should not have been called (different parents)
Expect(repo.getCallCount).To(Equal(0))
})
It("does not query parent for single-folder albums", func() {
// A single-folder album's parent is typically the artist folder,
// which should not be searched for cover art
repo.result = []model.Folder{
{
ID: "folder1",
Path: "Artist",
Name: "Album",
ParentID: "artistFolder",
ImagesUpdatedAt: now,
ImageFiles: []string{"cover.jpg"},
},
}
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
Expect(err).ToNot(HaveOccurred())
Expect(imgFiles).To(HaveLen(1))
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
// Get should not have been called (single folder, no parent lookup)
Expect(repo.getCallCount).To(Equal(0))
})
It("propagates non-ErrNotFound errors from parent folder lookup", func() {
repo.result = []model.Folder{
{
ID: "folder1",
Path: "Artist/Album",
Name: "CD1",
ParentID: "parentFolder",
ImagesUpdatedAt: now,
ImageFiles: []string{"cover.jpg"},
},
{
ID: "folder2",
Path: "Artist/Album",
Name: "CD2",
ParentID: "parentFolder",
ImagesUpdatedAt: now,
ImageFiles: []string{},
},
}
repo.getErr = errors.New("db connection failed")
_, _, _, err := loadAlbumFoldersPaths(ctx, ds, album)
Expect(err).To(MatchError("db connection failed"))
Expect(repo.getCallCount).To(Equal(1))
})
It("continues gracefully when parent folder is not found", func() {
// Parent folder may have been deleted; should log a warning and continue
repo.result = []model.Folder{
{
ID: "folder1",
Path: "Artist/Album",
Name: "CD1",
ParentID: "missingParent",
ImagesUpdatedAt: now,
ImageFiles: []string{"cover.jpg"},
},
{
ID: "folder2",
Path: "Artist/Album",
Name: "CD2",
ParentID: "missingParent",
ImagesUpdatedAt: now,
ImageFiles: []string{},
},
}
// parentResult is nil, so Get will return ErrNotFound
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
Expect(err).ToNot(HaveOccurred())
Expect(imgFiles).To(HaveLen(1))
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/CD1/cover.jpg")))
Expect(repo.getCallCount).To(Equal(1))
})
})
})

View File

@ -29,12 +29,11 @@ const (
type artistReader struct {
cacheKey
a *artwork
provider external.Provider
artist model.Artist
artistFolder string
imgFiles []string
imgFolderImgPath string // cached path from ArtistImageFolder lookup
a *artwork
provider external.Provider
artist model.Artist
artistFolder string
imgFiles []string
}
func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
@ -72,26 +71,15 @@ 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))
hash := md5.Sum([]byte(conf.Server.Agents + conf.Server.Spotify.ID))
return fmt.Sprintf(
"%s.%t.%x",
a.cacheKey.Key(),
@ -105,15 +93,10 @@ func (a *artistReader) LastUpdated() time.Time {
}
func (a *artistReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
ff := []sourceFunc{a.fromArtistUploadedImage()}
ff = append(ff, a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority)...)
var 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), ",") {
@ -121,8 +104,6 @@ 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:
@ -215,51 +196,3 @@ 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 ""
}

View File

@ -8,8 +8,6 @@ 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"
@ -415,283 +413,18 @@ 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 {
model.FolderRepository
result []model.Folder
parentResult *model.Folder
getErr error
getCallCount int
err error
result []model.Folder
err error
}
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) {
return f.result, f.err
}
func (f *fakeFolderRepo) Get(id string) (*model.Folder, error) {
f.getCallCount++
if f.getErr != nil {
return nil, f.getErr
}
if f.parentResult != nil {
return f.parentResult, nil
}
return nil, model.ErrNotFound
}
type fakeDataStore struct {
model.DataStore
folderRepo *fakeFolderRepo

View File

@ -1,268 +0,0 @@
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)
}
}

View File

@ -1,285 +0,0 @@
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))
})
})
})
})

View File

@ -26,22 +26,16 @@ 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
a.cacheKey.lastUpdate = mf.UpdatedAt
if al.UpdatedAt.After(a.cacheKey.lastUpdate) {
if al.UpdatedAt.After(mf.UpdatedAt) {
a.cacheKey.lastUpdate = al.UpdatedAt
}
if imagesUpdatedAt != nil && imagesUpdatedAt.After(a.cacheKey.lastUpdate) {
a.cacheKey.lastUpdate = *imagesUpdatedAt
} else {
a.cacheKey.lastUpdate = mf.UpdatedAt
}
return a, nil
}
@ -66,12 +60,6 @@ func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, str
fromFFmpegTag(ctx, a.a.ffmpeg, path),
}
}
// 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()))
}
ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.AlbumCoverArtID()))
return selectImageReader(ctx, a.artID, ff...)
}

View File

@ -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 fillCenter(img, tileSize/2, tileSize/2), nil
return imaging.Fill(img, tileSize/2, tileSize/2, imaging.Center, imaging.Lanczos), nil
}
func (a *playlistArtworkReader) createTiledImage(_ context.Context, tiles []image.Image) (io.ReadCloser, error) {
@ -238,32 +238,3 @@ 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
}

View File

@ -1,40 +0,0 @@
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())
}

View File

@ -1,84 +0,0 @@
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())
})
})
})
})

View File

@ -5,26 +5,17 @@ import (
"context"
"fmt"
"image"
"image/draw"
"image/jpeg"
"image/png"
"io"
"sync"
"time"
"github.com/gen2brain/webp"
"github.com/disintegration/imaging"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
xdraw "golang.org/x/image/draw"
)
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
type resizedArtworkReader struct {
artID model.ArtworkID
cacheKey string
@ -55,7 +46,7 @@ func (a *resizedArtworkReader) Key() string {
if a.square {
return baseKey + ".square"
}
return fmt.Sprintf("%s.%d", baseKey, conf.Server.CoverArtQuality)
return fmt.Sprintf("%s.%d", baseKey, conf.Server.CoverJpegQuality)
}
func (a *resizedArtworkReader) LastUpdated() time.Time {
@ -70,7 +61,7 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin
}
defer orig.Close()
resized, origSize, err := a.resizeImage(ctx, orig)
resized, origSize, err := resizeImage(orig, a.size, a.square)
if resized == nil {
log.Trace(ctx, "Image smaller than requested size", "artID", a.artID, "original", origSize, "resized", a.size, "square", a.square)
} else {
@ -84,42 +75,11 @@ 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 (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 (skip for square thumbnails)
if !a.square {
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, _, err := image.Decode(bytes.NewReader(data))
func resizeImage(reader io.Reader, size int, square bool) (io.Reader, int, error) {
original, format, err := image.Decode(reader)
if err != nil {
return nil, 0, err
}
@ -136,45 +96,26 @@ func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, erro
return nil, originalSize, nil
}
// 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)
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)
var resized image.Image
if originalSize >= size {
resized = imaging.Fit(original, size, size, imaging.Lanczos)
} else {
// Tight-fit canvas
dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
dstRect = dst.Bounds()
}
xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil)
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
if conf.Server.DevJpegCoverArt {
if square {
err = png.Encode(buf, dst)
if bounds.Max.Y < bounds.Max.X {
resized = imaging.Resize(original, size, 0, imaging.Lanczos)
} else {
err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality})
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)
}
buf := new(bytes.Buffer)
if format == "png" || square {
err = png.Encode(buf, resized)
} else {
err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality})
err = jpeg.Encode(buf, resized, &jpeg.Options{Quality: conf.Server.CoverJpegQuality})
}
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
return buf, originalSize, err
}

View File

@ -1,170 +0,0 @@
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("skips animation for square thumbnails even with animated GIF", func() {
r.square = true
data := createAnimatedGIF(3)
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
// Should fall through to static resize (not ffmpeg conversion)
// The minimal test GIF may or may not resize successfully,
// but ffmpeg should NOT have been called for animated conversion
_ = result
_ = err
// Verify by checking the mock wasn't used for animated conversion:
// If ffmpeg was called, it would return mock data, not static resize result
})
})
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("does not passthrough animated WebP for square thumbnails", func() {
r.square = true
data := createAnimatedWebPBytes()
// Should fall through to static resize, which will fail on fake WebP data
_, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
// Static decode will fail on our minimal test WebP bytes (not a real image)
Expect(err).To(HaveOccurred())
})
})
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("does not passthrough animated PNG for square thumbnails", func() {
r.square = true
data := createAPNGBytes()
// Should fall through to static resize
result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data))
// Static PNG decode should succeed on our APNG (it's a valid PNG)
if err == nil {
Expect(result).ToNot(BeNil())
}
})
})
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
}

View File

@ -15,6 +15,8 @@ import (
"strings"
"time"
"github.com/dhowden/tag"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
@ -84,6 +86,58 @@ var picTypeRegexes = []*regexp.Regexp{
}
func fromTag(ctx context.Context, path string) sourceFunc {
if conf.Server.DevLegacyEmbedImage {
return fromTagLegacy(ctx, path)
}
return fromTagGoTaglib(ctx, path)
}
func fromTagLegacy(ctx context.Context, path string) sourceFunc {
return func() (io.ReadCloser, string, error) {
if path == "" {
return nil, "", nil
}
f, err := os.Open(path)
if err != nil {
return nil, "", err
}
defer f.Close()
m, err := tag.ReadFrom(f)
if err != nil {
return nil, "", err
}
types := m.PictureTypes()
if len(types) == 0 {
return nil, "", fmt.Errorf("no embedded image found in %s", path)
}
var picture *tag.Picture
for _, regex := range picTypeRegexes {
for _, t := range types {
if regex.MatchString(t) {
log.Trace(ctx, "Found embedded image", "type", t, "path", path)
picture = m.Pictures(t)
break
}
}
if picture != nil {
break
}
}
if picture == nil {
log.Trace(ctx, "Could not find a front image. Getting the first one", "type", types[0], "path", path)
picture = m.Picture()
}
if picture == nil {
return nil, "", fmt.Errorf("could not load embedded image from %s", path)
}
return io.NopCloser(bytes.NewReader(picture.Data)), path, nil
}
}
func fromTagGoTaglib(ctx context.Context, path string) sourceFunc {
return func() (io.ReadCloser, string, error) {
if path == "" {
return nil, "", nil
@ -130,25 +184,10 @@ func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourc
if err != nil {
return nil, "", err
}
// 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
return 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)

View File

@ -120,19 +120,6 @@ func createNewSecret(ctx context.Context, ds model.DataStore) string {
return secret
}
// EncodeToken creates a signed JWT from an arbitrary claims map.
// It sets the issuer claim automatically.
func EncodeToken(claims map[string]any) (string, error) {
claims[jwt.IssuerKey] = consts.JWTIssuer
_, token, err := TokenAuth.Encode(claims)
return token, err
}
// DecodeAndVerifyToken verifies a JWT string and returns the parsed token.
func DecodeAndVerifyToken(tokenStr string) (jwt.Token, error) {
return jwtauth.VerifyToken(TokenAuth, tokenStr)
}
func getEncKey() []byte {
key := cmp.Or(
conf.Server.PasswordEncryptionKey,

View File

@ -86,11 +86,9 @@ func ClaimsFromToken(token jwt.Token) Claims {
if err := token.Get("f", &f); err == nil {
c.Format = f
}
if err := token.Get("b", &c.BitRate); err != nil {
var bf float64
if err := token.Get("b", &bf); err == nil {
c.BitRate = int(bf)
}
var b int
if err := token.Get("b", &b); err == nil {
c.BitRate = b
}
return c
}

View File

@ -374,25 +374,13 @@ func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error)
return nil, 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)
}
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 == "" {
return nil, model.ErrNotFound
}

View File

@ -1,17 +1,14 @@
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"
@ -269,68 +266,6 @@ 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

View File

@ -6,6 +6,7 @@ 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"

View File

@ -1,52 +1,24 @@
package ffmpeg
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
)
// TranscodeOptions contains all parameters for a transcoding operation.
type TranscodeOptions struct {
Command string // DB command template (used to detect custom vs default)
Format string // Target format (mp3, opus, aac, flac)
FilePath string
BitRate int // kbps, 0 = codec default
SampleRate int // 0 = no constraint
Channels int // 0 = no constraint
BitDepth int // 0 = no constraint; valid values: 16, 24, 32
Offset int // seconds
}
// AudioProbeResult contains authoritative audio stream properties from ffprobe.
type AudioProbeResult struct {
Codec string `json:"codec"`
Profile string `json:"profile,omitempty"`
BitRate int `json:"bitRate"`
SampleRate int `json:"sampleRate"`
BitDepth int `json:"bitDepth"`
Channels int `json:"channels"`
}
type FFmpeg interface {
Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error)
Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error)
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error)
Probe(ctx context.Context, files []string) (string, error)
ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error)
CmdPath() (string, error)
IsAvailable() bool
Version() string
@ -57,50 +29,29 @@ func New() FFmpeg {
}
const (
extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
probeCmd = "ffmpeg %s -f ffmetadata"
probeAudioStreamCmd = "ffprobe -v quiet -select_streams a:0 -print_format json -show_streams -show_format %s"
extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
probeCmd = "ffmpeg %s -f ffmetadata"
)
type ffmpeg struct{}
func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error) {
func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) {
if _, err := ffmpegCmd(); err != nil {
return nil, err
}
if err := fileExists(opts.FilePath); err != nil {
// First make sure the file exists
if err := fileExists(path); err != nil {
return nil, err
}
var args []string
if isDefaultCommand(opts.Format, opts.Command) {
args = buildDynamicArgs(opts)
} else {
args = buildTemplateArgs(opts)
}
args := createFFmpegCommand(command, path, maxBitRate, offset)
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
}
// First make sure the file exists
if err := fileExists(path); err != nil {
return nil, err
}
@ -130,91 +81,6 @@ func (e *ffmpeg) Probe(ctx context.Context, files []string) (string, error) {
return string(output), nil
}
func (e *ffmpeg) ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) {
if _, err := ffmpegCmd(); err != nil {
return nil, err
}
if err := fileExists(filePath); err != nil {
return nil, err
}
args := createFFmpegCommand(probeAudioStreamCmd, filePath, 0, 0)
log.Trace(ctx, "Executing ffprobe command", "args", args)
cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("running ffprobe on %q: %w", filePath, err)
}
return parseProbeOutput(output)
}
type probeOutput struct {
Streams []probeStream `json:"streams"`
Format probeFormat `json:"format"`
}
type probeFormat struct {
BitRate string `json:"bit_rate"`
}
type probeStream struct {
CodecName string `json:"codec_name"`
CodecType string `json:"codec_type"`
Profile string `json:"profile"`
SampleRate string `json:"sample_rate"`
BitRate string `json:"bit_rate"`
Channels int `json:"channels"`
BitsPerSample int `json:"bits_per_sample"`
BitsPerRawSample string `json:"bits_per_raw_sample"`
}
func parseProbeOutput(data []byte) (*AudioProbeResult, error) {
var output probeOutput
if err := json.Unmarshal(data, &output); err != nil {
return nil, fmt.Errorf("parsing ffprobe output: %w", err)
}
for _, s := range output.Streams {
if s.CodecType != "audio" {
continue
}
bitDepth := s.BitsPerSample
if bitDepth == 0 && s.BitsPerRawSample != "" {
bitDepth, _ = strconv.Atoi(s.BitsPerRawSample)
}
result := &AudioProbeResult{
Codec: s.CodecName,
Channels: s.Channels,
BitDepth: bitDepth,
}
// Profile: "unknown" → empty
if s.Profile != "" && !strings.EqualFold(s.Profile, "unknown") {
result.Profile = s.Profile
}
// Sample rate: string → int
if s.SampleRate != "" {
result.SampleRate, _ = strconv.Atoi(s.SampleRate)
}
// Bit rate: bps string → kbps int
if s.BitRate != "" {
bps, _ := strconv.Atoi(s.BitRate)
result.BitRate = bps / 1000
}
// Fallback to format-level bit_rate (needed for FLAC, Opus, etc.)
if result.BitRate == 0 && output.Format.BitRate != "" {
bps, _ := strconv.Atoi(output.Format.BitRate)
result.BitRate = bps / 1000
}
return result, nil
}
return nil, fmt.Errorf("no audio stream found in ffprobe output")
}
func (e *ffmpeg) CmdPath() (string, error) {
return ffmpegCmd()
}
@ -242,12 +108,9 @@ func (e *ffmpeg) Version() string {
return parts[2]
}
func (e *ffmpeg) start(ctx context.Context, args []string, input ...io.Reader) (io.ReadCloser, error) {
func (e *ffmpeg) start(ctx context.Context, args []string) (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 {
@ -259,25 +122,18 @@ func (e *ffmpeg) start(ctx context.Context, args []string, input ...io.Reader) (
type ffCmd struct {
*io.PipeReader
out *io.PipeWriter
args []string
cmd *exec.Cmd
input io.Reader // optional stdin source
stderr *bytes.Buffer
out *io.PipeWriter
args []string
cmd *exec.Cmd
}
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 = io.MultiWriter(os.Stderr, stderrWriter)
cmd.Stderr = os.Stderr
} else {
cmd.Stderr = stderrWriter
cmd.Stderr = io.Discard
}
j.cmd = cmd
@ -291,11 +147,7 @@ func (j *ffCmd) wait() {
if err := j.cmd.Wait(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
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))
_ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()))
} else {
_ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err))
}
@ -304,156 +156,6 @@ 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",
"opus": "libopus",
"aac": "aac",
"flac": "flac",
}
// formatOutputMap maps target format to ffmpeg output format flag (-f).
var formatOutputMap = map[string]string{
"mp3": "mp3",
"opus": "opus",
"aac": "adts",
"flac": "flac",
}
// defaultCommands is used to detect whether a user has customized their transcoding command.
var defaultCommands = func() map[string]string {
m := make(map[string]string, len(consts.DefaultTranscodings))
for _, t := range consts.DefaultTranscodings {
m[t.TargetFormat] = t.Command
}
return m
}()
// isDefaultCommand returns true if the command matches the known default for this format.
func isDefaultCommand(format, command string) bool {
return defaultCommands[format] == command
}
// buildDynamicArgs programmatically constructs ffmpeg arguments for known formats,
// including all transcoding parameters (bitrate, sample rate, channels).
func buildDynamicArgs(opts TranscodeOptions) []string {
cmdPath, _ := ffmpegCmd()
args := []string{cmdPath, "-i", opts.FilePath}
if opts.Offset > 0 {
args = append(args, "-ss", strconv.Itoa(opts.Offset))
}
args = append(args, "-map", "0:a:0")
if codec, ok := formatCodecMap[opts.Format]; ok {
args = append(args, "-c:a", codec)
}
if opts.BitRate > 0 {
args = append(args, "-b:a", strconv.Itoa(opts.BitRate)+"k")
}
if opts.SampleRate > 0 {
args = append(args, "-ar", strconv.Itoa(opts.SampleRate))
}
if opts.Channels > 0 {
args = append(args, "-ac", strconv.Itoa(opts.Channels))
}
// Only pass -sample_fmt for lossless output formats where bit depth matters.
// Lossy codecs (mp3, aac, opus) handle sample format conversion internally,
// and passing interleaved formats like "s16" causes silent failures.
if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) {
args = append(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth))
}
args = append(args, "-v", "0")
if outputFmt, ok := formatOutputMap[opts.Format]; ok {
args = append(args, "-f", outputFmt)
}
args = append(args, "-")
return args
}
// buildTemplateArgs handles user-customized command templates, with dynamic injection
// of sample rate, channels, and bit depth when requested by the transcode decision.
// Note: these flags are injected unconditionally when non-zero, even if the template
// already includes them. FFmpeg uses the last occurrence of duplicate flags.
func buildTemplateArgs(opts TranscodeOptions) []string {
args := createFFmpegCommand(opts.Command, opts.FilePath, opts.BitRate, opts.Offset)
// Dynamically inject -ar, -ac, and -sample_fmt before the output target
if opts.SampleRate > 0 {
args = injectBeforeOutput(args, "-ar", strconv.Itoa(opts.SampleRate))
}
if opts.Channels > 0 {
args = injectBeforeOutput(args, "-ac", strconv.Itoa(opts.Channels))
}
if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) {
args = injectBeforeOutput(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth))
}
return args
}
// injectBeforeOutput inserts a flag and value before the trailing "-" (stdout output).
func injectBeforeOutput(args []string, flag, value string) []string {
if len(args) > 0 && args[len(args)-1] == "-" {
result := make([]string, 0, len(args)+2)
result = append(result, args[:len(args)-1]...)
result = append(result, flag, value, "-")
return result
}
return append(args, flag, value)
}
// isLosslessOutputFormat returns true if the format is a lossless audio format
// where preserving bit depth via -sample_fmt is meaningful.
// Note: this covers only formats ffmpeg can produce as output. For the full set of
// lossless formats used in transcoding decisions, see core/stream/codec.go:isLosslessFormat.
func isLosslessOutputFormat(format string) bool {
switch strings.ToLower(format) {
case "flac", "alac", "wav", "aiff":
return true
}
return false
}
// bitDepthToSampleFmt converts a bit depth value to the ffmpeg sample_fmt string.
// FLAC only supports s16 and s32; for 24-bit sources, s32 is the correct format
// (ffmpeg packs 24-bit samples into 32-bit containers).
func bitDepthToSampleFmt(bitDepth int) string {
switch bitDepth {
case 16:
return "s16"
case 32:
return "s32"
default:
// 24-bit and other depths: use s32 (the next valid container size)
return "s32"
}
}
// Path will always be an absolute path
func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
var args []string
@ -494,20 +196,10 @@ func fixCmd(cmd string) []string {
if s == "ffmpeg" || s == "ffmpeg.exe" {
split[i] = cmdPath
}
if s == "ffprobe" || s == "ffprobe.exe" {
split[i] = ffprobePath(cmdPath)
}
}
return split
}
// ffprobePath derives the ffprobe binary path from the resolved ffmpeg path.
func ffprobePath(ffmpegCmd string) string {
dir := filepath.Dir(ffmpegCmd)
base := filepath.Base(ffmpegCmd)
return filepath.Join(dir, strings.Replace(base, "ffmpeg", "ffprobe", 1))
}
func ffmpegCmd() (string, error) {
ffOnce.Do(func() {
if conf.Server.FFmpegPath != "" {

View File

@ -2,27 +2,19 @@ package ffmpeg
import (
"context"
"os"
"path/filepath"
"runtime"
sync "sync"
"testing"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestFFmpeg(t *testing.T) {
// Inline test init to avoid import cycle with tests package
//nolint:dogsled
_, file, _, _ := runtime.Caller(0)
appPath, _ := filepath.Abs(filepath.Join(filepath.Dir(file), "..", ".."))
confPath := filepath.Join(appPath, "tests", "navidrome-test.toml")
_ = os.Chdir(appPath)
conf.LoadFromFile(confPath)
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "FFmpeg Suite")
@ -78,472 +70,6 @@ var _ = Describe("ffmpeg", func() {
})
})
Describe("isDefaultCommand", func() {
It("returns true for known default mp3 command", func() {
Expect(isDefaultCommand("mp3", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue())
})
It("returns true for known default opus command", 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 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())
})
It("returns false for a custom command", func() {
Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse())
})
It("returns false for unknown format", func() {
Expect(isDefaultCommand("wav", "ffmpeg -i %s -f wav -")).To(BeFalse())
})
})
Describe("buildDynamicArgs", func() {
It("builds mp3 args with bitrate, samplerate, and channels", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "mp3",
FilePath: "/music/file.flac",
BitRate: 256,
SampleRate: 48000,
Channels: 2,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-map", "0:a:0",
"-c:a", "libmp3lame",
"-b:a", "256k",
"-ar", "48000",
"-ac", "2",
"-v", "0",
"-f", "mp3",
"-",
}))
})
It("builds flac args without bitrate", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "flac",
FilePath: "/music/file.dsf",
SampleRate: 48000,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.dsf",
"-map", "0:a:0",
"-c:a", "flac",
"-ar", "48000",
"-v", "0",
"-f", "flac",
"-",
}))
})
It("builds opus args with bitrate only", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "opus",
FilePath: "/music/file.flac",
BitRate: 128,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-map", "0:a:0",
"-c:a", "libopus",
"-b:a", "128k",
"-v", "0",
"-f", "opus",
"-",
}))
})
It("includes offset when specified", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "mp3",
FilePath: "/music/file.mp3",
BitRate: 192,
Offset: 30,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.mp3",
"-ss", "30",
"-map", "0:a:0",
"-c:a", "libmp3lame",
"-b:a", "192k",
"-v", "0",
"-f", "mp3",
"-",
}))
})
It("builds aac args with ADTS output", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "aac",
FilePath: "/music/file.flac",
BitRate: 256,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-map", "0:a:0",
"-c:a", "aac",
"-b:a", "256k",
"-v", "0",
"-f", "adts",
"-",
}))
})
It("builds flac args with bit depth", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "flac",
FilePath: "/music/file.dsf",
BitDepth: 24,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.dsf",
"-map", "0:a:0",
"-c:a", "flac",
"-sample_fmt", "s32",
"-v", "0",
"-f", "flac",
"-",
}))
})
It("omits -sample_fmt when bit depth is 0", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "flac",
FilePath: "/music/file.flac",
BitDepth: 0,
})
Expect(args).ToNot(ContainElement("-sample_fmt"))
})
It("omits -sample_fmt when bit depth is too low (DSD)", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "flac",
FilePath: "/music/file.dsf",
BitDepth: 1,
})
Expect(args).ToNot(ContainElement("-sample_fmt"))
})
DescribeTable("omits -sample_fmt for lossy formats even when bit depth >= 16",
func(format string, bitRate int) {
args := buildDynamicArgs(TranscodeOptions{
Format: format,
FilePath: "/music/file.flac",
BitRate: bitRate,
BitDepth: 16,
})
Expect(args).ToNot(ContainElement("-sample_fmt"))
},
Entry("mp3", "mp3", 256),
Entry("aac", "aac", 256),
Entry("opus", "opus", 128),
)
})
Describe("bitDepthToSampleFmt", func() {
It("converts 16-bit", func() {
Expect(bitDepthToSampleFmt(16)).To(Equal("s16"))
})
It("converts 24-bit to s32 (FLAC only supports s16/s32)", func() {
Expect(bitDepthToSampleFmt(24)).To(Equal("s32"))
})
It("converts 32-bit", func() {
Expect(bitDepthToSampleFmt(32)).To(Equal("s32"))
})
})
Describe("buildTemplateArgs", func() {
It("injects -ar and -ac into custom template", func() {
args := buildTemplateArgs(TranscodeOptions{
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
FilePath: "/music/file.flac",
BitRate: 192,
SampleRate: 44100,
Channels: 2,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-b:a", "192k", "-v", "0", "-f", "mp3",
"-ar", "44100", "-ac", "2",
"-",
}))
})
It("injects only -ar when channels is 0", func() {
args := buildTemplateArgs(TranscodeOptions{
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
FilePath: "/music/file.flac",
BitRate: 192,
SampleRate: 48000,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-b:a", "192k", "-v", "0", "-f", "mp3",
"-ar", "48000",
"-",
}))
})
It("does not inject anything when sample rate and channels are 0", func() {
args := buildTemplateArgs(TranscodeOptions{
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
FilePath: "/music/file.flac",
BitRate: 192,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-b:a", "192k", "-v", "0", "-f", "mp3",
"-",
}))
})
It("injects -sample_fmt for lossless output format with bit depth", func() {
args := buildTemplateArgs(TranscodeOptions{
Command: "ffmpeg -i %s -v 0 -c:a flac -f flac -",
Format: "flac",
FilePath: "/music/file.dsf",
BitDepth: 24,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.dsf",
"-v", "0", "-c:a", "flac", "-f", "flac",
"-sample_fmt", "s32",
"-",
}))
})
It("does not inject -sample_fmt for lossy output format even with bit depth", func() {
args := buildTemplateArgs(TranscodeOptions{
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
Format: "mp3",
FilePath: "/music/file.flac",
BitRate: 192,
BitDepth: 16,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-b:a", "192k", "-v", "0", "-f", "mp3",
"-",
}))
})
})
Describe("injectBeforeOutput", func() {
It("inserts flag before trailing dash", func() {
args := injectBeforeOutput([]string{"ffmpeg", "-i", "file.mp3", "-f", "mp3", "-"}, "-ar", "48000")
Expect(args).To(Equal([]string{"ffmpeg", "-i", "file.mp3", "-f", "mp3", "-ar", "48000", "-"}))
})
It("appends when no trailing dash", func() {
args := injectBeforeOutput([]string{"ffmpeg", "-i", "file.mp3"}, "-ar", "48000")
Expect(args).To(Equal([]string{"ffmpeg", "-i", "file.mp3", "-ar", "48000"}))
})
})
Describe("parseProbeOutput", func() {
It("parses MP3 with embedded artwork (real ffprobe output)", func() {
// Real: MP3 file with mjpeg artwork stream after audio
data := []byte(`{"streams":[` +
`{"index":0,"codec_name":"mp3","codec_long_name":"MP3 (MPEG audio layer 3)","codec_type":"audio",` +
`"sample_fmt":"fltp","sample_rate":"44100","channels":2,"channel_layout":"stereo",` +
`"bits_per_sample":0,"bit_rate":"198314","tags":{"encoder":"LAME3.99r"}},` +
`{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline","width":400,"height":400}]}`)
result, err := parseProbeOutput(data)
Expect(err).ToNot(HaveOccurred())
Expect(result.Codec).To(Equal("mp3"))
Expect(result.Profile).To(BeEmpty()) // MP3 has no profile field
Expect(result.SampleRate).To(Equal(44100))
Expect(result.Channels).To(Equal(2))
Expect(result.BitRate).To(Equal(198)) // 198314 bps -> 198 kbps
Expect(result.BitDepth).To(Equal(0)) // lossy codec
})
It("parses AAC-LC in m4a container (real ffprobe output)", func() {
// Real: AAC LC file with profile and artwork
data := []byte(`{"streams":[` +
`{"index":0,"codec_name":"aac","codec_long_name":"AAC (Advanced Audio Coding)",` +
`"profile":"LC","codec_type":"audio","sample_fmt":"fltp","sample_rate":"44100",` +
`"channels":2,"channel_layout":"stereo","bits_per_sample":0,"bit_rate":"279958"},` +
`{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`)
result, err := parseProbeOutput(data)
Expect(err).ToNot(HaveOccurred())
Expect(result.Codec).To(Equal("aac"))
Expect(result.Profile).To(Equal("LC"))
Expect(result.SampleRate).To(Equal(44100))
Expect(result.Channels).To(Equal(2))
Expect(result.BitRate).To(Equal(279)) // 279958 bps -> 279 kbps
})
It("parses HE-AACv2 in mp4 container with video stream (real ffprobe output)", func() {
// Real: Fraunhofer HE-AACv2 sample (LFE-SBRstereo.mp4), video stream before audio
data := []byte(`{"streams":[` +
`{"index":0,"codec_name":"h264","codec_type":"video","profile":"Main"},` +
`{"index":1,"codec_name":"aac","codec_long_name":"AAC (Advanced Audio Coding)",` +
`"profile":"HE-AACv2","codec_type":"audio","sample_fmt":"fltp",` +
`"sample_rate":"48000","channels":2,"channel_layout":"stereo",` +
`"bits_per_sample":0,"bit_rate":"55999"}]}`)
result, err := parseProbeOutput(data)
Expect(err).ToNot(HaveOccurred())
Expect(result.Codec).To(Equal("aac"))
Expect(result.Profile).To(Equal("HE-AACv2"))
Expect(result.SampleRate).To(Equal(48000))
Expect(result.Channels).To(Equal(2))
Expect(result.BitRate).To(Equal(55)) // 55999 bps -> 55 kbps
})
It("parses FLAC using bits_per_raw_sample and format-level bit_rate (real ffprobe output)", func() {
// Real: FLAC reports bit depth in bits_per_raw_sample, not bits_per_sample.
// Stream-level bit_rate is absent; format-level bit_rate is used as fallback.
data := []byte(`{"streams":[` +
`{"index":0,"codec_name":"flac","codec_long_name":"FLAC (Free Lossless Audio Codec)",` +
`"codec_type":"audio","sample_fmt":"s16","sample_rate":"44100","channels":2,` +
`"channel_layout":"stereo","bits_per_sample":0,"bits_per_raw_sample":"16"},` +
`{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}],` +
`"format":{"bit_rate":"906900"}}`)
result, err := parseProbeOutput(data)
Expect(err).ToNot(HaveOccurred())
Expect(result.Codec).To(Equal("flac"))
Expect(result.SampleRate).To(Equal(44100))
Expect(result.BitDepth).To(Equal(16)) // from bits_per_raw_sample
Expect(result.BitRate).To(Equal(906)) // format-level: 906900 bps -> 906 kbps
Expect(result.Profile).To(BeEmpty()) // no profile field in real output
})
It("parses Opus with format-level bit_rate fallback (real ffprobe output)", func() {
// Real: Opus stream-level bit_rate is absent; format-level is used as fallback.
data := []byte(`{"streams":[` +
`{"index":0,"codec_name":"opus","codec_long_name":"Opus (Opus Interactive Audio Codec)",` +
`"codec_type":"audio","sample_fmt":"fltp","sample_rate":"48000","channels":2,` +
`"channel_layout":"stereo","bits_per_sample":0}],` +
`"format":{"bit_rate":"128000"}}`)
result, err := parseProbeOutput(data)
Expect(err).ToNot(HaveOccurred())
Expect(result.Codec).To(Equal("opus"))
Expect(result.SampleRate).To(Equal(48000))
Expect(result.Channels).To(Equal(2))
Expect(result.BitRate).To(Equal(128)) // format-level: 128000 bps -> 128 kbps
Expect(result.BitDepth).To(Equal(0))
})
It("parses WAV/PCM with bits_per_sample (real ffprobe output)", func() {
// Real: WAV uses bits_per_sample directly
data := []byte(`{"streams":[` +
`{"index":0,"codec_name":"pcm_s16le","codec_long_name":"PCM signed 16-bit little-endian",` +
`"codec_type":"audio","sample_fmt":"s16","sample_rate":"44100","channels":2,` +
`"bits_per_sample":16,"bit_rate":"1411200"}]}`)
result, err := parseProbeOutput(data)
Expect(err).ToNot(HaveOccurred())
Expect(result.Codec).To(Equal("pcm_s16le"))
Expect(result.SampleRate).To(Equal(44100))
Expect(result.Channels).To(Equal(2))
Expect(result.BitDepth).To(Equal(16))
Expect(result.BitRate).To(Equal(1411))
})
It("parses ALAC in m4a container (real ffprobe output)", func() {
// Real: Beatles - You Can't Do That (2023 Mix), ALAC 16-bit
data := []byte(`{"streams":[` +
`{"index":0,"codec_name":"alac","codec_long_name":"ALAC (Apple Lossless Audio Codec)",` +
`"codec_type":"audio","sample_fmt":"s16p","sample_rate":"44100","channels":2,` +
`"channel_layout":"stereo","bits_per_sample":0,"bit_rate":"1011003",` +
`"bits_per_raw_sample":"16"},` +
`{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`)
result, err := parseProbeOutput(data)
Expect(err).ToNot(HaveOccurred())
Expect(result.Codec).To(Equal("alac"))
Expect(result.BitDepth).To(Equal(16)) // from bits_per_raw_sample
Expect(result.SampleRate).To(Equal(44100))
Expect(result.Channels).To(Equal(2))
Expect(result.BitRate).To(Equal(1011)) // 1011003 bps -> 1011 kbps
})
It("skips video-only streams", func() {
data := []byte(`{"streams":[{"index":0,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`)
_, err := parseProbeOutput(data)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("no audio stream"))
})
It("returns error for empty streams array", func() {
data := []byte(`{"streams":[]}`)
_, err := parseProbeOutput(data)
Expect(err).To(HaveOccurred())
})
It("returns error for invalid JSON", func() {
data := []byte(`not json`)
_, err := parseProbeOutput(data)
Expect(err).To(HaveOccurred())
})
It("parses HiRes multichannel FLAC with format-level bit_rate (real ffprobe output)", func() {
// Real: Pink Floyd - 192kHz/24-bit/7.1 surround FLAC
data := []byte(`{"streams":[` +
`{"index":0,"codec_name":"flac","codec_long_name":"FLAC (Free Lossless Audio Codec)",` +
`"codec_type":"audio","sample_fmt":"s32","sample_rate":"192000","channels":8,` +
`"channel_layout":"7.1","bits_per_sample":0,"bits_per_raw_sample":"24"},` +
`{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Progressive"}],` +
`"format":{"bit_rate":"18432000"}}`)
result, err := parseProbeOutput(data)
Expect(err).ToNot(HaveOccurred())
Expect(result.Codec).To(Equal("flac"))
Expect(result.SampleRate).To(Equal(192000))
Expect(result.BitDepth).To(Equal(24))
Expect(result.Channels).To(Equal(8))
Expect(result.BitRate).To(Equal(18432)) // format-level: 18432000 bps -> 18432 kbps
})
It("parses DSD/DSF file (real ffprobe output)", func() {
// Real: Yes - Owner of a Lonely Heart, DSD64 DSF
data := []byte(`{"streams":[` +
`{"index":0,"codec_name":"dsd_lsbf_planar",` +
`"codec_long_name":"DSD (Direct Stream Digital), least significant bit first, planar",` +
`"codec_type":"audio","sample_fmt":"fltp","sample_rate":"352800","channels":2,` +
`"channel_layout":"stereo","bits_per_sample":8,"bit_rate":"5644800"},` +
`{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`)
result, err := parseProbeOutput(data)
Expect(err).ToNot(HaveOccurred())
Expect(result.Codec).To(Equal("dsd_lsbf_planar"))
Expect(result.BitDepth).To(Equal(8)) // DSD reports 8 bits_per_sample
Expect(result.SampleRate).To(Equal(352800)) // DSD64 sample rate
Expect(result.Channels).To(Equal(2))
Expect(result.BitRate).To(Equal(5644)) // 5644800 bps -> 5644 kbps
})
It("prefers stream-level bit_rate over format-level when both are present", func() {
// ALAC/DSD: stream has bit_rate, format also has bit_rate — stream wins
data := []byte(`{"streams":[` +
`{"index":0,"codec_name":"alac","codec_type":"audio","sample_fmt":"s16p",` +
`"sample_rate":"44100","channels":2,"bits_per_sample":0,` +
`"bit_rate":"1011003","bits_per_raw_sample":"16"}],` +
`"format":{"bit_rate":"1050000"}}`)
result, err := parseProbeOutput(data)
Expect(err).ToNot(HaveOccurred())
Expect(result.BitRate).To(Equal(1011)) // stream-level: 1011003 bps -> 1011 kbps (not format's 1050)
})
It("returns BitRate 0 when neither stream nor format has bit_rate", func() {
data := []byte(`{"streams":[` +
`{"index":0,"codec_name":"flac","codec_type":"audio","sample_fmt":"s16",` +
`"sample_rate":"44100","channels":2,"bits_per_sample":0,"bits_per_raw_sample":"16"}],` +
`"format":{}}`)
result, err := parseProbeOutput(data)
Expect(err).ToNot(HaveOccurred())
Expect(result.BitRate).To(Equal(0))
})
It("clears 'unknown' profile to empty string", func() {
data := []byte(`{"streams":[{"index":0,"codec_name":"flac",` +
`"codec_type":"audio","profile":"unknown","sample_rate":"44100",` +
`"channels":2,"bits_per_sample":0}]}`)
result, err := parseProbeOutput(data)
Expect(err).ToNot(HaveOccurred())
Expect(result.Profile).To(BeEmpty())
})
})
Describe("FFmpeg", func() {
Context("when FFmpeg is available", func() {
var ff FFmpeg
@ -567,12 +93,7 @@ var _ = Describe("ffmpeg", func() {
command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -f mp3 -"
// The input file is not used here, but we need to provide a valid path to the Transcode function
stream, err := ff.Transcode(ctx, TranscodeOptions{
Command: command,
Format: "mp3",
FilePath: "tests/fixtures/test.mp3",
BitRate: 128,
})
stream, err := ff.Transcode(ctx, command, "tests/fixtures/test.mp3", 128, 0)
Expect(err).ToNot(HaveOccurred())
defer stream.Close()
@ -584,12 +105,9 @@ var _ = Describe("ffmpeg", func() {
// Cancel the context
cancel()
// 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())
// Next read should fail due to cancelled context
_, err = stream.Read(buf)
Expect(err).To(HaveOccurred())
})
It("should handle immediate context cancellation", func() {
@ -597,56 +115,11 @@ var _ = Describe("ffmpeg", func() {
cancel() // Cancel immediately
// This should fail immediately
_, err := ff.Transcode(ctx, TranscodeOptions{
Command: "ffmpeg -i %s -f mp3 -",
Format: "mp3",
FilePath: "tests/fixtures/test.mp3",
BitRate: 128,
})
_, err := ff.Transcode(ctx, "ffmpeg -i %s -f mp3 -", "tests/fixtures/test.mp3", 128, 0)
Expect(err).To(MatchError(context.Canceled))
})
})
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() {
@ -669,10 +142,7 @@ var _ = Describe("ffmpeg", func() {
defer cancel()
// Start a process that will run for a while
stream, err := ff.Transcode(ctx, TranscodeOptions{
Command: longRunningCmd,
FilePath: "tests/fixtures/test.mp3",
})
stream, err := ff.Transcode(ctx, longRunningCmd, "tests/fixtures/test.mp3", 0, 0)
Expect(err).ToNot(HaveOccurred())
defer stream.Close()

View File

@ -1,71 +0,0 @@
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
}

View File

@ -1,99 +0,0 @@
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())
})
})
})

View File

@ -9,45 +9,23 @@ import (
"github.com/navidrome/navidrome/model"
)
// Lyrics can fetch lyrics for a media file.
type Lyrics interface {
GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error)
}
// PluginLoader discovers and loads lyrics provider plugins.
type PluginLoader interface {
LoadLyricsProvider(name string) (Lyrics, bool)
}
type lyricsService struct {
pluginLoader PluginLoader
}
// NewLyrics creates a new lyrics service. pluginLoader may be nil if no plugin
// system is available.
func NewLyrics(pluginLoader PluginLoader) Lyrics {
return &lyricsService{pluginLoader: pluginLoader}
}
// GetLyrics returns lyrics for the given media file, trying sources in the
// order specified by conf.Server.LyricsPriority.
func (l *lyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
func GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
var lyricsList model.LyricList
var err error
for pattern := range strings.SplitSeq(conf.Server.LyricsPriority, ",") {
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.LyricsPriority), ",") {
pattern = strings.TrimSpace(pattern)
switch {
case strings.EqualFold(pattern, "embedded"):
case pattern == "embedded":
lyricsList, err = fromEmbedded(ctx, mf)
case strings.HasPrefix(pattern, "."):
lyricsList, err = fromExternalFile(ctx, mf, strings.ToLower(pattern))
lyricsList, err = fromExternalFile(ctx, mf, pattern)
default:
lyricsList, err = l.fromPlugin(ctx, mf, pattern)
log.Error(ctx, "Invalid lyric pattern", "pattern", pattern)
}
if err != nil {
log.Error(ctx, "error getting lyrics", "source", pattern, err)
log.Error(ctx, "error parsing lyrics", "source", pattern, err)
}
if len(lyricsList) > 0 {

View File

@ -3,7 +3,6 @@ package lyrics_test
import (
"context"
"encoding/json"
"fmt"
"os"
"github.com/navidrome/navidrome/conf"
@ -73,8 +72,7 @@ var _ = Describe("sources", func() {
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) {
conf.Server.LyricsPriority = priority
svc := lyrics.NewLyrics(nil)
list, err := svc.GetLyrics(ctx, &mf)
list, err := lyrics.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(expected))
},
@ -109,8 +107,7 @@ var _ = Describe("sources", func() {
It("should fallback to embedded if an error happens when parsing file", func() {
conf.Server.LyricsPriority = ".mp3,embedded"
svc := lyrics.NewLyrics(nil)
list, err := svc.GetLyrics(ctx, &mf)
list, err := lyrics.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics))
})
@ -118,109 +115,10 @@ var _ = Describe("sources", func() {
It("should return nothing if error happens when trying to parse file", func() {
conf.Server.LyricsPriority = ".mp3"
svc := lyrics.NewLyrics(nil)
list, err := svc.GetLyrics(ctx, &mf)
list, err := lyrics.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(BeEmpty())
})
})
})
Context("plugin sources", func() {
var mockLoader *mockPluginLoader
BeforeEach(func() {
mockLoader = &mockPluginLoader{}
})
It("should return lyrics from a plugin", func() {
conf.Server.LyricsPriority = "test-lyrics-plugin"
mockLoader.lyrics = unsyncedLyrics
svc := lyrics.NewLyrics(mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(unsyncedLyrics))
})
It("should try plugin after embedded returns nothing", func() {
conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
mf.Lyrics = "" // No embedded lyrics
mockLoader.lyrics = unsyncedLyrics
svc := lyrics.NewLyrics(mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(unsyncedLyrics))
})
It("should skip plugin if embedded has lyrics", func() {
conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
mockLoader.lyrics = unsyncedLyrics
svc := lyrics.NewLyrics(mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics)) // embedded wins
})
It("should skip unknown plugin names gracefully", func() {
conf.Server.LyricsPriority = "nonexistent-plugin,embedded"
mockLoader.notFound = true
svc := lyrics.NewLyrics(mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
})
It("should preserve plugin name case from config", func() {
conf.Server.LyricsPriority = "MyLyricsPlugin"
mockLoader.pluginName = "MyLyricsPlugin"
mockLoader.lyrics = unsyncedLyrics
svc := lyrics.NewLyrics(mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(unsyncedLyrics))
})
It("should handle plugin error gracefully", func() {
conf.Server.LyricsPriority = "test-lyrics-plugin,embedded"
mockLoader.err = fmt.Errorf("plugin error")
svc := lyrics.NewLyrics(mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
})
})
})
type mockPluginLoader struct {
lyrics model.LyricList
err error
notFound bool
pluginName string // expected plugin name (exact match, like real manager)
}
func (m *mockPluginLoader) PluginNames(_ string) []string {
if m.notFound {
return nil
}
return []string{"test-lyrics-plugin"}
}
func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) {
if m.notFound {
return nil, false
}
// If pluginName is set, require exact match (like the real plugin manager)
if m.pluginName != "" && name != m.pluginName {
return nil, false
}
return &mockLyricsProvider{lyrics: m.lyrics, err: m.err}, true
}
type mockLyricsProvider struct {
lyrics model.LyricList
err error
}
func (m *mockLyricsProvider) GetLyrics(_ context.Context, _ *model.MediaFile) (model.LyricList, error) {
return m.lyrics, m.err
}

View File

@ -49,27 +49,3 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (
return model.LyricList{*lyrics}, nil
}
// fromPlugin attempts to load lyrics from a plugin with the given name.
func (l *lyricsService) fromPlugin(ctx context.Context, mf *model.MediaFile, pluginName string) (model.LyricList, error) {
if l.pluginLoader == nil {
log.Debug(ctx, "Invalid lyric source", "source", pluginName)
return nil, nil
}
provider, ok := l.pluginLoader.LoadLyricsProvider(pluginName)
if !ok {
log.Warn(ctx, "Lyrics plugin not found", "plugin", pluginName)
return nil, nil
}
lyricsList, err := provider.GetLyrics(ctx, mf)
if err != nil {
return nil, err
}
if len(lyricsList) > 0 {
log.Trace(ctx, "Retrieved lyrics from plugin", "plugin", pluginName, "count", len(lyricsList))
}
return lyricsList, nil
}

227
core/media_streamer.go Normal file
View File

@ -0,0 +1,227 @@
package core
import (
"context"
"fmt"
"io"
"mime"
"os"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/cache"
)
type MediaStreamer interface {
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, offset int) (*Stream, error)
DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error)
}
type TranscodingCache cache.FileCache
func NewMediaStreamer(ds model.DataStore, t ffmpeg.FFmpeg, cache TranscodingCache) MediaStreamer {
return &mediaStreamer{ds: ds, transcoder: t, cache: cache}
}
type mediaStreamer struct {
ds model.DataStore
transcoder ffmpeg.FFmpeg
cache cache.FileCache
}
type streamJob struct {
ms *mediaStreamer
mf *model.MediaFile
filePath string
format string
bitRate int
offset int
}
func (j *streamJob) Key() string {
return fmt.Sprintf("%s.%s.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format, j.offset)
}
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
mf, err := ms.ds.MediaFile(ctx).Get(id)
if err != nil {
return nil, err
}
return ms.DoStream(ctx, mf, reqFormat, reqBitRate, reqOffset)
}
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
var format string
var bitRate int
var cached bool
defer func() {
log.Info(ctx, "Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached,
"bitRate", bitRate, "user", userName(ctx), "transcoding", format != "raw",
"originalFormat", mf.Suffix, "originalBitRate", mf.BitRate)
}()
format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate)
s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
filePath := mf.AbsolutePath()
if format == "raw" {
log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", filePath,
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format)
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
s.ReadCloser = f
s.Seeker = f
s.format = mf.Suffix
return s, nil
}
job := &streamJob{
ms: ms,
mf: mf,
filePath: filePath,
format: format,
bitRate: bitRate,
offset: reqOffset,
}
r, err := ms.cache.Get(ctx, job)
if err != nil {
log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err)
return nil, err
}
cached = r.Cached
s.ReadCloser = r
s.Seeker = r.Seeker
log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", filePath,
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
return s, nil
}
type Stream struct {
ctx context.Context
mf *model.MediaFile
bitRate int
format string
io.ReadCloser
io.Seeker
}
func (s *Stream) Seekable() bool { return s.Seeker != nil }
func (s *Stream) Duration() float32 { return s.mf.Duration }
func (s *Stream) ContentType() string { return mime.TypeByExtension("." + s.format) }
func (s *Stream) Name() string { return s.mf.Title + "." + s.format }
func (s *Stream) ModTime() time.Time { return s.mf.UpdatedAt }
func (s *Stream) EstimatedContentLength() int {
return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
}
// TODO This function deserves some love (refactoring)
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) {
format = "raw"
if reqFormat == "raw" {
return format, 0
}
if reqFormat == mf.Suffix && reqBitRate == 0 {
bitRate = mf.BitRate
return format, bitRate
}
trc, hasDefault := request.TranscodingFrom(ctx)
var cFormat string
var cBitRate int
if reqFormat != "" {
cFormat = reqFormat
} else {
if hasDefault {
cFormat = trc.TargetFormat
cBitRate = trc.DefaultBitRate
if p, ok := request.PlayerFrom(ctx); ok {
cBitRate = p.MaxBitRate
}
} else if reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "" {
// If no format is specified and no transcoding associated to the player, but a bitrate is specified,
// and there is no transcoding set for the player, we use the default downsampling format.
// But only if the requested bitRate is lower than the original bitRate.
log.Debug("Default Downsampling", "Using default downsampling format", conf.Server.DefaultDownsamplingFormat)
cFormat = conf.Server.DefaultDownsamplingFormat
}
}
if reqBitRate > 0 {
cBitRate = reqBitRate
}
if cBitRate == 0 && cFormat == "" {
return format, bitRate
}
t, err := ds.Transcoding(ctx).FindByFormat(cFormat)
if err == nil {
format = t.TargetFormat
if cBitRate != 0 {
bitRate = cBitRate
} else {
bitRate = t.DefaultBitRate
}
}
if format == mf.Suffix && bitRate >= mf.BitRate {
format = "raw"
bitRate = 0
}
return format, bitRate
}
var (
onceTranscodingCache sync.Once
instanceTranscodingCache TranscodingCache
)
func GetTranscodingCache() TranscodingCache {
onceTranscodingCache.Do(func() {
instanceTranscodingCache = NewTranscodingCache()
})
return instanceTranscodingCache
}
func NewTranscodingCache() TranscodingCache {
return cache.NewFileCache("Transcoding", conf.Server.TranscodingCacheSize,
consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems,
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
job := arg.(*streamJob)
t, err := job.ms.ds.Transcoding(ctx).FindByFormat(job.format)
if err != nil {
log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
return nil, os.ErrInvalid
}
// Choose the appropriate context based on EnableTranscodingCancellation configuration.
// This is where we decide whether transcoding processes should be cancellable or not.
var transcodingCtx context.Context
if conf.Server.EnableTranscodingCancellation {
// Use the request context directly, allowing cancellation when client disconnects
transcodingCtx = ctx
} else {
// Use background context with request values preserved.
// This prevents cancellation but maintains request metadata (user, client, etc.)
transcodingCtx = request.AddValues(context.Background(), ctx)
}
out, err := job.ms.transcoder.Transcode(transcodingCtx, t.Command, job.filePath, job.bitRate, job.offset)
if err != nil {
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
return nil, os.ErrInvalid
}
return out, nil
})
}

View File

@ -0,0 +1,162 @@
package core
import (
"context"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("MediaStreamer", func() {
var ds model.DataStore
ctx := log.NewContext(context.Background())
BeforeEach(func() {
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
})
Context("selectTranscodingOptions", func() {
mf := &model.MediaFile{}
Context("player is not configured", func() {
It("returns raw if raw is requested", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
Expect(format).To(Equal("raw"))
})
It("returns raw if a transcoder does not exists", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0)
Expect(format).To(Equal("raw"))
})
It("returns the requested format if a transcoder exists", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
Expect(format).To(Equal("mp3"))
Expect(bitRate).To(Equal(160)) // Default Bit Rate
})
It("returns raw if requested format is the same as the original and it is not necessary to downsample", func() {
mf.Suffix = "mp3"
mf.BitRate = 112
format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128)
Expect(format).To(Equal("raw"))
})
It("returns the requested format if requested BitRate is lower than original", func() {
mf.Suffix = "mp3"
mf.BitRate = 320
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
Expect(format).To(Equal("mp3"))
Expect(bitRate).To(Equal(192))
})
It("returns raw if requested format is the same as the original, but requested BitRate is 0", func() {
mf.Suffix = "mp3"
mf.BitRate = 320
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
Expect(format).To(Equal("raw"))
Expect(bitRate).To(Equal(320))
})
Context("Downsampling", func() {
BeforeEach(func() {
conf.Server.DefaultDownsamplingFormat = "opus"
mf.Suffix = "FLAC"
mf.BitRate = 960
})
It("returns the DefaultDownsamplingFormat if a maxBitrate is requested but not the format", func() {
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 128)
Expect(format).To(Equal("opus"))
Expect(bitRate).To(Equal(128))
})
It("returns raw if maxBitrate is equal or greater than original", func() {
// This happens with DSub (and maybe other clients?). See https://github.com/navidrome/navidrome/issues/2066
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 960)
Expect(format).To(Equal("raw"))
Expect(bitRate).To(Equal(0))
})
})
})
Context("player has format configured", func() {
BeforeEach(func() {
t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
ctx = request.WithTranscoding(ctx, t)
})
It("returns raw if raw is requested", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
Expect(format).To(Equal("raw"))
})
It("returns configured format/bitrate as default", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(96))
})
It("returns requested format", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
Expect(format).To(Equal("mp3"))
Expect(bitRate).To(Equal(160)) // Default Bit Rate
})
It("returns requested bitrate", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(80))
})
It("returns raw if selected bitrate and format is the same as original", func() {
mf.Suffix = "mp3"
mf.BitRate = 192
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
Expect(format).To(Equal("raw"))
Expect(bitRate).To(Equal(0))
})
})
Context("player has maxBitRate configured", func() {
BeforeEach(func() {
t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
p := model.Player{ID: "player1", TranscodingId: t.ID, MaxBitRate: 192}
ctx = request.WithTranscoding(ctx, t)
ctx = request.WithPlayer(ctx, p)
})
It("returns raw if raw is requested", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
Expect(format).To(Equal("raw"))
})
It("returns configured format/bitrate as default", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(192))
})
It("returns requested format", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
Expect(format).To(Equal("mp3"))
Expect(bitRate).To(Equal(160)) // Default Bit Rate
})
It("returns requested bitrate", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 160)
Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(160))
})
})
})
})

View File

@ -1,4 +1,4 @@
package stream_test
package core_test
import (
"context"
@ -7,7 +7,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
@ -16,7 +16,7 @@ import (
)
var _ = Describe("MediaStreamer", func() {
var streamer stream.MediaStreamer
var streamer core.MediaStreamer
var ds model.DataStore
ffmpeg := tests.NewMockFFmpeg("fake data")
ctx := log.NewContext(context.TODO())
@ -29,45 +29,44 @@ var _ = Describe("MediaStreamer", func() {
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0},
})
testCache := stream.NewTranscodingCache()
testCache := core.NewTranscodingCache()
Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue())
streamer = stream.NewMediaStreamer(ds, ffmpeg, testCache)
streamer = core.NewMediaStreamer(ds, ffmpeg, testCache)
})
AfterEach(func() {
_ = os.RemoveAll(conf.Server.CacheFolder)
})
Context("NewStream", func() {
var mf *model.MediaFile
BeforeEach(func() {
var err error
mf, err = ds.MediaFile(ctx).Get("123")
Expect(err).ToNot(HaveOccurred())
})
It("returns a seekable stream if format is 'raw'", func() {
s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "raw"})
s, err := streamer.NewStream(ctx, "123", "raw", 0, 0)
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a seekable stream if no format is specified (direct play)", func() {
s, err := streamer.NewStream(ctx, mf, stream.Request{})
It("returns a seekable stream if maxBitRate is 0", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 0, 0)
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a seekable stream if maxBitRate is higher than file bitRate", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 320, 0)
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a NON seekable stream if transcode is required", func() {
s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 64})
s, err := streamer.NewStream(ctx, "123", "mp3", 64, 0)
Expect(err).To(BeNil())
Expect(s.Seekable()).To(BeFalse())
Expect(s.Duration()).To(Equal(float32(257.0)))
})
It("returns a seekable stream if the file is complete in the cache", func() {
s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 32})
s, err := streamer.NewStream(ctx, "123", "mp3", 32, 0)
Expect(err).To(BeNil())
_, _ = io.ReadAll(s)
_ = s.Close()
Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
s, err = streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 32})
s, err = streamer.NewStream(ctx, "123", "mp3", 32, 0)
Expect(err).To(BeNil())
Expect(s.Seekable()).To(BeTrue())
})

View File

@ -193,14 +193,13 @@ 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.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

View File

@ -61,10 +61,9 @@ 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"`
EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"`
EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`

View File

@ -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) {

View File

@ -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(BeEmpty())
Expect(args).To(Equal([]string{}))
})
})
})

View File

@ -11,7 +11,6 @@ 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"
@ -43,7 +42,7 @@ var _ = Describe("Playlists - Import", func() {
var folder *model.Folder
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
ps = playlists.NewPlaylists(ds)
ds.MockedMediaFile = &mockedMediaFileRepo{}
libPath, _ := os.Getwd()
// Set up library with the actual library path that matches the folder
@ -118,7 +117,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, core.NewImageUploadService())
ps = playlists.NewPlaylists(ds)
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
@ -136,7 +135,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, core.NewImageUploadService())
ps = playlists.NewPlaylists(ds)
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
@ -155,7 +154,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, core.NewImageUploadService())
ps = playlists.NewPlaylists(ds)
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
@ -174,7 +173,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, core.NewImageUploadService())
ps = playlists.NewPlaylists(ds)
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
@ -191,7 +190,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, core.NewImageUploadService())
ps = playlists.NewPlaylists(ds)
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
@ -208,7 +207,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, core.NewImageUploadService())
ps = playlists.NewPlaylists(ds)
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
@ -225,7 +224,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, core.NewImageUploadService())
ps = playlists.NewPlaylists(ds)
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
@ -243,7 +242,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, core.NewImageUploadService())
ps = playlists.NewPlaylists(ds)
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
@ -257,7 +256,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, core.NewImageUploadService())
ps = playlists.NewPlaylists(ds)
m3u := "#EXTALBUMARTURL:https://example.com/new-cover.jpg\ntest.mp3\n"
plsFile := filepath.Join(tmpDir, "test.m3u")
@ -284,7 +283,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, core.NewImageUploadService())
ps = playlists.NewPlaylists(ds)
m3u := "test.mp3\n"
plsFile := filepath.Join(tmpDir, "test.m3u")
@ -359,7 +358,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, core.NewImageUploadService())
ps = playlists.NewPlaylists(ds)
// Create the playlist file on disk with the filesystem's normalization form
plsFile := tmpDir + "/" + filesystemName + ".m3u"
@ -419,7 +418,7 @@ var _ = Describe("Playlists - Import", func() {
"def.mp3", // This is playlists/def.mp3 relative to plsDir
},
}
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
ps = playlists.NewPlaylists(ds)
})
It("handles relative paths that reference files in other libraries", func() {
@ -575,7 +574,7 @@ var _ = Describe("Playlists - Import", func() {
},
}
// Recreate playlists service to pick up new mock
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
ps = playlists.NewPlaylists(ds)
// Create playlist in music library that references both tracks
plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3"
@ -618,7 +617,7 @@ var _ = Describe("Playlists - Import", func() {
BeforeEach(func() {
repo = &mockedMediaFileFromListRepo{}
ds.MockedMediaFile = repo
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
ps = playlists.NewPlaylists(ds)
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
ctx = request.WithUser(ctx, model.User{ID: "123"})
})

View File

@ -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 after, ok := strings.CutPrefix(line, "#PLAYLIST:"); ok {
pls.Name = after
if strings.HasPrefix(line, "#PLAYLIST:") {
pls.Name = line[len("#PLAYLIST:"):]
continue
}
if after, ok := strings.CutPrefix(line, "#EXTALBUMARTURL:"); ok {

View File

@ -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) {

View File

@ -122,21 +122,6 @@ var _ = Describe("parseNSP", func() {
Expect(pls.Name).To(Equal("Original"))
})
It("parses limitPercent from NSP", func() {
nsp := `{
"all": [{"is": {"loved": true}}],
"sort": "playCount",
"order": "desc",
"limitPercent": 25
}`
pls := &model.Playlist{}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
Expect(pls.Rules).ToNot(BeNil())
Expect(pls.Rules.LimitPercent).To(Equal(25))
Expect(pls.Rules.Limit).To(Equal(0))
})
It("parses criteria with multiple rules", func() {
nsp := `{
"all": [

View File

@ -2,6 +2,7 @@ package playlists
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
@ -11,7 +12,6 @@ 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,20 +50,12 @@ type Playlists interface {
TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository
}
// 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
}
type playlists struct {
ds model.DataStore
imgUpload ImageUploadService
ds model.DataStore
}
func NewPlaylists(ds model.DataStore, imgUpload ImageUploadService) Playlists {
return &playlists{ds: ds, imgUpload: imgUpload}
func NewPlaylists(ds model.DataStore) Playlists {
return &playlists{ds: ds}
}
func InPath(folder model.Folder) bool {
@ -296,13 +288,33 @@ func (s *playlists) SetImage(ctx context.Context, playlistID string, reader io.R
return err
}
filename := pls.ImageFilename(ext)
oldPath := pls.UploadedImagePath()
filename, err := s.imgUpload.SetImage(ctx, consts.EntityPlaylist, pls.ID, pls.Name, oldPath, reader, ext)
if err != nil {
return err
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)
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)
}
pls.UploadedImage = filename
return s.ds.Playlist(ctx).Put(pls)
}
@ -312,8 +324,10 @@ func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error {
return err
}
if err := s.imgUpload.RemoveImage(ctx, pls.UploadedImagePath()); err != nil {
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)
}
}
pls.UploadedImage = ""

View File

@ -8,7 +8,6 @@ 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 +41,7 @@ var _ = Describe("Playlists", func() {
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
ps = playlists.NewPlaylists(ds)
})
It("allows owner to delete their playlist", func() {
@ -81,7 +80,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, core.NewImageUploadService())
ps = playlists.NewPlaylists(ds)
})
It("creates a new playlist with owner set from context", func() {
@ -139,7 +138,7 @@ var _ = Describe("Playlists", func() {
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
ps = playlists.NewPlaylists(ds)
})
It("allows owner to update their playlist", func() {
@ -202,7 +201,7 @@ var _ = Describe("Playlists", func() {
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
ps = playlists.NewPlaylists(ds)
})
It("allows owner to add tracks", func() {
@ -250,7 +249,7 @@ var _ = Describe("Playlists", func() {
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
ps = playlists.NewPlaylists(ds)
})
It("allows owner to remove tracks", func() {
@ -284,7 +283,7 @@ var _ = Describe("Playlists", func() {
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
ps = playlists.NewPlaylists(ds)
})
It("allows owner to reorder", func() {
@ -313,7 +312,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, core.NewImageUploadService())
ps = playlists.NewPlaylists(ds)
})
It("saves image file and updates UploadedImage", func() {
@ -383,7 +382,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, core.NewImageUploadService())
ps = playlists.NewPlaylists(ds)
})
It("removes file and clears UploadedImage", func() {

View File

@ -58,16 +58,10 @@ func (s *playlists) TracksRepository(ctx context.Context, playlistId string, ref
}
// savePlaylist creates a new playlist, assigning the owner from context.
// Only Name, Comment, Public, and Rules are user-settable via the REST API.
func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (string, error) {
usr, _ := request.UserFrom(ctx)
pls.OwnerID = usr.ID
pls.ID = "" // Force new creation
pls.Path = "" // Server-managed (M3U file path)
pls.Sync = false // Server-managed (M3U sync flag)
pls.UploadedImage = "" // Managed by image upload endpoint
pls.ExternalImageURL = "" // Managed by M3U import / plugins only
pls.EvaluatedAt = nil // Server-managed
pls.ID = "" // Force new creation
err := s.ds.Playlist(ctx).Put(pls)
if err != nil {
return "", err
@ -97,7 +91,5 @@ 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)
}

View File

@ -2,13 +2,10 @@ package playlists_test
import (
"context"
"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"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
@ -37,7 +34,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, core.NewImageUploadService())
ps = playlists.NewPlaylists(ds)
})
Describe("Save", func() {
@ -59,38 +56,6 @@ var _ = Describe("REST Adapter", func() {
Expect(err).ToNot(HaveOccurred())
Expect(pls.ID).ToNot(Equal("should-be-cleared"))
})
It("clears server-managed fields to prevent injection via REST API", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
now := time.Now()
pls := &model.Playlist{
Name: "Legit Playlist",
Comment: "A comment",
Public: true,
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}},
Path: "/some/path/playlist.m3u",
Sync: true,
UploadedImage: "injected-image-path",
ExternalImageURL: "http://evil.example.com/ssrf",
EvaluatedAt: &now,
}
_, err := repo.Save(pls)
Expect(err).ToNot(HaveOccurred())
saved := mockPlsRepo.Last
// User-settable fields are preserved
Expect(saved.Name).To(Equal("Legit Playlist"))
Expect(saved.Comment).To(Equal("A comment"))
Expect(saved.Public).To(BeTrue())
Expect(saved.Rules).ToNot(BeNil())
// Server-managed fields are cleared
Expect(saved.Path).To(BeEmpty())
Expect(saved.Sync).To(BeFalse())
Expect(saved.UploadedImage).To(BeEmpty())
Expect(saved.ExternalImageURL).To(BeEmpty())
Expect(saved.EvaluatedAt).To(BeNil())
})
})
Describe("Update", func() {
@ -126,22 +91,6 @@ 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)

View File

@ -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 := nanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 10)
id, err := gonanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 10)
if err != nil {
return "", err
}

View File

@ -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, 500)
output := make(chan string, 500)
input := make(chan notify.EventInfo, 1)
output := make(chan string, 1)
started := make(chan struct{})
go func() {

View File

@ -284,9 +284,6 @@ func (ffs *FakeFS) parseFile(filePath string) (*metadata.Info, error) {
p.AudioProperties.BitDepth = getInt("bitdepth")
p.AudioProperties.SampleRate = getInt("samplerate")
p.AudioProperties.Channels = getInt("channels")
if codec, ok := data["codec"].(string); ok {
p.AudioProperties.Codec = codec
}
for k, v := range data {
p.Tags[k] = []string{fmt.Sprintf("%v", v)}
}

View File

@ -1,92 +0,0 @@
package stream
import (
"slices"
"strings"
)
// containerAliasGroups maps each container alias to a canonical group name.
var containerAliasGroups = func() map[string]string {
groups := [][]string{
{"aac", "adts", "m4a", "mp4", "m4b", "m4p"},
{"mpeg", "mp3", "mp2"},
{"ogg", "oga", "opus"},
{"aif", "aiff"},
{"asf", "wma"},
{"mpc", "mpp"},
{"wv"},
}
m := make(map[string]string)
for _, g := range groups {
canonical := g[0]
for _, name := range g {
m[name] = canonical
}
}
return m
}()
// codecAliasGroups maps each codec alias to a canonical group name.
// Codecs within the same group are considered equivalent.
var codecAliasGroups = func() map[string]string {
groups := [][]string{
{"aac", "adts"},
{"ac3", "ac-3"},
{"eac3", "e-ac3", "e-ac-3", "eac-3"},
{"mpc7", "musepack7"},
{"mpc8", "musepack8"},
{"wma1", "wmav1"},
{"wma2", "wmav2"},
{"wmalossless", "wma9lossless"},
{"wmapro", "wma9pro"},
{"shn", "shorten"},
{"mp4als", "als"},
}
m := make(map[string]string)
for _, g := range groups {
for _, name := range g {
m[name] = g[0] // canonical = first entry
}
}
return m
}()
// matchesWithAliases checks if a value matches any entry in candidates,
// consulting the alias map for equivalent names.
func matchesWithAliases(value string, candidates []string, aliases map[string]string) bool {
value = strings.ToLower(value)
canonical := aliases[value]
for _, c := range candidates {
c = strings.ToLower(c)
if c == value {
return true
}
if canonical != "" && aliases[c] == canonical {
return true
}
}
return false
}
// matchesContainer checks if a file suffix matches any of the container names,
// including common aliases.
func matchesContainer(suffix string, containers []string) bool {
return matchesWithAliases(suffix, containers, containerAliasGroups)
}
// matchesCodec checks if a codec matches any of the codec names,
// including common aliases.
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)
})
}

View File

@ -1,30 +0,0 @@
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())
})
})
})

View File

@ -1,77 +0,0 @@
package stream
import "strings"
// normalizeProbeCodec maps ffprobe codec_name values to the simplified internal
// codec names used throughout Navidrome (matching inferCodecFromSuffix output).
// Most ffprobe names match directly; this handles the exceptions.
func normalizeProbeCodec(codec string) string {
c := strings.ToLower(codec)
// DSD variants: dsd_lsbf_planar, dsd_msbf_planar, dsd_lsbf, dsd_msbf
if strings.HasPrefix(c, "dsd") {
return "dsd"
}
// PCM variants: pcm_s16le, pcm_s24le, pcm_s32be, pcm_f32le, etc.
if strings.HasPrefix(c, "pcm_") {
return "pcm"
}
return c
}
// isLosslessFormat returns true if the format is a known lossless audio codec/format.
// Detection is based on codec name only, not bit depth — some lossy codecs (e.g. ADPCM)
// report non-zero bits_per_sample in ffprobe, so bit depth alone is not a reliable signal.
//
// Note: core/ffmpeg has a separate isLosslessOutputFormat that covers only formats
// ffmpeg can produce as output (a smaller set).
func isLosslessFormat(format string) bool {
switch strings.ToLower(format) {
case "flac", "alac", "wav", "aiff", "ape", "wv", "wavpack", "tta", "tak", "shn", "dsd", "pcm":
return true
}
return false
}
// normalizeSourceSampleRate adjusts the source sample rate for codecs that store
// it differently than PCM. Currently handles DSD (÷8):
// DSD64=2822400→352800, DSD128=5644800→705600, etc.
// For other codecs, returns the rate unchanged.
func normalizeSourceSampleRate(sampleRate int, codec string) int {
if strings.EqualFold(codec, "dsd") && sampleRate > 0 {
return sampleRate / 8
}
return sampleRate
}
// normalizeSourceBitDepth adjusts the source bit depth for codecs that use
// non-standard bit depths. Currently handles DSD (1-bit → 24-bit PCM, which is
// what ffmpeg produces). For other codecs, returns the depth unchanged.
func normalizeSourceBitDepth(bitDepth int, codec string) int {
if strings.EqualFold(codec, "dsd") && bitDepth == 1 {
return 24
}
return bitDepth
}
// codecFixedOutputSampleRate returns the mandatory output sample rate for codecs
// that always resample regardless of input (e.g., Opus always outputs 48000Hz).
// Returns 0 if the codec has no fixed output rate.
func codecFixedOutputSampleRate(codec string) int {
switch strings.ToLower(codec) {
case "opus":
return 48000
}
return 0
}
// codecMaxSampleRate returns the hard maximum output sample rate for a codec.
// Returns 0 if the codec has no hard limit.
func codecMaxSampleRate(codec string) int {
switch strings.ToLower(codec) {
case "mp3":
return 48000
case "aac":
return 96000
}
return 0
}

View File

@ -1,69 +0,0 @@
package stream
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Codec", func() {
Describe("isLosslessFormat", func() {
It("returns true for known lossless codecs", func() {
Expect(isLosslessFormat("flac")).To(BeTrue())
Expect(isLosslessFormat("alac")).To(BeTrue())
Expect(isLosslessFormat("pcm")).To(BeTrue())
Expect(isLosslessFormat("wav")).To(BeTrue())
Expect(isLosslessFormat("dsd")).To(BeTrue())
Expect(isLosslessFormat("ape")).To(BeTrue())
Expect(isLosslessFormat("wv")).To(BeTrue())
Expect(isLosslessFormat("wavpack")).To(BeTrue()) // ffprobe codec_name for WavPack
})
It("returns false for lossy codecs", func() {
Expect(isLosslessFormat("mp3")).To(BeFalse())
Expect(isLosslessFormat("aac")).To(BeFalse())
Expect(isLosslessFormat("opus")).To(BeFalse())
Expect(isLosslessFormat("vorbis")).To(BeFalse())
})
It("returns false for unknown codecs", func() {
Expect(isLosslessFormat("unknown_codec")).To(BeFalse())
})
It("is case-insensitive", func() {
Expect(isLosslessFormat("FLAC")).To(BeTrue())
Expect(isLosslessFormat("Alac")).To(BeTrue())
})
})
Describe("normalizeProbeCodec", func() {
It("passes through common codec names unchanged", func() {
Expect(normalizeProbeCodec("mp3")).To(Equal("mp3"))
Expect(normalizeProbeCodec("aac")).To(Equal("aac"))
Expect(normalizeProbeCodec("flac")).To(Equal("flac"))
Expect(normalizeProbeCodec("opus")).To(Equal("opus"))
Expect(normalizeProbeCodec("vorbis")).To(Equal("vorbis"))
Expect(normalizeProbeCodec("alac")).To(Equal("alac"))
Expect(normalizeProbeCodec("wmav2")).To(Equal("wmav2"))
})
It("normalizes DSD variants to dsd", func() {
Expect(normalizeProbeCodec("dsd_lsbf_planar")).To(Equal("dsd"))
Expect(normalizeProbeCodec("dsd_msbf_planar")).To(Equal("dsd"))
Expect(normalizeProbeCodec("dsd_lsbf")).To(Equal("dsd"))
Expect(normalizeProbeCodec("dsd_msbf")).To(Equal("dsd"))
})
It("normalizes PCM variants to pcm", func() {
Expect(normalizeProbeCodec("pcm_s16le")).To(Equal("pcm"))
Expect(normalizeProbeCodec("pcm_s24le")).To(Equal("pcm"))
Expect(normalizeProbeCodec("pcm_s32be")).To(Equal("pcm"))
Expect(normalizeProbeCodec("pcm_f32le")).To(Equal("pcm"))
})
It("lowercases input", func() {
Expect(normalizeProbeCodec("MP3")).To(Equal("mp3"))
Expect(normalizeProbeCodec("AAC")).To(Equal("aac"))
Expect(normalizeProbeCodec("DSD_LSBF_PLANAR")).To(Equal("dsd"))
})
})
})

View File

@ -1,449 +0,0 @@
package stream
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
)
const fallbackBitrate = 256 // kbps
// TranscodeDecider is the core service interface for making transcoding decisions
type TranscodeDecider interface {
MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts TranscodeOptions) (*TranscodeDecision, error)
CreateTranscodeParams(decision *TranscodeDecision) (string, error)
ResolveRequestFromToken(ctx context.Context, token string, mf *model.MediaFile, offset int) (Request, error)
ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) Request
}
func NewTranscodeDecider(ds model.DataStore, ff ffmpeg.FFmpeg) TranscodeDecider {
return &deciderService{
ds: ds,
ff: ff,
}
}
type deciderService struct {
ds model.DataStore
ff ffmpeg.FFmpeg
}
func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts TranscodeOptions) (*TranscodeDecision, error) {
decision := &TranscodeDecision{
MediaID: mf.ID,
SourceUpdatedAt: mf.UpdatedAt,
}
var probe *ffmpeg.AudioProbeResult
if !opts.SkipProbe {
var err error
probe, err = s.ensureProbed(ctx, mf)
if err != nil {
return nil, err
}
}
// Build source stream details (uses probe data if available)
decision.SourceStream = buildSourceStream(mf, probe)
src := &decision.SourceStream
// Check for server-side player transcoding override
if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" {
clientInfo = applyServerOverride(ctx, clientInfo, &trc)
} else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 {
if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate {
modified := *clientInfo
modified.MaxAudioBitrate = player.MaxBitRate
clientInfo = &modified
log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name)
}
}
log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", src.Container,
"codec", src.Codec, "bitrate", src.Bitrate, "channels", src.Channels,
"sampleRate", src.SampleRate, "lossless", src.IsLossless, "client", clientInfo.Name)
// Check global bitrate constraint first.
if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate {
log.Trace(ctx, "Global bitrate constraint exceeded, skipping direct play",
"sourceBitrate", src.Bitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
decision.TranscodeReasons = append(decision.TranscodeReasons, "audio bitrate not supported")
// Skip direct play profiles entirely — global constraint fails
} else {
// Try direct play profiles, collecting reasons for each failure
for _, profile := range clientInfo.DirectPlayProfiles {
if reason := s.checkDirectPlayProfile(src, &profile, clientInfo); reason == "" {
decision.CanDirectPlay = true
decision.TranscodeReasons = nil // Clear any previously collected reasons
break
} else {
decision.TranscodeReasons = append(decision.TranscodeReasons, reason)
}
}
}
// If direct play is possible, we're done
if decision.CanDirectPlay {
log.Debug(ctx, "Transcode decision: direct play", "mediaID", mf.ID, "container", src.Container, "codec", src.Codec)
return decision, nil
}
// Try transcoding profiles (in order of preference)
for _, profile := range clientInfo.TranscodingProfiles {
if ts, transcodeFormat := s.computeTranscodedStream(ctx, src, &profile, clientInfo); ts != nil {
decision.CanTranscode = true
decision.TargetFormat = transcodeFormat
decision.TargetBitrate = ts.Bitrate
decision.TargetChannels = ts.Channels
decision.TargetSampleRate = ts.SampleRate
decision.TargetBitDepth = ts.BitDepth
decision.TranscodeStream = ts
break
}
}
if decision.CanTranscode {
log.Debug(ctx, "Transcode decision: transcode", "mediaID", mf.ID,
"targetFormat", decision.TargetFormat, "targetBitrate", decision.TargetBitrate,
"targetChannels", decision.TargetChannels, "reasons", decision.TranscodeReasons)
}
// If neither direct play nor transcode is possible
if !decision.CanDirectPlay && !decision.CanTranscode {
decision.ErrorReason = "no compatible playback profile found"
log.Warn(ctx, "Transcode decision: no compatible profile", "mediaID", mf.ID,
"container", src.Container, "codec", src.Codec, "reasons", decision.TranscodeReasons)
}
return decision, nil
}
func buildSourceStream(mf *model.MediaFile, probe *ffmpeg.AudioProbeResult) Details {
sd := Details{
Container: mf.Suffix,
Duration: mf.Duration,
Size: mf.Size,
}
// Use pre-parsed probe result, or fall back to parsing stored probe data
if probe == nil {
probe, _ = parseProbeData(mf.ProbeData)
}
// Use probe data if available for authoritative values
if probe != nil {
sd.Codec = normalizeProbeCodec(probe.Codec)
sd.Profile = probe.Profile
sd.Bitrate = probe.BitRate
sd.SampleRate = probe.SampleRate
sd.BitDepth = probe.BitDepth
sd.Channels = probe.Channels
} else {
sd.Codec = mf.AudioCodec()
sd.Bitrate = mf.BitRate
sd.SampleRate = mf.SampleRate
sd.BitDepth = mf.BitDepth
sd.Channels = mf.Channels
}
sd.IsLossless = isLosslessFormat(sd.Codec)
return sd
}
// applyServerOverride replaces the client-provided profiles with synthetic ones
// matching the server-forced transcoding format and bitrate.
func applyServerOverride(ctx context.Context, original *ClientInfo, trc *model.Transcoding) *ClientInfo {
maxBitRate := trc.DefaultBitRate
if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 {
maxBitRate = player.MaxBitRate
}
log.Debug(ctx, "Applying server-side transcoding override",
"targetFormat", trc.TargetFormat, "maxBitRate", maxBitRate,
"client", original.Name)
return &ClientInfo{
Name: original.Name,
Platform: original.Platform,
MaxAudioBitrate: maxBitRate,
MaxTranscodingAudioBitrate: maxBitRate,
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{trc.TargetFormat}, AudioCodecs: []string{trc.TargetFormat}, Protocols: []string{ProtocolHTTP}},
},
TranscodingProfiles: []Profile{
{Container: trc.TargetFormat, AudioCodec: trc.TargetFormat, Protocol: ProtocolHTTP},
},
}
}
func parseProbeData(data string) (*ffmpeg.AudioProbeResult, error) {
if data == "" {
return nil, nil
}
var result ffmpeg.AudioProbeResult
if err := json.Unmarshal([]byte(data), &result); err != nil {
return nil, err
}
return &result, nil
}
// checkDirectPlayProfile returns "" if the profile matches (direct play OK),
// or a typed reason string if it doesn't match.
func (s *deciderService) checkDirectPlayProfile(src *Details, profile *DirectPlayProfile, clientInfo *ClientInfo) string {
// Check protocol (only http for now)
if len(profile.Protocols) > 0 && !containsIgnoreCase(profile.Protocols, ProtocolHTTP) {
return "protocol not supported"
}
// Check container
if len(profile.Containers) > 0 && !matchesContainer(src.Container, profile.Containers) {
return "container not supported"
}
// Check codec
if len(profile.AudioCodecs) > 0 && !matchesCodec(src.Codec, profile.AudioCodecs) {
return "audio codec not supported"
}
// Check channels
if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels {
return "audio channels not supported"
}
// Check codec-specific limitations
for _, codecProfile := range clientInfo.CodecProfiles {
if strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) && matchesCodec(src.Codec, []string{codecProfile.Name}) {
if reason := checkLimitations(src, codecProfile.Limitations); reason != "" {
return reason
}
}
}
return ""
}
// computeTranscodedStream attempts to build a valid transcoded stream for the given profile.
// Returns the stream details and the internal transcoding format (which may differ from the
// response container when a codec fallback occurs, e.g., "mp4"→"aac").
// Returns nil, "" if the profile cannot produce a valid output.
func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Details, profile *Profile, clientInfo *ClientInfo) (*Details, string) {
// Check protocol (only http for now)
if profile.Protocol != "" && !strings.EqualFold(profile.Protocol, ProtocolHTTP) {
log.Trace(ctx, "Skipping transcoding profile: unsupported protocol", "protocol", profile.Protocol)
return nil, ""
}
responseContainer, targetFormat := resolveTargetFormat(profile)
if targetFormat == "" {
return nil, ""
}
// Verify we have a transcoding command available (DB custom or built-in default)
if LookupTranscodeCommand(ctx, s.ds, targetFormat) == "" {
log.Trace(ctx, "Skipping transcoding profile: no transcoding command available", "targetFormat", targetFormat)
return nil, ""
}
targetIsLossless := isLosslessFormat(targetFormat)
// Reject lossy to lossless conversion
if !src.IsLossless && targetIsLossless {
log.Trace(ctx, "Skipping transcoding profile: lossy to lossless not allowed", "targetFormat", targetFormat)
return nil, ""
}
ts := &Details{
Container: responseContainer,
Codec: strings.ToLower(profile.AudioCodec),
SampleRate: normalizeSourceSampleRate(src.SampleRate, src.Codec),
Channels: src.Channels,
BitDepth: normalizeSourceBitDepth(src.BitDepth, src.Codec),
IsLossless: targetIsLossless,
}
if ts.Codec == "" {
ts.Codec = targetFormat
}
// Apply codec-intrinsic sample rate adjustments before codec profile limitations
if fixedRate := codecFixedOutputSampleRate(ts.Codec); fixedRate > 0 {
ts.SampleRate = fixedRate
}
if maxRate := codecMaxSampleRate(ts.Codec); maxRate > 0 && ts.SampleRate > maxRate {
ts.SampleRate = maxRate
}
// Determine target bitrate (all in kbps)
if ok := s.computeBitrate(ctx, src, targetFormat, targetIsLossless, clientInfo, ts); !ok {
return nil, ""
}
// Apply MaxAudioChannels from the transcoding profile
if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels {
ts.Channels = profile.MaxAudioChannels
}
// Apply codec profile limitations to the TARGET codec
if ok := s.applyCodecLimitations(ctx, src.Bitrate, targetFormat, targetIsLossless, clientInfo, ts); !ok {
return nil, ""
}
return ts, targetFormat
}
// lookupDefaultBitrate returns the default bitrate for the given format.
// It checks the DB first (for user-customized values), then falls back to
// the built-in defaults, and finally to fallbackBitrate.
func lookupDefaultBitrate(ctx context.Context, ds model.DataStore, format string) int {
if t, err := ds.Transcoding(ctx).FindByFormat(format); err == nil && t.DefaultBitRate > 0 {
return t.DefaultBitRate
}
for _, dt := range consts.DefaultTranscodings {
if dt.TargetFormat == format && dt.DefaultBitRate > 0 {
return dt.DefaultBitRate
}
}
return fallbackBitrate
}
// LookupTranscodeCommand returns the ffmpeg command for the given format.
// It checks the DB first (for user-customized commands), then falls back to
// the built-in default command. Returns "" if the format is unknown.
func LookupTranscodeCommand(ctx context.Context, ds model.DataStore, format string) string {
t, err := ds.Transcoding(ctx).FindByFormat(format)
if err == nil && t.Command != "" {
return t.Command
}
// Fall back to built-in defaults
for _, dt := range consts.DefaultTranscodings {
if dt.TargetFormat == format {
return dt.Command
}
}
return ""
}
// resolveTargetFormat determines the response container and internal target format
// from the profile's Container and AudioCodec fields. When an AudioCodec is specified
// it is preferred as targetFormat (e.g. container "mp4" with audioCodec "aac" → targetFormat "aac").
func resolveTargetFormat(profile *Profile) (responseContainer, targetFormat string) {
responseContainer = strings.ToLower(profile.Container)
targetFormat = responseContainer
// Prefer the audioCodec as targetFormat when provided (handles container-to-codec
// mapping like "mp4" → "aac", "ogg" → "opus").
if profile.AudioCodec != "" {
targetFormat = strings.ToLower(profile.AudioCodec)
}
// If neither container nor audioCodec is set, we can't resolve a format.
if targetFormat == "" {
return "", ""
}
// When no container was specified, use the targetFormat as container too.
if responseContainer == "" {
responseContainer = targetFormat
}
return responseContainer, targetFormat
}
// computeBitrate determines the target bitrate for the transcoded stream.
// Returns false if the profile should be rejected.
func (s *deciderService) computeBitrate(ctx context.Context, src *Details, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *Details) bool {
if src.IsLossless {
if !targetIsLossless {
if clientInfo.MaxTranscodingAudioBitrate > 0 {
ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate
} else if clientInfo.MaxAudioBitrate > 0 {
ts.Bitrate = clientInfo.MaxAudioBitrate
} else {
ts.Bitrate = lookupDefaultBitrate(ctx, s.ds, targetFormat)
}
} else {
if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate {
log.Trace(ctx, "Skipping transcoding profile: lossless target exceeds bitrate limit",
"targetFormat", targetFormat, "sourceBitrate", src.Bitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
return false
}
}
} else {
ts.Bitrate = src.Bitrate
}
// Apply maxAudioBitrate as final cap
if clientInfo.MaxAudioBitrate > 0 && ts.Bitrate > 0 && ts.Bitrate > clientInfo.MaxAudioBitrate {
ts.Bitrate = clientInfo.MaxAudioBitrate
}
return true
}
// applyCodecLimitations applies codec profile limitations to the transcoded stream.
// Returns false if the profile should be rejected.
func (s *deciderService) applyCodecLimitations(ctx context.Context, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *Details) bool {
targetCodec := ts.Codec
for _, codecProfile := range clientInfo.CodecProfiles {
if !strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) {
continue
}
if !matchesCodec(targetCodec, []string{codecProfile.Name}) {
continue
}
for _, lim := range codecProfile.Limitations {
result := applyLimitation(sourceBitrate, &lim, ts)
if strings.EqualFold(lim.Name, LimitationAudioBitrate) && targetIsLossless && result == adjustAdjusted {
log.Trace(ctx, "Skipping transcoding profile: cannot adjust bitrate for lossless target",
"targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name)
return false
}
if result == adjustCannotFit {
log.Trace(ctx, "Skipping transcoding profile: codec limitation cannot be satisfied",
"targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name,
"comparison", lim.Comparison, "values", lim.Values)
return false
}
}
}
return true
}
// ensureProbed runs ffprobe if probe data is missing, persists it, and returns
// the parsed result. Returns (nil, nil) when probing is skipped or data already exists
// (in which case the caller should parse mf.ProbeData).
func (s *deciderService) ensureProbed(ctx context.Context, mf *model.MediaFile) (*ffmpeg.AudioProbeResult, error) {
if mf.ProbeData != "" {
return nil, nil
}
if !conf.Server.DevEnableMediaFileProbe {
return nil, nil
}
result, err := s.ff.ProbeAudioStream(ctx, mf.AbsolutePath())
if err != nil {
return nil, fmt.Errorf("probing media file %s: %w", mf.ID, err)
}
data, err := json.Marshal(result)
if err != nil {
return nil, fmt.Errorf("marshaling probe result for %s: %w", mf.ID, err)
}
mf.ProbeData = string(data)
if err := s.ds.MediaFile(ctx).UpdateProbeData(mf.ID, mf.ProbeData); err != nil {
log.Error(ctx, "Failed to persist probe data", "mediaID", mf.ID, err)
// Don't fail the decision — we have the data in memory
}
log.Debug(ctx, "Probed media file", "mediaID", mf.ID, "codec", result.Codec,
"profile", result.Profile, "bitRate", result.BitRate,
"sampleRate", result.SampleRate, "bitDepth", result.BitDepth, "channels", result.Channels)
return result, nil
}

File diff suppressed because it is too large Load Diff

View File

@ -1,101 +0,0 @@
package stream
import (
"context"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
// buildLegacyClientInfo translates legacy Subsonic stream/download parameters
// into a ClientInfo for use with MakeDecision.
// It does NOT read request.TranscodingFrom(ctx) — that is handled by
// MakeDecision's applyServerOverride.
func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *ClientInfo {
ci := &ClientInfo{Name: "legacy"}
// Determine target format for transcoding
var targetFormat string
switch {
case reqFormat != "":
targetFormat = reqFormat
case reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "":
targetFormat = conf.Server.DefaultDownsamplingFormat
}
if targetFormat != "" {
// Add a direct play profile for the source format when no explicit
// format was requested (bitrate-only downsampling) or when the
// requested format matches the source. When the client explicitly
// requests a different format, direct play must not match the
// source — otherwise the source is returned untranscoded.
if reqFormat == "" || strings.EqualFold(reqFormat, mf.Suffix) {
ci.DirectPlayProfiles = []DirectPlayProfile{
{Containers: []string{mf.Suffix}, AudioCodecs: []string{mf.AudioCodec()}, Protocols: []string{ProtocolHTTP}},
}
}
ci.TranscodingProfiles = []Profile{
{Container: targetFormat, AudioCodec: targetFormat, Protocol: ProtocolHTTP},
}
if reqBitRate > 0 {
ci.MaxAudioBitrate = reqBitRate
ci.MaxTranscodingAudioBitrate = reqBitRate
}
} else {
// No transcoding requested — direct play everything
ci.DirectPlayProfiles = []DirectPlayProfile{
{Protocols: []string{ProtocolHTTP}},
}
}
return ci
}
// ResolveRequest uses MakeDecision to resolve legacy Subsonic stream parameters
// into a fully specified Request.
func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) Request {
var req Request
req.Offset = offset
if reqFormat == "raw" {
req.Format = "raw"
return req
}
clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate)
decision, err := s.MakeDecision(ctx, mf, clientInfo, TranscodeOptions{SkipProbe: true})
if err != nil {
log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err)
req.Format = "raw"
return req
}
if decision.CanDirectPlay {
req.Format = "raw"
return req
}
if decision.CanTranscode {
req.Format = decision.TargetFormat
req.BitRate = decision.TargetBitrate
req.SampleRate = decision.TargetSampleRate
req.BitDepth = decision.TargetBitDepth
req.Channels = decision.TargetChannels
return req
}
// 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
}

View File

@ -1,240 +0,0 @@
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"
)
var _ = Describe("buildLegacyClientInfo", func() {
var mf *model.MediaFile
BeforeEach(func() {
mf = &model.MediaFile{Suffix: "flac", BitRate: 960}
})
It("sets transcoding profile for explicit format without bitrate", func() {
ci := buildLegacyClientInfo(mf, "mp3", 0)
Expect(ci.Name).To(Equal("legacy"))
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3"))
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("mp3"))
Expect(ci.TranscodingProfiles[0].Protocol).To(Equal(ProtocolHTTP))
Expect(ci.MaxAudioBitrate).To(BeZero())
Expect(ci.MaxTranscodingAudioBitrate).To(BeZero())
Expect(ci.DirectPlayProfiles).To(BeEmpty())
})
It("does not add direct play profile when explicit format differs from source (no bitrate)", func() {
ci := buildLegacyClientInfo(mf, "opus", 0)
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus"))
Expect(ci.DirectPlayProfiles).To(BeEmpty())
})
It("adds direct play profile when explicit format matches source format", func() {
ci := buildLegacyClientInfo(mf, "flac", 0)
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("flac"))
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"}))
Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(Equal([]string{mf.AudioCodec()}))
})
It("sets transcoding profile and bitrate for explicit format with bitrate", func() {
ci := buildLegacyClientInfo(mf, "mp3", 192)
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3"))
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("mp3"))
Expect(ci.MaxAudioBitrate).To(Equal(192))
Expect(ci.MaxTranscodingAudioBitrate).To(Equal(192))
Expect(ci.DirectPlayProfiles).To(BeEmpty())
})
It("returns direct play profile when no format and no bitrate", func() {
ci := buildLegacyClientInfo(mf, "", 0)
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(BeEmpty())
Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{ProtocolHTTP}))
Expect(ci.TranscodingProfiles).To(BeEmpty())
Expect(ci.MaxAudioBitrate).To(BeZero())
})
It("uses default downsampling format for bitrate-only downsampling", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
ci := buildLegacyClientInfo(mf, "", 128)
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus"))
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
Expect(ci.TranscodingProfiles[0].Protocol).To(Equal(ProtocolHTTP))
Expect(ci.MaxAudioBitrate).To(Equal(128))
Expect(ci.MaxTranscodingAudioBitrate).To(Equal(128))
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"}))
Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(Equal([]string{mf.AudioCodec()}))
})
It("returns direct play when bitrate >= source bitrate", func() {
ci := buildLegacyClientInfo(mf, "", 960)
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(BeEmpty())
Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{ProtocolHTTP}))
Expect(ci.TranscodingProfiles).To(BeEmpty())
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))
})
})
})

View File

@ -1,171 +0,0 @@
package stream
import (
"strconv"
"strings"
)
// adjustResult represents the outcome of applying a limitation to a transcoded stream value
type adjustResult int
const (
adjustNone adjustResult = iota // Value already satisfies the limitation
adjustAdjusted // Value was changed to fit the limitation
adjustCannotFit // Cannot satisfy the limitation (reject this profile)
)
// checkLimitations checks codec profile limitations against source stream details.
// Returns "" if all limitations pass, or a typed reason string for the first failure.
func checkLimitations(src *Details, limitations []Limitation) string {
for _, lim := range limitations {
var ok bool
var reason string
switch lim.Name {
case LimitationAudioChannels:
ok = checkIntLimitation(src.Channels, lim.Comparison, lim.Values)
reason = "audio channels not supported"
case LimitationAudioSamplerate:
ok = checkIntLimitation(src.SampleRate, lim.Comparison, lim.Values)
reason = "audio samplerate not supported"
case LimitationAudioBitrate:
ok = checkIntLimitation(src.Bitrate, lim.Comparison, lim.Values)
reason = "audio bitrate not supported"
case LimitationAudioBitdepth:
ok = checkIntLimitation(src.BitDepth, lim.Comparison, lim.Values)
reason = "audio bitdepth not supported"
case LimitationAudioProfile:
ok = checkStringLimitation(src.Profile, lim.Comparison, lim.Values)
reason = "audio profile not supported"
default:
continue
}
if !ok && lim.Required {
return reason
}
}
return ""
}
// applyLimitation adjusts a transcoded stream parameter to satisfy the limitation.
// Returns the adjustment result.
func applyLimitation(sourceBitrate int, lim *Limitation, ts *Details) adjustResult {
switch lim.Name {
case LimitationAudioChannels:
return applyIntLimitation(lim.Comparison, lim.Values, ts.Channels, func(v int) { ts.Channels = v })
case LimitationAudioBitrate:
current := ts.Bitrate
if current == 0 {
current = sourceBitrate
}
return applyIntLimitation(lim.Comparison, lim.Values, current, func(v int) { ts.Bitrate = v })
case LimitationAudioSamplerate:
return applyIntLimitation(lim.Comparison, lim.Values, ts.SampleRate, func(v int) { ts.SampleRate = v })
case LimitationAudioBitdepth:
if ts.BitDepth > 0 {
return applyIntLimitation(lim.Comparison, lim.Values, ts.BitDepth, func(v int) { ts.BitDepth = v })
}
case LimitationAudioProfile:
// TODO: implement when audio profile data is available
}
return adjustNone
}
// applyIntLimitation applies a limitation comparison to a value.
// If the value needs adjusting, calls the setter and returns the result.
func applyIntLimitation(comparison string, values []string, current int, setter func(int)) adjustResult {
if len(values) == 0 {
return adjustNone
}
switch comparison {
case ComparisonLessThanEqual:
limit, ok := parseInt(values[0])
if !ok {
return adjustNone
}
if current <= limit {
return adjustNone
}
setter(limit)
return adjustAdjusted
case ComparisonGreaterThanEqual:
limit, ok := parseInt(values[0])
if !ok {
return adjustNone
}
if current >= limit {
return adjustNone
}
// Cannot upscale
return adjustCannotFit
case ComparisonEquals:
// Check if current value matches any allowed value
for _, v := range values {
if limit, ok := parseInt(v); ok && current == limit {
return adjustNone
}
}
// Find the closest allowed value below current (don't upscale)
var closest int
found := false
for _, v := range values {
if limit, ok := parseInt(v); ok && limit < current {
if !found || limit > closest {
closest = limit
found = true
}
}
}
if found {
setter(closest)
return adjustAdjusted
}
return adjustCannotFit
case ComparisonNotEquals:
for _, v := range values {
if limit, ok := parseInt(v); ok && current == limit {
return adjustCannotFit
}
}
return adjustNone
}
return adjustNone
}
func checkIntLimitation(value int, comparison string, values []string) bool {
return applyIntLimitation(comparison, values, value, func(int) {}) == adjustNone
}
// checkStringLimitation checks a string value against a limitation.
// Only Equals and NotEquals comparisons are meaningful for strings.
// LessThanEqual/GreaterThanEqual are not applicable and always pass.
func checkStringLimitation(value string, comparison string, values []string) bool {
switch comparison {
case ComparisonEquals:
for _, v := range values {
if strings.EqualFold(value, v) {
return true
}
}
return false
case ComparisonNotEquals:
for _, v := range values {
if strings.EqualFold(value, v) {
return false
}
}
return true
}
return true
}
func parseInt(s string) (int, bool) {
v, err := strconv.Atoi(s)
if err != nil || v < 0 {
return 0, false
}
return v, true
}

View File

@ -1,257 +0,0 @@
package stream
import (
"context"
"fmt"
"io"
"mime"
"net/http"
"os"
"strconv"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"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 {
NewStream(ctx context.Context, mf *model.MediaFile, req Request) (*Stream, error)
}
type TranscodingCache cache.FileCache
func NewMediaStreamer(ds model.DataStore, t ffmpeg.FFmpeg, cache TranscodingCache) MediaStreamer {
return &mediaStreamer{ds: ds, transcoder: t, cache: cache}
}
type mediaStreamer struct {
ds model.DataStore
transcoder ffmpeg.FFmpeg
cache cache.FileCache
}
type streamJob struct {
ms *mediaStreamer
mf *model.MediaFile
filePath string
format string
bitRate int
sampleRate int
bitDepth int
channels int
offset int
}
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
var cached bool
defer func() {
log.Info(ctx, "Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached,
"bitRate", bitRate, "sampleRate", req.SampleRate, "bitDepth", req.BitDepth, "channels", req.Channels,
"user", userName(ctx), "transcoding", format != "raw",
"originalFormat", mf.Suffix, "originalBitRate", mf.BitRate)
}()
format = req.Format
bitRate = req.BitRate
if format == "" || format == "raw" {
format = "raw"
bitRate = 0
}
s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
filePath := mf.AbsolutePath()
if format == "raw" {
log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", filePath,
"requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format)
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
s.ReadCloser = f
s.Seeker = f
s.format = mf.Suffix
return s, nil
}
job := &streamJob{
ms: ms,
mf: mf,
filePath: filePath,
format: format,
bitRate: bitRate,
sampleRate: req.SampleRate,
bitDepth: req.BitDepth,
channels: req.Channels,
offset: req.Offset,
}
r, err := ms.cache.Get(ctx, job)
if err != nil {
log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err)
return nil, err
}
cached = r.Cached
s.ReadCloser = r
s.Seeker = r.Seeker
log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", filePath,
"requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
return s, nil
}
type Stream struct {
ctx context.Context
mf *model.MediaFile
bitRate int
format string
io.ReadCloser
io.Seeker
}
func (s *Stream) Seekable() bool { return s.Seeker != nil }
func (s *Stream) Duration() float32 { return s.mf.Duration }
func (s *Stream) ContentType() string { return mime.TypeByExtension("." + s.format) }
func (s *Stream) Name() string { return s.mf.Title + "." + s.format }
func (s *Stream) ModTime() time.Time { return s.mf.UpdatedAt }
func (s *Stream) EstimatedContentLength() int {
return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
}
// 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: r,
}
}
var (
onceTranscodingCache sync.Once
instanceTranscodingCache TranscodingCache
)
func GetTranscodingCache() TranscodingCache {
onceTranscodingCache.Do(func() {
instanceTranscodingCache = NewTranscodingCache()
})
return instanceTranscodingCache
}
func NewTranscodingCache() TranscodingCache {
return cache.NewFileCache("Transcoding", conf.Server.TranscodingCacheSize,
consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems,
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
job := arg.(*streamJob)
command := LookupTranscodeCommand(ctx, job.ms.ds, job.format)
if command == "" {
log.Error(ctx, "No transcoding command available", "format", job.format)
return nil, os.ErrInvalid
}
// Choose the appropriate context based on EnableTranscodingCancellation configuration.
// This is where we decide whether transcoding processes should be cancellable or not.
var transcodingCtx context.Context
if conf.Server.EnableTranscodingCancellation {
// Use the request context directly, allowing cancellation when client disconnects
transcodingCtx = ctx
} else {
// Use background context with request values preserved.
// This prevents cancellation but maintains request metadata (user, client, etc.)
transcodingCtx = request.AddValues(context.Background(), ctx)
}
out, err := job.ms.transcoder.Transcode(transcodingCtx, ffmpeg.TranscodeOptions{
Command: command,
Format: job.format,
FilePath: job.filePath,
BitRate: job.bitRate,
SampleRate: job.sampleRate,
BitDepth: job.bitDepth,
Channels: job.channels,
Offset: job.offset,
})
if err != nil {
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
return nil, os.ErrInvalid
}
return out, nil
})
}
// userName extracts the username from the context for logging purposes.
func userName(ctx context.Context) string {
if user, ok := request.UserFrom(ctx); !ok {
return "UNKNOWN"
} else {
return user.UserName
}
}

View File

@ -1,148 +0,0 @@
package stream
import (
"context"
"errors"
"fmt"
"time"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
const tokenTTL = 48 * time.Hour
// params contains the parameters extracted from a transcode token.
// TargetBitrate is in kilobits per second (kbps).
type params struct {
MediaID string
DirectPlay bool
TargetFormat string
TargetBitrate int
TargetChannels int
TargetSampleRate int
TargetBitDepth int
SourceUpdatedAt time.Time
}
// toClaimsMap converts a Decision into a JWT claims map for token encoding.
// Only non-zero transcode fields are included.
func (d *TranscodeDecision) toClaimsMap() map[string]any {
m := map[string]any{
"mid": d.MediaID,
"ua": d.SourceUpdatedAt.Truncate(time.Second).Unix(),
jwt.ExpirationKey: time.Now().Add(tokenTTL).UTC().Unix(),
}
if d.CanDirectPlay {
m["dp"] = true
}
if d.CanTranscode && d.TargetFormat != "" {
m["f"] = d.TargetFormat
if d.TargetBitrate != 0 {
m["b"] = d.TargetBitrate
}
if d.TargetChannels != 0 {
m["ch"] = d.TargetChannels
}
if d.TargetSampleRate != 0 {
m["sr"] = d.TargetSampleRate
}
if d.TargetBitDepth != 0 {
m["bd"] = d.TargetBitDepth
}
}
return m
}
// paramsFromToken extracts and validates Params from a parsed JWT token.
// Returns an error if required claims (media ID, source timestamp) are missing.
func paramsFromToken(token jwt.Token) (*params, error) {
var p params
var mid string
if err := token.Get("mid", &mid); err == nil {
p.MediaID = mid
}
if p.MediaID == "" {
return nil, fmt.Errorf("%w: missing media ID", ErrTokenInvalid)
}
var dp bool
if err := token.Get("dp", &dp); err == nil {
p.DirectPlay = dp
}
ua := getIntClaim(token, "ua")
if ua != 0 {
p.SourceUpdatedAt = time.Unix(int64(ua), 0)
}
if p.SourceUpdatedAt.IsZero() {
return nil, fmt.Errorf("%w: missing source timestamp", ErrTokenInvalid)
}
var f string
if err := token.Get("f", &f); err == nil {
p.TargetFormat = f
}
p.TargetBitrate = getIntClaim(token, "b")
p.TargetChannels = getIntClaim(token, "ch")
p.TargetSampleRate = getIntClaim(token, "sr")
p.TargetBitDepth = getIntClaim(token, "bd")
return &p, nil
}
// getIntClaim extracts an int claim from a JWT token, handling the case where
// the value may be stored as int64 or float64 (common in JSON-based JWT libraries).
func getIntClaim(token jwt.Token, key string) int {
var v int
if err := token.Get(key, &v); err == nil {
return v
}
var v64 int64
if err := token.Get(key, &v64); err == nil {
return int(v64)
}
var f float64
if err := token.Get(key, &f); err == nil {
return int(f)
}
return 0
}
func (s *deciderService) CreateTranscodeParams(decision *TranscodeDecision) (string, error) {
return auth.EncodeToken(decision.toClaimsMap())
}
func (s *deciderService) parseTranscodeParams(tokenStr string) (*params, error) {
token, err := auth.DecodeAndVerifyToken(tokenStr)
if err != nil {
return nil, err
}
return paramsFromToken(token)
}
func (s *deciderService) ResolveRequestFromToken(ctx context.Context, token string, mf *model.MediaFile, offset int) (Request, error) {
p, err := s.parseTranscodeParams(token)
if err != nil {
return Request{}, errors.Join(ErrTokenInvalid, err)
}
if p.MediaID != mf.ID {
return Request{}, fmt.Errorf("%w: token mediaID %q does not match %q", ErrTokenInvalid, p.MediaID, mf.ID)
}
if !mf.UpdatedAt.Truncate(time.Second).Equal(p.SourceUpdatedAt) {
log.Info(ctx, "Transcode token is stale", "mediaID", mf.ID,
"tokenUpdatedAt", p.SourceUpdatedAt, "fileUpdatedAt", mf.UpdatedAt)
return Request{}, ErrTokenStale
}
req := Request{Offset: offset}
if !p.DirectPlay && p.TargetFormat != "" {
req.Format = p.TargetFormat
req.BitRate = p.TargetBitrate
req.SampleRate = p.TargetSampleRate
req.BitDepth = p.TargetBitDepth
req.Channels = p.TargetChannels
}
return req, nil
}

View File

@ -1,256 +0,0 @@
package stream
import (
"context"
"time"
"github.com/go-chi/jwtauth/v5"
"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"
)
var _ = Describe("Token", func() {
var (
ds *tests.MockDataStore
ff *tests.MockFFmpeg
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)
})
Describe("Token round-trip", func() {
var (
sourceTime time.Time
impl *deciderService
)
BeforeEach(func() {
sourceTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC)
impl = svc.(*deciderService)
})
It("creates and parses a direct play token", func() {
decision := &TranscodeDecision{
MediaID: "media-123",
CanDirectPlay: true,
SourceUpdatedAt: sourceTime,
}
token, err := svc.CreateTranscodeParams(decision)
Expect(err).ToNot(HaveOccurred())
Expect(token).ToNot(BeEmpty())
params, err := impl.parseTranscodeParams(token)
Expect(err).ToNot(HaveOccurred())
Expect(params.MediaID).To(Equal("media-123"))
Expect(params.DirectPlay).To(BeTrue())
Expect(params.TargetFormat).To(BeEmpty())
Expect(params.SourceUpdatedAt.Unix()).To(Equal(sourceTime.Unix()))
})
It("creates and parses a transcode token with kbps bitrate", func() {
decision := &TranscodeDecision{
MediaID: "media-456",
CanDirectPlay: false,
CanTranscode: true,
TargetFormat: "mp3",
TargetBitrate: 256, // kbps
TargetChannels: 2,
SourceUpdatedAt: sourceTime,
}
token, err := svc.CreateTranscodeParams(decision)
Expect(err).ToNot(HaveOccurred())
params, err := impl.parseTranscodeParams(token)
Expect(err).ToNot(HaveOccurred())
Expect(params.MediaID).To(Equal("media-456"))
Expect(params.DirectPlay).To(BeFalse())
Expect(params.TargetFormat).To(Equal("mp3"))
Expect(params.TargetBitrate).To(Equal(256)) // kbps
Expect(params.TargetChannels).To(Equal(2))
Expect(params.SourceUpdatedAt.Unix()).To(Equal(sourceTime.Unix()))
})
It("creates and parses a transcode token with sample rate", func() {
decision := &TranscodeDecision{
MediaID: "media-789",
CanDirectPlay: false,
CanTranscode: true,
TargetFormat: "flac",
TargetBitrate: 0,
TargetChannels: 2,
TargetSampleRate: 48000,
SourceUpdatedAt: sourceTime,
}
token, err := svc.CreateTranscodeParams(decision)
Expect(err).ToNot(HaveOccurred())
params, err := impl.parseTranscodeParams(token)
Expect(err).ToNot(HaveOccurred())
Expect(params.MediaID).To(Equal("media-789"))
Expect(params.DirectPlay).To(BeFalse())
Expect(params.TargetFormat).To(Equal("flac"))
Expect(params.TargetSampleRate).To(Equal(48000))
Expect(params.TargetChannels).To(Equal(2))
})
It("creates and parses a transcode token with bit depth", func() {
decision := &TranscodeDecision{
MediaID: "media-bd",
CanDirectPlay: false,
CanTranscode: true,
TargetFormat: "flac",
TargetBitrate: 0,
TargetChannels: 2,
TargetBitDepth: 24,
SourceUpdatedAt: sourceTime,
}
token, err := svc.CreateTranscodeParams(decision)
Expect(err).ToNot(HaveOccurred())
params, err := impl.parseTranscodeParams(token)
Expect(err).ToNot(HaveOccurred())
Expect(params.MediaID).To(Equal("media-bd"))
Expect(params.TargetBitDepth).To(Equal(24))
})
It("omits bit depth from token when 0", func() {
decision := &TranscodeDecision{
MediaID: "media-nobd",
CanDirectPlay: false,
CanTranscode: true,
TargetFormat: "mp3",
TargetBitrate: 256,
TargetBitDepth: 0,
SourceUpdatedAt: sourceTime,
}
token, err := svc.CreateTranscodeParams(decision)
Expect(err).ToNot(HaveOccurred())
params, err := impl.parseTranscodeParams(token)
Expect(err).ToNot(HaveOccurred())
Expect(params.TargetBitDepth).To(Equal(0))
})
It("omits sample rate from token when 0", func() {
decision := &TranscodeDecision{
MediaID: "media-100",
CanDirectPlay: false,
CanTranscode: true,
TargetFormat: "mp3",
TargetBitrate: 256,
TargetSampleRate: 0,
SourceUpdatedAt: sourceTime,
}
token, err := svc.CreateTranscodeParams(decision)
Expect(err).ToNot(HaveOccurred())
params, err := impl.parseTranscodeParams(token)
Expect(err).ToNot(HaveOccurred())
Expect(params.TargetSampleRate).To(Equal(0))
})
It("truncates SourceUpdatedAt to seconds", func() {
timeWithNanos := time.Date(2025, 6, 15, 10, 30, 0, 123456789, time.UTC)
decision := &TranscodeDecision{
MediaID: "media-trunc",
CanDirectPlay: true,
SourceUpdatedAt: timeWithNanos,
}
token, err := svc.CreateTranscodeParams(decision)
Expect(err).ToNot(HaveOccurred())
params, err := impl.parseTranscodeParams(token)
Expect(err).ToNot(HaveOccurred())
Expect(params.SourceUpdatedAt.Unix()).To(Equal(timeWithNanos.Truncate(time.Second).Unix()))
})
It("rejects an invalid token", func() {
_, err := impl.parseTranscodeParams("invalid-token")
Expect(err).To(HaveOccurred())
})
})
Describe("ResolveRequestFromToken", func() {
var sourceTime time.Time
BeforeEach(func() {
sourceTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC)
})
createTokenForMedia := func(mediaID string, updatedAt time.Time) string {
decision := &TranscodeDecision{
MediaID: mediaID,
CanDirectPlay: true,
SourceUpdatedAt: updatedAt,
}
token, err := svc.CreateTranscodeParams(decision)
Expect(err).ToNot(HaveOccurred())
return token
}
It("returns stream request for valid token", func() {
mf := &model.MediaFile{ID: "song-1", UpdatedAt: sourceTime}
token := createTokenForMedia("song-1", sourceTime)
req, err := svc.ResolveRequestFromToken(ctx, token, mf, 0)
Expect(err).ToNot(HaveOccurred())
Expect(req.Format).To(BeEmpty()) // direct play has no target format
})
It("returns ErrTokenInvalid for invalid token", func() {
mf := &model.MediaFile{ID: "song-1", UpdatedAt: sourceTime}
_, err := svc.ResolveRequestFromToken(ctx, "bad-token", mf, 0)
Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error())))
})
It("returns ErrTokenInvalid when mediaID does not match token", func() {
mf := &model.MediaFile{ID: "song-2", UpdatedAt: sourceTime}
token := createTokenForMedia("song-1", sourceTime)
_, err := svc.ResolveRequestFromToken(ctx, token, mf, 0)
Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error())))
})
It("returns ErrTokenStale when media file has changed", func() {
newTime := sourceTime.Add(1 * time.Hour)
mf := &model.MediaFile{ID: "song-1", UpdatedAt: newTime}
token := createTokenForMedia("song-1", sourceTime)
_, err := svc.ResolveRequestFromToken(ctx, token, mf, 0)
Expect(err).To(MatchError(ErrTokenStale))
})
})
Describe("paramsFromToken", func() {
It("returns error when media ID is missing", func() {
tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
token, _, err := tokenAuth.Encode(map[string]any{"ua": int64(1700000000)})
Expect(err).NotTo(HaveOccurred())
_, err = paramsFromToken(token)
Expect(err).To(MatchError(ContainSubstring("missing media ID")))
})
It("returns error when source timestamp is missing", func() {
tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
token, _, err := tokenAuth.Encode(map[string]any{"mid": "song-5"})
Expect(err).NotTo(HaveOccurred())
_, err = paramsFromToken(token)
Expect(err).To(MatchError(ContainSubstring("missing source timestamp")))
})
})
})

View File

@ -1,132 +0,0 @@
package stream
import (
"errors"
"time"
)
var (
ErrTokenInvalid = errors.New("invalid or expired transcode token")
ErrTokenStale = errors.New("transcode token is stale: media file has changed")
)
// TranscodeOptions controls optional behavior of MakeTranscodeDecision.
type TranscodeOptions struct {
// SkipProbe prevents MakeTranscodeDecision from running ffprobe on the media file.
// When true, source stream details are derived from tag metadata only.
SkipProbe bool
}
// Request contains the resolved parameters for creating a media stream.
type Request struct {
Format string
BitRate int // kbps
SampleRate int
BitDepth int
Channels int
Offset int // seconds
}
// ClientInfo represents client playback capabilities.
// All bitrate values are in kilobits per second (kbps)
type ClientInfo struct {
Name string
Platform string
MaxAudioBitrate int
MaxTranscodingAudioBitrate int
DirectPlayProfiles []DirectPlayProfile
TranscodingProfiles []Profile
CodecProfiles []CodecProfile
}
// DirectPlayProfile describes a format the client can play directly
type DirectPlayProfile struct {
Containers []string
AudioCodecs []string
Protocols []string
MaxAudioChannels int
}
// Profile describes a transcoding target the client supports
type Profile struct {
Container string
AudioCodec string
Protocol string
MaxAudioChannels int
}
// CodecProfile describes codec-specific limitations
type CodecProfile struct {
Type string
Name string
Limitations []Limitation
}
// Limitation describes a specific codec limitation
type Limitation struct {
Name string
Comparison string
Values []string
Required bool
}
// Protocol values (OpenSubsonic spec enum)
const (
ProtocolHTTP = "http"
ProtocolHLS = "hls"
)
// Comparison operators (OpenSubsonic spec enum)
const (
ComparisonEquals = "Equals"
ComparisonNotEquals = "NotEquals"
ComparisonLessThanEqual = "LessThanEqual"
ComparisonGreaterThanEqual = "GreaterThanEqual"
)
// Limitation names (OpenSubsonic spec enum)
const (
LimitationAudioChannels = "audioChannels"
LimitationAudioBitrate = "audioBitrate"
LimitationAudioProfile = "audioProfile"
LimitationAudioSamplerate = "audioSamplerate"
LimitationAudioBitdepth = "audioBitdepth"
)
// Codec profile types (OpenSubsonic spec enum)
const (
CodecProfileTypeAudio = "AudioCodec"
)
// TranscodeDecision represents the internal decision result.
// All bitrate values are in kilobits per second (kbps).
type TranscodeDecision struct {
MediaID string
CanDirectPlay bool
CanTranscode bool
TranscodeReasons []string
ErrorReason string
TargetFormat string
TargetBitrate int
TargetChannels int
TargetSampleRate int
TargetBitDepth int
SourceStream Details
SourceUpdatedAt time.Time
TranscodeStream *Details
}
// Details describes audio stream properties.
// Bitrate is in kilobits per second (kbps).
type Details struct {
Container string
Codec string
Profile string // Audio profile (e.g., "LC", "HE-AACv2"). Populated from ffprobe data.
Bitrate int
SampleRate int
BitDepth int
Channels int
Duration float32
Size int64
IsLossless bool
}

View File

@ -5,17 +5,15 @@ import (
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/stream"
)
var Set = wire.NewSet(
stream.NewMediaStreamer,
stream.GetTranscodingCache,
NewMediaStreamer,
GetTranscodingCache,
NewArchiver,
NewPlayers,
NewShare,
@ -23,9 +21,6 @@ var Set = wire.NewSet(
NewLibrary,
NewUser,
NewMaintenance,
NewImageUploadService,
wire.Bind(new(playlists.ImageUploadService), new(ImageUploadService)),
stream.NewTranscodeDecider,
agents.GetAgents,
external.NewProvider,
wire.Bind(new(external.Agents), new(*agents.Agents)),
@ -33,5 +28,4 @@ var Set = wire.NewSet(
scrobbler.GetPlayTracker,
playback.GetInstance,
metrics.GetInstance,
lyrics.NewLyrics,
)

View File

@ -1,73 +0,0 @@
package migrations
import (
"context"
"database/sql"
"github.com/navidrome/navidrome/model/id"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddCodecAndUpdateTranscodings, downAddCodecAndUpdateTranscodings)
}
func upAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error {
// Add codec column to media_file.
_, err := tx.Exec(`ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL`)
if err != nil {
return err
}
_, err = tx.Exec(`CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec)`)
if err != nil {
return err
}
// Update old AAC default (adts) to new default (ipod with fragmented MP4).
// Only affects users who still have the unmodified old default command.
_, err = tx.Exec(
`UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND 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 -",
"ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
)
if err != nil {
return err
}
// Add FLAC transcoding for existing installations that were seeded before FLAC was added.
var count int
err = tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = 'flac'").Scan(&count)
if err != nil {
return err
}
if count == 0 {
_, err = tx.Exec(
"INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)",
id.NewRandom(), "flac audio", "flac", 0,
"ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -",
)
if err != nil {
return err
}
}
// Add probe_data column for caching ffprobe results.
_, err = tx.Exec(`ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT NULL`)
if err != nil {
return err
}
return nil
}
func downAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`ALTER TABLE media_file DROP COLUMN probe_data`)
if err != nil {
return err
}
_, err = tx.Exec(`DROP INDEX IF EXISTS media_file_codec`)
if err != nil {
return err
}
_, err = tx.Exec(`ALTER TABLE media_file DROP COLUMN codec`)
return err
}

View File

@ -1,28 +0,0 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upFixProbeDataNull, downFixProbeDataNull)
}
func upFixProbeDataNull(_ context.Context, tx *sql.Tx) error {
// Recreate probe_data column as NOT NULL with empty string default.
// The previous migration created it with DEFAULT NULL, which causes
// scan errors when reading into Go string fields.
_, err := tx.Exec(`ALTER TABLE media_file DROP COLUMN probe_data`)
if err != nil {
return err
}
_, err = tx.Exec(`ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT '' NOT NULL`)
return err
}
func downFixProbeDataNull(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,44 +0,0 @@
package migrations
import (
"context"
"database/sql"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model/id"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upEnsureDefaultTranscodings, downEnsureDefaultTranscodings)
}
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 = ? OR name = ?", t.TargetFormat, t.Name).Scan(&count)
if err != nil {
return err
}
if count == 0 {
_, err = tx.Exec(
"INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)",
id.NewRandom(), t.Name, t.TargetFormat, t.DefaultBitRate, t.Command,
)
if err != nil {
return err
}
}
}
return nil
}
func downEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,30 +0,0 @@
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
}

View File

@ -1,22 +0,0 @@
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
}

View File

@ -1,74 +0,0 @@
-- +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);

Some files were not shown because too many files have changed in this diff Show More