Merge branch 'master' into long-playlist-album-names/5068

This commit is contained in:
Deluan Quintão 2026-03-09 18:46:34 -04:00 committed by GitHub
commit e7f2df310a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
334 changed files with 22418 additions and 3016 deletions

View File

@ -117,7 +117,7 @@ jobs:
- name: Test
run: |
pkg-config --define-prefix --cflags --libs taglib # for debugging
go test -shuffle=on -tags netgo -race ./... -v
go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v
- name: Test ndpgen
run: |
@ -221,7 +221,7 @@ jobs:
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Build Binaries
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile
@ -235,7 +235,7 @@ jobs:
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
- name: Upload Binaries
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: navidrome-${{ env.PLATFORM }}
path: ./output
@ -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@v6
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile
@ -266,7 +266,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
with:
name: digests-${{ env.PLATFORM }}
@ -288,7 +288,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download digests
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
path: /tmp/digests
pattern: digests-*
@ -322,7 +322,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download digests
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
path: /tmp/digests
pattern: digests-*
@ -374,7 +374,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v7
- uses: actions/download-artifact@v8
with:
path: ./binaries
pattern: navidrome-windows*
@ -393,7 +393,7 @@ jobs:
du -h binaries/msi/*.msi
- name: Upload MSI files
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: navidrome-windows-installers
path: binaries/msi/*.msi
@ -411,7 +411,7 @@ jobs:
fetch-depth: 0
fetch-tags: true
- uses: actions/download-artifact@v7
- uses: actions/download-artifact@v8
with:
path: ./binaries
pattern: navidrome-*
@ -424,7 +424,7 @@ jobs:
run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
uses: goreleaser/goreleaser-action@v7
with:
version: '~> v2'
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
@ -437,7 +437,7 @@ jobs:
rm ./dist/*.tar.gz ./dist/*.zip
- name: Upload all-packages artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: packages
path: dist/navidrome_0*
@ -460,13 +460,13 @@ jobs:
item: ${{ fromJson(needs.release.outputs.package_list) }}
steps:
- name: Download all-packages artifact
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: packages
path: ./dist
- name: Upload all-packages artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: navidrome_linux_${{ matrix.item }}
path: dist/navidrome_0*_linux_${{ matrix.item }}

View File

@ -2,6 +2,7 @@ version: "2"
run:
build-tags:
- netgo
- sqlite_fts5
linters:
enable:
- asasalint
@ -39,6 +40,11 @@ linters:
enable:
- nilness
exclusions:
rules:
- linters:
- gosec
path: _test\.go
text: "G703"
generated: lax
presets:
- comments

View File

@ -109,7 +109,7 @@ RUN --mount=type=bind,source=. \
export EXT=".exe"
fi
go build -tags=netgo -ldflags="${LD_EXTRA} -w -s \
go build -tags=netgo,sqlite_fts5 -ldflags="${LD_EXTRA} -w -s \
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
-o /out/navidrome${EXT} .

View File

@ -1,5 +1,6 @@
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
NODE_VERSION=$(shell cat .nvmrc)
GO_BUILD_TAGS=netgo,sqlite_fts5
# Set global environment variables, required for most targets
export CGO_CFLAGS_ALLOW=--define-prefix
@ -19,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.0-1
GOLANGCI_LINT_VERSION ?= v2.10.0
CROSS_TAGLIB_VERSION ?= 2.2.1-1
GOLANGCI_LINT_VERSION ?= v2.11.1
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
@ -46,12 +47,12 @@ stop: ##@Development Stop development servers (UI and backend)
.PHONY: stop
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
go tool ginkgo watch -tags=netgo -notify ./...
go tool ginkgo watch -tags=$(GO_BUILD_TAGS) -notify ./...
.PHONY: watch
PKG ?= ./...
test: ##@Development Run Go tests. Use PKG variable to specify packages to test, e.g. make test PKG=./server
go test -tags netgo $(PKG)
go test -tags $(GO_BUILD_TAGS) $(PKG)
.PHONY: test
test-ndpgen: ##@Development Run tests for ndpgen plugin
@ -62,7 +63,7 @@ testall: test test-ndpgen test-i18n test-js ##@Development Run Go and JS tests
.PHONY: testall
test-race: ##@Development Run Go tests with race detector
go test -tags netgo -race -shuffle=on $(PKG)
go test -tags $(GO_BUILD_TAGS) -race -shuffle=on $(PKG)
.PHONY: test-race
test-js: ##@Development Run JS tests
@ -108,7 +109,7 @@ format: ##@Development Format code
.PHONY: format
wire: check_go_env ##@Development Update Dependency Injection
go tool wire gen -tags=netgo ./...
go tool wire gen -tags=$(GO_BUILD_TAGS) ./...
.PHONY: wire
gen: check_go_env ##@Development Run go generate for code generation
@ -144,14 +145,14 @@ setup-git: ##@Development Setup Git hooks (pre-commit and pre-push)
.PHONY: setup-git
build: check_go_env buildjs ##@Build Build the project
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=$(GO_BUILD_TAGS)
.PHONY: build
buildall: deprecated build
.PHONY: buildall
debug-build: check_go_env buildjs ##@Build Build the project (with remote debug on)
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=$(GO_BUILD_TAGS)
.PHONY: debug-build
buildjs: check_node_env ui/build/index.html ##@Build Build only frontend

View File

@ -10,7 +10,7 @@ import (
"sync"
"time"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/navidrome/navidrome/log"
)
@ -84,8 +84,8 @@ func (c *client) getJWT(ctx context.Context) (string, error) {
}
// Calculate TTL with a 1-minute buffer for clock skew and network delays
expiresAt := token.Expiration()
if expiresAt.IsZero() {
expiresAt, ok := token.Expiration()
if !ok || expiresAt.IsZero() {
return "", errors.New("deezer: JWT token has no expiration time")
}

View File

@ -9,7 +9,7 @@ import (
"sync"
"time"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/lestrrat-go/jwx/v3/jwt"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -179,7 +179,8 @@ var _ = Describe("JWT Authentication", func() {
Expect(err).To(BeNil())
// Verify token has no expiration
Expect(testToken.Expiration().IsZero()).To(BeTrue())
_, hasExp := testToken.Expiration()
Expect(hasExp).To(BeFalse())
testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
Expect(err).To(BeNil())

View File

@ -44,7 +44,18 @@ func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
}
func (e extractor) Version() string {
return "2.2 WASM"
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"
}
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
@ -66,6 +77,7 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
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)

View File

@ -196,7 +196,8 @@ func runInitialScan(ctx context.Context) func() error {
if err != nil {
return err
}
scanNeeded := conf.Server.Scanner.ScanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
if scanNeeded {
s := CreateScanner(ctx)

View File

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

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"
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo sqlite_fts5"
//go:build !wireinject
// +build !wireinject
@ -16,9 +16,12 @@ 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/transcode"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
@ -61,7 +64,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
share := core.NewShare(dataStore)
playlists := core.NewPlaylists(dataStore)
playlistsPlaylists := playlists.NewPlaylists(dataStore)
insights := metrics.GetInstance(dataStore)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
@ -72,12 +75,12 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, modelScanner)
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
user := core.NewUser(dataStore, manager)
maintenance := core.NewMaintenance(dataStore)
router := nativeapi.New(dataStore, share, playlists, insights, library, user, maintenance, manager)
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager)
return router
}
@ -92,17 +95,19 @@ 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 := core.GetTranscodingCache()
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
transcodingCache := transcode.GetTranscodingCache()
mediaStreamer := transcode.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
share := core.NewShare(dataStore)
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
players := core.NewPlayers(dataStore)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
playlists := core.NewPlaylists(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
playlistsPlaylists := playlists.NewPlaylists(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
lyricsLyrics := lyrics.NewLyrics(manager)
decider := transcode.NewDecider(dataStore, fFmpeg)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, decider)
return router
}
@ -117,8 +122,8 @@ func CreatePublicRouter() *public.Router {
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
transcodingCache := core.GetTranscodingCache()
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
transcodingCache := transcode.GetTranscodingCache()
mediaStreamer := transcode.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
share := core.NewShare(dataStore)
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
@ -165,8 +170,8 @@ func CreateScanner(ctx context.Context) model.Scanner {
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
playlists := core.NewPlaylists(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
playlistsPlaylists := playlists.NewPlaylists(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
return modelScanner
}
@ -182,8 +187,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
playlists := core.NewPlaylists(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
playlistsPlaylists := playlists.NewPlaylists(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, modelScanner)
return watcher
}
@ -206,7 +211,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(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(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)))
func GetPluginManager(ctx context.Context) *plugins.Manager {
manager := getPluginManager()

View File

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

@ -1,4 +0,0 @@
package buildtags
// This file is left intentionally empty. It is used to make sure the package is not empty, in the case all
// required build tags are disabled.

6
conf/buildtags/doc.go Normal file
View File

@ -0,0 +1,6 @@
// Package buildtags provides compile-time enforcement of required build tags.
//
// Each file in this package is guarded by a build constraint and exports a variable
// that main.go references. If a required tag is missing during compilation, the build
// fails with an "undefined" error, directing the developer to use `make build`.
package buildtags

View File

@ -2,10 +2,6 @@
package buildtags
// NOTICE: This file was created to force the inclusion of the `netgo` tag when compiling the project.
// If the tag is not included, the compilation will fail because this variable won't be defined, and the `main.go`
// file requires it.
// Why this tag is required? See https://github.com/navidrome/navidrome/issues/700
// The `netgo` tag is required when compiling the project. See https://github.com/navidrome/navidrome/issues/700
var NETGO = true

View File

@ -0,0 +1,8 @@
//go:build sqlite_fts5
package buildtags
// FTS5 is required for full-text search. Without this tag, the SQLite driver
// won't include FTS5 support, causing runtime failures on migrations and search queries.
var SQLITE_FTS5 = true

View File

@ -46,6 +46,7 @@ type configOptions struct {
EnableTranscodingCancellation bool
EnableDownloads bool
EnableExternalServices bool
EnableM3UExternalAlbumArt bool
EnableInsightsCollector bool
EnableMediaFileCoverArt bool
TranscodingCacheSize string
@ -58,7 +59,7 @@ type configOptions struct {
SmartPlaylistRefreshDelay time.Duration
AutoTranscodeDownload bool
DefaultDownsamplingFormat string
SearchFullString bool
Search searchOptions `json:",omitzero"`
SimilarSongsMatchThreshold int
RecentlyAddedByModTime bool
PreferSortTags bool
@ -75,6 +76,7 @@ type configOptions struct {
EnableFavourites bool
EnableStarRating bool
EnableUserEditing bool
EnableCoverArtUpload bool
EnableSharing bool
ShareURL string
DefaultShareExpiration time.Duration
@ -82,6 +84,7 @@ type configOptions struct {
DefaultTheme string
DefaultLanguage string
DefaultUIVolume int
UISearchDebounceMs int
EnableReplayGain bool
EnableCoverAnimation bool
EnableNowPlaying bool
@ -129,7 +132,6 @@ type configOptions struct {
DevExternalScanner bool
DevScannerThreads uint
DevSelectiveWatcher bool
DevLegacyEmbedImage bool
DevInsightsInitialDelay time.Duration
DevEnablePlayerInsights bool
DevEnablePluginsInsights bool
@ -137,6 +139,7 @@ type configOptions struct {
DevExternalArtistFetchMultiplier float64
DevOptimizeDB bool
DevPreserveUnicodeInExternalCalls bool
DevEnableMediaFileProbe bool
}
type scannerOptions struct {
@ -154,6 +157,7 @@ type scannerOptions struct {
type subsonicOptions struct {
AppendSubtitle bool
AppendAlbumVersion bool
ArtistParticipations bool
DefaultReportRealPath bool
EnableAverageRating bool
@ -249,6 +253,12 @@ type pluginsOptions struct {
type extAuthOptions struct {
TrustedSources string
UserHeader string
LogoutURL string
}
type searchOptions struct {
Backend string
FullString bool
}
var (
@ -295,6 +305,12 @@ func Load(noConfigDump bool) {
os.Exit(1)
}
err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating artwork path:", err)
os.Exit(1)
}
if Server.Plugins.Enabled {
if Server.Plugins.Folder == "" {
Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins")
@ -339,11 +355,14 @@ func Load(noConfigDump bool) {
validateBackupSchedule,
validatePlaylistsPath,
validatePurgeMissingOption,
validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
)
if err != nil {
os.Exit(1)
}
Server.Search.Backend = normalizeSearchBackend(Server.Search.Backend)
if Server.BaseURL != "" {
u, err := url.Parse(Server.BaseURL)
if err != nil {
@ -392,6 +411,7 @@ func Load(noConfigDump bool) {
logDeprecatedOptions("Scanner.GenreSeparators", "")
logDeprecatedOptions("Scanner.GroupAlbumReleases", "")
logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
logDeprecatedOptions("SearchFullString", "Search.FullString")
logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
@ -456,6 +476,7 @@ func parseIniFileConfiguration() {
func disableExternalServices() {
log.Info("All external integrations are DISABLED!")
Server.EnableInsightsCollector = false
Server.EnableM3UExternalAlbumArt = false
Server.LastFM.Enabled = false
Server.Spotify.ID = ""
Server.Deezer.Enabled = false
@ -539,6 +560,44 @@ func validateSchedule(schedule, field string) (string, error) {
return schedule, err
}
// validateURL checks if the provided URL is valid and has either http or https scheme.
// It returns a function that can be used as a hook to validate URLs in the config.
func validateURL(optionName, optionURL string) func() error {
return func() error {
if optionURL == "" {
return nil
}
u, err := url.Parse(optionURL)
if err != nil {
log.Error(fmt.Sprintf("Invalid %s: it could not be parsed", optionName), "url", optionURL, "err", err)
return err
}
if u.Scheme != "http" && u.Scheme != "https" {
err := fmt.Errorf("invalid scheme for %s: '%s'. Only 'http' and 'https' are allowed", optionName, u.Scheme)
log.Error(err.Error())
return err
}
// Require an absolute URL with a non-empty host and no opaque component.
if u.Host == "" || u.Opaque != "" {
err := fmt.Errorf("invalid %s: '%s'. A full http(s) URL with a non-empty host is required", optionName, optionURL)
log.Error(err.Error())
return err
}
return nil
}
}
func normalizeSearchBackend(value string) string {
v := strings.ToLower(strings.TrimSpace(value))
switch v {
case "fts", "legacy":
return v
default:
log.Error("Invalid Search.Backend value, falling back to 'fts'", "value", value)
return "fts"
}
}
// 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)
@ -582,10 +641,12 @@ func setViperDefaults() {
viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second)
viper.SetDefault("enabledownloads", true)
viper.SetDefault("enableexternalservices", true)
viper.SetDefault("enablem3uexternalalbumart", false)
viper.SetDefault("enablemediafilecoverart", true)
viper.SetDefault("autotranscodedownload", false)
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
viper.SetDefault("searchfullstring", false)
viper.SetDefault("search.fullstring", false)
viper.SetDefault("search.backend", "fts")
viper.SetDefault("similarsongsmatchthreshold", 85)
viper.SetDefault("recentlyaddedbymodtime", false)
viper.SetDefault("prefersorttags", false)
@ -604,9 +665,11 @@ func setViperDefaults() {
viper.SetDefault("defaulttheme", "Dark")
viper.SetDefault("defaultlanguage", "")
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs)
viper.SetDefault("enablereplaygain", true)
viper.SetDefault("enablecoveranimation", true)
viper.SetDefault("enablenowplaying", true)
viper.SetDefault("enablecoverartupload", true)
viper.SetDefault("enablesharing", false)
viper.SetDefault("shareurl", "")
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
@ -619,6 +682,7 @@ func setViperDefaults() {
viper.SetDefault("passwordencryptionkey", "")
viper.SetDefault("extauth.userheader", "Remote-User")
viper.SetDefault("extauth.trustedsources", "")
viper.SetDefault("extauth.logouturl", "")
viper.SetDefault("prometheus.enabled", false)
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
viper.SetDefault("prometheus.password", "")
@ -637,6 +701,7 @@ func setViperDefaults() {
viper.SetDefault("scanner.followsymlinks", true)
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
viper.SetDefault("subsonic.appendsubtitle", true)
viper.SetDefault("subsonic.appendalbumversion", true)
viper.SetDefault("subsonic.artistparticipations", false)
viper.SetDefault("subsonic.defaultreportrealpath", false)
viper.SetDefault("subsonic.enableaveragerating", true)
@ -699,6 +764,7 @@ func setViperDefaults() {
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
viper.SetDefault("devoptimizedb", true)
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
viper.SetDefault("devenablemediafileprobe", true)
}
func init() {

View File

@ -52,6 +52,62 @@ var _ = Describe("Configuration", func() {
})
})
Describe("ValidateURL", func() {
It("accepts a valid http URL", func() {
fn := conf.ValidateURL("TestOption", "http://example.com/path")
Expect(fn()).To(Succeed())
})
It("accepts a valid https URL", func() {
fn := conf.ValidateURL("TestOption", "https://example.com/path")
Expect(fn()).To(Succeed())
})
It("rejects a URL with no scheme", func() {
fn := conf.ValidateURL("TestOption", "example.com/path")
Expect(fn()).To(MatchError(ContainSubstring("invalid scheme")))
})
It("rejects a URL with an unsupported scheme", func() {
fn := conf.ValidateURL("TestOption", "javascript://example.com/path")
Expect(fn()).To(MatchError(ContainSubstring("invalid scheme")))
})
It("accepts an empty URL (optional config)", func() {
fn := conf.ValidateURL("TestOption", "")
Expect(fn()).To(Succeed())
})
It("includes the option name in the error message", func() {
fn := conf.ValidateURL("MyOption", "ftp://example.com")
Expect(fn()).To(MatchError(ContainSubstring("MyOption")))
})
It("rejects a URL that cannot be parsed", func() {
fn := conf.ValidateURL("TestOption", "://invalid")
Expect(fn()).To(HaveOccurred())
})
It("rejects a URL without a host", func() {
fn := conf.ValidateURL("TestOption", "http:///path")
Expect(fn()).To(MatchError(ContainSubstring("non-empty host is required")))
})
})
DescribeTable("NormalizeSearchBackend",
func(input, expected string) {
Expect(conf.NormalizeSearchBackend(input)).To(Equal(expected))
},
Entry("accepts 'fts'", "fts", "fts"),
Entry("accepts 'legacy'", "legacy", "legacy"),
Entry("normalizes 'FTS' to lowercase", "FTS", "fts"),
Entry("normalizes 'Legacy' to lowercase", "Legacy", "legacy"),
Entry("trims whitespace", " fts ", "fts"),
Entry("falls back to 'fts' for 'fts5'", "fts5", "fts"),
Entry("falls back to 'fts' for unrecognized values", "invalid", "fts"),
Entry("falls back to 'fts' for empty string", "", "fts"),
)
DescribeTable("should load configuration from",
func(format string) {
filename := filepath.Join("testdata", "cfg."+format)

View File

@ -7,3 +7,7 @@ func ResetConf() {
var SetViperDefaults = setViperDefaults
var ParseLanguages = parseLanguages
var ValidateURL = validateURL
var NormalizeSearchBackend = normalizeSearchBackend

View File

@ -65,12 +65,14 @@ const (
I18nFolder = "i18n"
ScanIgnoreFile = ".ndignore"
ArtworkFolder = "artwork"
PlaceholderArtistArt = "artist-placeholder.webp"
PlaceholderAlbumArt = "album-placeholder.webp"
PlaceholderAvatar = "logo-192x192.png"
UICoverArtSize = 300
DefaultUIVolume = 100
PlaceholderArtistArt = "artist-placeholder.webp"
PlaceholderAlbumArt = "album-placeholder.webp"
PlaceholderAvatar = "logo-192x192.png"
UICoverArtSize = 300
DefaultUIVolume = 100
DefaultUISearchDebounceMs = 200
DefaultHttpClientTimeOut = 10 * time.Second
@ -151,7 +153,13 @@ var (
Name: "aac audio",
TargetFormat: "aac",
DefaultBitRate: 256,
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
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 -",
},
{
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

@ -10,6 +10,7 @@ import (
"strings"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/transcode"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
@ -22,13 +23,13 @@ type Archiver interface {
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
}
func NewArchiver(ms MediaStreamer, ds model.DataStore, shares Share) Archiver {
func NewArchiver(ms transcode.MediaStreamer, ds model.DataStore, shares Share) Archiver {
return &archiver{ds: ds, ms: ms, shares: shares}
}
type archiver struct {
ds model.DataStore
ms MediaStreamer
ms transcode.MediaStreamer
shares Share
}
@ -176,7 +177,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.DoStream(ctx, &mf, format, bitrate, 0)
r, err = a.ms.DoStream(ctx, &mf, transcode.StreamRequest{Format: format, BitRate: bitrate})
} else {
r, err = os.Open(path)
}

View File

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

View File

@ -235,6 +235,113 @@ var _ = Describe("Artwork", func() {
})
})
})
Describe("playlistArtworkReader", func() {
Describe("findPlaylistSidecarPath", func() {
It("discovers sidecar image next to playlist file", func() {
tmpDir := GinkgoT().TempDir()
plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u")
imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg")
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath)
Expect(result).To(Equal(imgPath))
})
It("returns empty string when no sidecar image exists", func() {
tmpDir := GinkgoT().TempDir()
plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u")
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath)
Expect(result).To(BeEmpty())
})
It("returns empty string when playlist has no path", func() {
result := findPlaylistSidecarPath(GinkgoT().Context(), "")
Expect(result).To(BeEmpty())
})
It("finds sidecar with different case base name", func() {
tmpDir := GinkgoT().TempDir()
plsPath := filepath.Join(tmpDir, "myplaylist.m3u")
imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg")
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath)
Expect(result).To(Equal(imgPath))
})
})
Describe("fromPlaylistExternalImage", func() {
It("opens local path from ExternalImageURL", func() {
tmpDir := GinkgoT().TempDir()
imgPath := filepath.Join(tmpDir, "cover.jpg")
Expect(os.WriteFile(imgPath, []byte("external image data"), 0600)).To(Succeed())
reader := &playlistArtworkReader{
pl: model.Playlist{ExternalImageURL: imgPath},
}
r, path, err := reader.fromPlaylistExternalImage(ctx)()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
Expect(path).To(Equal(imgPath))
data, _ := io.ReadAll(r)
Expect(string(data)).To(Equal("external image data"))
r.Close()
})
It("returns nil when ExternalImageURL is empty", func() {
reader := &playlistArtworkReader{
pl: model.Playlist{ExternalImageURL: ""},
}
r, path, err := reader.fromPlaylistExternalImage(ctx)()
Expect(err).ToNot(HaveOccurred())
Expect(r).To(BeNil())
Expect(path).To(BeEmpty())
})
It("returns error when local file does not exist", func() {
reader := &playlistArtworkReader{
pl: model.Playlist{ExternalImageURL: "/non/existent/path/cover.jpg"},
}
r, _, err := reader.fromPlaylistExternalImage(ctx)()
Expect(err).To(HaveOccurred())
Expect(r).To(BeNil())
})
It("skips HTTP URL when EnableM3UExternalAlbumArt is false", func() {
conf.Server.EnableM3UExternalAlbumArt = false
reader := &playlistArtworkReader{
pl: model.Playlist{ExternalImageURL: "https://example.com/cover.jpg"},
}
r, path, err := reader.fromPlaylistExternalImage(ctx)()
Expect(err).ToNot(HaveOccurred())
Expect(r).To(BeNil())
Expect(path).To(BeEmpty())
})
It("still opens local path when EnableM3UExternalAlbumArt is false", func() {
conf.Server.EnableM3UExternalAlbumArt = false
tmpDir := GinkgoT().TempDir()
imgPath := filepath.Join(tmpDir, "cover.jpg")
Expect(os.WriteFile(imgPath, []byte("local image"), 0600)).To(Succeed())
reader := &playlistArtworkReader{
pl: model.Playlist{ExternalImageURL: imgPath},
}
r, path, err := reader.fromPlaylistExternalImage(ctx)()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
Expect(path).To(Equal(imgPath))
r.Close()
})
})
})
Describe("resizedArtworkReader", func() {
BeforeEach(func() {
folderRepo.result = []model.Folder{{

View File

@ -4,6 +4,7 @@ import (
"cmp"
"context"
"crypto/md5"
"errors"
"fmt"
"io"
"path/filepath"
@ -17,6 +18,7 @@ import (
"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"
)
@ -103,6 +105,28 @@ 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
@ -125,6 +149,24 @@ 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,6 +2,7 @@ package artwork
import (
"context"
"errors"
"path/filepath"
"time"
@ -116,5 +117,181 @@ 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

@ -417,14 +417,28 @@ var _ = Describe("artistArtworkReader", func() {
type fakeFolderRepo struct {
model.FolderRepository
result []model.Folder
err error
result []model.Folder
parentResult *model.Folder
getErr error
getCallCount int
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

@ -8,9 +8,14 @@ import (
"image/draw"
"image/png"
"io"
"net/url"
"os"
"path/filepath"
"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"
@ -35,6 +40,24 @@ func newPlaylistArtworkReader(ctx context.Context, artwork *artwork, artID model
}
a.cacheKey.artID = artID
a.cacheKey.lastUpdate = pl.UpdatedAt
// Check sidecar and ExternalImageURL local file ModTimes for cache invalidation.
// If either is newer than the playlist's UpdatedAt, use that instead so the
// cache is busted when a user replaces a sidecar image or local file reference.
for _, path := range []string{
findPlaylistSidecarPath(ctx, pl.Path),
pl.ExternalImageURL,
} {
if path == "" || strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
continue
}
if info, err := os.Stat(path); err == nil {
if info.ModTime().After(a.cacheKey.lastUpdate) {
a.cacheKey.lastUpdate = info.ModTime()
}
}
}
return a, nil
}
@ -43,11 +66,81 @@ func (a *playlistArtworkReader) LastUpdated() time.Time {
}
func (a *playlistArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
ff := []sourceFunc{
return selectImageReader(ctx, a.artID,
a.fromPlaylistUploadedImage(),
a.fromPlaylistSidecar(ctx),
a.fromPlaylistExternalImage(ctx),
a.fromGeneratedTiledCover(ctx),
fromAlbumPlaceholder(),
)
}
func (a *playlistArtworkReader) fromPlaylistUploadedImage() sourceFunc {
return fromLocalFile(a.pl.UploadedImagePath())
}
func (a *playlistArtworkReader) fromPlaylistSidecar(ctx context.Context) sourceFunc {
return fromLocalFile(findPlaylistSidecarPath(ctx, a.pl.Path))
}
func (a *playlistArtworkReader) fromPlaylistExternalImage(ctx context.Context) sourceFunc {
return func() (io.ReadCloser, string, error) {
imgURL := a.pl.ExternalImageURL
if imgURL == "" {
return nil, "", nil
}
parsed, err := url.Parse(imgURL)
if err != nil {
return nil, "", err
}
if parsed.Scheme == "http" || parsed.Scheme == "https" {
if !conf.Server.EnableM3UExternalAlbumArt {
return nil, "", nil
}
return fromURL(ctx, parsed)
}
return fromLocalFile(imgURL)()
}
return selectImageReader(ctx, a.artID, ff...)
}
// fromLocalFile returns a sourceFunc that opens the given local path.
// Returns (nil, "", nil) if path is empty — signalling "not found, try next source".
func fromLocalFile(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
}
return f, path, nil
}
}
// findPlaylistSidecarPath scans the directory of the playlist file for a sidecar
// image file with the same base name (case-insensitive). Returns empty string if
// no matching image is found or if plsPath is empty.
func findPlaylistSidecarPath(ctx context.Context, plsPath string) string {
if plsPath == "" {
return ""
}
dir := filepath.Dir(plsPath)
base := strings.TrimSuffix(filepath.Base(plsPath), filepath.Ext(plsPath))
entries, err := os.ReadDir(dir)
if err != nil {
log.Warn(ctx, "Could not read directory for playlist sidecar", "dir", dir, err)
return ""
}
for _, entry := range entries {
name := entry.Name()
nameBase := strings.TrimSuffix(name, filepath.Ext(name))
if !entry.IsDir() && strings.EqualFold(nameBase, base) && model.IsImageFile(name) {
return filepath.Join(dir, name)
}
}
return ""
}
func (a *playlistArtworkReader) fromGeneratedTiledCover(ctx context.Context) sourceFunc {

View File

@ -15,8 +15,6 @@ 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"
@ -86,58 +84,6 @@ 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

View File

@ -4,12 +4,11 @@ import (
"cmp"
"context"
"crypto/sha256"
"maps"
"sync"
"time"
"github.com/go-chi/jwtauth/v5"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
@ -46,38 +45,30 @@ func Init(ds model.DataStore) {
})
}
func createBaseClaims() map[string]any {
tokenClaims := map[string]any{}
tokenClaims[jwt.IssuerKey] = consts.JWTIssuer
return tokenClaims
}
func CreatePublicToken(claims map[string]any) (string, error) {
tokenClaims := createBaseClaims()
maps.Copy(tokenClaims, claims)
_, token, err := TokenAuth.Encode(tokenClaims)
func CreatePublicToken(claims Claims) (string, error) {
claims.Issuer = consts.JWTIssuer
_, token, err := TokenAuth.Encode(claims.ToMap())
return token, err
}
func CreateExpiringPublicToken(exp time.Time, claims map[string]any) (string, error) {
tokenClaims := createBaseClaims()
func CreateExpiringPublicToken(exp time.Time, claims Claims) (string, error) {
claims.Issuer = consts.JWTIssuer
if !exp.IsZero() {
tokenClaims[jwt.ExpirationKey] = exp.UTC().Unix()
claims.ExpiresAt = exp
}
maps.Copy(tokenClaims, claims)
_, token, err := TokenAuth.Encode(tokenClaims)
_, token, err := TokenAuth.Encode(claims.ToMap())
return token, err
}
func CreateToken(u *model.User) (string, error) {
claims := createBaseClaims()
claims[jwt.SubjectKey] = u.UserName
claims[jwt.IssuedAtKey] = time.Now().UTC().Unix()
claims["uid"] = u.ID
claims["adm"] = u.IsAdmin
token, _, err := TokenAuth.Encode(claims)
claims := Claims{
Issuer: consts.JWTIssuer,
Subject: u.UserName,
IssuedAt: time.Now(),
UserID: u.ID,
IsAdmin: u.IsAdmin,
}
token, _, err := TokenAuth.Encode(claims.ToMap())
if err != nil {
return "", err
}
@ -86,23 +77,18 @@ func CreateToken(u *model.User) (string, error) {
}
func TouchToken(token jwt.Token) (string, error) {
claims, err := token.AsMap(context.Background())
if err != nil {
return "", err
}
claims[jwt.ExpirationKey] = time.Now().UTC().Add(conf.Server.SessionTimeout).Unix()
_, newToken, err := TokenAuth.Encode(claims)
claims := ClaimsFromToken(token).
WithExpiresAt(time.Now().UTC().Add(conf.Server.SessionTimeout))
_, newToken, err := TokenAuth.Encode(claims.ToMap())
return newToken, err
}
func Validate(tokenStr string) (map[string]any, error) {
func Validate(tokenStr string) (Claims, error) {
token, err := jwtauth.VerifyToken(TokenAuth, tokenStr)
if err != nil {
return nil, err
return Claims{}, err
}
return token.AsMap(context.Background())
return ClaimsFromToken(token), nil
}
func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
@ -134,6 +120,19 @@ 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

@ -54,7 +54,7 @@ var _ = Describe("Auth", func() {
decodedClaims, err := auth.Validate(tokenStr)
Expect(err).NotTo(HaveOccurred())
Expect(decodedClaims["iss"]).To(Equal("issuer"))
Expect(decodedClaims.Issuer).To(Equal("issuer"))
})
It("returns ErrExpired if the `exp` field is in the past", func() {
@ -82,11 +82,11 @@ var _ = Describe("Auth", func() {
claims, err := auth.Validate(tokenStr)
Expect(err).NotTo(HaveOccurred())
Expect(claims["iss"]).To(Equal(consts.JWTIssuer))
Expect(claims["sub"]).To(Equal("johndoe"))
Expect(claims["uid"]).To(Equal("123"))
Expect(claims["adm"]).To(Equal(true))
Expect(claims["exp"]).To(BeTemporally(">", time.Now()))
Expect(claims.Issuer).To(Equal(consts.JWTIssuer))
Expect(claims.Subject).To(Equal("johndoe"))
Expect(claims.UserID).To(Equal("123"))
Expect(claims.IsAdmin).To(Equal(true))
Expect(claims.ExpiresAt).To(BeTemporally(">", time.Now()))
})
})
@ -104,8 +104,7 @@ var _ = Describe("Auth", func() {
decodedClaims, err := auth.Validate(touched)
Expect(err).NotTo(HaveOccurred())
exp := decodedClaims["exp"].(time.Time)
Expect(exp.Sub(yesterday)).To(BeNumerically(">=", oneDay))
Expect(decodedClaims.ExpiresAt.Sub(yesterday)).To(BeNumerically(">=", oneDay))
})
})
})

96
core/auth/claims.go Normal file
View File

@ -0,0 +1,96 @@
package auth
import (
"time"
"github.com/lestrrat-go/jwx/v3/jwt"
)
// Claims represents the typed JWT claims used throughout Navidrome,
// replacing the untyped map[string]any approach.
type Claims struct {
// Standard JWT claims
Issuer string
Subject string // username for session tokens
IssuedAt time.Time
ExpiresAt time.Time
// Custom claims
UserID string // "uid"
IsAdmin bool // "adm"
ID string // "id" - artwork/mediafile ID
Format string // "f" - audio format
BitRate int // "b" - audio bitrate
}
// ToMap converts Claims to a map[string]any for use with TokenAuth.Encode().
// Only non-zero fields are included.
func (c Claims) ToMap() map[string]any {
m := make(map[string]any)
if c.Issuer != "" {
m[jwt.IssuerKey] = c.Issuer
}
if c.Subject != "" {
m[jwt.SubjectKey] = c.Subject
}
if !c.IssuedAt.IsZero() {
m[jwt.IssuedAtKey] = c.IssuedAt.UTC().Unix()
}
if !c.ExpiresAt.IsZero() {
m[jwt.ExpirationKey] = c.ExpiresAt.UTC().Unix()
}
if c.UserID != "" {
m["uid"] = c.UserID
}
if c.IsAdmin {
m["adm"] = c.IsAdmin
}
if c.ID != "" {
m["id"] = c.ID
}
if c.Format != "" {
m["f"] = c.Format
}
if c.BitRate != 0 {
m["b"] = c.BitRate
}
return m
}
func (c Claims) WithExpiresAt(t time.Time) Claims {
c.ExpiresAt = t
return c
}
// ClaimsFromToken extracts Claims directly from a jwt.Token using token.Get().
func ClaimsFromToken(token jwt.Token) Claims {
var c Claims
c.Issuer, _ = token.Issuer()
c.Subject, _ = token.Subject()
c.IssuedAt, _ = token.IssuedAt()
c.ExpiresAt, _ = token.Expiration()
var uid string
if err := token.Get("uid", &uid); err == nil {
c.UserID = uid
}
var adm bool
if err := token.Get("adm", &adm); err == nil {
c.IsAdmin = adm
}
var id string
if err := token.Get("id", &id); err == nil {
c.ID = id
}
var f string
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)
}
}
return c
}

99
core/auth/claims_test.go Normal file
View File

@ -0,0 +1,99 @@
package auth_test
import (
"time"
"github.com/go-chi/jwtauth/v5"
"github.com/navidrome/navidrome/core/auth"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Claims", func() {
Describe("ToMap", func() {
It("includes only non-zero fields", func() {
c := auth.Claims{
Issuer: "ND",
Subject: "johndoe",
UserID: "123",
IsAdmin: true,
}
m := c.ToMap()
Expect(m).To(HaveKeyWithValue("iss", "ND"))
Expect(m).To(HaveKeyWithValue("sub", "johndoe"))
Expect(m).To(HaveKeyWithValue("uid", "123"))
Expect(m).To(HaveKeyWithValue("adm", true))
Expect(m).NotTo(HaveKey("exp"))
Expect(m).NotTo(HaveKey("iat"))
Expect(m).NotTo(HaveKey("id"))
Expect(m).NotTo(HaveKey("f"))
Expect(m).NotTo(HaveKey("b"))
})
It("includes expiration and issued-at when set", func() {
now := time.Now()
c := auth.Claims{
IssuedAt: now,
ExpiresAt: now.Add(time.Hour),
}
m := c.ToMap()
Expect(m).To(HaveKey("iat"))
Expect(m).To(HaveKey("exp"))
})
It("includes custom claims for public tokens", func() {
c := auth.Claims{
ID: "al-123",
Format: "mp3",
BitRate: 192,
}
m := c.ToMap()
Expect(m).To(HaveKeyWithValue("id", "al-123"))
Expect(m).To(HaveKeyWithValue("f", "mp3"))
Expect(m).To(HaveKeyWithValue("b", 192))
})
})
Describe("ClaimsFromToken", func() {
It("round-trips session claims through encode/decode", func() {
tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
now := time.Now().Truncate(time.Second)
original := auth.Claims{
Issuer: "ND",
Subject: "johndoe",
UserID: "123",
IsAdmin: true,
}
m := original.ToMap()
m["iat"] = now.UTC().Unix()
token, _, err := tokenAuth.Encode(m)
Expect(err).NotTo(HaveOccurred())
c := auth.ClaimsFromToken(token)
Expect(c.Issuer).To(Equal("ND"))
Expect(c.Subject).To(Equal("johndoe"))
Expect(c.UserID).To(Equal("123"))
Expect(c.IsAdmin).To(BeTrue())
Expect(c.IssuedAt.UTC()).To(Equal(now.UTC()))
})
It("round-trips public token claims through encode/decode", func() {
tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
original := auth.Claims{
Issuer: "ND",
ID: "al-456",
Format: "opus",
BitRate: 128,
}
token, _, err := tokenAuth.Encode(original.ToMap())
Expect(err).NotTo(HaveOccurred())
c := auth.ClaimsFromToken(token)
Expect(c.Issuer).To(Equal("ND"))
Expect(c.ID).To(Equal("al-456"))
Expect(c.Format).To(Equal("opus"))
Expect(c.BitRate).To(Equal(128))
})
})
})

View File

@ -2,23 +2,49 @@ package ffmpeg
import (
"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, command, path string, maxBitRate, offset int) (io.ReadCloser, error)
Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error)
ExtractImage(ctx context.Context, path string) (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
@ -29,21 +55,26 @@ func New() FFmpeg {
}
const (
extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
probeCmd = "ffmpeg %s -f ffmetadata"
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"
)
type ffmpeg struct{}
func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) {
func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error) {
if _, err := ffmpegCmd(); err != nil {
return nil, err
}
// First make sure the file exists
if err := fileExists(path); err != nil {
if err := fileExists(opts.FilePath); err != nil {
return nil, err
}
args := createFFmpegCommand(command, path, maxBitRate, offset)
var args []string
if isDefaultCommand(opts.Format, opts.Command) {
args = buildDynamicArgs(opts)
} else {
args = buildTemplateArgs(opts)
}
return e.start(ctx, args)
}
@ -51,7 +82,6 @@ func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser,
if _, err := ffmpegCmd(); err != nil {
return nil, err
}
// First make sure the file exists
if err := fileExists(path); err != nil {
return nil, err
}
@ -81,6 +111,91 @@ 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()
}
@ -156,6 +271,141 @@ func (j *ffCmd) wait() {
_ = j.out.Close()
}
// 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": "ipod",
"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)
}
// For AAC in MP4 container, enable fragmented MP4 for pipe-safe streaming
if opts.Format == "aac" {
args = append(args, "-movflags", "frag_keyframe+empty_moov")
}
args = append(args, "-")
return args
}
// 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/transcode/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
@ -196,10 +446,20 @@ 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,19 +2,27 @@ 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) {
tests.Init(t, false)
// 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)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "FFmpeg Suite")
@ -70,6 +78,473 @@ 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 ipod -movflags frag_keyframe+empty_moov -")).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 fragmented MP4 container", 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", "ipod",
"-movflags", "frag_keyframe+empty_moov",
"-",
}))
})
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
@ -93,7 +568,12 @@ 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, command, "tests/fixtures/test.mp3", 128, 0)
stream, err := ff.Transcode(ctx, TranscodeOptions{
Command: command,
Format: "mp3",
FilePath: "tests/fixtures/test.mp3",
BitRate: 128,
})
Expect(err).ToNot(HaveOccurred())
defer stream.Close()
@ -115,7 +595,12 @@ var _ = Describe("ffmpeg", func() {
cancel() // Cancel immediately
// This should fail immediately
_, err := ff.Transcode(ctx, "ffmpeg -i %s -f mp3 -", "tests/fixtures/test.mp3", 128, 0)
_, err := ff.Transcode(ctx, TranscodeOptions{
Command: "ffmpeg -i %s -f mp3 -",
Format: "mp3",
FilePath: "tests/fixtures/test.mp3",
BitRate: 128,
})
Expect(err).To(MatchError(context.Canceled))
})
})
@ -142,7 +627,10 @@ var _ = Describe("ffmpeg", func() {
defer cancel()
// Start a process that will run for a while
stream, err := ff.Transcode(ctx, longRunningCmd, "tests/fixtures/test.mp3", 0, 0)
stream, err := ff.Transcode(ctx, TranscodeOptions{
Command: longRunningCmd,
FilePath: "tests/fixtures/test.mp3",
})
Expect(err).ToNot(HaveOccurred())
defer stream.Close()

View File

@ -9,23 +9,45 @@ import (
"github.com/navidrome/navidrome/model"
)
func GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
// 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) {
var lyricsList model.LyricList
var err error
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.LyricsPriority), ",") {
for pattern := range strings.SplitSeq(conf.Server.LyricsPriority, ",") {
pattern = strings.TrimSpace(pattern)
switch {
case pattern == "embedded":
case strings.EqualFold(pattern, "embedded"):
lyricsList, err = fromEmbedded(ctx, mf)
case strings.HasPrefix(pattern, "."):
lyricsList, err = fromExternalFile(ctx, mf, pattern)
lyricsList, err = fromExternalFile(ctx, mf, strings.ToLower(pattern))
default:
log.Error(ctx, "Invalid lyric pattern", "pattern", pattern)
lyricsList, err = l.fromPlugin(ctx, mf, pattern)
}
if err != nil {
log.Error(ctx, "error parsing lyrics", "source", pattern, err)
log.Error(ctx, "error getting lyrics", "source", pattern, err)
}
if len(lyricsList) > 0 {

View File

@ -3,6 +3,7 @@ package lyrics_test
import (
"context"
"encoding/json"
"fmt"
"os"
"github.com/navidrome/navidrome/conf"
@ -72,7 +73,8 @@ var _ = Describe("sources", func() {
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) {
conf.Server.LyricsPriority = priority
list, err := lyrics.GetLyrics(ctx, &mf)
svc := lyrics.NewLyrics(nil)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(expected))
},
@ -107,7 +109,8 @@ var _ = Describe("sources", func() {
It("should fallback to embedded if an error happens when parsing file", func() {
conf.Server.LyricsPriority = ".mp3,embedded"
list, err := lyrics.GetLyrics(ctx, &mf)
svc := lyrics.NewLyrics(nil)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics))
})
@ -115,10 +118,109 @@ var _ = Describe("sources", func() {
It("should return nothing if error happens when trying to parse file", func() {
conf.Server.LyricsPriority = ".mp3"
list, err := lyrics.GetLyrics(ctx, &mf)
svc := lyrics.NewLyrics(nil)
list, err := svc.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,3 +49,27 @@ 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
}

View File

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

@ -208,7 +208,8 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Config.TranscodingCacheSize = conf.Server.TranscodingCacheSize
data.Config.ImageCacheSize = conf.Server.ImageCacheSize
data.Config.SessionTimeout = uint64(math.Trunc(conf.Server.SessionTimeout.Seconds()))
data.Config.SearchFullString = conf.Server.SearchFullString
data.Config.SearchFullString = conf.Server.Search.FullString
data.Config.SearchBackend = conf.Server.Search.Backend
data.Config.RecentlyAddedByModTime = conf.Server.RecentlyAddedByModTime
data.Config.PreferSortTags = conf.Server.PreferSortTags
data.Config.BackupSchedule = conf.Server.Backup.Schedule

View File

@ -68,6 +68,7 @@ type Data struct {
EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
SearchFullString bool `json:"searchFullString,omitempty"`
SearchBackend string `json:"searchBackend,omitempty"`
RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"`
PreferSortTags bool `json:"preferSortTags,omitempty"`
BackupSchedule string `json:"backupSchedule,omitempty"`

120
core/playlists/import.go Normal file
View File

@ -0,0 +1,120 @@
package playlists
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"time"
"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/utils/ioutils"
"golang.org/x/text/unicode/norm"
)
func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
pls, err := s.parsePlaylist(ctx, filename, folder)
if err != nil {
log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
return nil, err
}
log.Debug(ctx, "Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks))
err = s.updatePlaylist(ctx, pls)
if err != nil {
log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
}
return pls, err
}
func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) {
owner, _ := request.UserFrom(ctx)
pls := &model.Playlist{
OwnerID: owner.ID,
Public: false,
Sync: false,
}
err := s.parseM3U(ctx, pls, nil, reader)
if err != nil {
log.Error(ctx, "Error parsing playlist", err)
return nil, err
}
err = s.ds.Playlist(ctx).Put(pls)
if err != nil {
log.Error(ctx, "Error saving playlist", err)
return nil, err
}
return pls, nil
}
func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, folder *model.Folder) (*model.Playlist, error) {
pls, err := s.newSyncedPlaylist(folder.AbsolutePath(), playlistFile)
if err != nil {
return nil, err
}
file, err := os.Open(pls.Path)
if err != nil {
return nil, err
}
defer file.Close()
reader := ioutils.UTF8Reader(file)
extension := strings.ToLower(filepath.Ext(playlistFile))
switch extension {
case ".nsp":
err = s.parseNSP(ctx, pls, reader)
default:
err = s.parseM3U(ctx, pls, folder, reader)
}
return pls, err
}
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
owner, _ := request.UserFrom(ctx)
// Try to find existing playlist by path. Since filesystem normalization differs across
// platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match
// playlists that may have been imported on a different platform.
pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
if errors.Is(err, model.ErrNotFound) {
// Try alternate normalization form
altPath := norm.NFD.String(newPls.Path)
if altPath == newPls.Path {
altPath = norm.NFC.String(newPls.Path)
}
if altPath != newPls.Path {
pls, err = s.ds.Playlist(ctx).FindByPath(altPath)
}
}
if err != nil && !errors.Is(err, model.ErrNotFound) {
return err
}
if err == nil && !pls.Sync {
log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path)
return nil
}
if err == nil {
log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path)
newPls.ID = pls.ID
newPls.Name = pls.Name
newPls.Comment = pls.Comment
newPls.OwnerID = pls.OwnerID
newPls.Public = pls.Public
newPls.UploadedImage = pls.UploadedImage // Preserve manual upload
newPls.EvaluatedAt = &time.Time{}
} else {
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
newPls.OwnerID = owner.ID
// For NSP files, Public may already be set from the file; for M3U, use server default
if !newPls.IsSmartPlaylist() {
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
}
}
return s.ds.Playlist(ctx).Put(newPls)
}

View File

@ -1,15 +1,17 @@
package core_test
package playlists_test
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"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"
"github.com/navidrome/navidrome/model/request"
@ -19,18 +21,18 @@ import (
"golang.org/x/text/unicode/norm"
)
var _ = Describe("Playlists", func() {
var _ = Describe("Playlists - Import", func() {
var ds *tests.MockDataStore
var ps core.Playlists
var mockPlsRepo mockedPlaylistRepo
var ps playlists.Playlists
var mockPlsRepo *tests.MockPlaylistRepo
var mockLibRepo *tests.MockLibraryRepo
ctx := context.Background()
BeforeEach(func() {
mockPlsRepo = mockedPlaylistRepo{}
mockPlsRepo = tests.CreateMockPlaylistRepo()
mockLibRepo = &tests.MockLibraryRepo{}
ds = &tests.MockDataStore{
MockedPlaylist: &mockPlsRepo,
MockedPlaylist: mockPlsRepo,
MockedLibrary: mockLibRepo,
}
ctx = request.WithUser(ctx, model.User{ID: "123"})
@ -39,7 +41,8 @@ var _ = Describe("Playlists", func() {
Describe("ImportFile", func() {
var folder *model.Folder
BeforeEach(func() {
ps = core.NewPlaylists(ds)
DeferCleanup(configtest.SetupConfig())
ps = playlists.NewPlaylists(ds)
ds.MockedMediaFile = &mockedMediaFileRepo{}
libPath, _ := os.Getwd()
// Set up library with the actual library path that matches the folder
@ -61,7 +64,7 @@ var _ = Describe("Playlists", func() {
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/playlists/test.ogg"))
Expect(mockPlsRepo.last).To(Equal(pls))
Expect(mockPlsRepo.Last).To(Equal(pls))
})
It("parses playlists using LF ending", func() {
@ -93,13 +96,220 @@ var _ = Describe("Playlists", func() {
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
})
It("parses #EXTALBUMARTURL with HTTP URL", func() {
conf.Server.EnableM3UExternalAlbumArt = true
pls, err := ps.ImportFile(ctx, folder, "pls-with-art-url.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal("https://example.com/cover.jpg"))
Expect(pls.Tracks).To(HaveLen(2))
})
It("parses #EXTALBUMARTURL with absolute local path", func() {
tmpDir := GinkgoT().TempDir()
imgPath := filepath.Join(tmpDir, "cover.jpg")
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
m3u := fmt.Sprintf("#EXTALBUMARTURL:%s\ntest.mp3\ntest.ogg\n", imgPath)
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}}
ps = playlists.NewPlaylists(ds)
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal(imgPath))
})
It("parses #EXTALBUMARTURL with relative local path", func() {
tmpDir := GinkgoT().TempDir()
Expect(os.WriteFile(filepath.Join(tmpDir, "cover.jpg"), []byte("fake image"), 0600)).To(Succeed())
m3u := "#EXTALBUMARTURL:cover.jpg\ntest.mp3\n"
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
ps = playlists.NewPlaylists(ds)
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal(filepath.Join(tmpDir, "cover.jpg")))
})
It("parses #EXTALBUMARTURL with file:// URL", func() {
tmpDir := GinkgoT().TempDir()
imgPath := filepath.Join(tmpDir, "my cover.jpg")
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
m3u := fmt.Sprintf("#EXTALBUMARTURL:file://%s\ntest.mp3\n", strings.ReplaceAll(imgPath, " ", "%20"))
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
ps = playlists.NewPlaylists(ds)
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal(imgPath))
})
It("preserves + in file:// URLs (PathUnescape, not QueryUnescape)", func() {
tmpDir := GinkgoT().TempDir()
imgPath := filepath.Join(tmpDir, "A+B.jpg")
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
m3u := fmt.Sprintf("#EXTALBUMARTURL:file://%s\ntest.mp3\n", imgPath)
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
ps = playlists.NewPlaylists(ds)
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal(imgPath))
})
It("rejects #EXTALBUMARTURL with absolute path outside library boundaries", func() {
tmpDir := GinkgoT().TempDir()
m3u := "#EXTALBUMARTURL:/etc/passwd\ntest.mp3\n"
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
ps = playlists.NewPlaylists(ds)
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
It("rejects #EXTALBUMARTURL with file:// URL outside library boundaries", func() {
tmpDir := GinkgoT().TempDir()
m3u := "#EXTALBUMARTURL:file:///etc/passwd\ntest.mp3\n"
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
ps = playlists.NewPlaylists(ds)
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
It("rejects #EXTALBUMARTURL with relative path escaping library", func() {
tmpDir := GinkgoT().TempDir()
m3u := "#EXTALBUMARTURL:../../etc/passwd\ntest.mp3\n"
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
ps = playlists.NewPlaylists(ds)
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
It("ignores HTTP #EXTALBUMARTURL when EnableM3UExternalAlbumArt is false", func() {
conf.Server.EnableM3UExternalAlbumArt = false
tmpDir := GinkgoT().TempDir()
m3u := "#EXTALBUMARTURL:https://example.com/cover.jpg\ntest.mp3\n"
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
ps = playlists.NewPlaylists(ds)
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
It("updates ExternalImageURL on re-scan even when UploadedImage is set", func() {
conf.Server.EnableM3UExternalAlbumArt = true
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
ps = playlists.NewPlaylists(ds)
m3u := "#EXTALBUMARTURL:https://example.com/new-cover.jpg\ntest.mp3\n"
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
existingPls := &model.Playlist{
ID: "existing-id",
Name: "Existing Playlist",
Path: plsFile,
Sync: true,
UploadedImage: "existing-id.jpg",
ExternalImageURL: "https://example.com/old-cover.jpg",
}
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.UploadedImage).To(Equal("existing-id.jpg"))
Expect(pls.ExternalImageURL).To(Equal("https://example.com/new-cover.jpg"))
})
It("clears ExternalImageURL on re-scan when directive is removed", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
ps = playlists.NewPlaylists(ds)
m3u := "test.mp3\n"
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
existingPls := &model.Playlist{
ID: "existing-id",
Name: "Existing Playlist",
Path: plsFile,
Sync: true,
ExternalImageURL: "https://example.com/old-cover.jpg",
}
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
})
Describe("NSP", func() {
It("parses well-formed playlists", func() {
pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.last).To(Equal(pls))
Expect(mockPlsRepo.Last).To(Equal(pls))
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Name).To(Equal("Recently Played"))
Expect(pls.Comment).To(Equal("Recently played tracks"))
@ -125,7 +335,6 @@ var _ = Describe("Playlists", func() {
Expect(pls.Public).To(BeFalse())
})
It("uses server default when public field is absent", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultPlaylistPublicVisibility = true
pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
@ -149,7 +358,7 @@ var _ = Describe("Playlists", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}}
ps = core.NewPlaylists(ds)
ps = playlists.NewPlaylists(ds)
// Create the playlist file on disk with the filesystem's normalization form
plsFile := tmpDir + "/" + filesystemName + ".m3u"
@ -163,7 +372,7 @@ var _ = Describe("Playlists", func() {
Path: storedPath,
Sync: true,
}
mockPlsRepo.data = map[string]*model.Playlist{storedPath: existingPls}
mockPlsRepo.PathMap = map[string]*model.Playlist{storedPath: existingPls}
// Import using the filesystem's normalization form
plsFolder := &model.Folder{
@ -209,7 +418,7 @@ var _ = Describe("Playlists", func() {
"def.mp3", // This is playlists/def.mp3 relative to plsDir
},
}
ps = core.NewPlaylists(ds)
ps = playlists.NewPlaylists(ds)
})
It("handles relative paths that reference files in other libraries", func() {
@ -365,7 +574,7 @@ var _ = Describe("Playlists", func() {
},
}
// Recreate playlists service to pick up new mock
ps = core.NewPlaylists(ds)
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"
@ -408,7 +617,7 @@ var _ = Describe("Playlists", func() {
BeforeEach(func() {
repo = &mockedMediaFileFromListRepo{}
ds.MockedMediaFile = repo
ps = core.NewPlaylists(ds)
ps = playlists.NewPlaylists(ds)
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
@ -439,7 +648,7 @@ var _ = Describe("Playlists", func() {
Expect(pls.Tracks[1].Path).To(Equal("tests/test.ogg"))
Expect(pls.Tracks[2].Path).To(Equal("downloads/newfile.flac"))
Expect(pls.Tracks[3].Path).To(Equal("tests/01 Invisible (RED) Edit Version.mp3"))
Expect(mockPlsRepo.last).To(Equal(pls))
Expect(mockPlsRepo.Last).To(Equal(pls))
})
It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
@ -460,7 +669,7 @@ var _ = Describe("Playlists", func() {
Expect(pls.Tracks).To(HaveLen(2))
})
It("returns only tracks that exist in the database and in the same other as the m3u", func() {
It("returns only tracks that exist in the database and in the same order as the m3u", func() {
repo.data = []string{
"album1/test1.mp3",
"album2/test2.mp3",
@ -495,6 +704,24 @@ var _ = Describe("Playlists", func() {
Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
})
It("parses #EXTALBUMARTURL with HTTP URL via ImportM3U", func() {
conf.Server.EnableM3UExternalAlbumArt = true
repo.data = []string{"tests/test.mp3"}
m3u := "#EXTALBUMARTURL:https://example.com/cover.jpg\n/music/tests/test.mp3\n"
pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u))
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal("https://example.com/cover.jpg"))
})
It("ignores relative #EXTALBUMARTURL when imported via API (no folder context)", func() {
repo.data = []string{"tests/test.mp3"}
m3u := "#EXTALBUMARTURL:cover.jpg\n/music/tests/test.mp3\n"
pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u))
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
// Fullwidth characters (e.g., ) are not handled by SQLite's NOCASE collation,
// so we need exact matching for non-ASCII characters.
It("matches fullwidth characters exactly (SQLite NOCASE limitation)", func() {
@ -570,7 +797,7 @@ var _ = Describe("Playlists", func() {
})
Describe("InPlaylistsPath", func() {
Describe("InPath", func() {
var folder model.Folder
BeforeEach(func() {
@ -584,27 +811,27 @@ var _ = Describe("Playlists", func() {
It("returns true if PlaylistsPath is empty", func() {
conf.Server.PlaylistsPath = ""
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
Expect(playlists.InPath(folder)).To(BeTrue())
})
It("returns true if PlaylistsPath is any (**/**)", func() {
conf.Server.PlaylistsPath = "**/**"
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
Expect(playlists.InPath(folder)).To(BeTrue())
})
It("returns true if folder is in PlaylistsPath", func() {
conf.Server.PlaylistsPath = "other/**:playlists/**"
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
Expect(playlists.InPath(folder)).To(BeTrue())
})
It("returns false if folder is not in PlaylistsPath", func() {
conf.Server.PlaylistsPath = "other"
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
Expect(playlists.InPath(folder)).To(BeFalse())
})
It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() {
conf.Server.PlaylistsPath = "."
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
Expect(playlists.InPath(folder)).To(BeFalse())
folder2 := model.Folder{
LibraryPath: "/music",
@ -612,7 +839,7 @@ var _ = Describe("Playlists", func() {
Name: ".",
}
Expect(core.InPlaylistsPath(folder2)).To(BeTrue())
Expect(playlists.InPath(folder2)).To(BeTrue())
})
})
})
@ -693,23 +920,3 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi
}
return mfs, nil
}
type mockedPlaylistRepo struct {
last *model.Playlist
data map[string]*model.Playlist // keyed by path
model.PlaylistRepository
}
func (r *mockedPlaylistRepo) FindByPath(path string) (*model.Playlist, error) {
if r.data != nil {
if pls, ok := r.data[path]; ok {
return pls, nil
}
}
return nil, model.ErrNotFound
}
func (r *mockedPlaylistRepo) Put(pls *model.Playlist) error {
r.last = pls
return nil
}

View File

@ -1,183 +1,29 @@
package core
package playlists
import (
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"time"
"github.com/RaveNoX/go-jsoncommentstrip"
"github.com/bmatcuk/doublestar/v4"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/ioutils"
"github.com/navidrome/navidrome/utils/slice"
"golang.org/x/text/unicode/norm"
)
type Playlists interface {
ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
}
type playlists struct {
ds model.DataStore
}
func NewPlaylists(ds model.DataStore) Playlists {
return &playlists{ds: ds}
}
func InPlaylistsPath(folder model.Folder) bool {
if conf.Server.PlaylistsPath == "" {
return true
}
rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
if match, _ := doublestar.Match(path, rel); match {
return true
}
}
return false
}
func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
pls, err := s.parsePlaylist(ctx, filename, folder)
if err != nil {
log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
return nil, err
}
log.Debug("Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks))
err = s.updatePlaylist(ctx, pls)
if err != nil {
log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
}
return pls, err
}
func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) {
owner, _ := request.UserFrom(ctx)
pls := &model.Playlist{
OwnerID: owner.ID,
Public: false,
Sync: false,
}
err := s.parseM3U(ctx, pls, nil, reader)
if err != nil {
log.Error(ctx, "Error parsing playlist", err)
return nil, err
}
err = s.ds.Playlist(ctx).Put(pls)
if err != nil {
log.Error(ctx, "Error saving playlist", err)
return nil, err
}
return pls, nil
}
func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, folder *model.Folder) (*model.Playlist, error) {
pls, err := s.newSyncedPlaylist(folder.AbsolutePath(), playlistFile)
if err != nil {
return nil, err
}
file, err := os.Open(pls.Path)
if err != nil {
return nil, err
}
defer file.Close()
reader := ioutils.UTF8Reader(file)
extension := strings.ToLower(filepath.Ext(playlistFile))
switch extension {
case ".nsp":
err = s.parseNSP(ctx, pls, reader)
default:
err = s.parseM3U(ctx, pls, folder, reader)
}
return pls, err
}
func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
playlistPath := filepath.Join(baseDir, playlistFile)
info, err := os.Stat(playlistPath)
if err != nil {
return nil, err
}
var extension = filepath.Ext(playlistFile)
var name = playlistFile[0 : len(playlistFile)-len(extension)]
pls := &model.Playlist{
Name: name,
Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile),
Public: false,
Path: playlistPath,
Sync: true,
UpdatedAt: info.ModTime(),
}
return pls, nil
}
func getPositionFromOffset(data []byte, offset int64) (line, column int) {
line = 1
for _, b := range data[:offset] {
if b == '\n' {
line++
column = 1
} else {
column++
}
}
return
}
func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.Reader) error {
nsp := &nspFile{}
reader = io.LimitReader(reader, 100*1024) // Limit to 100KB
reader = jsoncommentstrip.NewReader(reader)
input, err := io.ReadAll(reader)
if err != nil {
return fmt.Errorf("reading SmartPlaylist: %w", err)
}
err = json.Unmarshal(input, nsp)
if err != nil {
var syntaxErr *json.SyntaxError
if errors.As(err, &syntaxErr) {
line, col := getPositionFromOffset(input, syntaxErr.Offset)
return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err)
}
return fmt.Errorf("JSON parsing error in SmartPlaylist: %w", err)
}
pls.Rules = &nsp.Criteria
if nsp.Name != "" {
pls.Name = nsp.Name
}
if nsp.Comment != "" {
pls.Comment = nsp.Comment
}
if nsp.Public != nil {
pls.Public = *nsp.Public
} else {
pls.Public = conf.Server.DefaultPlaylistPublicVisibility
}
return nil
}
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error {
mediaFileRepository := s.ds.MediaFile(ctx)
resolver, err := newPathResolver(ctx, s.ds)
if err != nil {
return err
}
var mfs model.MediaFiles
// Chunk size of 100 lines, as each line can generate up to 4 lookup candidates
// (NFC/NFD × raw/lowercase), and SQLite has a max expression tree depth of 1000.
@ -189,20 +35,24 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
pls.Name = line[len("#PLAYLIST:"):]
continue
}
if after, ok := strings.CutPrefix(line, "#EXTALBUMARTURL:"); ok {
pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher)
continue
}
// Skip empty lines and extended info
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if after, ok := strings.CutPrefix(line, "file://"); ok {
line = after
line, _ = url.QueryUnescape(line)
line, _ = url.PathUnescape(line)
}
if !model.IsAudioFile(line) {
continue
}
filteredLines = append(filteredLines, line)
}
resolvedPaths, err := s.resolvePaths(ctx, folder, filteredLines)
resolvedPaths, err := resolver.resolvePaths(ctx, folder, filteredLines)
if err != nil {
log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err)
continue
@ -258,7 +108,9 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
existing[key] = idx
}
// Find media files in the order of the resolved paths, to keep playlist order
// Find media files in the order of the resolved paths, to keep playlist order.
// Both `existing` keys and `resolvedPaths` use the library-qualified format "libraryID:relativePath",
// so normalizing the full string produces matching keys (digits and ':' are ASCII-invariant).
for _, path := range resolvedPaths {
key := strings.ToLower(norm.NFC.String(path))
idx, ok := existing[key]
@ -398,15 +250,10 @@ func (r *pathResolver) findInLibraries(absolutePath string) pathResolution {
// resolvePaths converts playlist file paths to library-qualified paths (format: "libraryID:relativePath").
// For relative paths, it resolves them to absolute paths first, then determines which
// library they belong to. This allows playlists to reference files across library boundaries.
func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
resolver, err := newPathResolver(ctx, s.ds)
if err != nil {
return nil, err
}
func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
results := make([]string, 0, len(lines))
for idx, line := range lines {
resolution := resolver.resolvePath(line, folder)
resolution := r.resolvePath(line, folder)
if !resolution.valid {
log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
@ -426,122 +273,52 @@ func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, line
return results, nil
}
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
owner, _ := request.UserFrom(ctx)
// Try to find existing playlist by path. Since filesystem normalization differs across
// platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match
// playlists that may have been imported on a different platform.
pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
if errors.Is(err, model.ErrNotFound) {
// Try alternate normalization form
altPath := norm.NFD.String(newPls.Path)
if altPath == newPls.Path {
altPath = norm.NFC.String(newPls.Path)
}
if altPath != newPls.Path {
pls, err = s.ds.Playlist(ctx).FindByPath(altPath)
}
}
if err != nil && !errors.Is(err, model.ErrNotFound) {
return err
}
if err == nil && !pls.Sync {
log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path)
return nil
// resolveImageURL resolves an #EXTALBUMARTURL value to a storable string.
// HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt).
// Local paths (file://, absolute, or relative) are resolved to an absolute path
// and validated against known library boundaries via matcher.
func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if err == nil {
log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path)
newPls.ID = pls.ID
newPls.Name = pls.Name
newPls.Comment = pls.Comment
newPls.OwnerID = pls.OwnerID
newPls.Public = pls.Public
newPls.EvaluatedAt = &time.Time{}
} else {
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
newPls.OwnerID = owner.ID
// For NSP files, Public may already be set from the file; for M3U, use server default
if !newPls.IsSmartPlaylist() {
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
// HTTP(S) URLs — store as-is, but only if external album art is enabled
if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
if !conf.Server.EnableM3UExternalAlbumArt {
return ""
}
return value
}
return s.ds.Playlist(ctx).Put(newPls)
// Resolve to local absolute path
localPath, ok := resolveLocalPath(value, folder)
if !ok {
return ""
}
// Validate path is within a known library
if libID, _ := matcher.findLibraryForPath(localPath); libID == 0 {
return ""
}
return localPath
}
func (s *playlists) Update(ctx context.Context, playlistID string,
name *string, comment *string, public *bool,
idsToAdd []string, idxToRemove []int) error {
needsInfoUpdate := name != nil || comment != nil || public != nil
needsTrackRefresh := len(idxToRemove) > 0
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
var pls *model.Playlist
var err error
repo := tx.Playlist(ctx)
tracks := repo.Tracks(playlistID, true)
if tracks == nil {
return fmt.Errorf("%w: playlist '%s'", model.ErrNotFound, playlistID)
}
if needsTrackRefresh {
pls, err = repo.GetWithTracks(playlistID, true, false)
pls.RemoveTracks(idxToRemove)
pls.AddMediaFilesByID(idsToAdd)
} else {
if len(idsToAdd) > 0 {
_, err = tracks.Add(idsToAdd)
if err != nil {
return err
}
}
if needsInfoUpdate {
pls, err = repo.Get(playlistID)
}
}
// resolveLocalPath converts a file://, absolute, or relative path to a clean absolute path.
// Returns ("", false) if the path cannot be resolved.
func resolveLocalPath(value string, folder *model.Folder) (string, bool) {
if after, ok := strings.CutPrefix(value, "file://"); ok {
decoded, err := url.PathUnescape(after)
if err != nil {
return err
return "", false
}
if !needsTrackRefresh && !needsInfoUpdate {
return nil
}
if name != nil {
pls.Name = *name
}
if comment != nil {
pls.Comment = *comment
}
if public != nil {
pls.Public = *public
}
// Special case: The playlist is now empty
if len(idxToRemove) > 0 && len(pls.Tracks) == 0 {
if err = tracks.DeleteAll(); err != nil {
return err
}
}
return repo.Put(pls)
})
}
type nspFile struct {
criteria.Criteria
Name string `json:"name"`
Comment string `json:"comment"`
Public *bool `json:"public"`
}
func (i *nspFile) UnmarshalJSON(data []byte) error {
m := map[string]any{}
err := json.Unmarshal(data, &m)
if err != nil {
return err
return filepath.Clean(decoded), true
}
i.Name, _ = m["name"].(string)
i.Comment, _ = m["comment"].(string)
if public, ok := m["public"].(bool); ok {
i.Public = &public
if filepath.IsAbs(value) {
return filepath.Clean(value), true
}
return json.Unmarshal(data, &i.Criteria)
if folder == nil {
return "", false
}
return filepath.Clean(filepath.Join(folder.AbsolutePath(), value)), true
}

View File

@ -1,4 +1,4 @@
package core
package playlists
import (
"context"
@ -214,38 +214,38 @@ var _ = Describe("pathResolver", func() {
})
Describe("resolvePath", func() {
It("resolves absolute paths", func() {
resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
Context("basic", func() {
It("resolves absolute paths", func() {
resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.libraryPath).To(Equal("/music"))
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.libraryPath).To(Equal("/music"))
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
})
It("resolves relative paths when folder is provided", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
})
It("returns invalid resolution for paths outside any library", func() {
resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
Expect(resolution.valid).To(BeFalse())
})
})
It("resolves relative paths when folder is provided", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
})
It("returns invalid resolution for paths outside any library", func() {
resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
Expect(resolution.valid).To(BeFalse())
})
})
Describe("resolvePath", func() {
Context("With absolute paths", func() {
Context("cross-library", func() {
It("resolves path within a library", func() {
resolution := resolver.resolvePath("/music/track.mp3", nil)

103
core/playlists/parse_nsp.go Normal file
View File

@ -0,0 +1,103 @@
package playlists
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/RaveNoX/go-jsoncommentstrip"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
)
func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
playlistPath := filepath.Join(baseDir, playlistFile)
info, err := os.Stat(playlistPath)
if err != nil {
return nil, err
}
var extension = filepath.Ext(playlistFile)
var name = playlistFile[0 : len(playlistFile)-len(extension)]
pls := &model.Playlist{
Name: name,
Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile),
Public: false,
Path: playlistPath,
Sync: true,
UpdatedAt: info.ModTime(),
}
return pls, nil
}
func getPositionFromOffset(data []byte, offset int64) (line, column int) {
line = 1
for _, b := range data[:offset] {
if b == '\n' {
line++
column = 1
} else {
column++
}
}
return
}
func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.Reader) error {
nsp := &nspFile{}
reader = io.LimitReader(reader, 100*1024) // Limit to 100KB
reader = jsoncommentstrip.NewReader(reader)
input, err := io.ReadAll(reader)
if err != nil {
return fmt.Errorf("reading SmartPlaylist: %w", err)
}
err = json.Unmarshal(input, nsp)
if err != nil {
var syntaxErr *json.SyntaxError
if errors.As(err, &syntaxErr) {
line, col := getPositionFromOffset(input, syntaxErr.Offset)
return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err)
}
return fmt.Errorf("JSON parsing error in SmartPlaylist: %w", err)
}
pls.Rules = &nsp.Criteria
if nsp.Name != "" {
pls.Name = nsp.Name
}
if nsp.Comment != "" {
pls.Comment = nsp.Comment
}
if nsp.Public != nil {
pls.Public = *nsp.Public
} else {
pls.Public = conf.Server.DefaultPlaylistPublicVisibility
}
return nil
}
type nspFile struct {
criteria.Criteria
Name string `json:"name"`
Comment string `json:"comment"`
Public *bool `json:"public"`
}
func (i *nspFile) UnmarshalJSON(data []byte) error {
m := map[string]any{}
err := json.Unmarshal(data, &m)
if err != nil {
return err
}
i.Name, _ = m["name"].(string)
i.Comment, _ = m["comment"].(string)
if public, ok := m["public"].(bool); ok {
i.Public = &public
}
return json.Unmarshal(data, &i.Criteria)
}

View File

@ -0,0 +1,228 @@
package playlists
import (
"context"
"os"
"path/filepath"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("parseNSP", func() {
var s *playlists
ctx := context.Background()
BeforeEach(func() {
s = &playlists{}
})
It("parses a well-formed NSP with all fields", func() {
nsp := `{
"name": "My Smart Playlist",
"comment": "A test playlist",
"public": true,
"all": [{"is": {"loved": true}}],
"sort": "title",
"order": "asc",
"limit": 50
}`
pls := &model.Playlist{Name: "default-name"}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("My Smart Playlist"))
Expect(pls.Comment).To(Equal("A test playlist"))
Expect(pls.Public).To(BeTrue())
Expect(pls.Rules).ToNot(BeNil())
Expect(pls.Rules.Sort).To(Equal("title"))
Expect(pls.Rules.Order).To(Equal("asc"))
Expect(pls.Rules.Limit).To(Equal(50))
Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
})
It("keeps existing name when NSP has no name field", func() {
nsp := `{"all": [{"is": {"loved": true}}]}`
pls := &model.Playlist{Name: "Original Name"}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("Original Name"))
})
It("keeps existing comment when NSP has no comment field", func() {
nsp := `{"all": [{"is": {"loved": true}}]}`
pls := &model.Playlist{Comment: "Original Comment"}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
Expect(pls.Comment).To(Equal("Original Comment"))
})
It("strips JSON comments before parsing", func() {
nsp := `{
// Line comment
"name": "Commented Playlist",
/* Block comment */
"all": [{"is": {"loved": true}}]
}`
pls := &model.Playlist{}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("Commented Playlist"))
})
It("uses server default when public field is absent", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultPlaylistPublicVisibility = true
nsp := `{"all": [{"is": {"loved": true}}]}`
pls := &model.Playlist{}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
Expect(pls.Public).To(BeTrue())
})
It("honors explicit public: false over server default", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultPlaylistPublicVisibility = true
nsp := `{"public": false, "all": [{"is": {"loved": true}}]}`
pls := &model.Playlist{}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
Expect(pls.Public).To(BeFalse())
})
It("returns a syntax error with line and column info", func() {
nsp := "{\n \"name\": \"Bad\",\n \"all\": [INVALID]\n}"
pls := &model.Playlist{}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("JSON syntax error in SmartPlaylist"))
Expect(err.Error()).To(MatchRegexp(`line \d+, column \d+`))
})
It("returns a parsing error for completely invalid JSON", func() {
nsp := `not json at all`
pls := &model.Playlist{}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("SmartPlaylist"))
})
It("gracefully handles non-string name field", func() {
nsp := `{"name": 123, "all": [{"is": {"loved": true}}]}`
pls := &model.Playlist{Name: "Original"}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
// Type assertion in UnmarshalJSON fails silently; name stays as original
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": [
{"is": {"loved": true}},
{"contains": {"title": "rock"}}
],
"sort": "lastPlayed",
"order": "desc",
"limit": 100
}`
pls := &model.Playlist{}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
Expect(pls.Rules).ToNot(BeNil())
Expect(pls.Rules.Sort).To(Equal("lastPlayed"))
Expect(pls.Rules.Order).To(Equal("desc"))
Expect(pls.Rules.Limit).To(Equal(100))
})
})
var _ = Describe("getPositionFromOffset", func() {
It("returns correct position on first line", func() {
data := []byte("hello world")
line, col := getPositionFromOffset(data, 5)
Expect(line).To(Equal(1))
Expect(col).To(Equal(5))
})
It("returns correct position after newlines", func() {
data := []byte("line1\nline2\nline3")
// Offsets: l(0) i(1) n(2) e(3) 1(4) \n(5) l(6) i(7) n(8)
line, col := getPositionFromOffset(data, 8)
Expect(line).To(Equal(2))
Expect(col).To(Equal(3))
})
It("returns correct position at start of new line", func() {
data := []byte("line1\nline2")
// After \n at offset 5, col resets to 1; offset 6 is 'l' -> col=1
line, col := getPositionFromOffset(data, 6)
Expect(line).To(Equal(2))
Expect(col).To(Equal(1))
})
It("handles multiple newlines", func() {
data := []byte("a\nb\nc\nd")
// a(0) \n(1) b(2) \n(3) c(4) \n(5) d(6)
line, col := getPositionFromOffset(data, 6)
Expect(line).To(Equal(4))
Expect(col).To(Equal(1))
})
})
var _ = Describe("newSyncedPlaylist", func() {
var s *playlists
BeforeEach(func() {
s = &playlists{}
})
It("creates a synced playlist with correct attributes", func() {
tmpDir := GinkgoT().TempDir()
Expect(os.WriteFile(filepath.Join(tmpDir, "test.m3u"), []byte("content"), 0600)).To(Succeed())
pls, err := s.newSyncedPlaylist(tmpDir, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("test"))
Expect(pls.Comment).To(Equal("Auto-imported from 'test.m3u'"))
Expect(pls.Public).To(BeFalse())
Expect(pls.Path).To(Equal(filepath.Join(tmpDir, "test.m3u")))
Expect(pls.Sync).To(BeTrue())
Expect(pls.UpdatedAt).ToNot(BeZero())
})
It("strips extension from filename to derive name", func() {
tmpDir := GinkgoT().TempDir()
Expect(os.WriteFile(filepath.Join(tmpDir, "My Favorites.nsp"), []byte("{}"), 0600)).To(Succeed())
pls, err := s.newSyncedPlaylist(tmpDir, "My Favorites.nsp")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("My Favorites"))
})
It("returns error for non-existent file", func() {
tmpDir := GinkgoT().TempDir()
_, err := s.newSyncedPlaylist(tmpDir, "nonexistent.m3u")
Expect(err).To(HaveOccurred())
})
})

335
core/playlists/playlists.go Normal file
View File

@ -0,0 +1,335 @@
package playlists
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/bmatcuk/doublestar/v4"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
)
type Playlists interface {
// Reads
GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error)
Get(ctx context.Context, id string) (*model.Playlist, error)
GetWithTracks(ctx context.Context, id string) (*model.Playlist, error)
GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error)
// Mutations
Create(ctx context.Context, playlistId string, name string, ids []string) (string, error)
Delete(ctx context.Context, id string) error
Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
// Track management
AddTracks(ctx context.Context, playlistID string, ids []string) (int, error)
AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error)
AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error)
AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error)
RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error
ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error
// Cover art
SetImage(ctx context.Context, playlistID string, reader io.Reader, ext string) error
RemoveImage(ctx context.Context, playlistID string) error
// Import
ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
// REST adapters (follows Share/Library pattern)
NewRepository(ctx context.Context) rest.Repository
TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository
}
type playlists struct {
ds model.DataStore
}
func NewPlaylists(ds model.DataStore) Playlists {
return &playlists{ds: ds}
}
func InPath(folder model.Folder) bool {
if conf.Server.PlaylistsPath == "" {
return true
}
rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
if match, _ := doublestar.Match(path, rel); match {
return true
}
}
return false
}
// --- Read operations ---
func (s *playlists) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) {
return s.ds.Playlist(ctx).GetAll(options...)
}
func (s *playlists) Get(ctx context.Context, id string) (*model.Playlist, error) {
return s.ds.Playlist(ctx).Get(id)
}
func (s *playlists) GetWithTracks(ctx context.Context, id string) (*model.Playlist, error) {
return s.ds.Playlist(ctx).GetWithTracks(id, true, false)
}
func (s *playlists) GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) {
return s.ds.Playlist(ctx).GetPlaylists(mediaFileId)
}
// --- Mutation operations ---
// Create creates a new playlist (when name is provided) or replaces tracks on an existing
// playlist (when playlistId is provided). This matches the Subsonic createPlaylist semantics.
func (s *playlists) Create(ctx context.Context, playlistId string, name string, ids []string) (string, error) {
usr, _ := request.UserFrom(ctx)
err := s.ds.WithTxImmediate(func(tx model.DataStore) error {
var pls *model.Playlist
var err error
if playlistId != "" {
pls, err = tx.Playlist(ctx).Get(playlistId)
if err != nil {
return err
}
if pls.IsSmartPlaylist() {
return model.ErrNotAuthorized
}
if !usr.IsAdmin && pls.OwnerID != usr.ID {
return model.ErrNotAuthorized
}
} else {
pls = &model.Playlist{Name: name}
pls.OwnerID = usr.ID
}
pls.Tracks = nil
pls.AddMediaFilesByID(ids)
err = tx.Playlist(ctx).Put(pls)
playlistId = pls.ID
return err
})
return playlistId, err
}
func (s *playlists) Delete(ctx context.Context, id string) error {
pls, err := s.checkWritable(ctx, id)
if err != nil {
return err
}
// Clean up custom cover image file if one exists
if path := pls.UploadedImagePath(); path != "" {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
log.Warn(ctx, "Failed to remove playlist image on delete", "path", path, err)
}
}
return s.ds.Playlist(ctx).Delete(id)
}
func (s *playlists) Update(ctx context.Context, playlistID string,
name *string, comment *string, public *bool,
idsToAdd []string, idxToRemove []int) error {
var pls *model.Playlist
var err error
hasTrackChanges := len(idsToAdd) > 0 || len(idxToRemove) > 0
if hasTrackChanges {
pls, err = s.checkTracksEditable(ctx, playlistID)
} else {
pls, err = s.checkWritable(ctx, playlistID)
}
if err != nil {
return err
}
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
repo := tx.Playlist(ctx)
if len(idxToRemove) > 0 {
tracksRepo := repo.Tracks(playlistID, false)
// Convert 0-based indices to 1-based position IDs and delete them directly,
// avoiding the need to load all tracks into memory.
positions := make([]string, len(idxToRemove))
for i, idx := range idxToRemove {
positions[i] = strconv.Itoa(idx + 1)
}
if err := tracksRepo.Delete(positions...); err != nil {
return err
}
if len(idsToAdd) > 0 {
if _, err := tracksRepo.Add(idsToAdd); err != nil {
return err
}
}
return s.updateMetadata(ctx, tx, pls, name, comment, public)
}
if len(idsToAdd) > 0 {
if _, err := repo.Tracks(playlistID, false).Add(idsToAdd); err != nil {
return err
}
}
if name == nil && comment == nil && public == nil {
return nil
}
// Reuse the playlist from checkWritable (no tracks loaded, so Put only refreshes counters)
return s.updateMetadata(ctx, tx, pls, name, comment, public)
})
}
// --- Permission helpers ---
// checkWritable fetches the playlist and verifies the current user can modify it.
func (s *playlists) checkWritable(ctx context.Context, id string) (*model.Playlist, error) {
pls, err := s.ds.Playlist(ctx).Get(id)
if err != nil {
return nil, err
}
usr, _ := request.UserFrom(ctx)
if !usr.IsAdmin && pls.OwnerID != usr.ID {
return nil, model.ErrNotAuthorized
}
return pls, nil
}
// checkTracksEditable verifies the user can modify tracks (ownership + not smart playlist).
func (s *playlists) checkTracksEditable(ctx context.Context, playlistID string) (*model.Playlist, error) {
pls, err := s.checkWritable(ctx, playlistID)
if err != nil {
return nil, err
}
if pls.IsSmartPlaylist() {
return nil, model.ErrNotAuthorized
}
return pls, nil
}
// updateMetadata applies optional metadata changes to a playlist and persists it.
// Accepts a DataStore parameter so it can be used inside transactions.
// The caller is responsible for permission checks.
func (s *playlists) updateMetadata(ctx context.Context, ds model.DataStore, pls *model.Playlist, name *string, comment *string, public *bool) error {
if name != nil {
pls.Name = *name
}
if comment != nil {
pls.Comment = *comment
}
if public != nil {
pls.Public = *public
}
return ds.Playlist(ctx).Put(pls)
}
// --- Track management operations ---
func (s *playlists) AddTracks(ctx context.Context, playlistID string, ids []string) (int, error) {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return 0, err
}
return s.ds.Playlist(ctx).Tracks(playlistID, false).Add(ids)
}
func (s *playlists) AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return 0, err
}
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddAlbums(albumIds)
}
func (s *playlists) AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error) {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return 0, err
}
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddArtists(artistIds)
}
func (s *playlists) AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error) {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return 0, err
}
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddDiscs(discs)
}
func (s *playlists) RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return err
}
return s.ds.WithTx(func(tx model.DataStore) error {
return tx.Playlist(ctx).Tracks(playlistID, false).Delete(trackIds...)
})
}
func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return err
}
return s.ds.WithTx(func(tx model.DataStore) error {
return tx.Playlist(ctx).Tracks(playlistID, false).Reorder(pos, newPos)
})
}
// --- Cover art operations ---
func (s *playlists) SetImage(ctx context.Context, playlistID string, reader io.Reader, ext string) error {
pls, err := s.checkWritable(ctx, playlistID)
if err != nil {
return err
}
filename := pls.ImageFilename(ext)
oldPath := pls.UploadedImagePath()
pls.UploadedImage = filename
absPath := pls.UploadedImagePath()
if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
return fmt.Errorf("creating playlist images directory: %w", err)
}
// Remove old image if it exists
if oldPath != "" {
if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) {
log.Warn(ctx, "Failed to remove old playlist image", "path", oldPath, err)
}
}
// Save new image
f, err := os.Create(absPath)
if err != nil {
return fmt.Errorf("creating playlist image file: %w", err)
}
defer f.Close()
if _, err := io.Copy(f, reader); err != nil {
return fmt.Errorf("writing playlist image file: %w", err)
}
return s.ds.Playlist(ctx).Put(pls)
}
func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error {
pls, err := s.checkWritable(ctx, playlistID)
if 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 = ""
return s.ds.Playlist(ctx).Put(pls)
}

View File

@ -0,0 +1,17 @@
package playlists_test
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestPlaylists(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Playlists Suite")
}

View File

@ -0,0 +1,417 @@
package playlists_test
import (
"context"
"os"
"path/filepath"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"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"
. "github.com/onsi/gomega"
)
var _ = Describe("Playlists", func() {
var ds *tests.MockDataStore
var ps playlists.Playlists
var mockPlsRepo *tests.MockPlaylistRepo
ctx := context.Background()
BeforeEach(func() {
mockPlsRepo = tests.CreateMockPlaylistRepo()
ds = &tests.MockDataStore{
MockedPlaylist: mockPlsRepo,
MockedLibrary: &tests.MockLibraryRepo{},
}
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
Describe("Delete", func() {
var mockTracks *tests.MockPlaylistTrackRepo
BeforeEach(func() {
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 3}
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds)
})
It("allows owner to delete their playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.Delete(ctx, "pls-1")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
})
It("allows admin to delete any playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
err := ps.Delete(ctx, "pls-1")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
})
It("denies non-owner, non-admin from deleting", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
err := ps.Delete(ctx, "pls-1")
Expect(err).To(MatchError(model.ErrNotAuthorized))
Expect(mockPlsRepo.Deleted).To(BeEmpty())
})
It("returns error when playlist not found", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.Delete(ctx, "nonexistent")
Expect(err).To(Equal(model.ErrNotFound))
})
})
Describe("Create", func() {
BeforeEach(func() {
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "Existing", OwnerID: "user-1"},
"pls-2": {ID: "pls-2", Name: "Other's", OwnerID: "other-user"},
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
}
ps = playlists.NewPlaylists(ds)
})
It("creates a new playlist with owner set from context", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
id, err := ps.Create(ctx, "", "New Playlist", []string{"song-1", "song-2"})
Expect(err).ToNot(HaveOccurred())
Expect(id).ToNot(BeEmpty())
Expect(mockPlsRepo.Last.Name).To(Equal("New Playlist"))
Expect(mockPlsRepo.Last.OwnerID).To(Equal("user-1"))
})
It("replaces tracks on existing playlist when owner matches", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
id, err := ps.Create(ctx, "pls-1", "", []string{"song-3"})
Expect(err).ToNot(HaveOccurred())
Expect(id).To(Equal("pls-1"))
Expect(mockPlsRepo.Last.Tracks).To(HaveLen(1))
})
It("allows admin to replace tracks on any playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
id, err := ps.Create(ctx, "pls-2", "", []string{"song-3"})
Expect(err).ToNot(HaveOccurred())
Expect(id).To(Equal("pls-2"))
})
It("denies non-owner, non-admin from replacing tracks on existing playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
_, err := ps.Create(ctx, "pls-2", "", []string{"song-3"})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("returns error when existing playlistId not found", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
_, err := ps.Create(ctx, "nonexistent", "", []string{"song-1"})
Expect(err).To(Equal(model.ErrNotFound))
})
It("denies replacing tracks on a smart playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
_, err := ps.Create(ctx, "pls-smart", "", []string{"song-1"})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
})
Describe("Update", func() {
var mockTracks *tests.MockPlaylistTrackRepo
BeforeEach(func() {
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2}
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds)
})
It("allows owner to update their playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
newName := "Updated Name"
err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
Expect(err).ToNot(HaveOccurred())
})
It("allows admin to update any playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
newName := "Updated Name"
err := ps.Update(ctx, "pls-other", &newName, nil, nil, nil, nil)
Expect(err).ToNot(HaveOccurred())
})
It("denies non-owner, non-admin from updating", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
newName := "Updated Name"
err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("returns error when playlist not found", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
newName := "Updated Name"
err := ps.Update(ctx, "nonexistent", &newName, nil, nil, nil, nil)
Expect(err).To(Equal(model.ErrNotFound))
})
It("denies adding tracks to a smart playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.Update(ctx, "pls-smart", nil, nil, nil, []string{"song-1"}, nil)
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("denies removing tracks from a smart playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.Update(ctx, "pls-smart", nil, nil, nil, nil, []int{0})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("allows metadata updates on a smart playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
newName := "Updated Smart"
err := ps.Update(ctx, "pls-smart", &newName, nil, nil, nil, nil)
Expect(err).ToNot(HaveOccurred())
})
})
Describe("AddTracks", func() {
var mockTracks *tests.MockPlaylistTrackRepo
BeforeEach(func() {
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2}
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds)
})
It("allows owner to add tracks", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
count, err := ps.AddTracks(ctx, "pls-1", []string{"song-1", "song-2"})
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(2))
Expect(mockTracks.AddedIds).To(ConsistOf("song-1", "song-2"))
})
It("allows admin to add tracks to any playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
count, err := ps.AddTracks(ctx, "pls-other", []string{"song-1"})
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(2))
})
It("denies non-owner, non-admin", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
_, err := ps.AddTracks(ctx, "pls-1", []string{"song-1"})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("denies editing smart playlists", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
_, err := ps.AddTracks(ctx, "pls-smart", []string{"song-1"})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("returns error when playlist not found", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
_, err := ps.AddTracks(ctx, "nonexistent", []string{"song-1"})
Expect(err).To(Equal(model.ErrNotFound))
})
})
Describe("RemoveTracks", func() {
var mockTracks *tests.MockPlaylistTrackRepo
BeforeEach(func() {
mockTracks = &tests.MockPlaylistTrackRepo{}
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds)
})
It("allows owner to remove tracks", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1", "track-2"})
Expect(err).ToNot(HaveOccurred())
Expect(mockTracks.DeletedIds).To(ConsistOf("track-1", "track-2"))
})
It("denies on smart playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.RemoveTracks(ctx, "pls-smart", []string{"track-1"})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("denies non-owner", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1"})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
})
Describe("ReorderTrack", func() {
var mockTracks *tests.MockPlaylistTrackRepo
BeforeEach(func() {
mockTracks = &tests.MockPlaylistTrackRepo{}
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds)
})
It("allows owner to reorder", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.ReorderTrack(ctx, "pls-1", 1, 3)
Expect(err).ToNot(HaveOccurred())
Expect(mockTracks.Reordered).To(BeTrue())
})
It("denies on smart playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.ReorderTrack(ctx, "pls-smart", 1, 3)
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
})
Describe("SetImage", func() {
var tmpDir string
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tmpDir = GinkgoT().TempDir()
conf.Server.DataFolder = tmpDir
mockPlsRepo.Data = map[string]*model.Playlist{
"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)
})
It("saves image file and updates UploadedImage", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
reader := strings.NewReader("fake image data")
err := ps.SetImage(ctx, "pls-1", reader, ".jpg")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.UploadedImage).To(Equal("pls-1_my_playlist.jpg"))
absPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1_my_playlist.jpg")
data, err := os.ReadFile(absPath)
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(Equal("fake image data"))
})
It("removes old image when replacing", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
// Upload first image
err := ps.SetImage(ctx, "pls-1", strings.NewReader("first"), ".png")
Expect(err).ToNot(HaveOccurred())
oldPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1_my_playlist.png")
Expect(oldPath).To(BeAnExistingFile())
// Upload replacement image
err = ps.SetImage(ctx, "pls-1", strings.NewReader("second"), ".jpg")
Expect(err).ToNot(HaveOccurred())
Expect(oldPath).ToNot(BeAnExistingFile())
newPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1_my_playlist.jpg")
Expect(newPath).To(BeAnExistingFile())
})
It("allows admin to set image on any playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
err := ps.SetImage(ctx, "pls-other", strings.NewReader("data"), ".jpg")
Expect(err).ToNot(HaveOccurred())
})
It("denies non-owner", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
err := ps.SetImage(ctx, "pls-1", strings.NewReader("data"), ".jpg")
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("returns error when playlist not found", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.SetImage(ctx, "nonexistent", strings.NewReader("data"), ".jpg")
Expect(err).To(Equal(model.ErrNotFound))
})
})
Describe("RemoveImage", func() {
var tmpDir string
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tmpDir = GinkgoT().TempDir()
conf.Server.DataFolder = tmpDir
// Create a real image file on disk
imgDir := filepath.Join(tmpDir, "artwork", "playlist")
Expect(os.MkdirAll(imgDir, 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(imgDir, "pls-1.jpg"), []byte("img data"), 0600)).To(Succeed())
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1", UploadedImage: "pls-1.jpg"},
"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)
})
It("removes file and clears UploadedImage", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.RemoveImage(ctx, "pls-1")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.UploadedImage).To(BeEmpty())
absPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1.jpg")
Expect(absPath).ToNot(BeAnExistingFile())
})
It("succeeds even if playlist has no image", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.RemoveImage(ctx, "pls-empty")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.UploadedImage).To(BeEmpty())
})
It("denies non-owner", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
err := ps.RemoveImage(ctx, "pls-1")
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("returns error when playlist not found", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.RemoveImage(ctx, "nonexistent")
Expect(err).To(Equal(model.ErrNotFound))
})
})
})

View File

@ -0,0 +1,101 @@
package playlists
import (
"context"
"errors"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
)
// --- REST adapter (follows Share/Library pattern) ---
func (s *playlists) NewRepository(ctx context.Context) rest.Repository {
return &playlistRepositoryWrapper{
ctx: ctx,
PlaylistRepository: s.ds.Playlist(ctx),
service: s,
}
}
// playlistRepositoryWrapper wraps the playlist repository as a thin REST-to-service adapter.
// It satisfies rest.Repository through the embedded PlaylistRepository (via ResourceRepository),
// and rest.Persistable by delegating to service methods for all mutations.
type playlistRepositoryWrapper struct {
model.PlaylistRepository
ctx context.Context
service *playlists
}
func (r *playlistRepositoryWrapper) Save(entity any) (string, error) {
return r.service.savePlaylist(r.ctx, entity.(*model.Playlist))
}
func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error {
return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...)
}
func (r *playlistRepositoryWrapper) Delete(id string) error {
err := r.service.Delete(r.ctx, id)
switch {
case errors.Is(err, model.ErrNotFound):
return rest.ErrNotFound
case errors.Is(err, model.ErrNotAuthorized):
return rest.ErrPermissionDenied
default:
return err
}
}
func (s *playlists) TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository {
repo := s.ds.Playlist(ctx)
tracks := repo.Tracks(playlistId, refreshSmartPlaylist)
if tracks == nil {
return nil
}
return tracks.(rest.Repository)
}
// 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
err := s.ds.Playlist(ctx).Put(pls)
if err != nil {
return "", err
}
return pls.ID, nil
}
// updatePlaylistEntity updates playlist metadata with permission checks.
// Used by the REST API wrapper.
func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error {
current, err := s.checkWritable(ctx, id)
if err != nil {
switch {
case errors.Is(err, model.ErrNotFound):
return rest.ErrNotFound
case errors.Is(err, model.ErrNotAuthorized):
return rest.ErrPermissionDenied
default:
return err
}
}
usr, _ := request.UserFrom(ctx)
if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID {
return rest.ErrPermissionDenied
}
// Apply ownership change (admin only)
if entity.OwnerID != "" {
current.OwnerID = entity.OwnerID
}
return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public)
}

View File

@ -0,0 +1,154 @@
package playlists_test
import (
"context"
"time"
"github.com/deluan/rest"
"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"
. "github.com/onsi/gomega"
)
var _ = Describe("REST Adapter", func() {
var ds *tests.MockDataStore
var ps playlists.Playlists
var mockPlsRepo *tests.MockPlaylistRepo
ctx := context.Background()
BeforeEach(func() {
mockPlsRepo = tests.CreateMockPlaylistRepo()
ds = &tests.MockDataStore{
MockedPlaylist: mockPlsRepo,
MockedLibrary: &tests.MockLibraryRepo{},
}
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
Describe("NewRepository", func() {
var repo rest.Persistable
BeforeEach(func() {
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
}
ps = playlists.NewPlaylists(ds)
})
Describe("Save", func() {
It("sets the owner from the context user", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "New Playlist"}
id, err := repo.Save(pls)
Expect(err).ToNot(HaveOccurred())
Expect(id).ToNot(BeEmpty())
Expect(pls.OwnerID).To(Equal("user-1"))
})
It("forces a new creation by clearing ID", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{ID: "should-be-cleared", Name: "New"}
_, err := repo.Save(pls)
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() {
It("allows owner to update their playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "Updated"}
err := repo.Update("pls-1", pls)
Expect(err).ToNot(HaveOccurred())
})
It("allows admin to update any playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "Updated"}
err := repo.Update("pls-1", pls)
Expect(err).ToNot(HaveOccurred())
})
It("denies non-owner, non-admin", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "Updated"}
err := repo.Update("pls-1", pls)
Expect(err).To(Equal(rest.ErrPermissionDenied))
})
It("denies regular user from changing ownership", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "Updated", OwnerID: "other-user"}
err := repo.Update("pls-1", pls)
Expect(err).To(Equal(rest.ErrPermissionDenied))
})
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)
pls := &model.Playlist{Name: "Updated"}
err := repo.Update("nonexistent", pls)
Expect(err).To(Equal(rest.ErrNotFound))
})
})
Describe("Delete", func() {
It("delegates to service Delete with permission checks", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Delete("pls-1")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
})
It("denies non-owner", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Delete("pls-1")
Expect(err).To(Equal(rest.ErrPermissionDenied))
})
})
})
})

View File

@ -18,7 +18,7 @@ import (
// ImageURL generates a public URL for artwork images.
// It creates a signed token for the artwork ID and builds a complete public URL.
func ImageURL(req *http.Request, artID model.ArtworkID, size int) string {
token, _ := auth.CreatePublicToken(map[string]any{"id": artID.String()})
token, _ := auth.CreatePublicToken(auth.Claims{ID: artID.String()})
uri := path.Join(consts.URLPathPublicImages, token)
params := url.Values{}
if size > 0 {

View File

@ -284,6 +284,9 @@ 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)}
}

87
core/transcode/aliases.go Normal file
View File

@ -0,0 +1,87 @@
package transcode
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)
}
func containsIgnoreCase(slice []string, s string) bool {
return slices.ContainsFunc(slice, func(item string) bool {
return strings.EqualFold(item, s)
})
}

77
core/transcode/codec.go Normal file
View File

@ -0,0 +1,77 @@
package transcode
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

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

449
core/transcode/decider.go Normal file
View File

@ -0,0 +1,449 @@
package transcode
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
// Decider is the core service interface for making transcoding decisions
type Decider interface {
MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts DecisionOptions) (*Decision, error)
CreateTranscodeParams(decision *Decision) (string, error)
ResolveRequestFromToken(ctx context.Context, token string, mediaID string, offset int) (StreamRequest, *model.MediaFile, error)
ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) StreamRequest
}
func NewDecider(ds model.DataStore, ff ffmpeg.FFmpeg) Decider {
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 DecisionOptions) (*Decision, error) {
decision := &Decision{
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) StreamDetails {
sd := StreamDetails{
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 *StreamDetails, 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 *StreamDetails, profile *Profile, clientInfo *ClientInfo) (*StreamDetails, 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 := &StreamDetails{
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 *StreamDetails, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *StreamDetails) 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 *StreamDetails) 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

@ -0,0 +1,85 @@
package transcode
import (
"context"
"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 != "" {
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 StreamRequest.
func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) StreamRequest {
var req StreamRequest
req.ID = mf.ID
req.Offset = offset
if reqFormat == "raw" {
req.Format = "raw"
return req
}
clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate)
decision, err := s.MakeDecision(ctx, mf, clientInfo, DecisionOptions{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 — fallback to raw
req.Format = "raw"
return req
}

View File

@ -0,0 +1,84 @@
package transcode
import (
"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("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(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"}))
Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(Equal([]string{mf.AudioCodec()}))
Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{ProtocolHTTP}))
})
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(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"}))
})
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())
})
})

View File

@ -0,0 +1,171 @@
package transcode
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 *StreamDetails, 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 *StreamDetails) 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,4 +1,4 @@
package core
package transcode
import (
"context"
@ -6,6 +6,7 @@ import (
"io"
"mime"
"os"
"strings"
"sync"
"time"
@ -19,8 +20,8 @@ import (
)
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)
NewStream(ctx context.Context, req StreamRequest) (*Stream, error)
DoStream(ctx context.Context, mf *model.MediaFile, req StreamRequest) (*Stream, error)
}
type TranscodingCache cache.FileCache
@ -36,44 +37,53 @@ type mediaStreamer struct {
}
type streamJob struct {
ms *mediaStreamer
mf *model.MediaFile
filePath string
format string
bitRate int
offset int
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.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format, j.offset)
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)
}
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)
func (ms *mediaStreamer) NewStream(ctx context.Context, req StreamRequest) (*Stream, error) {
mf, err := ms.ds.MediaFile(ctx).Get(req.ID)
if err != nil {
return nil, err
}
return ms.DoStream(ctx, mf, reqFormat, reqBitRate, reqOffset)
return ms.DoStream(ctx, mf, req)
}
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req StreamRequest) (*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",
"bitRate", bitRate, "sampleRate", req.SampleRate, "bitDepth", req.BitDepth, "channels", req.Channels,
"user", userName(ctx), "transcoding", format != "raw",
"originalFormat", mf.Suffix, "originalBitRate", mf.BitRate)
}()
format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate)
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", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
"requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format)
f, err := os.Open(filePath)
@ -87,12 +97,15 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
}
job := &streamJob{
ms: ms,
mf: mf,
filePath: filePath,
format: format,
bitRate: bitRate,
offset: reqOffset,
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 {
@ -105,7 +118,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
s.Seeker = r.Seeker
log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", filePath,
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
"requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
@ -130,56 +143,15 @@ 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
// NewTestStream creates a Stream for testing purposes.
func NewTestStream(mf *model.MediaFile, format string, bitRate int) *Stream {
return &Stream{
ctx: context.Background(),
mf: mf,
format: format,
bitRate: bitRate,
ReadCloser: io.NopCloser(strings.NewReader("")),
}
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 (
@ -199,9 +171,9 @@ func NewTranscodingCache() TranscodingCache {
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)
command := LookupTranscodeCommand(ctx, job.ms.ds, job.format)
if command == "" {
log.Error(ctx, "No transcoding command available", "format", job.format)
return nil, os.ErrInvalid
}
@ -217,7 +189,16 @@ func NewTranscodingCache() TranscodingCache {
transcodingCtx = request.AddValues(context.Background(), ctx)
}
out, err := job.ms.transcoder.Transcode(transcodingCtx, t.Command, job.filePath, job.bitRate, job.offset)
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
@ -225,3 +206,12 @@ func NewTranscodingCache() TranscodingCache {
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,4 +1,4 @@
package core_test
package transcode_test
import (
"context"
@ -7,7 +7,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/transcode"
"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 core.MediaStreamer
var streamer transcode.MediaStreamer
var ds model.DataStore
ffmpeg := tests.NewMockFFmpeg("fake data")
ctx := log.NewContext(context.TODO())
@ -29,9 +29,9 @@ 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 := core.NewTranscodingCache()
testCache := transcode.NewTranscodingCache()
Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue())
streamer = core.NewMediaStreamer(ds, ffmpeg, testCache)
streamer = transcode.NewMediaStreamer(ds, ffmpeg, testCache)
})
AfterEach(func() {
_ = os.RemoveAll(conf.Server.CacheFolder)
@ -39,34 +39,29 @@ var _ = Describe("MediaStreamer", func() {
Context("NewStream", func() {
It("returns a seekable stream if format is 'raw'", func() {
s, err := streamer.NewStream(ctx, "123", "raw", 0, 0)
s, err := streamer.NewStream(ctx, transcode.StreamRequest{ID: "123", Format: "raw"})
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
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)
It("returns a seekable stream if no format is specified (direct play)", func() {
s, err := streamer.NewStream(ctx, transcode.StreamRequest{ID: "123"})
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, "123", "mp3", 64, 0)
s, err := streamer.NewStream(ctx, transcode.StreamRequest{ID: "123", Format: "mp3", BitRate: 64})
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, "123", "mp3", 32, 0)
s, err := streamer.NewStream(ctx, transcode.StreamRequest{ID: "123", Format: "mp3", BitRate: 32})
Expect(err).To(BeNil())
_, _ = io.ReadAll(s)
_ = s.Close()
Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
s, err = streamer.NewStream(ctx, "123", "mp3", 32, 0)
s, err = streamer.NewStream(ctx, transcode.StreamRequest{ID: "123", Format: "mp3", BitRate: 32})
Expect(err).To(BeNil())
Expect(s.Seekable()).To(BeTrue())
})

155
core/transcode/token.go Normal file
View File

@ -0,0 +1,155 @@
package transcode
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 = 12 * 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 *Decision) 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 *Decision) (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, mediaID string, offset int) (StreamRequest, *model.MediaFile, error) {
p, err := s.parseTranscodeParams(token)
if err != nil {
return StreamRequest{}, nil, errors.Join(ErrTokenInvalid, err)
}
if p.MediaID != mediaID {
return StreamRequest{}, nil, fmt.Errorf("%w: token mediaID %q does not match %q", ErrTokenInvalid, p.MediaID, mediaID)
}
mf, err := s.ds.MediaFile(ctx).Get(mediaID)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return StreamRequest{}, nil, ErrMediaNotFound
}
return StreamRequest{}, nil, err
}
if !mf.UpdatedAt.Truncate(time.Second).Equal(p.SourceUpdatedAt) {
log.Info(ctx, "Transcode token is stale", "mediaID", mediaID,
"tokenUpdatedAt", p.SourceUpdatedAt, "fileUpdatedAt", mf.UpdatedAt)
return StreamRequest{}, nil, ErrTokenStale
}
req := StreamRequest{ID: mediaID, 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, mf, nil
}

View File

@ -0,0 +1,272 @@
package transcode
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 Decider
ctx context.Context
)
BeforeEach(func() {
ctx = GinkgoT().Context()
ds = &tests.MockDataStore{
MockedProperty: &tests.MockedPropertyRepo{},
MockedTranscoding: &tests.MockTranscodingRepo{},
}
ff = tests.NewMockFFmpeg("")
auth.Init(ds)
svc = NewDecider(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 := &Decision{
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 := &Decision{
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 := &Decision{
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 := &Decision{
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 := &Decision{
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 := &Decision{
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 := &Decision{
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 (
mockMFRepo *tests.MockMediaFileRepo
sourceTime time.Time
)
BeforeEach(func() {
sourceTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC)
mockMFRepo = &tests.MockMediaFileRepo{}
ds.MockedMediaFile = mockMFRepo
})
createTokenForMedia := func(mediaID string, updatedAt time.Time) string {
decision := &Decision{
MediaID: mediaID,
CanDirectPlay: true,
SourceUpdatedAt: updatedAt,
}
token, err := svc.CreateTranscodeParams(decision)
Expect(err).ToNot(HaveOccurred())
return token
}
It("returns stream request and media file for valid token", func() {
mockMFRepo.SetData(model.MediaFiles{
{ID: "song-1", UpdatedAt: sourceTime},
})
token := createTokenForMedia("song-1", sourceTime)
req, mf, err := svc.ResolveRequestFromToken(ctx, token, "song-1", 0)
Expect(err).ToNot(HaveOccurred())
Expect(req.ID).To(Equal("song-1"))
Expect(req.Format).To(BeEmpty()) // direct play has no target format
Expect(mf.ID).To(Equal("song-1"))
})
It("returns ErrTokenInvalid for invalid token", func() {
_, _, err := svc.ResolveRequestFromToken(ctx, "bad-token", "song-1", 0)
Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error())))
})
It("returns ErrTokenInvalid when mediaID does not match token", func() {
token := createTokenForMedia("song-1", sourceTime)
_, _, err := svc.ResolveRequestFromToken(ctx, token, "song-2", 0)
Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error())))
})
It("returns ErrMediaNotFound when media file does not exist", func() {
token := createTokenForMedia("gone-id", sourceTime)
_, _, err := svc.ResolveRequestFromToken(ctx, token, "gone-id", 0)
Expect(err).To(MatchError(ErrMediaNotFound))
})
It("returns ErrTokenStale when media file has changed", func() {
newTime := sourceTime.Add(1 * time.Hour)
mockMFRepo.SetData(model.MediaFiles{
{ID: "song-1", UpdatedAt: newTime},
})
token := createTokenForMedia("song-1", sourceTime)
_, _, err := svc.ResolveRequestFromToken(ctx, token, "song-1", 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

@ -0,0 +1,17 @@
package transcode
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestTranscode(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Transcode Suite")
}

134
core/transcode/types.go Normal file
View File

@ -0,0 +1,134 @@
package transcode
import (
"errors"
"time"
)
var (
ErrTokenInvalid = errors.New("invalid or expired transcode token")
ErrMediaNotFound = errors.New("media file not found")
ErrTokenStale = errors.New("transcode token is stale: media file has changed")
)
// DecisionOptions controls optional behavior of MakeDecision.
type DecisionOptions struct {
// SkipProbe prevents MakeDecision from running ffprobe on the media file.
// When true, source stream details are derived from tag metadata only.
SkipProbe bool
}
// StreamRequest contains the resolved parameters for creating a media stream.
type StreamRequest struct {
ID string
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"
)
// Decision represents the internal decision result.
// All bitrate values are in kilobits per second (kbps).
type Decision struct {
MediaID string
CanDirectPlay bool
CanTranscode bool
TranscodeReasons []string
ErrorReason string
TargetFormat string
TargetBitrate int
TargetChannels int
TargetSampleRate int
TargetBitDepth int
SourceStream StreamDetails
SourceUpdatedAt time.Time
TranscodeStream *StreamDetails
}
// StreamDetails describes audio stream properties.
// Bitrate is in kilobits per second (kbps).
type StreamDetails 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,21 +5,25 @@ 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/transcode"
)
var Set = wire.NewSet(
NewMediaStreamer,
GetTranscodingCache,
transcode.NewMediaStreamer,
transcode.GetTranscodingCache,
NewArchiver,
NewPlayers,
NewShare,
NewPlaylists,
playlists.NewPlaylists,
NewLibrary,
NewUser,
NewMaintenance,
transcode.NewDecider,
agents.GetAgents,
external.NewProvider,
wire.Bind(new(external.Agents), new(*agents.Agents)),
@ -27,4 +31,5 @@ var Set = wire.NewSet(
scrobbler.GetPlayTracker,
playback.GetInstance,
metrics.GetInstance,
lyrics.NewLyrics,
)

View File

@ -0,0 +1,391 @@
package migrations
import (
"context"
"database/sql"
"fmt"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddFts5Search, downAddFts5Search)
}
// stripPunct generates a SQL expression that strips common punctuation from a column or expression.
// Used during migration to approximate the Go normalizeForFTS function for bulk-populating search_normalized.
func stripPunct(col string) string {
return fmt.Sprintf(
`REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(%s, '.', ''), '/', ''), '-', ''), '''', ''), '&', ''), ',', '')`,
col,
)
}
func upAddFts5Search(ctx context.Context, tx *sql.Tx) error {
notice(tx, "Adding FTS5 full-text search indexes. This may take a moment on large libraries.")
// Step 1: Add search_participants and search_normalized columns to media_file, album, and artist
_, err := tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN search_participants TEXT NOT NULL DEFAULT ''`)
if err != nil {
return fmt.Errorf("adding search_participants to media_file: %w", err)
}
_, err = tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN search_normalized TEXT NOT NULL DEFAULT ''`)
if err != nil {
return fmt.Errorf("adding search_normalized to media_file: %w", err)
}
_, err = tx.ExecContext(ctx, `ALTER TABLE album ADD COLUMN search_participants TEXT NOT NULL DEFAULT ''`)
if err != nil {
return fmt.Errorf("adding search_participants to album: %w", err)
}
_, err = tx.ExecContext(ctx, `ALTER TABLE album ADD COLUMN search_normalized TEXT NOT NULL DEFAULT ''`)
if err != nil {
return fmt.Errorf("adding search_normalized to album: %w", err)
}
_, err = tx.ExecContext(ctx, `ALTER TABLE artist ADD COLUMN search_normalized TEXT NOT NULL DEFAULT ''`)
if err != nil {
return fmt.Errorf("adding search_normalized to artist: %w", err)
}
// Step 2: Populate search_participants from participants JSON.
// Extract all "name" values from the participants JSON structure.
// participants is a JSON object like: {"artist":[{"name":"...","id":"..."}],"albumartist":[...]}
// We use json_each + json_extract to flatten all names into a space-separated string.
_, err = tx.ExecContext(ctx, `
UPDATE media_file SET search_participants = COALESCE(
(SELECT group_concat(json_extract(je2.value, '$.name'), ' ')
FROM json_each(media_file.participants) AS je1,
json_each(je1.value) AS je2
WHERE json_extract(je2.value, '$.name') IS NOT NULL),
''
)
WHERE participants IS NOT NULL AND participants != '' AND participants != '{}'
`)
if err != nil {
return fmt.Errorf("populating media_file search_participants: %w", err)
}
_, err = tx.ExecContext(ctx, `
UPDATE album SET search_participants = COALESCE(
(SELECT group_concat(json_extract(je2.value, '$.name'), ' ')
FROM json_each(album.participants) AS je1,
json_each(je1.value) AS je2
WHERE json_extract(je2.value, '$.name') IS NOT NULL),
''
)
WHERE participants IS NOT NULL AND participants != '' AND participants != '{}'
`)
if err != nil {
return fmt.Errorf("populating album search_participants: %w", err)
}
// Step 2b: Populate search_normalized using SQL REPLACE chains for common punctuation.
// The Go code will compute the precise value on next scan; this is a best-effort approximation.
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
UPDATE artist SET search_normalized = %s
WHERE name != %s`,
stripPunct("name"), stripPunct("name")))
if err != nil {
return fmt.Errorf("populating artist search_normalized: %w", err)
}
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
UPDATE album SET search_normalized = TRIM(%s || ' ' || %s)
WHERE name != %s OR COALESCE(album_artist, '') != %s`,
stripPunct("name"), stripPunct("COALESCE(album_artist, '')"),
stripPunct("name"), stripPunct("COALESCE(album_artist, '')")))
if err != nil {
return fmt.Errorf("populating album search_normalized: %w", err)
}
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
UPDATE media_file SET search_normalized =
TRIM(%s || ' ' || %s || ' ' || %s || ' ' || %s)
WHERE title != %s
OR COALESCE(album, '') != %s
OR COALESCE(artist, '') != %s
OR COALESCE(album_artist, '') != %s`,
stripPunct("title"), stripPunct("COALESCE(album, '')"),
stripPunct("COALESCE(artist, '')"), stripPunct("COALESCE(album_artist, '')"),
stripPunct("title"), stripPunct("COALESCE(album, '')"),
stripPunct("COALESCE(artist, '')"), stripPunct("COALESCE(album_artist, '')")))
if err != nil {
return fmt.Errorf("populating media_file search_normalized: %w", err)
}
// Step 3: Create FTS5 virtual tables
_, err = tx.ExecContext(ctx, `
CREATE VIRTUAL TABLE IF NOT EXISTS media_file_fts USING fts5(
title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized,
content='', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
)
`)
if err != nil {
return fmt.Errorf("creating media_file_fts: %w", err)
}
_, err = tx.ExecContext(ctx, `
CREATE VIRTUAL TABLE IF NOT EXISTS album_fts USING fts5(
name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized,
content='', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
)
`)
if err != nil {
return fmt.Errorf("creating album_fts: %w", err)
}
_, err = tx.ExecContext(ctx, `
CREATE VIRTUAL TABLE IF NOT EXISTS artist_fts USING fts5(
name, sort_artist_name, search_normalized,
content='', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
)
`)
if err != nil {
return fmt.Errorf("creating artist_fts: %w", err)
}
// Step 4: Bulk-populate FTS5 indexes from existing data
_, err = tx.ExecContext(ctx, `
INSERT INTO media_file_fts(rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
SELECT rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
COALESCE(disc_subtitle, ''), COALESCE(search_participants, ''),
COALESCE(search_normalized, '')
FROM media_file
`)
if err != nil {
return fmt.Errorf("populating media_file_fts: %w", err)
}
_, err = tx.ExecContext(ctx, `
INSERT INTO album_fts(rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
SELECT rowid, name, COALESCE(sort_album_name, ''), COALESCE(album_artist, ''),
COALESCE(search_participants, ''), COALESCE(discs, ''),
COALESCE(catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(album.tags, '$.albumversion') AS je), ''),
COALESCE(search_normalized, '')
FROM album
`)
if err != nil {
return fmt.Errorf("populating album_fts: %w", err)
}
_, err = tx.ExecContext(ctx, `
INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
SELECT rowid, name, COALESCE(sort_artist_name, ''), COALESCE(search_normalized, '')
FROM artist
`)
if err != nil {
return fmt.Errorf("populating artist_fts: %w", err)
}
// Step 5: Create triggers for media_file
_, err = tx.ExecContext(ctx, `
CREATE TRIGGER media_file_fts_ai AFTER INSERT ON media_file BEGIN
INSERT INTO media_file_fts(rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist,
NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name,
COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''),
COALESCE(NEW.search_normalized, ''));
END
`)
if err != nil {
return fmt.Errorf("creating media_file_fts insert trigger: %w", err)
}
_, err = tx.ExecContext(ctx, `
CREATE TRIGGER media_file_fts_ad AFTER DELETE ON media_file BEGIN
INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist,
OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name,
COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''),
COALESCE(OLD.search_normalized, ''));
END
`)
if err != nil {
return fmt.Errorf("creating media_file_fts delete trigger: %w", err)
}
_, err = tx.ExecContext(ctx, `
CREATE TRIGGER media_file_fts_au AFTER UPDATE ON media_file
WHEN
OLD.title IS NOT NEW.title OR
OLD.album IS NOT NEW.album OR
OLD.artist IS NOT NEW.artist OR
OLD.album_artist IS NOT NEW.album_artist OR
OLD.sort_title IS NOT NEW.sort_title OR
OLD.sort_album_name IS NOT NEW.sort_album_name OR
OLD.sort_artist_name IS NOT NEW.sort_artist_name OR
OLD.sort_album_artist_name IS NOT NEW.sort_album_artist_name OR
OLD.disc_subtitle IS NOT NEW.disc_subtitle OR
OLD.search_participants IS NOT NEW.search_participants OR
OLD.search_normalized IS NOT NEW.search_normalized
BEGIN
INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist,
OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name,
COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''),
COALESCE(OLD.search_normalized, ''));
INSERT INTO media_file_fts(rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist,
NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name,
COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''),
COALESCE(NEW.search_normalized, ''));
END
`)
if err != nil {
return fmt.Errorf("creating media_file_fts update trigger: %w", err)
}
// Step 6: Create triggers for album
_, err = tx.ExecContext(ctx, `
CREATE TRIGGER album_fts_ai AFTER INSERT ON album BEGIN
INSERT INTO album_fts(rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''),
COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''),
COALESCE(NEW.catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(NEW.tags, '$.albumversion') AS je), ''),
COALESCE(NEW.search_normalized, ''));
END
`)
if err != nil {
return fmt.Errorf("creating album_fts insert trigger: %w", err)
}
_, err = tx.ExecContext(ctx, `
CREATE TRIGGER album_fts_ad AFTER DELETE ON album BEGIN
INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''),
COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''),
COALESCE(OLD.catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(OLD.tags, '$.albumversion') AS je), ''),
COALESCE(OLD.search_normalized, ''));
END
`)
if err != nil {
return fmt.Errorf("creating album_fts delete trigger: %w", err)
}
_, err = tx.ExecContext(ctx, `
CREATE TRIGGER album_fts_au AFTER UPDATE ON album
WHEN
OLD.name IS NOT NEW.name OR
OLD.sort_album_name IS NOT NEW.sort_album_name OR
OLD.album_artist IS NOT NEW.album_artist OR
OLD.search_participants IS NOT NEW.search_participants OR
OLD.discs IS NOT NEW.discs OR
OLD.catalog_num IS NOT NEW.catalog_num OR
OLD.tags IS NOT NEW.tags OR
OLD.search_normalized IS NOT NEW.search_normalized
BEGIN
INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''),
COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''),
COALESCE(OLD.catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(OLD.tags, '$.albumversion') AS je), ''),
COALESCE(OLD.search_normalized, ''));
INSERT INTO album_fts(rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''),
COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''),
COALESCE(NEW.catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(NEW.tags, '$.albumversion') AS je), ''),
COALESCE(NEW.search_normalized, ''));
END
`)
if err != nil {
return fmt.Errorf("creating album_fts update trigger: %w", err)
}
// Step 7: Create triggers for artist
_, err = tx.ExecContext(ctx, `
CREATE TRIGGER artist_fts_ai AFTER INSERT ON artist BEGIN
INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''),
COALESCE(NEW.search_normalized, ''));
END
`)
if err != nil {
return fmt.Errorf("creating artist_fts insert trigger: %w", err)
}
_, err = tx.ExecContext(ctx, `
CREATE TRIGGER artist_fts_ad AFTER DELETE ON artist BEGIN
INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized)
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''),
COALESCE(OLD.search_normalized, ''));
END
`)
if err != nil {
return fmt.Errorf("creating artist_fts delete trigger: %w", err)
}
_, err = tx.ExecContext(ctx, `
CREATE TRIGGER artist_fts_au AFTER UPDATE ON artist
WHEN
OLD.name IS NOT NEW.name OR
OLD.sort_artist_name IS NOT NEW.sort_artist_name OR
OLD.search_normalized IS NOT NEW.search_normalized
BEGIN
INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized)
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''),
COALESCE(OLD.search_normalized, ''));
INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''),
COALESCE(NEW.search_normalized, ''));
END
`)
if err != nil {
return fmt.Errorf("creating artist_fts update trigger: %w", err)
}
return nil
}
func downAddFts5Search(ctx context.Context, tx *sql.Tx) error {
for _, trigger := range []string{
"media_file_fts_ai", "media_file_fts_ad", "media_file_fts_au",
"album_fts_ai", "album_fts_ad", "album_fts_au",
"artist_fts_ai", "artist_fts_ad", "artist_fts_au",
} {
_, err := tx.ExecContext(ctx, "DROP TRIGGER IF EXISTS "+trigger)
if err != nil {
return fmt.Errorf("dropping trigger %s: %w", trigger, err)
}
}
for _, table := range []string{"media_file_fts", "album_fts", "artist_fts"} {
_, err := tx.ExecContext(ctx, "DROP TABLE IF EXISTS "+table)
if err != nil {
return fmt.Errorf("dropping table %s: %w", table, err)
}
}
// Note: We don't drop search_participants columns because SQLite doesn't support DROP COLUMN
// on older versions, and the column is harmless if left in place.
return nil
}

View File

@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE plugin ADD COLUMN allow_write_access BOOL NOT NULL DEFAULT false;
-- +goose Down
ALTER TABLE plugin DROP COLUMN allow_write_access;

View File

@ -0,0 +1,22 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddPlaylistImageFile, downAddPlaylistImageFile)
}
func upAddPlaylistImageFile(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `ALTER TABLE playlist ADD COLUMN image_file VARCHAR(255) DEFAULT '';`)
return err
}
func downAddPlaylistImageFile(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `ALTER TABLE playlist DROP COLUMN image_file;`)
return err
}

View File

@ -0,0 +1,30 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upRenamePlaylistImageFields, downRenamePlaylistImageFields)
}
func upRenamePlaylistImageFields(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `ALTER TABLE playlist RENAME COLUMN image_file TO uploaded_image;`)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `ALTER TABLE playlist ADD COLUMN external_image_url VARCHAR(255) DEFAULT '';`)
return err
}
func downRenamePlaylistImageFields(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `ALTER TABLE playlist DROP COLUMN external_image_url;`)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `ALTER TABLE playlist RENAME COLUMN uploaded_image TO image_file;`)
return err
}

View File

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

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

@ -0,0 +1,41 @@
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.
for _, t := range consts.DefaultTranscodings {
var count int
err := tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = ?", t.TargetFormat).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
}

41
go.mod
View File

@ -1,14 +1,9 @@
module github.com/navidrome/navidrome
go 1.25
go 1.25.0
replace (
// Fork to fix https://github.com/navidrome/navidrome/issues/3254
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
// Fork to implement raw tags support
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260221220301-2fab4903f48e
)
// Fork to implement raw tags support
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260307161927-168f6e74ada7
require (
github.com/Masterminds/squirrel v1.5.4
@ -19,7 +14,6 @@ require (
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/disintegration/imaging v1.6.2
github.com/djherbis/atime v1.1.0
github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4
@ -31,7 +25,7 @@ require (
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/cors v1.2.2
github.com/go-chi/httprate v0.15.0
github.com/go-chi/jwtauth/v5 v5.3.3
github.com/go-chi/jwtauth/v5 v5.4.0
github.com/go-viper/encoding/ini v0.1.1
github.com/gohugoio/hashstructure v0.6.0
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc
@ -43,7 +37,7 @@ require (
github.com/kardianos/service v1.2.4
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v2 v2.1.6
github.com/lestrrat-go/jwx/v3 v3.0.13
github.com/maruel/natural v1.3.0
github.com/matoous/go-nanoid/v2 v2.1.0
github.com/mattn/go-sqlite3 v1.14.34
@ -53,7 +47,7 @@ require (
github.com/onsi/gomega v1.39.1
github.com/pelletier/go-toml/v2 v2.2.4
github.com/pocketbase/dbx v1.12.0
github.com/pressly/goose/v3 v3.26.0
github.com/pressly/goose/v3 v3.27.0
github.com/prometheus/client_golang v1.23.2
github.com/rjeczalik/notify v0.9.3
github.com/robfig/cron/v3 v3.0.1
@ -69,12 +63,12 @@ require (
go.senan.xyz/taglib v0.11.1
go.uber.org/goleak v1.3.0
golang.org/x/image v0.36.0
golang.org/x/net v0.50.0
golang.org/x/sync v0.19.0
golang.org/x/sys v0.41.0
golang.org/x/net v0.51.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.42.0
golang.org/x/term v0.40.0
golang.org/x/text v0.34.0
golang.org/x/time v0.14.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
)
@ -88,7 +82,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
@ -98,7 +92,7 @@ require (
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef // indirect
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
github.com/google/subcommands v1.2.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
@ -109,10 +103,11 @@ require (
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
github.com/lestrrat-go/dsig v1.0.0 // indirect
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/lestrrat-go/httprc/v3 v3.0.4 // indirect
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
@ -134,13 +129,13 @@ require (
github.com/stretchr/objx v0.5.3 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect
github.com/valyala/fastjson v1.6.10 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect
golang.org/x/tools v0.42.0 // indirect

86
go.sum
View File

@ -1,7 +1,7 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
@ -34,16 +34,14 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/deluan/go-taglib v0.0.0-20260221220301-2fab4903f48e h1:yQF3eOcI2dMMtxqdKXm3cgfYZlDcq9SUDDv90bsMj2I=
github.com/deluan/go-taglib v0.0.0-20260221220301-2fab4903f48e/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/deluan/go-taglib v0.0.0-20260307161927-168f6e74ada7 h1:RpRSTEsAdLHx3Ci0d3M5wtpjcBZiKzhnGfnNAxGXrAE=
github.com/deluan/go-taglib v0.0.0-20260307161927-168f6e74ada7/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E=
github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d h1:x/R3+oPEjnisl1zBx2f2v7Gf6f11l0N0JoD6BkwcJyA=
github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 h1:r4hxcT6GBIA/j8Ox4OXI5MNgMKfR+9plcAWYi1OnmOg=
github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
@ -83,8 +81,8 @@ github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo=
github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ=
github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI=
github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
@ -110,8 +108,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno=
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -143,8 +141,8 @@ github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2Og
github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@ -163,16 +161,18 @@ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhR
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38=
github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo=
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lestrrat-go/httprc/v3 v3.0.4 h1:pXyH2ppK8GYYggygxJ3TvxpCZnbEUWc9qSwRTTApaLA=
github.com/lestrrat-go/httprc/v3 v3.0.4/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk=
github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU=
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
@ -193,8 +193,8 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
@ -212,8 +212,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM=
github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
@ -296,6 +296,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@ -303,8 +305,8 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@ -321,8 +323,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
@ -344,8 +346,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -353,8 +355,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -370,8 +372,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0=
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=
@ -397,8 +399,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@ -423,11 +425,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=

View File

@ -9,11 +9,12 @@ import (
//goland:noinspection GoBoolExpressions
func main() {
// This import is used to force the inclusion of the `netgo` tag when compiling the project.
// These references force the inclusion of build tags when compiling the project.
// If you get compilation errors like "undefined: buildtags.NETGO", this means you forgot to specify
// the `netgo` build tag when compiling the project.
// the required build tags when compiling the project.
// To avoid these kind of errors, you should use `make build` to compile the project.
_ = buildtags.NETGO
_ = buildtags.SQLITE_FTS5
cmd.Execute()
}

View File

@ -1,11 +1,14 @@
package model
import (
"fmt"
"iter"
"math"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/gohugoio/hashstructure"
)
@ -70,6 +73,13 @@ func (a Album) CoverArtID() ArtworkID {
return artworkIDFromAlbum(a)
}
func (a Album) FullName() string {
if conf.Server.Subsonic.AppendAlbumVersion && len(a.Tags[TagAlbumVersion]) > 0 {
return fmt.Sprintf("%s (%s)", a.Name, a.Tags[TagAlbumVersion][0])
}
return a.Name
}
// Equals compares two Album structs, ignoring calculated fields
func (a Album) Equals(other Album) bool {
// Normalize float32 values to avoid false negatives

View File

@ -3,11 +3,30 @@ package model_test
import (
"encoding/json"
"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("Album", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
DescribeTable("FullName",
func(enabled bool, tags Tags, expected string) {
conf.Server.Subsonic.AppendAlbumVersion = enabled
a := Album{Name: "Album", Tags: tags}
Expect(a.FullName()).To(Equal(expected))
},
Entry("appends version when enabled and tag is present", true, Tags{TagAlbumVersion: []string{"Remastered"}}, "Album (Remastered)"),
Entry("returns just name when disabled", false, Tags{TagAlbumVersion: []string{"Remastered"}}, "Album"),
Entry("returns just name when tag is absent", true, Tags{}, "Album"),
Entry("returns just name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"),
)
})
var _ = Describe("Albums", func() {
var albums Albums

View File

@ -15,10 +15,38 @@ type Expression = squirrel.Sqlizer
type Criteria struct {
Expression
Sort string
Order string
Limit int
Offset int
Sort string
Order string
Limit int
LimitPercent int
Offset int
}
// EffectiveLimit resolves the effective limit for a query. If a fixed Limit is
// set it takes precedence. Otherwise, if LimitPercent is set, the limit is
// computed as a percentage of totalCount (minimum 1 when totalCount > 0).
// Returns 0 when no limit applies.
func (c Criteria) EffectiveLimit(totalCount int64) int {
if c.Limit > 0 {
return c.Limit
}
if c.LimitPercent > 0 && c.LimitPercent <= 100 {
if totalCount <= 0 {
return 0
}
result := int(totalCount) * c.LimitPercent / 100
if result < 1 {
return 1
}
return result
}
return 0
}
// IsPercentageLimit returns true when the criteria uses a valid percentage-based
// limit (i.e. LimitPercent is in [1, 100] and no fixed Limit overrides it).
func (c Criteria) IsPercentageLimit() bool {
return c.Limit == 0 && c.LimitPercent > 0 && c.LimitPercent <= 100
}
func (c Criteria) OrderBy() string {
@ -95,6 +123,35 @@ func (c Criteria) ToSql() (sql string, args []any, err error) {
return c.Expression.ToSql()
}
// ExpressionJoins returns only the JOINs needed by the WHERE-clause expression,
// excluding any JOINs required solely for sorting. This is useful for COUNT
// queries where sort order is irrelevant.
func (c Criteria) ExpressionJoins() JoinType {
if c.Expression == nil {
return JoinNone
}
return extractJoinTypes(c.Expression)
}
// RequiredJoins inspects the expression tree and Sort field to determine which
// additional JOINs are needed when evaluating this criteria.
func (c Criteria) RequiredJoins() JoinType {
result := JoinNone
if c.Expression != nil {
result |= extractJoinTypes(c.Expression)
}
// Also check Sort fields
if c.Sort != "" {
for _, p := range strings.Split(c.Sort, ",") {
p = strings.TrimSpace(p)
p = strings.TrimLeft(p, "+-")
p = strings.TrimSpace(p)
result |= fieldJoinType(p)
}
}
return result
}
func (c Criteria) ChildPlaylistIds() []string {
if c.Expression == nil {
return nil
@ -109,17 +166,19 @@ func (c Criteria) ChildPlaylistIds() []string {
func (c Criteria) MarshalJSON() ([]byte, error) {
aux := struct {
All []Expression `json:"all,omitempty"`
Any []Expression `json:"any,omitempty"`
Sort string `json:"sort,omitempty"`
Order string `json:"order,omitempty"`
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
All []Expression `json:"all,omitempty"`
Any []Expression `json:"any,omitempty"`
Sort string `json:"sort,omitempty"`
Order string `json:"order,omitempty"`
Limit int `json:"limit,omitempty"`
LimitPercent int `json:"limitPercent,omitempty"`
Offset int `json:"offset,omitempty"`
}{
Sort: c.Sort,
Order: c.Order,
Limit: c.Limit,
Offset: c.Offset,
Sort: c.Sort,
Order: c.Order,
Limit: c.Limit,
LimitPercent: c.LimitPercent,
Offset: c.Offset,
}
switch rules := c.Expression.(type) {
case Any:
@ -134,12 +193,13 @@ func (c Criteria) MarshalJSON() ([]byte, error) {
func (c *Criteria) UnmarshalJSON(data []byte) error {
var aux struct {
All unmarshalConjunctionType `json:"all"`
Any unmarshalConjunctionType `json:"any"`
Sort string `json:"sort"`
Order string `json:"order"`
Limit int `json:"limit"`
Offset int `json:"offset"`
All unmarshalConjunctionType `json:"all"`
Any unmarshalConjunctionType `json:"any"`
Sort string `json:"sort"`
Order string `json:"order"`
Limit int `json:"limit"`
LimitPercent int `json:"limitPercent"`
Offset int `json:"offset"`
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
@ -155,5 +215,15 @@ func (c *Criteria) UnmarshalJSON(data []byte) error {
c.Order = aux.Order
c.Limit = aux.Limit
c.Offset = aux.Offset
// Clamp LimitPercent to [0, 100]
if aux.LimitPercent < 0 {
log.Warn("limitPercent value out of range, clamping to 0", "value", aux.LimitPercent)
aux.LimitPercent = 0
} else if aux.LimitPercent > 100 {
log.Warn("limitPercent value out of range, clamping to 100", "value", aux.LimitPercent)
aux.LimitPercent = 100
}
c.LimitPercent = aux.LimitPercent
return nil
}

View File

@ -27,6 +27,7 @@ var _ = Describe("Criteria", func() {
StartsWith{"comment": "this"},
InTheRange{"year": []int{1980, 1990}},
IsNot{"genre": "Rock"},
Gt{"albumrating": 3},
},
},
Sort: "title",
@ -48,7 +49,8 @@ var _ = Describe("Criteria", func() {
{ "all": [
{ "startsWith": {"comment": "this"} },
{ "inTheRange": {"year":[1980,1990]} },
{ "isNot": { "genre": "Rock" }}
{ "isNot": { "genre": "Rock" }},
{ "gt": { "albumrating": 3 } }
]
}
],
@ -68,10 +70,10 @@ var _ = Describe("Criteria", func() {
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal(
`(media_file.title LIKE ? AND media_file.title NOT LIKE ? ` +
`AND (not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?) ` +
`AND (not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?) ` +
`OR media_file.album = ?) AND (media_file.comment LIKE ? AND (media_file.year >= ? AND media_file.year <= ?) ` +
`AND not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)))`))
gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "Rock"))
`AND not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?) AND COALESCE(album_annotation.rating, 0) > ?))`))
gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "Rock", 3))
})
It("marshals to JSON", func() {
j, err := json.Marshal(goObj)
@ -172,13 +174,237 @@ var _ = Describe("Criteria", func() {
sql, args, err := goObj.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal(
`(exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?) AND ` +
`exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?))`,
`(exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?) AND ` +
`exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?))`,
))
gomega.Expect(args).To(gomega.HaveExactElements("The Beatles", "%Lennon%"))
})
})
Describe("ExpressionJoins", func() {
It("excludes sort-only joins", func() {
c := Criteria{
Expression: All{
Contains{"title": "love"},
},
Sort: "albumRating",
}
gomega.Expect(c.ExpressionJoins()).To(gomega.Equal(JoinNone))
gomega.Expect(c.RequiredJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
})
It("includes expression-based joins", func() {
c := Criteria{
Expression: All{
Gt{"albumRating": 3},
},
}
gomega.Expect(c.ExpressionJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
})
})
Describe("RequiredJoins", func() {
It("returns JoinNone when no annotation fields are used", func() {
c := Criteria{
Expression: All{
Contains{"title": "love"},
},
}
gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinNone))
})
It("returns JoinNone for media_file annotation fields", func() {
c := Criteria{
Expression: All{
Is{"loved": true},
Gt{"playCount": 5},
},
}
gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinNone))
})
It("returns JoinAlbumAnnotation for album annotation fields", func() {
c := Criteria{
Expression: All{
Gt{"albumRating": 3},
},
}
gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinAlbumAnnotation))
})
It("returns JoinArtistAnnotation for artist annotation fields", func() {
c := Criteria{
Expression: All{
Is{"artistLoved": true},
},
}
gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinArtistAnnotation))
})
It("returns both join types when both are used", func() {
c := Criteria{
Expression: All{
Gt{"albumRating": 3},
Is{"artistLoved": true},
},
}
j := c.RequiredJoins()
gomega.Expect(j.Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
gomega.Expect(j.Has(JoinArtistAnnotation)).To(gomega.BeTrue())
})
It("detects join types in nested expressions", func() {
c := Criteria{
Expression: All{
Any{
All{
Is{"albumLoved": true},
},
},
Any{
Gt{"artistPlayCount": 10},
},
},
}
j := c.RequiredJoins()
gomega.Expect(j.Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
gomega.Expect(j.Has(JoinArtistAnnotation)).To(gomega.BeTrue())
})
It("detects join types from Sort field", func() {
c := Criteria{
Expression: All{
Contains{"title": "love"},
},
Sort: "albumRating",
}
gomega.Expect(c.RequiredJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
})
It("detects join types from Sort field with direction prefix", func() {
c := Criteria{
Expression: All{
Contains{"title": "love"},
},
Sort: "-artistRating",
}
gomega.Expect(c.RequiredJoins().Has(JoinArtistAnnotation)).To(gomega.BeTrue())
})
})
Describe("LimitPercent", func() {
Describe("JSON round-trip", func() {
It("marshals and unmarshals limitPercent", func() {
goObj := Criteria{
Expression: All{Contains{"title": "love"}},
Sort: "title",
Order: "asc",
LimitPercent: 10,
}
j, err := json.Marshal(goObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).To(gomega.ContainSubstring(`"limitPercent":10`))
gomega.Expect(string(j)).ToNot(gomega.ContainSubstring(`"limit"`))
var newObj Criteria
err = json.Unmarshal(j, &newObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(newObj.LimitPercent).To(gomega.Equal(10))
gomega.Expect(newObj.Limit).To(gomega.Equal(0))
})
It("does not include limitPercent when zero", func() {
goObj := Criteria{
Expression: All{Contains{"title": "love"}},
Limit: 50,
}
j, err := json.Marshal(goObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).To(gomega.ContainSubstring(`"limit":50`))
gomega.Expect(string(j)).ToNot(gomega.ContainSubstring(`limitPercent`))
})
It("backward compatible: JSON with only limit still works", func() {
jsonStr := `{"all":[{"contains":{"title":"love"}}],"limit":20}`
var c Criteria
err := json.Unmarshal([]byte(jsonStr), &c)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(c.Limit).To(gomega.Equal(20))
gomega.Expect(c.LimitPercent).To(gomega.Equal(0))
})
})
Describe("UnmarshalJSON clamping", func() {
It("clamps values above 100 to 100", func() {
jsonStr := `{"all":[{"contains":{"title":"love"}}],"limitPercent":150}`
var c Criteria
err := json.Unmarshal([]byte(jsonStr), &c)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(c.LimitPercent).To(gomega.Equal(100))
})
It("clamps negative values to 0", func() {
jsonStr := `{"all":[{"contains":{"title":"love"}}],"limitPercent":-5}`
var c Criteria
err := json.Unmarshal([]byte(jsonStr), &c)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(c.LimitPercent).To(gomega.Equal(0))
})
})
Describe("EffectiveLimit", func() {
It("returns fixed limit when Limit is set", func() {
c := Criteria{Limit: 50, LimitPercent: 10}
gomega.Expect(c.EffectiveLimit(1000)).To(gomega.Equal(50))
})
It("returns percentage-based limit", func() {
c := Criteria{LimitPercent: 10}
gomega.Expect(c.EffectiveLimit(450)).To(gomega.Equal(45))
})
It("returns minimum 1 when totalCount > 0 and percentage rounds to 0", func() {
c := Criteria{LimitPercent: 1}
gomega.Expect(c.EffectiveLimit(5)).To(gomega.Equal(1))
})
It("returns 0 when totalCount is 0", func() {
c := Criteria{LimitPercent: 10}
gomega.Expect(c.EffectiveLimit(0)).To(gomega.Equal(0))
})
It("returns 0 when no limit is set", func() {
c := Criteria{}
gomega.Expect(c.EffectiveLimit(1000)).To(gomega.Equal(0))
})
It("returns full count for 100%", func() {
c := Criteria{LimitPercent: 100}
gomega.Expect(c.EffectiveLimit(450)).To(gomega.Equal(450))
})
It("returns 1 for 1% of 50 items", func() {
c := Criteria{LimitPercent: 1}
gomega.Expect(c.EffectiveLimit(50)).To(gomega.Equal(1))
})
})
Describe("IsPercentageLimit", func() {
It("returns true when LimitPercent is set and Limit is 0", func() {
c := Criteria{LimitPercent: 10}
gomega.Expect(c.IsPercentageLimit()).To(gomega.BeTrue())
})
It("returns false when Limit is set", func() {
c := Criteria{Limit: 50, LimitPercent: 10}
gomega.Expect(c.IsPercentageLimit()).To(gomega.BeFalse())
})
It("returns false when neither is set", func() {
c := Criteria{}
gomega.Expect(c.IsPercentageLimit()).To(gomega.BeFalse())
})
It("returns false when LimitPercent is out of range", func() {
c := Criteria{LimitPercent: 150}
gomega.Expect(c.IsPercentageLimit()).To(gomega.BeFalse())
})
})
})
Context("with child playlists", func() {
var (
topLevelInPlaylistID string

View File

@ -9,45 +9,72 @@ import (
"github.com/navidrome/navidrome/log"
)
// JoinType is a bitmask indicating which additional JOINs are needed by a smart playlist expression.
type JoinType int
const (
JoinNone JoinType = 0
JoinAlbumAnnotation JoinType = 1 << iota
JoinArtistAnnotation
)
// Has returns true if j contains all bits in other.
func (j JoinType) Has(other JoinType) bool { return j&other != 0 }
var fieldMap = map[string]*mappedField{
"title": {field: "media_file.title"},
"album": {field: "media_file.album"},
"hascoverart": {field: "media_file.has_cover_art"},
"tracknumber": {field: "media_file.track_number"},
"discnumber": {field: "media_file.disc_number"},
"year": {field: "media_file.year"},
"date": {field: "media_file.date", alias: "recordingdate"},
"originalyear": {field: "media_file.original_year"},
"originaldate": {field: "media_file.original_date"},
"releaseyear": {field: "media_file.release_year"},
"releasedate": {field: "media_file.release_date"},
"size": {field: "media_file.size"},
"compilation": {field: "media_file.compilation"},
"explicitstatus": {field: "media_file.explicit_status"},
"dateadded": {field: "media_file.created_at"},
"datemodified": {field: "media_file.updated_at"},
"discsubtitle": {field: "media_file.disc_subtitle"},
"comment": {field: "media_file.comment"},
"lyrics": {field: "media_file.lyrics"},
"sorttitle": {field: "media_file.sort_title"},
"sortalbum": {field: "media_file.sort_album_name"},
"sortartist": {field: "media_file.sort_artist_name"},
"sortalbumartist": {field: "media_file.sort_album_artist_name"},
"albumcomment": {field: "media_file.mbz_album_comment"},
"catalognumber": {field: "media_file.catalog_num"},
"filepath": {field: "media_file.path"},
"filetype": {field: "media_file.suffix"},
"duration": {field: "media_file.duration"},
"bitrate": {field: "media_file.bit_rate"},
"bitdepth": {field: "media_file.bit_depth"},
"bpm": {field: "media_file.bpm"},
"channels": {field: "media_file.channels"},
"loved": {field: "COALESCE(annotation.starred, false)"},
"dateloved": {field: "annotation.starred_at"},
"lastplayed": {field: "annotation.play_date"},
"daterated": {field: "annotation.rated_at"},
"playcount": {field: "COALESCE(annotation.play_count, 0)"},
"rating": {field: "COALESCE(annotation.rating, 0)"},
"title": {field: "media_file.title"},
"album": {field: "media_file.album"},
"hascoverart": {field: "media_file.has_cover_art"},
"tracknumber": {field: "media_file.track_number"},
"discnumber": {field: "media_file.disc_number"},
"year": {field: "media_file.year"},
"date": {field: "media_file.date", alias: "recordingdate"},
"originalyear": {field: "media_file.original_year"},
"originaldate": {field: "media_file.original_date"},
"releaseyear": {field: "media_file.release_year"},
"releasedate": {field: "media_file.release_date"},
"size": {field: "media_file.size"},
"compilation": {field: "media_file.compilation"},
"explicitstatus": {field: "media_file.explicit_status"},
"dateadded": {field: "media_file.created_at"},
"datemodified": {field: "media_file.updated_at"},
"discsubtitle": {field: "media_file.disc_subtitle"},
"comment": {field: "media_file.comment"},
"lyrics": {field: "media_file.lyrics"},
"sorttitle": {field: "media_file.sort_title"},
"sortalbum": {field: "media_file.sort_album_name"},
"sortartist": {field: "media_file.sort_artist_name"},
"sortalbumartist": {field: "media_file.sort_album_artist_name"},
"albumcomment": {field: "media_file.mbz_album_comment"},
"catalognumber": {field: "media_file.catalog_num"},
"filepath": {field: "media_file.path"},
"filetype": {field: "media_file.suffix"},
"duration": {field: "media_file.duration"},
"bitrate": {field: "media_file.bit_rate"},
"bitdepth": {field: "media_file.bit_depth"},
"bpm": {field: "media_file.bpm"},
"channels": {field: "media_file.channels"},
"loved": {field: "COALESCE(annotation.starred, false)"},
"dateloved": {field: "annotation.starred_at"},
"lastplayed": {field: "annotation.play_date"},
"daterated": {field: "annotation.rated_at"},
"playcount": {field: "COALESCE(annotation.play_count, 0)"},
"rating": {field: "COALESCE(annotation.rating, 0)"},
"averagerating": {field: "media_file.average_rating", numeric: true},
"albumrating": {field: "COALESCE(album_annotation.rating, 0)", joinType: JoinAlbumAnnotation},
"albumloved": {field: "COALESCE(album_annotation.starred, false)", joinType: JoinAlbumAnnotation},
"albumplaycount": {field: "COALESCE(album_annotation.play_count, 0)", joinType: JoinAlbumAnnotation},
"albumlastplayed": {field: "album_annotation.play_date", joinType: JoinAlbumAnnotation},
"albumdateloved": {field: "album_annotation.starred_at", joinType: JoinAlbumAnnotation},
"albumdaterated": {field: "album_annotation.rated_at", joinType: JoinAlbumAnnotation},
"artistrating": {field: "COALESCE(artist_annotation.rating, 0)", joinType: JoinArtistAnnotation},
"artistloved": {field: "COALESCE(artist_annotation.starred, false)", joinType: JoinArtistAnnotation},
"artistplaycount": {field: "COALESCE(artist_annotation.play_count, 0)", joinType: JoinArtistAnnotation},
"artistlastplayed": {field: "artist_annotation.play_date", joinType: JoinArtistAnnotation},
"artistdateloved": {field: "artist_annotation.starred_at", joinType: JoinArtistAnnotation},
"artistdaterated": {field: "artist_annotation.rated_at", joinType: JoinArtistAnnotation},
"mbz_album_id": {field: "media_file.mbz_album_id"},
"mbz_album_artist_id": {field: "media_file.mbz_album_artist_id"},
"mbz_artist_id": {field: "media_file.mbz_artist_id"},
@ -65,12 +92,13 @@ var fieldMap = map[string]*mappedField{
}
type mappedField struct {
field string
order string
isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.)
isTag bool // true if the field is a tag imported from the file metadata
alias string // name from `mappings.yml` that may differ from the name used in the smart playlist
numeric bool // true if the field/tag should be treated as numeric
field string
order string
isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.)
isTag bool // true if the field is a tag imported from the file metadata
alias string // name from `mappings.yml` that may differ from the name used in the smart playlist
numeric bool // true if the field/tag should be treated as numeric
joinType JoinType // which additional JOINs this field requires
}
func mapFields(expr map[string]any) map[string]any {
@ -94,27 +122,24 @@ func mapExpr(expr squirrel.Sqlizer, negate bool, exprFunc func(string, squirrel.
log.Fatal(fmt.Sprintf("expr is not a map-based operator: %T", expr))
}
// Extract into a generic map
// Extract the field name and value, then build a new map keyed by "value"
// for the inner condition. The original map is left untouched so that
// ToSql can be called multiple times without corruption.
var k string
m := make(map[string]any, rv.Len())
var v any
for _, key := range rv.MapKeys() {
// Save the key to build the expression, and use the provided keyName as the key
k = key.String()
m["value"] = rv.MapIndex(key).Interface()
v = rv.MapIndex(key).Interface()
break // only one key is expected (and supported)
}
// Clear the original map
for _, key := range rv.MapKeys() {
rv.SetMapIndex(key, reflect.Value{})
}
// Create a new map-based expression with "value" as the key, matching the
// column name inside json_tree subqueries.
newMap := reflect.MakeMap(rv.Type())
newMap.SetMapIndex(reflect.ValueOf("value"), reflect.ValueOf(v))
newExpr := newMap.Interface().(squirrel.Sqlizer)
// Write the updated map back into the original variable
for key, val := range m {
rv.SetMapIndex(reflect.ValueOf(key), reflect.ValueOf(val))
}
return exprFunc(k, expr, negate)
return exprFunc(k, newExpr, negate)
}
// mapTagExpr maps a normal field expression to a tag expression.
@ -169,7 +194,7 @@ func (e tagCond) ToSql() (string, []any, error) {
}
}
cond = fmt.Sprintf("exists (select 1 from json_tree(tags, '$.%s') where key='value' and %s)",
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)",
tagName, cond)
if e.not {
cond = "not " + cond
@ -189,7 +214,7 @@ type roleCond struct {
func (e roleCond) ToSql() (string, []any, error) {
cond, args, err := e.cond.ToSql()
cond = fmt.Sprintf(`exists (select 1 from json_tree(participants, '$.%s') where key='name' and %s)`,
cond = fmt.Sprintf(`exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)`,
e.role, cond)
if e.not {
cond = "not " + cond
@ -197,6 +222,38 @@ func (e roleCond) ToSql() (string, []any, error) {
return cond, args, err
}
// fieldJoinType returns the JoinType for a given field name (case-insensitive).
func fieldJoinType(name string) JoinType {
if f, ok := fieldMap[strings.ToLower(name)]; ok {
return f.joinType
}
return JoinNone
}
// extractJoinTypes walks an expression tree and collects all required JoinType flags.
func extractJoinTypes(expr any) JoinType {
result := JoinNone
switch e := expr.(type) {
case All:
for _, sub := range e {
result |= extractJoinTypes(sub)
}
case Any:
for _, sub := range e {
result |= extractJoinTypes(sub)
}
default:
// Leaf expression: use reflection to check if it's a map with field names
rv := reflect.ValueOf(expr)
if rv.Kind() == reflect.Map && rv.Type().Key().Kind() == reflect.String {
for _, key := range rv.MapKeys() {
result |= fieldJoinType(key.String())
}
}
}
return result
}
// AddRoles adds roles to the field map. This is used to add all artist roles to the field map, so they can be used in
// smart playlists. If a role already exists in the field map, it is ignored, so calls to this function are idempotent.
func AddRoles(roles []string) {

View File

@ -54,23 +54,43 @@ var _ = Describe("Operators", func() {
Entry("inTheLast", InTheLast{"lastPlayed": 30}, "annotation.play_date > ?", StartOfPeriod(30, time.Now())),
Entry("notInTheLast", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),
// Album annotation fields
Entry("albumRating", Gt{"albumRating": 3}, "COALESCE(album_annotation.rating, 0) > ?", 3),
Entry("albumLoved", Is{"albumLoved": true}, "COALESCE(album_annotation.starred, false) = ?", true),
Entry("albumPlayCount", Gt{"albumPlayCount": 5}, "COALESCE(album_annotation.play_count, 0) > ?", 5),
Entry("albumLastPlayed", After{"albumLastPlayed": rangeStart}, "album_annotation.play_date > ?", rangeStart),
Entry("albumDateLoved", Before{"albumDateLoved": rangeStart}, "album_annotation.starred_at < ?", rangeStart),
Entry("albumDateRated", After{"albumDateRated": rangeStart}, "album_annotation.rated_at > ?", rangeStart),
Entry("albumLastPlayed inTheLast", InTheLast{"albumLastPlayed": 30}, "album_annotation.play_date > ?", StartOfPeriod(30, time.Now())),
Entry("albumLastPlayed notInTheLast", NotInTheLast{"albumLastPlayed": 30}, "(album_annotation.play_date < ? OR album_annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),
// Artist annotation fields
Entry("artistRating", Gt{"artistRating": 3}, "COALESCE(artist_annotation.rating, 0) > ?", 3),
Entry("artistLoved", Is{"artistLoved": true}, "COALESCE(artist_annotation.starred, false) = ?", true),
Entry("artistPlayCount", Gt{"artistPlayCount": 5}, "COALESCE(artist_annotation.play_count, 0) > ?", 5),
Entry("artistLastPlayed", After{"artistLastPlayed": rangeStart}, "artist_annotation.play_date > ?", rangeStart),
Entry("artistDateLoved", Before{"artistDateLoved": rangeStart}, "artist_annotation.starred_at < ?", rangeStart),
Entry("artistDateRated", After{"artistDateRated": rangeStart}, "artist_annotation.rated_at > ?", rangeStart),
Entry("artistLastPlayed inTheLast", InTheLast{"artistLastPlayed": 30}, "artist_annotation.play_date > ?", StartOfPeriod(30, time.Now())),
Entry("artistLastPlayed notInTheLast", NotInTheLast{"artistLastPlayed": 30}, "(artist_annotation.play_date < ? OR artist_annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),
// Tag tests
Entry("tag is [string]", Is{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)", "Rock"),
Entry("tag isNot [string]", IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)", "Rock"),
Entry("tag gt", Gt{"genre": "A"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value > ?)", "A"),
Entry("tag lt", Lt{"genre": "Z"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value < ?)", "Z"),
Entry("tag contains", Contains{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
Entry("tag not contains", NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
Entry("tag startsWith", StartsWith{"genre": "Soft"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "Soft%"),
Entry("tag endsWith", EndsWith{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock"),
Entry("tag is [string]", Is{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"),
Entry("tag isNot [string]", IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"),
Entry("tag gt", Gt{"genre": "A"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value > ?)", "A"),
Entry("tag lt", Lt{"genre": "Z"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value < ?)", "Z"),
Entry("tag contains", Contains{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
Entry("tag not contains", NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
Entry("tag startsWith", StartsWith{"genre": "Soft"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "Soft%"),
Entry("tag endsWith", EndsWith{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock"),
// Artist roles tests
Entry("role is [string]", Is{"artist": "u2"}, "exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?)", "u2"),
Entry("role isNot [string]", IsNot{"artist": "u2"}, "not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?)", "u2"),
Entry("role contains [string]", Contains{"artist": "u2"}, "exists (select 1 from json_tree(participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
Entry("role not contains [string]", NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
Entry("role startsWith [string]", StartsWith{"composer": "John"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "John%"),
Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"),
Entry("role is [string]", Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"),
Entry("role isNot [string]", IsNot{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"),
Entry("role contains [string]", Contains{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
Entry("role not contains [string]", NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
Entry("role startsWith [string]", StartsWith{"composer": "John"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "John%"),
Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"),
)
// TODO Validate operators that are not valid for each field type.
@ -88,7 +108,7 @@ var _ = Describe("Operators", func() {
op := EndsWith{"mood": "Soft"}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.mood') where key='value' and value LIKE ?)"))
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.mood') where key='value' and value LIKE ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("%Soft"))
})
It("casts numeric comparisons", func() {
@ -96,7 +116,7 @@ var _ = Describe("Operators", func() {
op := Lt{"rate": 6}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)"))
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)"))
gomega.Expect(args).To(gomega.HaveExactElements(6))
})
It("skips unknown tag names", func() {
@ -110,7 +130,7 @@ var _ = Describe("Operators", func() {
op := Contains{"releasetype": "soundtrack"}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)"))
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value LIKE ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("%soundtrack%"))
})
It("supports albumtype as alias for releasetype", func() {
@ -118,7 +138,7 @@ var _ = Describe("Operators", func() {
op := Contains{"albumtype": "live"}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)"))
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value LIKE ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("%live%"))
})
It("supports albumtype alias with Is operator", func() {
@ -127,7 +147,7 @@ var _ = Describe("Operators", func() {
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
// Should query $.releasetype, not $.albumtype
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)"))
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("album"))
})
It("supports albumtype alias with IsNot operator", func() {
@ -136,7 +156,7 @@ var _ = Describe("Operators", func() {
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
// Should query $.releasetype, not $.albumtype
gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)"))
gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("compilation"))
})
})
@ -147,7 +167,7 @@ var _ = Describe("Operators", func() {
op := EndsWith{"producer": "Eno"}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(participants, '$.producer') where key='name' and value LIKE ?)"))
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.participants, '$.producer') where key='name' and value LIKE ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("%Eno"))
})
It("skips unknown roles", func() {
@ -158,6 +178,21 @@ var _ = Describe("Operators", func() {
})
})
DescribeTable("ToSql idempotency",
func(expr Expression) {
sql1, args1, err1 := expr.ToSql()
sql2, args2, err2 := expr.ToSql()
gomega.Expect(err1).ToNot(gomega.HaveOccurred())
gomega.Expect(err2).ToNot(gomega.HaveOccurred())
gomega.Expect(sql2).To(gomega.Equal(sql1))
gomega.Expect(args2).To(gomega.Equal(args1))
},
Entry("tag expression", Is{"genre": "Rock"}),
Entry("role expression", Contains{"artist": "Beatles"}),
Entry("nested criteria", Criteria{Expression: All{Is{"genre": "Rock"}, Contains{"artist": "Beatles"}}}),
)
DescribeTable("JSON Marshaling",
func(op Expression, jsonString string) {
obj := And{op}

View File

@ -56,6 +56,8 @@ type MediaFile struct {
SampleRate int `structs:"sample_rate" json:"sampleRate"`
BitDepth int `structs:"bit_depth" json:"bitDepth"`
Channels int `structs:"channels" json:"channels"`
Codec string `structs:"codec" json:"codec"`
ProbeData string `structs:"probe_data" json:"-" hash:"ignore"`
Genre string `structs:"genre" json:"genre"`
Genres Genres `structs:"-" json:"genres,omitempty"`
SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"`
@ -95,12 +97,19 @@ type MediaFile struct {
}
func (mf MediaFile) FullTitle() string {
if conf.Server.Subsonic.AppendSubtitle && mf.Tags[TagSubtitle] != nil {
if conf.Server.Subsonic.AppendSubtitle && len(mf.Tags[TagSubtitle]) > 0 {
return fmt.Sprintf("%s (%s)", mf.Title, mf.Tags[TagSubtitle][0])
}
return mf.Title
}
func (mf MediaFile) FullAlbumName() string {
if conf.Server.Subsonic.AppendAlbumVersion && len(mf.Tags[TagAlbumVersion]) > 0 {
return fmt.Sprintf("%s (%s)", mf.Album, mf.Tags[TagAlbumVersion][0])
}
return mf.Album
}
func (mf MediaFile) ContentType() string {
return mime.TypeByExtension("." + mf.Suffix)
}
@ -161,6 +170,63 @@ func (mf MediaFile) AbsolutePath() string {
return filepath.Join(mf.LibraryPath, mf.Path)
}
// AudioCodec returns the audio codec for this file.
// Uses the stored Codec field if available, otherwise infers from Suffix and audio properties.
func (mf MediaFile) AudioCodec() string {
// If we have a stored codec from scanning, normalize and return it
if mf.Codec != "" {
return strings.ToLower(mf.Codec)
}
// Fallback: infer from Suffix + BitDepth
return mf.inferCodecFromSuffix()
}
// inferCodecFromSuffix infers the codec from the file extension when Codec field is empty.
func (mf MediaFile) inferCodecFromSuffix() string {
switch strings.ToLower(mf.Suffix) {
case "mp3", "mpga":
return "mp3"
case "mp2":
return "mp2"
case "ogg", "oga":
return "vorbis"
case "opus":
return "opus"
case "mpc":
return "mpc"
case "wma":
return "wma"
case "flac":
return "flac"
case "wav":
return "pcm"
case "aif", "aiff", "aifc":
return "pcm"
case "ape":
return "ape"
case "wv", "wvp":
return "wv"
case "tta":
return "tta"
case "tak":
return "tak"
case "shn":
return "shn"
case "dsf", "dff":
return "dsd"
case "m4a":
// AAC if BitDepth==0, ALAC if BitDepth>0
if mf.BitDepth > 0 {
return "alac"
}
return "aac"
case "m4b", "m4p", "m4r":
return "aac"
default:
return ""
}
}
type MediaFiles []MediaFile
// ToAlbum creates an Album object based on the attributes of this MediaFiles collection.
@ -356,6 +422,7 @@ type MediaFileRepository interface {
CountBySuffix(options ...QueryOptions) (map[string]int64, error)
Exists(id string) (bool, error)
Put(m *MediaFile) error
UpdateProbeData(id string, data string) error
Get(id string) (*MediaFile, error)
GetWithParticipants(id string) (*MediaFile, error)
GetAll(options ...QueryOptions) (MediaFiles, error)

View File

@ -475,7 +475,29 @@ var _ = Describe("MediaFile", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableMediaFileCoverArt = true
})
Describe(".CoverArtId()", func() {
DescribeTable("FullTitle",
func(enabled bool, tags Tags, expected string) {
conf.Server.Subsonic.AppendSubtitle = enabled
mf := MediaFile{Title: "Song", Tags: tags}
Expect(mf.FullTitle()).To(Equal(expected))
},
Entry("appends subtitle when enabled and tag is present", true, Tags{TagSubtitle: []string{"Live"}}, "Song (Live)"),
Entry("returns just title when disabled", false, Tags{TagSubtitle: []string{"Live"}}, "Song"),
Entry("returns just title when tag is absent", true, Tags{}, "Song"),
Entry("returns just title when tag is an empty slice", true, Tags{TagSubtitle: []string{}}, "Song"),
)
DescribeTable("FullAlbumName",
func(enabled bool, tags Tags, expected string) {
conf.Server.Subsonic.AppendAlbumVersion = enabled
mf := MediaFile{Album: "Album", Tags: tags}
Expect(mf.FullAlbumName()).To(Equal(expected))
},
Entry("appends version when enabled and tag is present", true, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album (Deluxe Edition)"),
Entry("returns just album name when disabled", false, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album"),
Entry("returns just album name when tag is absent", true, Tags{}, "Album"),
Entry("returns just album name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"),
)
Describe("CoverArtId", func() {
It("returns its own id if it HasCoverArt", func() {
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
id := mf.CoverArtID()
@ -496,6 +518,58 @@ var _ = Describe("MediaFile", func() {
Expect(id.ID).To(Equal(mf.AlbumID))
})
})
Describe("AudioCodec", func() {
It("returns normalized stored codec when available", func() {
mf := MediaFile{Codec: "AAC", Suffix: "m4a"}
Expect(mf.AudioCodec()).To(Equal("aac"))
})
It("returns stored codec lowercased", func() {
mf := MediaFile{Codec: "ALAC", Suffix: "m4a"}
Expect(mf.AudioCodec()).To(Equal("alac"))
})
DescribeTable("infers codec from suffix when Codec field is empty",
func(suffix string, bitDepth int, expected string) {
mf := MediaFile{Suffix: suffix, BitDepth: bitDepth}
Expect(mf.AudioCodec()).To(Equal(expected))
},
Entry("mp3", "mp3", 0, "mp3"),
Entry("mpga", "mpga", 0, "mp3"),
Entry("mp2", "mp2", 0, "mp2"),
Entry("ogg", "ogg", 0, "vorbis"),
Entry("oga", "oga", 0, "vorbis"),
Entry("opus", "opus", 0, "opus"),
Entry("mpc", "mpc", 0, "mpc"),
Entry("wma", "wma", 0, "wma"),
Entry("flac", "flac", 0, "flac"),
Entry("wav", "wav", 0, "pcm"),
Entry("aif", "aif", 0, "pcm"),
Entry("aiff", "aiff", 0, "pcm"),
Entry("aifc", "aifc", 0, "pcm"),
Entry("ape", "ape", 0, "ape"),
Entry("wv", "wv", 0, "wv"),
Entry("wvp", "wvp", 0, "wv"),
Entry("tta", "tta", 0, "tta"),
Entry("tak", "tak", 0, "tak"),
Entry("shn", "shn", 0, "shn"),
Entry("dsf", "dsf", 0, "dsd"),
Entry("dff", "dff", 0, "dsd"),
Entry("m4a with BitDepth=0 (AAC)", "m4a", 0, "aac"),
Entry("m4a with BitDepth>0 (ALAC)", "m4a", 16, "alac"),
Entry("m4b", "m4b", 0, "aac"),
Entry("m4p", "m4p", 0, "aac"),
Entry("m4r", "m4r", 0, "aac"),
Entry("unknown suffix", "xyz", 0, ""),
)
It("prefers stored codec over suffix inference", func() {
mf := MediaFile{Codec: "ALAC", Suffix: "m4a", BitDepth: 0}
Expect(mf.AudioCodec()).To(Equal("alac"))
})
})
})
func t(v string) time.Time {

View File

@ -65,6 +65,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
mf.SampleRate = md.AudioProperties().SampleRate
mf.BitDepth = md.AudioProperties().BitDepth
mf.Channels = md.AudioProperties().Channels
mf.Codec = md.AudioProperties().Codec
mf.Path = md.FilePath()
mf.Suffix = md.Suffix()
mf.Size = md.Size()

View File

@ -35,6 +35,7 @@ type AudioProperties struct {
BitDepth int
SampleRate int
Channels int
Codec string
}
type Date string

View File

@ -1,28 +1,34 @@
package model
import (
"path/filepath"
"slices"
"strconv"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/utils"
)
type Playlist struct {
ID string `structs:"id" json:"id"`
Name string `structs:"name" json:"name"`
Comment string `structs:"comment" json:"comment"`
Duration float32 `structs:"duration" json:"duration"`
Size int64 `structs:"size" json:"size"`
SongCount int `structs:"song_count" json:"songCount"`
OwnerName string `structs:"-" json:"ownerName"`
OwnerID string `structs:"owner_id" json:"ownerId"`
Public bool `structs:"public" json:"public"`
Tracks PlaylistTracks `structs:"-" json:"tracks,omitempty"`
Path string `structs:"path" json:"path"`
Sync bool `structs:"sync" json:"sync"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
ID string `structs:"id" json:"id"`
Name string `structs:"name" json:"name"`
Comment string `structs:"comment" json:"comment"`
Duration float32 `structs:"duration" json:"duration"`
Size int64 `structs:"size" json:"size"`
SongCount int `structs:"song_count" json:"songCount"`
OwnerName string `structs:"-" json:"ownerName"`
OwnerID string `structs:"owner_id" json:"ownerId"`
Public bool `structs:"public" json:"public"`
Tracks PlaylistTracks `structs:"-" json:"tracks,omitempty"`
Path string `structs:"path" json:"path"`
Sync bool `structs:"sync" json:"sync"`
UploadedImage string `structs:"uploaded_image" json:"uploadedImage"`
ExternalImageURL string `structs:"external_image_url" json:"externalImageUrl,omitempty"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
// SmartPlaylist attributes
Rules *criteria.Criteria `structs:"rules" json:"rules"`
@ -102,10 +108,31 @@ func (pls *Playlist) AddMediaFiles(mfs MediaFiles) {
pls.refreshStats()
}
// ImageFilename returns a human-friendly filename for an uploaded playlist cover image.
// Format: <ID>_<clean_name><ext>, falling back to <ID><ext> if the name cleans to empty.
func (pls Playlist) ImageFilename(ext string) string {
clean := utils.CleanFileName(pls.Name)
if clean == "" {
return pls.ID + ext
}
return pls.ID + "_" + clean + ext
}
func (pls Playlist) CoverArtID() ArtworkID {
return artworkIDFromPlaylist(pls)
}
// UploadedImagePath returns the absolute filesystem path for a manually uploaded
// playlist cover image. Returns empty string if no image has been uploaded.
// This does NOT cover sidecar images or external URLs — those are resolved
// by the artwork reader's fallback chain.
func (pls Playlist) UploadedImagePath() string {
if pls.UploadedImage == "" {
return ""
}
return filepath.Join(conf.Server.DataFolder, consts.ArtworkFolder, "playlist", pls.UploadedImage)
}
type Playlists []Playlist
type PlaylistRepository interface {

View File

@ -7,6 +7,28 @@ import (
)
var _ = Describe("Playlist", func() {
Describe("ImageFilename", func() {
It("returns ID_cleanname.ext for a normal name", func() {
pls := model.Playlist{ID: "abc123", Name: "My Cool Playlist"}
Expect(pls.ImageFilename(".jpg")).To(Equal("abc123_my_cool_playlist.jpg"))
})
It("falls back to ID.ext when name cleans to empty", func() {
pls := model.Playlist{ID: "abc123", Name: "!!!"}
Expect(pls.ImageFilename(".png")).To(Equal("abc123.png"))
})
It("falls back to ID.ext for empty name", func() {
pls := model.Playlist{ID: "abc123", Name: ""}
Expect(pls.ImageFilename(".jpg")).To(Equal("abc123.jpg"))
})
It("handles names with special characters", func() {
pls := model.Playlist{ID: "x1", Name: "Rock & Roll! (2024)"}
Expect(pls.ImageFilename(".webp")).To(Equal("x1_rock__roll_2024.webp"))
})
})
Describe("ToM3U8()", func() {
var pls model.Playlist
BeforeEach(func() {

View File

@ -3,25 +3,27 @@ package model
import "time"
type Plugin struct {
ID string `structs:"id" json:"id"`
Path string `structs:"path" json:"path"`
Manifest string `structs:"manifest" json:"manifest"`
Config string `structs:"config" json:"config,omitempty"`
Users string `structs:"users" json:"users,omitempty"`
AllUsers bool `structs:"all_users" json:"allUsers,omitempty"`
Libraries string `structs:"libraries" json:"libraries,omitempty"`
AllLibraries bool `structs:"all_libraries" json:"allLibraries,omitempty"`
Enabled bool `structs:"enabled" json:"enabled"`
LastError string `structs:"last_error" json:"lastError,omitempty"`
SHA256 string `structs:"sha256" json:"sha256"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
ID string `structs:"id" json:"id"`
Path string `structs:"path" json:"path"`
Manifest string `structs:"manifest" json:"manifest"`
Config string `structs:"config" json:"config,omitempty"`
Users string `structs:"users" json:"users,omitempty"`
AllUsers bool `structs:"all_users" json:"allUsers,omitempty"`
Libraries string `structs:"libraries" json:"libraries,omitempty"`
AllLibraries bool `structs:"all_libraries" json:"allLibraries,omitempty"`
AllowWriteAccess bool `structs:"allow_write_access" json:"allowWriteAccess,omitempty"`
Enabled bool `structs:"enabled" json:"enabled"`
LastError string `structs:"last_error" json:"lastError,omitempty"`
SHA256 string `structs:"sha256" json:"sha256"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
}
type Plugins []Plugin
type PluginRepository interface {
ResourceRepository
ClearErrors() error
CountAll(options ...QueryOptions) (int64, error)
Delete(id string) error
Get(id string) (*Plugin, error)

View File

@ -1,5 +1,5 @@
package model
type SearchableRepository[T any] interface {
Search(q string, offset, size int, options ...QueryOptions) (T, error)
Search(q string, options ...QueryOptions) (T, error)
}

View File

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"iter"
"maps"
"slices"
"strings"
@ -12,7 +13,6 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@ -62,11 +62,14 @@ func (a *dbAlbum) PostScan() error {
func (a *dbAlbum) PostMapArgs(args map[string]any) error {
fullText := []string{a.Name, a.SortAlbumName, a.AlbumArtist}
fullText = append(fullText, a.Album.Participants.AllNames()...)
participantNames := a.Album.Participants.AllNames()
fullText = append(fullText, participantNames...)
fullText = append(fullText, slices.Collect(maps.Values(a.Album.Discs))...)
fullText = append(fullText, a.Album.Tags[model.TagAlbumVersion]...)
fullText = append(fullText, a.Album.Tags[model.TagCatalogNumber]...)
args["full_text"] = formatFullText(fullText...)
args["search_participants"] = strings.Join(participantNames, " ")
args["search_normalized"] = normalizeForFTS(a.Name, a.AlbumArtist)
args["tags"] = marshalTags(a.Album.Tags)
args["participants"] = marshalParticipants(a.Album.Participants)
@ -200,12 +203,11 @@ func (r *albumRepository) Put(al *model.Album) error {
}
al.ID = id
if len(al.Participants) > 0 {
err = r.updateParticipants(al.ID, al.Participants)
if err != nil {
if err = r.updateParticipants(al.ID, al.Participants); err != nil {
return err
}
}
return err
return nil
}
// TODO Move external metadata to a separated table
@ -239,7 +241,7 @@ func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, e
if err != nil {
return nil, err
}
return res.toModels(), err
return res.toModels(), nil
}
func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error {
@ -300,17 +302,21 @@ func (r *albumRepository) GetTouchedAlbums(libID int) (model.AlbumCursor, error)
if err != nil {
return nil, err
}
return wrapAlbumCursor(cursor), nil
}
func wrapAlbumCursor(cursor iter.Seq2[dbAlbum, error]) model.AlbumCursor {
return func(yield func(model.Album, error) bool) {
for a, err := range cursor {
if a.Album == nil {
yield(model.Album{}, fmt.Errorf("unexpected nil album: %v", a))
yield(model.Album{}, fmt.Errorf("unexpected nil album (%v): %w", a, err))
return
}
if !yield(*a.Album, err) || err != nil {
return
}
}
}, nil
}
}
// RefreshPlayCounts updates the play count and last play date annotations for all albums, based
@ -350,18 +356,21 @@ func (r *albumRepository) purgeEmpty(libraryIDs ...int) error {
return nil
}
func (r *albumRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Albums, error) {
var albumSearchConfig = searchConfig{
NaturalOrder: "album.rowid",
OrderBy: []string{"name"},
MBIDFields: []string{"mbz_album_id", "mbz_release_group_id"},
}
func (r *albumRepository) Search(q string, options ...model.QueryOptions) (model.Albums, error) {
var opts model.QueryOptions
if len(options) > 0 {
opts = options[0]
}
var res dbAlbums
if uuid.Validate(q) == nil {
err := r.searchByMBID(r.selectAlbum(options...), q, []string{"mbz_album_id", "mbz_release_group_id"}, &res)
if err != nil {
return nil, fmt.Errorf("searching album by MBID %q: %w", q, err)
}
} else {
err := r.doSearch(r.selectAlbum(options...), q, offset, size, &res, "album.rowid", "name")
if err != nil {
return nil, fmt.Errorf("searching album by query %q: %w", q, err)
}
err := r.doSearch(r.selectAlbum(options...), q, &res, albumSearchConfig, opts)
if err != nil {
return nil, fmt.Errorf("searching album %q: %w", q, err)
}
return res.toModels(), nil
}

View File

@ -1,6 +1,7 @@
package persistence
import (
"errors"
"fmt"
"time"
@ -56,17 +57,23 @@ var _ = Describe("AlbumRepository", func() {
It("returns all records sorted", func() {
Expect(GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{
albumAbbeyRoad,
albumWithVersion,
albumCJK,
albumMultiDisc,
albumRadioactivity,
albumSgtPeppers,
albumPunctuation,
}))
})
It("returns all records sorted desc", func() {
Expect(GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{
albumPunctuation,
albumSgtPeppers,
albumRadioactivity,
albumMultiDisc,
albumCJK,
albumWithVersion,
albumAbbeyRoad,
}))
})
@ -737,6 +744,46 @@ var _ = Describe("AlbumRepository", func() {
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID}))
})
})
Describe("wrapAlbumCursor", func() {
It("does not panic when the cursor yields a dbAlbum with nil Album", func() {
// Simulate what queryWithStableResults does on the rows.Err() path:
// it yields a zero-value dbAlbum (where Album is nil) with an error.
dbErr := fmt.Errorf("database is locked")
cursor := func(yield func(dbAlbum, error) bool) {
var empty dbAlbum // Album pointer is nil
yield(empty, dbErr)
}
// wrapAlbumCursor should handle the nil Album without panicking
wrappedCursor := wrapAlbumCursor(cursor)
var gotErr error
Expect(func() {
for _, err := range wrappedCursor {
gotErr = err
}
}).ToNot(Panic())
Expect(gotErr).To(HaveOccurred())
Expect(gotErr.Error()).To(ContainSubstring("unexpected nil album"))
Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error")
})
It("yields albums from a valid cursor", func() {
album := &model.Album{ID: "a1", Name: "Test"}
cursor := func(yield func(dbAlbum, error) bool) {
yield(dbAlbum{Album: album}, nil)
}
wrappedCursor := wrapAlbumCursor(cursor)
var albums []model.Album
for a, err := range wrappedCursor {
Expect(err).ToNot(HaveOccurred())
albums = append(albums, a)
}
Expect(albums).To(HaveLen(1))
Expect(albums[0].ID).To(Equal("a1"))
})
})
})
func _p(id, name string, sortName ...string) model.Participant {

View File

@ -11,7 +11,6 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@ -102,6 +101,7 @@ func (a *dbArtist) PostMapArgs(m map[string]any) error {
similarArtists, _ := json.Marshal(sa)
m["similar_artists"] = string(similarArtists)
m["full_text"] = formatFullText(a.Name, a.SortArtistName)
m["search_normalized"] = normalizeForFTS(a.Name)
// Do not override the sort_artist_name and mbz_artist_id fields if they are empty
// TODO: Better way to handle this?
@ -134,6 +134,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
"id": idFilter(r.tableName),
"name": fullTextFilter(r.tableName, "mbz_artist_id"),
"starred": annotationBoolFilter("starred"),
"has_rating": annotationBoolFilter("rating"),
"role": roleFilter,
"missing": booleanFilter,
"library_id": artistLibraryIdFilter,
@ -512,20 +513,25 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
return totalRowsAffected, nil
}
func (r *artistRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Artists, error) {
var res dbArtists
if uuid.Validate(q) == nil {
err := r.searchByMBID(r.selectArtist(options...), q, []string{"mbz_artist_id"}, &res)
if err != nil {
return nil, fmt.Errorf("searching artist by MBID %q: %w", q, err)
}
} else {
func (r *artistRepository) searchCfg() searchConfig {
return searchConfig{
// Natural order for artists is more performant by ID, due to GROUP BY clause in selectArtist
err := r.doSearch(r.selectArtist(options...), q, offset, size, &res, "artist.id",
"sum(json_extract(stats, '$.total.m')) desc", "name")
if err != nil {
return nil, fmt.Errorf("searching artist by query %q: %w", q, err)
}
NaturalOrder: "artist.id",
OrderBy: []string{"sum(json_extract(stats, '$.total.m')) desc", "name"},
MBIDFields: []string{"mbz_artist_id"},
LibraryFilter: r.applyLibraryFilterToArtistQuery,
}
}
func (r *artistRepository) Search(q string, options ...model.QueryOptions) (model.Artists, error) {
var opts model.QueryOptions
if len(options) > 0 {
opts = options[0]
}
var res dbArtists
err := r.doSearch(r.selectArtist(options...), q, &res, r.searchCfg(), opts)
if err != nil {
return nil, fmt.Errorf("searching artist %q: %w", q, err)
}
return res.toModels(), nil
}

View File

@ -193,7 +193,7 @@ var _ = Describe("ArtistRepository", func() {
Describe("Basic Operations", func() {
Describe("Count", func() {
It("returns the number of artists in the DB", func() {
Expect(repo.CountAll()).To(Equal(int64(2)))
Expect(repo.CountAll()).To(Equal(int64(4)))
})
})
@ -228,13 +228,19 @@ var _ = Describe("ArtistRepository", func() {
idx, err := repo.GetIndex(false, []int{1})
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2))
Expect(idx).To(HaveLen(4))
Expect(idx[0].ID).To(Equal("F"))
Expect(idx[0].Artists).To(HaveLen(1))
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
Expect(idx[1].ID).To(Equal("K"))
Expect(idx[1].Artists).To(HaveLen(1))
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
Expect(idx[2].ID).To(Equal("R"))
Expect(idx[2].Artists).To(HaveLen(1))
Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
Expect(idx[3].ID).To(Equal("S"))
Expect(idx[3].Artists).To(HaveLen(1))
Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
// Restore the original value
artistBeatles.SortArtistName = ""
@ -246,13 +252,19 @@ var _ = Describe("ArtistRepository", func() {
XIt("returns the index when PreferSortTags is true and SortArtistName is empty", func() {
idx, err := repo.GetIndex(false, []int{1})
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2))
Expect(idx).To(HaveLen(4))
Expect(idx[0].ID).To(Equal("B"))
Expect(idx[0].Artists).To(HaveLen(1))
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
Expect(idx[1].ID).To(Equal("K"))
Expect(idx[1].Artists).To(HaveLen(1))
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
Expect(idx[2].ID).To(Equal("R"))
Expect(idx[2].Artists).To(HaveLen(1))
Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
Expect(idx[3].ID).To(Equal("S"))
Expect(idx[3].Artists).To(HaveLen(1))
Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
})
})
@ -268,13 +280,19 @@ var _ = Describe("ArtistRepository", func() {
idx, err := repo.GetIndex(false, []int{1})
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2))
Expect(idx).To(HaveLen(4))
Expect(idx[0].ID).To(Equal("B"))
Expect(idx[0].Artists).To(HaveLen(1))
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
Expect(idx[1].ID).To(Equal("K"))
Expect(idx[1].Artists).To(HaveLen(1))
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
Expect(idx[2].ID).To(Equal("R"))
Expect(idx[2].Artists).To(HaveLen(1))
Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
Expect(idx[3].ID).To(Equal("S"))
Expect(idx[3].Artists).To(HaveLen(1))
Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
// Restore the original value
artistBeatles.SortArtistName = ""
@ -285,13 +303,19 @@ var _ = Describe("ArtistRepository", func() {
It("returns the index when SortArtistName is empty", func() {
idx, err := repo.GetIndex(false, []int{1})
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2))
Expect(idx).To(HaveLen(4))
Expect(idx[0].ID).To(Equal("B"))
Expect(idx[0].Artists).To(HaveLen(1))
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
Expect(idx[1].ID).To(Equal("K"))
Expect(idx[1].Artists).To(HaveLen(1))
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
Expect(idx[2].ID).To(Equal("R"))
Expect(idx[2].Artists).To(HaveLen(1))
Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
Expect(idx[3].ID).To(Equal("S"))
Expect(idx[3].Artists).To(HaveLen(1))
Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
})
})
@ -377,7 +401,7 @@ var _ = Describe("ArtistRepository", func() {
// Admin users can see all content when valid library IDs are provided
idx, err := repo.GetIndex(false, []int{1})
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2))
Expect(idx).To(HaveLen(4))
// With non-existent library ID, admin users see no content because no artists are associated with that library
idx, err = repo.GetIndex(false, []int{999})
@ -488,7 +512,7 @@ var _ = Describe("ArtistRepository", func() {
Expect(err).ToNot(HaveOccurred())
// Test the search
results, err := (*testRepo).Search("550e8400-e29b-41d4-a716-446655440010", 0, 10)
results, err := (*testRepo).Search("550e8400-e29b-41d4-a716-446655440010", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
if shouldFind {
@ -519,12 +543,12 @@ var _ = Describe("ArtistRepository", func() {
Expect(err).ToNot(HaveOccurred())
// Restricted user should not find this artist
results, err := restrictedRepo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", 0, 10)
results, err := restrictedRepo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
// But admin should find it
results, err = repo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", 0, 10)
results, err = repo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
@ -536,7 +560,7 @@ var _ = Describe("ArtistRepository", func() {
Context("Text Search", func() {
It("allows admin to find artists by name regardless of library", func() {
results, err := repo.Search("Beatles", 0, 10)
results, err := repo.Search("Beatles", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].Name).To(Equal("The Beatles"))
@ -556,7 +580,7 @@ var _ = Describe("ArtistRepository", func() {
Expect(err).ToNot(HaveOccurred())
// Restricted user should not find this artist
results, err := restrictedRepo.Search("Unique Search Name", 0, 10)
results, err := restrictedRepo.Search("Unique Search Name", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty(), "Text search should respect library filtering")
@ -625,11 +649,11 @@ var _ = Describe("ArtistRepository", func() {
It("sees all artists regardless of library permissions", func() {
count, err := repo.CountAll()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(int64(2)))
Expect(count).To(Equal(int64(4)))
artists, err := repo.GetAll()
Expect(err).ToNot(HaveOccurred())
Expect(artists).To(HaveLen(2))
Expect(artists).To(HaveLen(4))
exists, err := repo.Exists(artistBeatles.ID)
Expect(err).ToNot(HaveOccurred())
@ -661,10 +685,10 @@ var _ = Describe("ArtistRepository", func() {
// Should see missing artist in GetAll by default for admin users
artists, err := repo.GetAll()
Expect(err).ToNot(HaveOccurred())
Expect(artists).To(HaveLen(3)) // Including the missing artist
Expect(artists).To(HaveLen(5)) // Including the missing artist
// Search never returns missing artists (hardcoded behavior)
results, err := repo.Search("Missing Artist", 0, 10)
results, err := repo.Search("Missing Artist", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
})
@ -718,11 +742,11 @@ var _ = Describe("ArtistRepository", func() {
})
It("Search returns empty results for users without library access", func() {
results, err := restrictedRepo.Search("Beatles", 0, 10)
results, err := restrictedRepo.Search("Beatles", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
results, err = restrictedRepo.Search("Kraftwerk", 0, 10)
results, err = restrictedRepo.Search("Kraftwerk", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
})
@ -767,19 +791,19 @@ var _ = Describe("ArtistRepository", func() {
It("CountAll returns correct count after gaining access", func() {
count, err := restrictedRepo.CountAll()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(int64(2))) // Beatles and Kraftwerk
Expect(count).To(Equal(int64(4))) // Beatles, Kraftwerk, Seatbelts, and The Roots
})
It("GetAll returns artists after gaining access", func() {
artists, err := restrictedRepo.GetAll()
Expect(err).ToNot(HaveOccurred())
Expect(artists).To(HaveLen(2))
Expect(artists).To(HaveLen(4))
var names []string
for _, artist := range artists {
names = append(names, artist.Name)
}
Expect(names).To(ContainElements("The Beatles", "Kraftwerk"))
Expect(names).To(ContainElements("The Beatles", "Kraftwerk", "シートベルツ", "The Roots"))
})
It("Exists returns true for accessible artists", func() {
@ -796,7 +820,7 @@ var _ = Describe("ArtistRepository", func() {
// With valid library access, should see artists
idx, err := restrictedRepo.GetIndex(false, []int{1})
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2))
Expect(idx).To(HaveLen(4))
// With non-existent library ID, should see nothing (non-admin user)
idx, err = restrictedRepo.GetIndex(false, []int{999})

View File

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"iter"
"maps"
"os"
"path/filepath"
@ -218,13 +219,21 @@ func (r folderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error)
if err != nil {
return nil, err
}
return wrapFolderCursor(cursor), nil
}
func wrapFolderCursor(cursor iter.Seq2[dbFolder, error]) model.FolderCursor {
return func(yield func(model.Folder, error) bool) {
for f, err := range cursor {
if f.Folder == nil {
yield(model.Folder{}, fmt.Errorf("unexpected nil folder (%v): %w", f, err))
return
}
if !yield(*f.Folder, err) || err != nil {
return
}
}
}, nil
}
}
func (r folderRepository) purgeEmpty(libraryIDs ...int) error {

View File

@ -2,6 +2,7 @@ package persistence
import (
"context"
"errors"
"fmt"
"github.com/navidrome/navidrome/log"
@ -210,4 +211,44 @@ var _ = Describe("FolderRepository", func() {
})
})
})
Describe("wrapFolderCursor", func() {
It("does not panic when the cursor yields a dbFolder with nil Folder", func() {
// Simulate what queryWithStableResults does on the rows.Err() path:
// it yields a zero-value dbFolder (where Folder is nil) with an error.
dbErr := fmt.Errorf("database is locked")
cursor := func(yield func(dbFolder, error) bool) {
var empty dbFolder // Folder pointer is nil
yield(empty, dbErr)
}
// wrapFolderCursor should handle the nil Folder without panicking
wrappedCursor := wrapFolderCursor(cursor)
var gotErr error
Expect(func() {
for _, err := range wrappedCursor {
gotErr = err
}
}).ToNot(Panic())
Expect(gotErr).To(HaveOccurred())
Expect(gotErr.Error()).To(ContainSubstring("unexpected nil folder"))
Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error")
})
It("yields folders from a valid cursor", func() {
folder := &model.Folder{ID: "f1", Name: "Test"}
cursor := func(yield func(dbFolder, error) bool) {
yield(dbFolder{Folder: folder}, nil)
}
wrappedCursor := wrapFolderCursor(cursor)
var folders []model.Folder
for f, err := range wrappedCursor {
Expect(err).ToNot(HaveOccurred())
folders = append(folders, f)
}
Expect(folders).To(HaveLen(1))
Expect(folders[0].ID).To(Equal("f1"))
})
})
})

View File

@ -3,6 +3,7 @@ package persistence
import (
"context"
"fmt"
"iter"
"slices"
"strconv"
"strings"
@ -11,7 +12,6 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@ -58,8 +58,11 @@ func (m *dbMediaFile) PostScan() error {
func (m *dbMediaFile) PostMapArgs(args map[string]any) error {
fullText := []string{m.FullTitle(), m.Album, m.Artist, m.AlbumArtist,
m.SortTitle, m.SortAlbumName, m.SortArtistName, m.SortAlbumArtistName, m.DiscSubtitle}
fullText = append(fullText, m.MediaFile.Participants.AllNames()...)
participantNames := m.MediaFile.Participants.AllNames()
fullText = append(fullText, participantNames...)
args["full_text"] = formatFullText(fullText...)
args["search_participants"] = strings.Join(participantNames, " ")
args["search_normalized"] = normalizeForFTS(m.FullTitle(), m.Album, m.Artist, m.AlbumArtist)
args["tags"] = marshalTags(m.MediaFile.Tags)
args["participants"] = marshalParticipants(m.MediaFile.Participants)
return nil
@ -96,6 +99,7 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc {
"id": idFilter("media_file"),
"title": fullTextFilter("media_file", "mbz_recording_id", "mbz_release_track_id"),
"starred": annotationBoolFilter("starred"),
"has_rating": annotationBoolFilter("rating"),
"genre_id": tagIDFilter,
"missing": booleanFilter,
"artists_id": artistFilter,
@ -159,6 +163,11 @@ func (r *mediaFileRepository) Put(m *model.MediaFile) error {
return r.updateParticipants(m.ID, m.Participants)
}
func (r *mediaFileRepository) UpdateProbeData(id string, data string) error {
_, err := r.executeSQL(Update(r.tableName).Set("probe_data", data).Where(Eq{"id": id}))
return err
}
func (r *mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder {
sql := r.newSelect(options...).Columns("media_file.*", "library.path as library_path", "library.name as library_name").
LeftJoin("library on media_file.library_id = library.id")
@ -228,17 +237,7 @@ func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.Me
if err != nil {
return nil, err
}
return func(yield func(model.MediaFile, error) bool) {
for m, err := range cursor {
if m.MediaFile == nil {
yield(model.MediaFile{}, fmt.Errorf("unexpected nil mediafile: %v", m))
return
}
if !yield(*m.MediaFile, err) || err != nil {
return
}
}
}, nil
return wrapMediaFileCursor(cursor), nil
}
// FindByPaths finds media files by their paths.
@ -368,13 +367,21 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC
if err != nil {
return nil, err
}
return wrapMediaFileCursor(cursor), nil
}
func wrapMediaFileCursor(cursor iter.Seq2[dbMediaFile, error]) model.MediaFileCursor {
return func(yield func(model.MediaFile, error) bool) {
for m, err := range cursor {
if m.MediaFile == nil {
yield(model.MediaFile{}, fmt.Errorf("unexpected nil mediafile (%v): %w", m, err))
return
}
if !yield(*m.MediaFile, err) || err != nil {
return
}
}
}, nil
}
}
// FindRecentFilesByMBZTrackID finds recently added files by MusicBrainz Track ID in other libraries
@ -425,18 +432,21 @@ func (r *mediaFileRepository) FindRecentFilesByProperties(missing model.MediaFil
return res.toModels(), nil
}
func (r *mediaFileRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.MediaFiles, error) {
var mediaFileSearchConfig = searchConfig{
NaturalOrder: "media_file.rowid",
OrderBy: []string{"title"},
MBIDFields: []string{"mbz_recording_id", "mbz_release_track_id"},
}
func (r *mediaFileRepository) Search(q string, options ...model.QueryOptions) (model.MediaFiles, error) {
var opts model.QueryOptions
if len(options) > 0 {
opts = options[0]
}
var res dbMediaFiles
if uuid.Validate(q) == nil {
err := r.searchByMBID(r.selectMediaFile(options...), q, []string{"mbz_recording_id", "mbz_release_track_id"}, &res)
if err != nil {
return nil, fmt.Errorf("searching media_file by MBID %q: %w", q, err)
}
} else {
err := r.doSearch(r.selectMediaFile(options...), q, offset, size, &res, "media_file.rowid", "title")
if err != nil {
return nil, fmt.Errorf("searching media_file by query %q: %w", q, err)
}
err := r.doSearch(r.selectMediaFile(options...), q, &res, mediaFileSearchConfig, opts)
if err != nil {
return nil, fmt.Errorf("searching media_file %q: %w", q, err)
}
return res.toModels(), nil
}

View File

@ -2,6 +2,8 @@ package persistence
import (
"context"
"errors"
"fmt"
"time"
"github.com/Masterminds/squirrel"
@ -39,7 +41,7 @@ var _ = Describe("MediaRepository", func() {
})
It("counts the number of mediafiles in the DB", func() {
Expect(mr.CountAll()).To(Equal(int64(10)))
Expect(mr.CountAll()).To(Equal(int64(13)))
})
Describe("CountBySuffix", func() {
@ -527,7 +529,7 @@ var _ = Describe("MediaRepository", func() {
Describe("Search", func() {
Context("text search", func() {
It("finds media files by title", func() {
results, err := mr.Search("Antenna", 0, 10)
results, err := mr.Search("Antenna", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(3)) // songAntenna, songAntennaWithLyrics, songAntenna2
for _, result := range results {
@ -536,7 +538,7 @@ var _ = Describe("MediaRepository", func() {
})
It("finds media files case insensitively", func() {
results, err := mr.Search("antenna", 0, 10)
results, err := mr.Search("antenna", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(3))
for _, result := range results {
@ -545,7 +547,7 @@ var _ = Describe("MediaRepository", func() {
})
It("returns empty result when no matches found", func() {
results, err := mr.Search("nonexistent", 0, 10)
results, err := mr.Search("nonexistent", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
})
@ -578,7 +580,7 @@ var _ = Describe("MediaRepository", func() {
})
It("finds media file by mbz_recording_id", func() {
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440020", 0, 10)
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440020", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].ID).To(Equal("test-mbid-mediafile"))
@ -586,7 +588,7 @@ var _ = Describe("MediaRepository", func() {
})
It("finds media file by mbz_release_track_id", func() {
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440021", 0, 10)
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440021", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].ID).To(Equal("test-mbid-mediafile"))
@ -594,7 +596,7 @@ var _ = Describe("MediaRepository", func() {
})
It("returns empty result when MBID is not found", func() {
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440099", 0, 10)
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440099", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
})
@ -614,7 +616,7 @@ var _ = Describe("MediaRepository", func() {
Expect(err).ToNot(HaveOccurred())
// Search never returns missing media files (hardcoded behavior)
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440022", 0, 10)
results, err := mr.Search("550e8400-e29b-41d4-a716-446655440022", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
@ -711,4 +713,44 @@ var _ = Describe("MediaRepository", func() {
Expect(results).To(BeEmpty())
})
})
Describe("wrapMediaFileCursor", func() {
It("does not panic when the cursor yields a dbMediaFile with nil MediaFile", func() {
// Simulate what queryWithStableResults does on the rows.Err() path:
// it yields a zero-value dbMediaFile (where MediaFile is nil) with an error.
dbErr := fmt.Errorf("database is locked")
cursor := func(yield func(dbMediaFile, error) bool) {
var empty dbMediaFile // MediaFile pointer is nil
yield(empty, dbErr)
}
// wrapMediaFileCursor should handle the nil MediaFile without panicking
wrappedCursor := wrapMediaFileCursor(cursor)
var gotErr error
Expect(func() {
for _, err := range wrappedCursor {
gotErr = err
}
}).ToNot(Panic())
Expect(gotErr).To(HaveOccurred())
Expect(gotErr.Error()).To(ContainSubstring("unexpected nil mediafile"))
Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error")
})
It("yields mediafiles from a valid cursor", func() {
mf := &model.MediaFile{ID: "mf1", Title: "Test"}
cursor := func(yield func(dbMediaFile, error) bool) {
yield(dbMediaFile{MediaFile: mf}, nil)
}
wrappedCursor := wrapMediaFileCursor(cursor)
var mediafiles []model.MediaFile
for m, err := range wrappedCursor {
Expect(err).ToNot(HaveOccurred())
mediafiles = append(mediafiles, m)
}
Expect(mediafiles).To(HaveLen(1))
Expect(mediafiles[0].ID).To(Equal("mf1"))
})
})
})

View File

@ -56,12 +56,22 @@ func al(al model.Album) model.Album {
return al
}
func alWithTags(a model.Album, tags model.Tags) model.Album {
a = al(a)
a.Tags = tags
return a
}
var (
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk"}
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles"}
testArtists = model.Artists{
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk"}
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles"}
artistCJK = model.Artist{ID: "4", Name: "シートベルツ", SortArtistName: "Seatbelts", OrderArtistName: "seatbelts"}
artistPunctuation = model.Artist{ID: "5", Name: "The Roots", OrderArtistName: "roots"}
testArtists = model.Artists{
artistKraftwerk,
artistBeatles,
artistCJK,
artistPunctuation,
}
)
@ -70,11 +80,18 @@ var (
albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969})
albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("/kraft/radio/radio.mp3"), SongCount: 2})
albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("/test/multi/disc1/track1.mp3"), SongCount: 4})
testAlbums = model.Albums{
albumCJK = al(model.Album{ID: "105", Name: "COWBOY BEBOP", AlbumArtist: "シートベルツ", OrderAlbumName: "cowboy bebop", AlbumArtistID: "4", EmbedArtPath: p("/seatbelts/cowboy-bebop/track1.mp3"), SongCount: 1})
albumWithVersion = alWithTags(model.Album{ID: "106", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/2/come together.mp3"), SongCount: 1, MaxYear: 2019},
model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}})
albumPunctuation = al(model.Album{ID: "107", Name: "Things Fall Apart", AlbumArtist: "The Roots", OrderAlbumName: "things fall apart", AlbumArtistID: "5", EmbedArtPath: p("/roots/things/track1.mp3"), SongCount: 1})
testAlbums = model.Albums{
albumSgtPeppers,
albumAbbeyRoad,
albumRadioactivity,
albumMultiDisc,
albumCJK,
albumWithVersion,
albumPunctuation,
}
)
@ -101,6 +118,9 @@ var (
songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("/test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("/test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("/test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
songCJK = mf(model.MediaFile{ID: "3001", Title: "プラチナ・ジェット", ArtistID: "4", Artist: "シートベルツ", AlbumID: "105", Album: "COWBOY BEBOP", Path: p("/seatbelts/cowboy-bebop/track1.mp3")})
songVersioned = mf(model.MediaFile{ID: "3002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "106", Album: "Abbey Road", Path: p("/beatles/2/come together.mp3")})
songPunctuation = mf(model.MediaFile{ID: "3003", Title: "!!!!!!!", ArtistID: "5", Artist: "The Roots", AlbumID: "107", Album: "Things Fall Apart", Path: p("/roots/things/track1.mp3")})
testSongs = model.MediaFiles{
songDayInALife,
songComeTogether,
@ -112,6 +132,9 @@ var (
songDisc1Track01,
songDisc2Track01,
songDisc1Track02,
songCJK,
songVersioned,
songPunctuation,
}
)

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