mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Merge branch 'master' into fix-schema-inconsistencies
This commit is contained in:
commit
4d2cf41176
26
.github/workflows/pipeline.yml
vendored
26
.github/workflows/pipeline.yml
vendored
@ -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 }}
|
||||
|
||||
@ -40,6 +40,11 @@ linters:
|
||||
enable:
|
||||
- nilness
|
||||
exclusions:
|
||||
rules:
|
||||
- linters:
|
||||
- gosec
|
||||
path: _test\.go
|
||||
text: "G703"
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
|
||||
6
Makefile
6
Makefile
@ -20,8 +20,8 @@ PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
||||
DOCKER_TAG ?= deluan/navidrome:develop
|
||||
|
||||
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
|
||||
CROSS_TAGLIB_VERSION ?= 2.2.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/*")
|
||||
|
||||
@ -109,7 +109,7 @@ format: ##@Development Format code
|
||||
.PHONY: format
|
||||
|
||||
wire: check_go_env ##@Development Update Dependency Injection
|
||||
go tool wire gen -tags=$(GO_BUILD_TAGS) ./...
|
||||
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
|
||||
.PHONY: wire
|
||||
|
||||
gen: check_go_env ##@Development Run go generate for code generation
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,10 +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/stream"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
@ -93,8 +95,8 @@ 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 := stream.GetTranscodingCache()
|
||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
share := core.NewShare(dataStore)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
players := core.NewPlayers(dataStore)
|
||||
@ -103,7 +105,9 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
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, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics)
|
||||
lyricsLyrics := lyrics.NewLyrics(manager)
|
||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider)
|
||||
return router
|
||||
}
|
||||
|
||||
@ -118,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 := stream.GetTranscodingCache()
|
||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
share := core.NewShare(dataStore)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
|
||||
@ -207,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()
|
||||
|
||||
@ -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)),
|
||||
|
||||
@ -46,6 +46,7 @@ type configOptions struct {
|
||||
EnableTranscodingCancellation bool
|
||||
EnableDownloads bool
|
||||
EnableExternalServices bool
|
||||
EnableM3UExternalAlbumArt bool
|
||||
EnableInsightsCollector bool
|
||||
EnableMediaFileCoverArt bool
|
||||
TranscodingCacheSize string
|
||||
@ -75,6 +76,7 @@ type configOptions struct {
|
||||
EnableFavourites bool
|
||||
EnableStarRating bool
|
||||
EnableUserEditing bool
|
||||
EnableCoverArtUpload bool
|
||||
EnableSharing bool
|
||||
ShareURL string
|
||||
DefaultShareExpiration time.Duration
|
||||
@ -130,7 +132,6 @@ type configOptions struct {
|
||||
DevExternalScanner bool
|
||||
DevScannerThreads uint
|
||||
DevSelectiveWatcher bool
|
||||
DevLegacyEmbedImage bool
|
||||
DevInsightsInitialDelay time.Duration
|
||||
DevEnablePlayerInsights bool
|
||||
DevEnablePluginsInsights bool
|
||||
@ -138,6 +139,7 @@ type configOptions struct {
|
||||
DevExternalArtistFetchMultiplier float64
|
||||
DevOptimizeDB bool
|
||||
DevPreserveUnicodeInExternalCalls bool
|
||||
DevEnableMediaFileProbe bool
|
||||
}
|
||||
|
||||
type scannerOptions struct {
|
||||
@ -155,6 +157,7 @@ type scannerOptions struct {
|
||||
|
||||
type subsonicOptions struct {
|
||||
AppendSubtitle bool
|
||||
AppendAlbumVersion bool
|
||||
ArtistParticipations bool
|
||||
DefaultReportRealPath bool
|
||||
EnableAverageRating bool
|
||||
@ -250,6 +253,7 @@ type pluginsOptions struct {
|
||||
type extAuthOptions struct {
|
||||
TrustedSources string
|
||||
UserHeader string
|
||||
LogoutURL string
|
||||
}
|
||||
|
||||
type searchOptions struct {
|
||||
@ -301,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")
|
||||
@ -345,6 +355,7 @@ func Load(noConfigDump bool) {
|
||||
validateBackupSchedule,
|
||||
validatePlaylistsPath,
|
||||
validatePurgeMissingOption,
|
||||
validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
|
||||
)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
@ -465,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
|
||||
@ -548,6 +560,33 @@ 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 {
|
||||
@ -602,6 +641,7 @@ 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)
|
||||
@ -629,6 +669,7 @@ func setViperDefaults() {
|
||||
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)
|
||||
@ -641,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", "")
|
||||
@ -659,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)
|
||||
@ -721,6 +764,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
|
||||
viper.SetDefault("devoptimizedb", true)
|
||||
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
|
||||
viper.SetDefault("devenablemediafileprobe", true)
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@ -52,6 +52,48 @@ 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))
|
||||
|
||||
@ -8,4 +8,6 @@ var SetViperDefaults = setViperDefaults
|
||||
|
||||
var ParseLanguages = parseLanguages
|
||||
|
||||
var ValidateURL = validateURL
|
||||
|
||||
var NormalizeSearchBackend = normalizeSearchBackend
|
||||
|
||||
@ -65,6 +65,7 @@ const (
|
||||
|
||||
I18nFolder = "i18n"
|
||||
ScanIgnoreFile = ".ndignore"
|
||||
ArtworkFolder = "artwork"
|
||||
|
||||
PlaceholderArtistArt = "artist-placeholder.webp"
|
||||
PlaceholderAlbumArt = "album-placeholder.webp"
|
||||
@ -152,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 -",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
@ -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 stream.MediaStreamer, ds model.DataStore, shares Share) Archiver {
|
||||
return &archiver{ds: ds, ms: ms, shares: shares}
|
||||
}
|
||||
|
||||
type archiver struct {
|
||||
ds model.DataStore
|
||||
ms MediaStreamer
|
||||
ms stream.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.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
|
||||
} else {
|
||||
r, err = os.Open(path)
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@ -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("NewStream", mock.Anything, mock.Anything, stream.Request{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("NewStream", mock.Anything, mock.Anything, stream.Request{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("NewStream", mock.Anything, mock.Anything, stream.Request{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("NewStream", mock.Anything, mock.Anything, stream.Request{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
|
||||
stream.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) NewStream(ctx context.Context, mf *model.MediaFile, req stream.Request) (*stream.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 &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
|
||||
}
|
||||
|
||||
type mockShare struct {
|
||||
|
||||
@ -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{{
|
||||
|
||||
@ -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".
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
96
core/auth/claims.go
Normal 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
99
core/auth/claims_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
@ -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/stream/codec.go:isLosslessFormat.
|
||||
func isLosslessOutputFormat(format string) bool {
|
||||
switch strings.ToLower(format) {
|
||||
case "flac", "alac", "wav", "aiff":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// bitDepthToSampleFmt converts a bit depth value to the ffmpeg sample_fmt string.
|
||||
// FLAC only supports s16 and s32; for 24-bit sources, s32 is the correct format
|
||||
// (ffmpeg packs 24-bit samples into 32-bit containers).
|
||||
func bitDepthToSampleFmt(bitDepth int) string {
|
||||
switch bitDepth {
|
||||
case 16:
|
||||
return "s16"
|
||||
case 32:
|
||||
return "s32"
|
||||
default:
|
||||
// 24-bit and other depths: use s32 (the next valid container size)
|
||||
return "s32"
|
||||
}
|
||||
}
|
||||
|
||||
// Path will always be an absolute path
|
||||
func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
|
||||
var args []string
|
||||
@ -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 != "" {
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -106,6 +106,7 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist)
|
||||
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)
|
||||
|
||||
@ -2,7 +2,9 @@ package playlists_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -39,6 +41,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Describe("ImportFile", func() {
|
||||
var folder *model.Folder
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ds.MockedMediaFile = &mockedMediaFileRepo{}
|
||||
libPath, _ := os.Getwd()
|
||||
@ -93,6 +96,213 @@ var _ = Describe("Playlists - Import", 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() {
|
||||
@ -125,7 +335,6 @@ var _ = Describe("Playlists - Import", 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")
|
||||
@ -495,6 +704,24 @@ var _ = Describe("Playlists - Import", 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., ABCD) 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() {
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
@ -34,13 +35,17 @@ 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
|
||||
@ -267,3 +272,53 @@ func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, l
|
||||
|
||||
return results, 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 ""
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 "", false
|
||||
}
|
||||
return filepath.Clean(decoded), true
|
||||
}
|
||||
if filepath.IsAbs(value) {
|
||||
return filepath.Clean(value), true
|
||||
}
|
||||
if folder == nil {
|
||||
return "", false
|
||||
}
|
||||
return filepath.Clean(filepath.Join(folder.AbsolutePath(), value)), true
|
||||
}
|
||||
|
||||
@ -122,6 +122,21 @@ var _ = Describe("parseNSP", func() {
|
||||
Expect(pls.Name).To(Equal("Original"))
|
||||
})
|
||||
|
||||
It("parses limitPercent from NSP", func() {
|
||||
nsp := `{
|
||||
"all": [{"is": {"loved": true}}],
|
||||
"sort": "playCount",
|
||||
"order": "desc",
|
||||
"limitPercent": 25
|
||||
}`
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Rules).ToNot(BeNil())
|
||||
Expect(pls.Rules.LimitPercent).To(Equal(25))
|
||||
Expect(pls.Rules.Limit).To(Equal(0))
|
||||
})
|
||||
|
||||
It("parses criteria with multiple rules", func() {
|
||||
nsp := `{
|
||||
"all": [
|
||||
|
||||
@ -2,7 +2,9 @@ package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -10,6 +12,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
@ -34,6 +37,10 @@ type Playlists interface {
|
||||
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)
|
||||
@ -118,9 +125,18 @@ func (s *playlists) Create(ctx context.Context, playlistId string, name string,
|
||||
}
|
||||
|
||||
func (s *playlists) Delete(ctx context.Context, id string) error {
|
||||
if _, err := s.checkWritable(ctx, id); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -263,3 +279,57 @@ func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int
|
||||
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)
|
||||
}
|
||||
|
||||
@ -2,7 +2,12 @@ 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"
|
||||
@ -294,4 +299,119 @@ var _ = Describe("Playlists", func() {
|
||||
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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -58,10 +58,16 @@ func (s *playlists) TracksRepository(ctx context.Context, playlistId string, ref
|
||||
}
|
||||
|
||||
// savePlaylist creates a new playlist, assigning the owner from context.
|
||||
// Only Name, Comment, Public, and Rules are user-settable via the REST API.
|
||||
func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (string, error) {
|
||||
usr, _ := request.UserFrom(ctx)
|
||||
pls.OwnerID = usr.ID
|
||||
pls.ID = "" // Force new creation
|
||||
pls.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
|
||||
|
||||
@ -2,10 +2,12 @@ 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"
|
||||
@ -56,6 +58,38 @@ var _ = Describe("REST Adapter", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ID).ToNot(Equal("should-be-cleared"))
|
||||
})
|
||||
|
||||
It("clears server-managed fields to prevent injection via REST API", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
now := time.Now()
|
||||
pls := &model.Playlist{
|
||||
Name: "Legit Playlist",
|
||||
Comment: "A comment",
|
||||
Public: true,
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}},
|
||||
Path: "/some/path/playlist.m3u",
|
||||
Sync: true,
|
||||
UploadedImage: "injected-image-path",
|
||||
ExternalImageURL: "http://evil.example.com/ssrf",
|
||||
EvaluatedAt: &now,
|
||||
}
|
||||
_, err := repo.Save(pls)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
saved := mockPlsRepo.Last
|
||||
// User-settable fields are preserved
|
||||
Expect(saved.Name).To(Equal("Legit Playlist"))
|
||||
Expect(saved.Comment).To(Equal("A comment"))
|
||||
Expect(saved.Public).To(BeTrue())
|
||||
Expect(saved.Rules).ToNot(BeNil())
|
||||
// Server-managed fields are cleared
|
||||
Expect(saved.Path).To(BeEmpty())
|
||||
Expect(saved.Sync).To(BeFalse())
|
||||
Expect(saved.UploadedImage).To(BeEmpty())
|
||||
Expect(saved.ExternalImageURL).To(BeEmpty())
|
||||
Expect(saved.EvaluatedAt).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Update", func() {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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/stream/aliases.go
Normal file
87
core/stream/aliases.go
Normal file
@ -0,0 +1,87 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// containerAliasGroups maps each container alias to a canonical group name.
|
||||
var containerAliasGroups = func() map[string]string {
|
||||
groups := [][]string{
|
||||
{"aac", "adts", "m4a", "mp4", "m4b", "m4p"},
|
||||
{"mpeg", "mp3", "mp2"},
|
||||
{"ogg", "oga", "opus"},
|
||||
{"aif", "aiff"},
|
||||
{"asf", "wma"},
|
||||
{"mpc", "mpp"},
|
||||
{"wv"},
|
||||
}
|
||||
m := make(map[string]string)
|
||||
for _, g := range groups {
|
||||
canonical := g[0]
|
||||
for _, name := range g {
|
||||
m[name] = canonical
|
||||
}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// codecAliasGroups maps each codec alias to a canonical group name.
|
||||
// Codecs within the same group are considered equivalent.
|
||||
var codecAliasGroups = func() map[string]string {
|
||||
groups := [][]string{
|
||||
{"aac", "adts"},
|
||||
{"ac3", "ac-3"},
|
||||
{"eac3", "e-ac3", "e-ac-3", "eac-3"},
|
||||
{"mpc7", "musepack7"},
|
||||
{"mpc8", "musepack8"},
|
||||
{"wma1", "wmav1"},
|
||||
{"wma2", "wmav2"},
|
||||
{"wmalossless", "wma9lossless"},
|
||||
{"wmapro", "wma9pro"},
|
||||
{"shn", "shorten"},
|
||||
{"mp4als", "als"},
|
||||
}
|
||||
m := make(map[string]string)
|
||||
for _, g := range groups {
|
||||
for _, name := range g {
|
||||
m[name] = g[0] // canonical = first entry
|
||||
}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// matchesWithAliases checks if a value matches any entry in candidates,
|
||||
// consulting the alias map for equivalent names.
|
||||
func matchesWithAliases(value string, candidates []string, aliases map[string]string) bool {
|
||||
value = strings.ToLower(value)
|
||||
canonical := aliases[value]
|
||||
for _, c := range candidates {
|
||||
c = strings.ToLower(c)
|
||||
if c == value {
|
||||
return true
|
||||
}
|
||||
if canonical != "" && aliases[c] == canonical {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesContainer checks if a file suffix matches any of the container names,
|
||||
// including common aliases.
|
||||
func matchesContainer(suffix string, containers []string) bool {
|
||||
return matchesWithAliases(suffix, containers, containerAliasGroups)
|
||||
}
|
||||
|
||||
// matchesCodec checks if a codec matches any of the codec names,
|
||||
// including common aliases.
|
||||
func matchesCodec(codec string, codecs []string) bool {
|
||||
return matchesWithAliases(codec, codecs, codecAliasGroups)
|
||||
}
|
||||
|
||||
func containsIgnoreCase(slice []string, s string) bool {
|
||||
return slices.ContainsFunc(slice, func(item string) bool {
|
||||
return strings.EqualFold(item, s)
|
||||
})
|
||||
}
|
||||
77
core/stream/codec.go
Normal file
77
core/stream/codec.go
Normal file
@ -0,0 +1,77 @@
|
||||
package stream
|
||||
|
||||
import "strings"
|
||||
|
||||
// normalizeProbeCodec maps ffprobe codec_name values to the simplified internal
|
||||
// codec names used throughout Navidrome (matching inferCodecFromSuffix output).
|
||||
// Most ffprobe names match directly; this handles the exceptions.
|
||||
func normalizeProbeCodec(codec string) string {
|
||||
c := strings.ToLower(codec)
|
||||
// DSD variants: dsd_lsbf_planar, dsd_msbf_planar, dsd_lsbf, dsd_msbf
|
||||
if strings.HasPrefix(c, "dsd") {
|
||||
return "dsd"
|
||||
}
|
||||
// PCM variants: pcm_s16le, pcm_s24le, pcm_s32be, pcm_f32le, etc.
|
||||
if strings.HasPrefix(c, "pcm_") {
|
||||
return "pcm"
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// isLosslessFormat returns true if the format is a known lossless audio codec/format.
|
||||
// Detection is based on codec name only, not bit depth — some lossy codecs (e.g. ADPCM)
|
||||
// report non-zero bits_per_sample in ffprobe, so bit depth alone is not a reliable signal.
|
||||
//
|
||||
// Note: core/ffmpeg has a separate isLosslessOutputFormat that covers only formats
|
||||
// ffmpeg can produce as output (a smaller set).
|
||||
func isLosslessFormat(format string) bool {
|
||||
switch strings.ToLower(format) {
|
||||
case "flac", "alac", "wav", "aiff", "ape", "wv", "wavpack", "tta", "tak", "shn", "dsd", "pcm":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeSourceSampleRate adjusts the source sample rate for codecs that store
|
||||
// it differently than PCM. Currently handles DSD (÷8):
|
||||
// DSD64=2822400→352800, DSD128=5644800→705600, etc.
|
||||
// For other codecs, returns the rate unchanged.
|
||||
func normalizeSourceSampleRate(sampleRate int, codec string) int {
|
||||
if strings.EqualFold(codec, "dsd") && sampleRate > 0 {
|
||||
return sampleRate / 8
|
||||
}
|
||||
return sampleRate
|
||||
}
|
||||
|
||||
// normalizeSourceBitDepth adjusts the source bit depth for codecs that use
|
||||
// non-standard bit depths. Currently handles DSD (1-bit → 24-bit PCM, which is
|
||||
// what ffmpeg produces). For other codecs, returns the depth unchanged.
|
||||
func normalizeSourceBitDepth(bitDepth int, codec string) int {
|
||||
if strings.EqualFold(codec, "dsd") && bitDepth == 1 {
|
||||
return 24
|
||||
}
|
||||
return bitDepth
|
||||
}
|
||||
|
||||
// codecFixedOutputSampleRate returns the mandatory output sample rate for codecs
|
||||
// that always resample regardless of input (e.g., Opus always outputs 48000Hz).
|
||||
// Returns 0 if the codec has no fixed output rate.
|
||||
func codecFixedOutputSampleRate(codec string) int {
|
||||
switch strings.ToLower(codec) {
|
||||
case "opus":
|
||||
return 48000
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// codecMaxSampleRate returns the hard maximum output sample rate for a codec.
|
||||
// Returns 0 if the codec has no hard limit.
|
||||
func codecMaxSampleRate(codec string) int {
|
||||
switch strings.ToLower(codec) {
|
||||
case "mp3":
|
||||
return 48000
|
||||
case "aac":
|
||||
return 96000
|
||||
}
|
||||
return 0
|
||||
}
|
||||
69
core/stream/codec_test.go
Normal file
69
core/stream/codec_test.go
Normal file
@ -0,0 +1,69 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Codec", func() {
|
||||
Describe("isLosslessFormat", func() {
|
||||
It("returns true for known lossless codecs", func() {
|
||||
Expect(isLosslessFormat("flac")).To(BeTrue())
|
||||
Expect(isLosslessFormat("alac")).To(BeTrue())
|
||||
Expect(isLosslessFormat("pcm")).To(BeTrue())
|
||||
Expect(isLosslessFormat("wav")).To(BeTrue())
|
||||
Expect(isLosslessFormat("dsd")).To(BeTrue())
|
||||
Expect(isLosslessFormat("ape")).To(BeTrue())
|
||||
Expect(isLosslessFormat("wv")).To(BeTrue())
|
||||
Expect(isLosslessFormat("wavpack")).To(BeTrue()) // ffprobe codec_name for WavPack
|
||||
})
|
||||
|
||||
It("returns false for lossy codecs", func() {
|
||||
Expect(isLosslessFormat("mp3")).To(BeFalse())
|
||||
Expect(isLosslessFormat("aac")).To(BeFalse())
|
||||
Expect(isLosslessFormat("opus")).To(BeFalse())
|
||||
Expect(isLosslessFormat("vorbis")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false for unknown codecs", func() {
|
||||
Expect(isLosslessFormat("unknown_codec")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("is case-insensitive", func() {
|
||||
Expect(isLosslessFormat("FLAC")).To(BeTrue())
|
||||
Expect(isLosslessFormat("Alac")).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("normalizeProbeCodec", func() {
|
||||
It("passes through common codec names unchanged", func() {
|
||||
Expect(normalizeProbeCodec("mp3")).To(Equal("mp3"))
|
||||
Expect(normalizeProbeCodec("aac")).To(Equal("aac"))
|
||||
Expect(normalizeProbeCodec("flac")).To(Equal("flac"))
|
||||
Expect(normalizeProbeCodec("opus")).To(Equal("opus"))
|
||||
Expect(normalizeProbeCodec("vorbis")).To(Equal("vorbis"))
|
||||
Expect(normalizeProbeCodec("alac")).To(Equal("alac"))
|
||||
Expect(normalizeProbeCodec("wmav2")).To(Equal("wmav2"))
|
||||
})
|
||||
|
||||
It("normalizes DSD variants to dsd", func() {
|
||||
Expect(normalizeProbeCodec("dsd_lsbf_planar")).To(Equal("dsd"))
|
||||
Expect(normalizeProbeCodec("dsd_msbf_planar")).To(Equal("dsd"))
|
||||
Expect(normalizeProbeCodec("dsd_lsbf")).To(Equal("dsd"))
|
||||
Expect(normalizeProbeCodec("dsd_msbf")).To(Equal("dsd"))
|
||||
})
|
||||
|
||||
It("normalizes PCM variants to pcm", func() {
|
||||
Expect(normalizeProbeCodec("pcm_s16le")).To(Equal("pcm"))
|
||||
Expect(normalizeProbeCodec("pcm_s24le")).To(Equal("pcm"))
|
||||
Expect(normalizeProbeCodec("pcm_s32be")).To(Equal("pcm"))
|
||||
Expect(normalizeProbeCodec("pcm_f32le")).To(Equal("pcm"))
|
||||
})
|
||||
|
||||
It("lowercases input", func() {
|
||||
Expect(normalizeProbeCodec("MP3")).To(Equal("mp3"))
|
||||
Expect(normalizeProbeCodec("AAC")).To(Equal("aac"))
|
||||
Expect(normalizeProbeCodec("DSD_LSBF_PLANAR")).To(Equal("dsd"))
|
||||
})
|
||||
})
|
||||
})
|
||||
449
core/stream/decider.go
Normal file
449
core/stream/decider.go
Normal file
@ -0,0 +1,449 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
)
|
||||
|
||||
const fallbackBitrate = 256 // kbps
|
||||
|
||||
// TranscodeDecider is the core service interface for making transcoding decisions
|
||||
type TranscodeDecider interface {
|
||||
MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts TranscodeOptions) (*TranscodeDecision, error)
|
||||
CreateTranscodeParams(decision *TranscodeDecision) (string, error)
|
||||
ResolveRequestFromToken(ctx context.Context, token string, mf *model.MediaFile, offset int) (Request, error)
|
||||
ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) Request
|
||||
}
|
||||
|
||||
func NewTranscodeDecider(ds model.DataStore, ff ffmpeg.FFmpeg) TranscodeDecider {
|
||||
return &deciderService{
|
||||
ds: ds,
|
||||
ff: ff,
|
||||
}
|
||||
}
|
||||
|
||||
type deciderService struct {
|
||||
ds model.DataStore
|
||||
ff ffmpeg.FFmpeg
|
||||
}
|
||||
|
||||
func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts TranscodeOptions) (*TranscodeDecision, error) {
|
||||
decision := &TranscodeDecision{
|
||||
MediaID: mf.ID,
|
||||
SourceUpdatedAt: mf.UpdatedAt,
|
||||
}
|
||||
|
||||
var probe *ffmpeg.AudioProbeResult
|
||||
if !opts.SkipProbe {
|
||||
var err error
|
||||
probe, err = s.ensureProbed(ctx, mf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Build source stream details (uses probe data if available)
|
||||
decision.SourceStream = buildSourceStream(mf, probe)
|
||||
src := &decision.SourceStream
|
||||
|
||||
// Check for server-side player transcoding override
|
||||
if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" {
|
||||
clientInfo = applyServerOverride(ctx, clientInfo, &trc)
|
||||
} else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 {
|
||||
if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate {
|
||||
modified := *clientInfo
|
||||
modified.MaxAudioBitrate = player.MaxBitRate
|
||||
clientInfo = &modified
|
||||
log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name)
|
||||
}
|
||||
}
|
||||
|
||||
log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", src.Container,
|
||||
"codec", src.Codec, "bitrate", src.Bitrate, "channels", src.Channels,
|
||||
"sampleRate", src.SampleRate, "lossless", src.IsLossless, "client", clientInfo.Name)
|
||||
|
||||
// Check global bitrate constraint first.
|
||||
if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate {
|
||||
log.Trace(ctx, "Global bitrate constraint exceeded, skipping direct play",
|
||||
"sourceBitrate", src.Bitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
|
||||
decision.TranscodeReasons = append(decision.TranscodeReasons, "audio bitrate not supported")
|
||||
// Skip direct play profiles entirely — global constraint fails
|
||||
} else {
|
||||
// Try direct play profiles, collecting reasons for each failure
|
||||
for _, profile := range clientInfo.DirectPlayProfiles {
|
||||
if reason := s.checkDirectPlayProfile(src, &profile, clientInfo); reason == "" {
|
||||
decision.CanDirectPlay = true
|
||||
decision.TranscodeReasons = nil // Clear any previously collected reasons
|
||||
break
|
||||
} else {
|
||||
decision.TranscodeReasons = append(decision.TranscodeReasons, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If direct play is possible, we're done
|
||||
if decision.CanDirectPlay {
|
||||
log.Debug(ctx, "Transcode decision: direct play", "mediaID", mf.ID, "container", src.Container, "codec", src.Codec)
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
// Try transcoding profiles (in order of preference)
|
||||
for _, profile := range clientInfo.TranscodingProfiles {
|
||||
if ts, transcodeFormat := s.computeTranscodedStream(ctx, src, &profile, clientInfo); ts != nil {
|
||||
decision.CanTranscode = true
|
||||
decision.TargetFormat = transcodeFormat
|
||||
decision.TargetBitrate = ts.Bitrate
|
||||
decision.TargetChannels = ts.Channels
|
||||
decision.TargetSampleRate = ts.SampleRate
|
||||
decision.TargetBitDepth = ts.BitDepth
|
||||
decision.TranscodeStream = ts
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if decision.CanTranscode {
|
||||
log.Debug(ctx, "Transcode decision: transcode", "mediaID", mf.ID,
|
||||
"targetFormat", decision.TargetFormat, "targetBitrate", decision.TargetBitrate,
|
||||
"targetChannels", decision.TargetChannels, "reasons", decision.TranscodeReasons)
|
||||
}
|
||||
|
||||
// If neither direct play nor transcode is possible
|
||||
if !decision.CanDirectPlay && !decision.CanTranscode {
|
||||
decision.ErrorReason = "no compatible playback profile found"
|
||||
log.Warn(ctx, "Transcode decision: no compatible profile", "mediaID", mf.ID,
|
||||
"container", src.Container, "codec", src.Codec, "reasons", decision.TranscodeReasons)
|
||||
}
|
||||
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func buildSourceStream(mf *model.MediaFile, probe *ffmpeg.AudioProbeResult) Details {
|
||||
sd := Details{
|
||||
Container: mf.Suffix,
|
||||
Duration: mf.Duration,
|
||||
Size: mf.Size,
|
||||
}
|
||||
|
||||
// Use pre-parsed probe result, or fall back to parsing stored probe data
|
||||
if probe == nil {
|
||||
probe, _ = parseProbeData(mf.ProbeData)
|
||||
}
|
||||
|
||||
// Use probe data if available for authoritative values
|
||||
if probe != nil {
|
||||
sd.Codec = normalizeProbeCodec(probe.Codec)
|
||||
sd.Profile = probe.Profile
|
||||
sd.Bitrate = probe.BitRate
|
||||
sd.SampleRate = probe.SampleRate
|
||||
sd.BitDepth = probe.BitDepth
|
||||
sd.Channels = probe.Channels
|
||||
} else {
|
||||
sd.Codec = mf.AudioCodec()
|
||||
sd.Bitrate = mf.BitRate
|
||||
sd.SampleRate = mf.SampleRate
|
||||
sd.BitDepth = mf.BitDepth
|
||||
sd.Channels = mf.Channels
|
||||
}
|
||||
sd.IsLossless = isLosslessFormat(sd.Codec)
|
||||
|
||||
return sd
|
||||
}
|
||||
|
||||
// applyServerOverride replaces the client-provided profiles with synthetic ones
|
||||
// matching the server-forced transcoding format and bitrate.
|
||||
func applyServerOverride(ctx context.Context, original *ClientInfo, trc *model.Transcoding) *ClientInfo {
|
||||
maxBitRate := trc.DefaultBitRate
|
||||
if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 {
|
||||
maxBitRate = player.MaxBitRate
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Applying server-side transcoding override",
|
||||
"targetFormat", trc.TargetFormat, "maxBitRate", maxBitRate,
|
||||
"client", original.Name)
|
||||
|
||||
return &ClientInfo{
|
||||
Name: original.Name,
|
||||
Platform: original.Platform,
|
||||
MaxAudioBitrate: maxBitRate,
|
||||
MaxTranscodingAudioBitrate: maxBitRate,
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{trc.TargetFormat}, AudioCodecs: []string{trc.TargetFormat}, Protocols: []string{ProtocolHTTP}},
|
||||
},
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: trc.TargetFormat, AudioCodec: trc.TargetFormat, Protocol: ProtocolHTTP},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func parseProbeData(data string) (*ffmpeg.AudioProbeResult, error) {
|
||||
if data == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var result ffmpeg.AudioProbeResult
|
||||
if err := json.Unmarshal([]byte(data), &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// checkDirectPlayProfile returns "" if the profile matches (direct play OK),
|
||||
// or a typed reason string if it doesn't match.
|
||||
func (s *deciderService) checkDirectPlayProfile(src *Details, profile *DirectPlayProfile, clientInfo *ClientInfo) string {
|
||||
// Check protocol (only http for now)
|
||||
if len(profile.Protocols) > 0 && !containsIgnoreCase(profile.Protocols, ProtocolHTTP) {
|
||||
return "protocol not supported"
|
||||
}
|
||||
|
||||
// Check container
|
||||
if len(profile.Containers) > 0 && !matchesContainer(src.Container, profile.Containers) {
|
||||
return "container not supported"
|
||||
}
|
||||
|
||||
// Check codec
|
||||
if len(profile.AudioCodecs) > 0 && !matchesCodec(src.Codec, profile.AudioCodecs) {
|
||||
return "audio codec not supported"
|
||||
}
|
||||
|
||||
// Check channels
|
||||
if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels {
|
||||
return "audio channels not supported"
|
||||
}
|
||||
|
||||
// Check codec-specific limitations
|
||||
for _, codecProfile := range clientInfo.CodecProfiles {
|
||||
if strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) && matchesCodec(src.Codec, []string{codecProfile.Name}) {
|
||||
if reason := checkLimitations(src, codecProfile.Limitations); reason != "" {
|
||||
return reason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// computeTranscodedStream attempts to build a valid transcoded stream for the given profile.
|
||||
// Returns the stream details and the internal transcoding format (which may differ from the
|
||||
// response container when a codec fallback occurs, e.g., "mp4"→"aac").
|
||||
// Returns nil, "" if the profile cannot produce a valid output.
|
||||
func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Details, profile *Profile, clientInfo *ClientInfo) (*Details, string) {
|
||||
// Check protocol (only http for now)
|
||||
if profile.Protocol != "" && !strings.EqualFold(profile.Protocol, ProtocolHTTP) {
|
||||
log.Trace(ctx, "Skipping transcoding profile: unsupported protocol", "protocol", profile.Protocol)
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
responseContainer, targetFormat := resolveTargetFormat(profile)
|
||||
if targetFormat == "" {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// Verify we have a transcoding command available (DB custom or built-in default)
|
||||
if LookupTranscodeCommand(ctx, s.ds, targetFormat) == "" {
|
||||
log.Trace(ctx, "Skipping transcoding profile: no transcoding command available", "targetFormat", targetFormat)
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
targetIsLossless := isLosslessFormat(targetFormat)
|
||||
|
||||
// Reject lossy to lossless conversion
|
||||
if !src.IsLossless && targetIsLossless {
|
||||
log.Trace(ctx, "Skipping transcoding profile: lossy to lossless not allowed", "targetFormat", targetFormat)
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
ts := &Details{
|
||||
Container: responseContainer,
|
||||
Codec: strings.ToLower(profile.AudioCodec),
|
||||
SampleRate: normalizeSourceSampleRate(src.SampleRate, src.Codec),
|
||||
Channels: src.Channels,
|
||||
BitDepth: normalizeSourceBitDepth(src.BitDepth, src.Codec),
|
||||
IsLossless: targetIsLossless,
|
||||
}
|
||||
if ts.Codec == "" {
|
||||
ts.Codec = targetFormat
|
||||
}
|
||||
|
||||
// Apply codec-intrinsic sample rate adjustments before codec profile limitations
|
||||
if fixedRate := codecFixedOutputSampleRate(ts.Codec); fixedRate > 0 {
|
||||
ts.SampleRate = fixedRate
|
||||
}
|
||||
if maxRate := codecMaxSampleRate(ts.Codec); maxRate > 0 && ts.SampleRate > maxRate {
|
||||
ts.SampleRate = maxRate
|
||||
}
|
||||
|
||||
// Determine target bitrate (all in kbps)
|
||||
if ok := s.computeBitrate(ctx, src, targetFormat, targetIsLossless, clientInfo, ts); !ok {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// Apply MaxAudioChannels from the transcoding profile
|
||||
if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels {
|
||||
ts.Channels = profile.MaxAudioChannels
|
||||
}
|
||||
|
||||
// Apply codec profile limitations to the TARGET codec
|
||||
if ok := s.applyCodecLimitations(ctx, src.Bitrate, targetFormat, targetIsLossless, clientInfo, ts); !ok {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
return ts, targetFormat
|
||||
}
|
||||
|
||||
// lookupDefaultBitrate returns the default bitrate for the given format.
|
||||
// It checks the DB first (for user-customized values), then falls back to
|
||||
// the built-in defaults, and finally to fallbackBitrate.
|
||||
func lookupDefaultBitrate(ctx context.Context, ds model.DataStore, format string) int {
|
||||
if t, err := ds.Transcoding(ctx).FindByFormat(format); err == nil && t.DefaultBitRate > 0 {
|
||||
return t.DefaultBitRate
|
||||
}
|
||||
for _, dt := range consts.DefaultTranscodings {
|
||||
if dt.TargetFormat == format && dt.DefaultBitRate > 0 {
|
||||
return dt.DefaultBitRate
|
||||
}
|
||||
}
|
||||
return fallbackBitrate
|
||||
}
|
||||
|
||||
// LookupTranscodeCommand returns the ffmpeg command for the given format.
|
||||
// It checks the DB first (for user-customized commands), then falls back to
|
||||
// the built-in default command. Returns "" if the format is unknown.
|
||||
func LookupTranscodeCommand(ctx context.Context, ds model.DataStore, format string) string {
|
||||
t, err := ds.Transcoding(ctx).FindByFormat(format)
|
||||
if err == nil && t.Command != "" {
|
||||
return t.Command
|
||||
}
|
||||
// Fall back to built-in defaults
|
||||
for _, dt := range consts.DefaultTranscodings {
|
||||
if dt.TargetFormat == format {
|
||||
return dt.Command
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveTargetFormat determines the response container and internal target format
|
||||
// from the profile's Container and AudioCodec fields. When an AudioCodec is specified
|
||||
// it is preferred as targetFormat (e.g. container "mp4" with audioCodec "aac" → targetFormat "aac").
|
||||
func resolveTargetFormat(profile *Profile) (responseContainer, targetFormat string) {
|
||||
responseContainer = strings.ToLower(profile.Container)
|
||||
targetFormat = responseContainer
|
||||
|
||||
// Prefer the audioCodec as targetFormat when provided (handles container-to-codec
|
||||
// mapping like "mp4" → "aac", "ogg" → "opus").
|
||||
if profile.AudioCodec != "" {
|
||||
targetFormat = strings.ToLower(profile.AudioCodec)
|
||||
}
|
||||
|
||||
// If neither container nor audioCodec is set, we can't resolve a format.
|
||||
if targetFormat == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// When no container was specified, use the targetFormat as container too.
|
||||
if responseContainer == "" {
|
||||
responseContainer = targetFormat
|
||||
}
|
||||
|
||||
return responseContainer, targetFormat
|
||||
}
|
||||
|
||||
// computeBitrate determines the target bitrate for the transcoded stream.
|
||||
// Returns false if the profile should be rejected.
|
||||
func (s *deciderService) computeBitrate(ctx context.Context, src *Details, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *Details) bool {
|
||||
if src.IsLossless {
|
||||
if !targetIsLossless {
|
||||
if clientInfo.MaxTranscodingAudioBitrate > 0 {
|
||||
ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate
|
||||
} else if clientInfo.MaxAudioBitrate > 0 {
|
||||
ts.Bitrate = clientInfo.MaxAudioBitrate
|
||||
} else {
|
||||
ts.Bitrate = lookupDefaultBitrate(ctx, s.ds, targetFormat)
|
||||
}
|
||||
} else {
|
||||
if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate {
|
||||
log.Trace(ctx, "Skipping transcoding profile: lossless target exceeds bitrate limit",
|
||||
"targetFormat", targetFormat, "sourceBitrate", src.Bitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
|
||||
return false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ts.Bitrate = src.Bitrate
|
||||
}
|
||||
|
||||
// Apply maxAudioBitrate as final cap
|
||||
if clientInfo.MaxAudioBitrate > 0 && ts.Bitrate > 0 && ts.Bitrate > clientInfo.MaxAudioBitrate {
|
||||
ts.Bitrate = clientInfo.MaxAudioBitrate
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// applyCodecLimitations applies codec profile limitations to the transcoded stream.
|
||||
// Returns false if the profile should be rejected.
|
||||
func (s *deciderService) applyCodecLimitations(ctx context.Context, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *Details) bool {
|
||||
targetCodec := ts.Codec
|
||||
for _, codecProfile := range clientInfo.CodecProfiles {
|
||||
if !strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) {
|
||||
continue
|
||||
}
|
||||
if !matchesCodec(targetCodec, []string{codecProfile.Name}) {
|
||||
continue
|
||||
}
|
||||
for _, lim := range codecProfile.Limitations {
|
||||
result := applyLimitation(sourceBitrate, &lim, ts)
|
||||
if strings.EqualFold(lim.Name, LimitationAudioBitrate) && targetIsLossless && result == adjustAdjusted {
|
||||
log.Trace(ctx, "Skipping transcoding profile: cannot adjust bitrate for lossless target",
|
||||
"targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name)
|
||||
return false
|
||||
}
|
||||
if result == adjustCannotFit {
|
||||
log.Trace(ctx, "Skipping transcoding profile: codec limitation cannot be satisfied",
|
||||
"targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name,
|
||||
"comparison", lim.Comparison, "values", lim.Values)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ensureProbed runs ffprobe if probe data is missing, persists it, and returns
|
||||
// the parsed result. Returns (nil, nil) when probing is skipped or data already exists
|
||||
// (in which case the caller should parse mf.ProbeData).
|
||||
func (s *deciderService) ensureProbed(ctx context.Context, mf *model.MediaFile) (*ffmpeg.AudioProbeResult, error) {
|
||||
if mf.ProbeData != "" {
|
||||
return nil, nil
|
||||
}
|
||||
if !conf.Server.DevEnableMediaFileProbe {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result, err := s.ff.ProbeAudioStream(ctx, mf.AbsolutePath())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("probing media file %s: %w", mf.ID, err)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshaling probe result for %s: %w", mf.ID, err)
|
||||
}
|
||||
mf.ProbeData = string(data)
|
||||
|
||||
if err := s.ds.MediaFile(ctx).UpdateProbeData(mf.ID, mf.ProbeData); err != nil {
|
||||
log.Error(ctx, "Failed to persist probe data", "mediaID", mf.ID, err)
|
||||
// Don't fail the decision — we have the data in memory
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Probed media file", "mediaID", mf.ID, "codec", result.Codec,
|
||||
"profile", result.Profile, "bitRate", result.BitRate,
|
||||
"sampleRate", result.SampleRate, "bitDepth", result.BitDepth, "channels", result.Channels)
|
||||
return result, nil
|
||||
}
|
||||
1190
core/stream/decider_test.go
Normal file
1190
core/stream/decider_test.go
Normal file
File diff suppressed because it is too large
Load Diff
92
core/stream/legacy_client.go
Normal file
92
core/stream/legacy_client.go
Normal file
@ -0,0 +1,92 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// buildLegacyClientInfo translates legacy Subsonic stream/download parameters
|
||||
// into a ClientInfo for use with MakeDecision.
|
||||
// It does NOT read request.TranscodingFrom(ctx) — that is handled by
|
||||
// MakeDecision's applyServerOverride.
|
||||
func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *ClientInfo {
|
||||
ci := &ClientInfo{Name: "legacy"}
|
||||
|
||||
// Determine target format for transcoding
|
||||
var targetFormat string
|
||||
switch {
|
||||
case reqFormat != "":
|
||||
targetFormat = reqFormat
|
||||
case reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "":
|
||||
targetFormat = conf.Server.DefaultDownsamplingFormat
|
||||
}
|
||||
|
||||
if targetFormat != "" {
|
||||
// Add a direct play profile for the source format when no explicit
|
||||
// format was requested (bitrate-only downsampling) or when the
|
||||
// requested format matches the source. When the client explicitly
|
||||
// requests a different format, direct play must not match the
|
||||
// source — otherwise the source is returned untranscoded.
|
||||
if reqFormat == "" || strings.EqualFold(reqFormat, mf.Suffix) {
|
||||
ci.DirectPlayProfiles = []DirectPlayProfile{
|
||||
{Containers: []string{mf.Suffix}, AudioCodecs: []string{mf.AudioCodec()}, Protocols: []string{ProtocolHTTP}},
|
||||
}
|
||||
}
|
||||
ci.TranscodingProfiles = []Profile{
|
||||
{Container: targetFormat, AudioCodec: targetFormat, Protocol: ProtocolHTTP},
|
||||
}
|
||||
if reqBitRate > 0 {
|
||||
ci.MaxAudioBitrate = reqBitRate
|
||||
ci.MaxTranscodingAudioBitrate = reqBitRate
|
||||
}
|
||||
} else {
|
||||
// No transcoding requested — direct play everything
|
||||
ci.DirectPlayProfiles = []DirectPlayProfile{
|
||||
{Protocols: []string{ProtocolHTTP}},
|
||||
}
|
||||
}
|
||||
|
||||
return ci
|
||||
}
|
||||
|
||||
// ResolveRequest uses MakeDecision to resolve legacy Subsonic stream parameters
|
||||
// into a fully specified Request.
|
||||
func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) Request {
|
||||
var req Request
|
||||
req.Offset = offset
|
||||
|
||||
if reqFormat == "raw" {
|
||||
req.Format = "raw"
|
||||
return req
|
||||
}
|
||||
|
||||
clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate)
|
||||
decision, err := s.MakeDecision(ctx, mf, clientInfo, TranscodeOptions{SkipProbe: true})
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err)
|
||||
req.Format = "raw"
|
||||
return req
|
||||
}
|
||||
|
||||
if decision.CanDirectPlay {
|
||||
req.Format = "raw"
|
||||
return req
|
||||
}
|
||||
|
||||
if decision.CanTranscode {
|
||||
req.Format = decision.TargetFormat
|
||||
req.BitRate = decision.TargetBitrate
|
||||
req.SampleRate = decision.TargetSampleRate
|
||||
req.BitDepth = decision.TargetBitDepth
|
||||
req.Channels = decision.TargetChannels
|
||||
return req
|
||||
}
|
||||
|
||||
// No compatible profile — fallback to raw
|
||||
req.Format = "raw"
|
||||
return req
|
||||
}
|
||||
98
core/stream/legacy_client_test.go
Normal file
98
core/stream/legacy_client_test.go
Normal file
@ -0,0 +1,98 @@
|
||||
package stream
|
||||
|
||||
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(BeEmpty())
|
||||
})
|
||||
|
||||
It("does not add direct play profile when explicit format differs from source (no bitrate)", func() {
|
||||
ci := buildLegacyClientInfo(mf, "opus", 0)
|
||||
|
||||
Expect(ci.TranscodingProfiles).To(HaveLen(1))
|
||||
Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus"))
|
||||
Expect(ci.DirectPlayProfiles).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("adds direct play profile when explicit format matches source format", func() {
|
||||
ci := buildLegacyClientInfo(mf, "flac", 0)
|
||||
|
||||
Expect(ci.TranscodingProfiles).To(HaveLen(1))
|
||||
Expect(ci.TranscodingProfiles[0].Container).To(Equal("flac"))
|
||||
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
|
||||
Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"}))
|
||||
Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(Equal([]string{mf.AudioCodec()}))
|
||||
})
|
||||
|
||||
It("sets transcoding profile and bitrate for explicit format with bitrate", func() {
|
||||
ci := buildLegacyClientInfo(mf, "mp3", 192)
|
||||
|
||||
Expect(ci.TranscodingProfiles).To(HaveLen(1))
|
||||
Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3"))
|
||||
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("mp3"))
|
||||
Expect(ci.MaxAudioBitrate).To(Equal(192))
|
||||
Expect(ci.MaxTranscodingAudioBitrate).To(Equal(192))
|
||||
Expect(ci.DirectPlayProfiles).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns direct play profile when no format and no bitrate", func() {
|
||||
ci := buildLegacyClientInfo(mf, "", 0)
|
||||
|
||||
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
|
||||
Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
|
||||
Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(BeEmpty())
|
||||
Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{ProtocolHTTP}))
|
||||
Expect(ci.TranscodingProfiles).To(BeEmpty())
|
||||
Expect(ci.MaxAudioBitrate).To(BeZero())
|
||||
})
|
||||
|
||||
It("uses default downsampling format for bitrate-only downsampling", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultDownsamplingFormat = "opus"
|
||||
|
||||
ci := buildLegacyClientInfo(mf, "", 128)
|
||||
|
||||
Expect(ci.TranscodingProfiles).To(HaveLen(1))
|
||||
Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus"))
|
||||
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
|
||||
Expect(ci.TranscodingProfiles[0].Protocol).To(Equal(ProtocolHTTP))
|
||||
Expect(ci.MaxAudioBitrate).To(Equal(128))
|
||||
Expect(ci.MaxTranscodingAudioBitrate).To(Equal(128))
|
||||
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
|
||||
Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"}))
|
||||
Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(Equal([]string{mf.AudioCodec()}))
|
||||
})
|
||||
|
||||
It("returns direct play when bitrate >= source bitrate", func() {
|
||||
ci := buildLegacyClientInfo(mf, "", 960)
|
||||
|
||||
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
|
||||
Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
|
||||
Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(BeEmpty())
|
||||
Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{ProtocolHTTP}))
|
||||
Expect(ci.TranscodingProfiles).To(BeEmpty())
|
||||
Expect(ci.MaxAudioBitrate).To(BeZero())
|
||||
})
|
||||
})
|
||||
171
core/stream/limitations.go
Normal file
171
core/stream/limitations.go
Normal file
@ -0,0 +1,171 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// adjustResult represents the outcome of applying a limitation to a transcoded stream value
|
||||
type adjustResult int
|
||||
|
||||
const (
|
||||
adjustNone adjustResult = iota // Value already satisfies the limitation
|
||||
adjustAdjusted // Value was changed to fit the limitation
|
||||
adjustCannotFit // Cannot satisfy the limitation (reject this profile)
|
||||
)
|
||||
|
||||
// checkLimitations checks codec profile limitations against source stream details.
|
||||
// Returns "" if all limitations pass, or a typed reason string for the first failure.
|
||||
func checkLimitations(src *Details, limitations []Limitation) string {
|
||||
for _, lim := range limitations {
|
||||
var ok bool
|
||||
var reason string
|
||||
|
||||
switch lim.Name {
|
||||
case LimitationAudioChannels:
|
||||
ok = checkIntLimitation(src.Channels, lim.Comparison, lim.Values)
|
||||
reason = "audio channels not supported"
|
||||
case LimitationAudioSamplerate:
|
||||
ok = checkIntLimitation(src.SampleRate, lim.Comparison, lim.Values)
|
||||
reason = "audio samplerate not supported"
|
||||
case LimitationAudioBitrate:
|
||||
ok = checkIntLimitation(src.Bitrate, lim.Comparison, lim.Values)
|
||||
reason = "audio bitrate not supported"
|
||||
case LimitationAudioBitdepth:
|
||||
ok = checkIntLimitation(src.BitDepth, lim.Comparison, lim.Values)
|
||||
reason = "audio bitdepth not supported"
|
||||
case LimitationAudioProfile:
|
||||
ok = checkStringLimitation(src.Profile, lim.Comparison, lim.Values)
|
||||
reason = "audio profile not supported"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
if !ok && lim.Required {
|
||||
return reason
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// applyLimitation adjusts a transcoded stream parameter to satisfy the limitation.
|
||||
// Returns the adjustment result.
|
||||
func applyLimitation(sourceBitrate int, lim *Limitation, ts *Details) adjustResult {
|
||||
switch lim.Name {
|
||||
case LimitationAudioChannels:
|
||||
return applyIntLimitation(lim.Comparison, lim.Values, ts.Channels, func(v int) { ts.Channels = v })
|
||||
case LimitationAudioBitrate:
|
||||
current := ts.Bitrate
|
||||
if current == 0 {
|
||||
current = sourceBitrate
|
||||
}
|
||||
return applyIntLimitation(lim.Comparison, lim.Values, current, func(v int) { ts.Bitrate = v })
|
||||
case LimitationAudioSamplerate:
|
||||
return applyIntLimitation(lim.Comparison, lim.Values, ts.SampleRate, func(v int) { ts.SampleRate = v })
|
||||
case LimitationAudioBitdepth:
|
||||
if ts.BitDepth > 0 {
|
||||
return applyIntLimitation(lim.Comparison, lim.Values, ts.BitDepth, func(v int) { ts.BitDepth = v })
|
||||
}
|
||||
case LimitationAudioProfile:
|
||||
// TODO: implement when audio profile data is available
|
||||
}
|
||||
return adjustNone
|
||||
}
|
||||
|
||||
// applyIntLimitation applies a limitation comparison to a value.
|
||||
// If the value needs adjusting, calls the setter and returns the result.
|
||||
func applyIntLimitation(comparison string, values []string, current int, setter func(int)) adjustResult {
|
||||
if len(values) == 0 {
|
||||
return adjustNone
|
||||
}
|
||||
|
||||
switch comparison {
|
||||
case ComparisonLessThanEqual:
|
||||
limit, ok := parseInt(values[0])
|
||||
if !ok {
|
||||
return adjustNone
|
||||
}
|
||||
if current <= limit {
|
||||
return adjustNone
|
||||
}
|
||||
setter(limit)
|
||||
return adjustAdjusted
|
||||
case ComparisonGreaterThanEqual:
|
||||
limit, ok := parseInt(values[0])
|
||||
if !ok {
|
||||
return adjustNone
|
||||
}
|
||||
if current >= limit {
|
||||
return adjustNone
|
||||
}
|
||||
// Cannot upscale
|
||||
return adjustCannotFit
|
||||
case ComparisonEquals:
|
||||
// Check if current value matches any allowed value
|
||||
for _, v := range values {
|
||||
if limit, ok := parseInt(v); ok && current == limit {
|
||||
return adjustNone
|
||||
}
|
||||
}
|
||||
// Find the closest allowed value below current (don't upscale)
|
||||
var closest int
|
||||
found := false
|
||||
for _, v := range values {
|
||||
if limit, ok := parseInt(v); ok && limit < current {
|
||||
if !found || limit > closest {
|
||||
closest = limit
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if found {
|
||||
setter(closest)
|
||||
return adjustAdjusted
|
||||
}
|
||||
return adjustCannotFit
|
||||
case ComparisonNotEquals:
|
||||
for _, v := range values {
|
||||
if limit, ok := parseInt(v); ok && current == limit {
|
||||
return adjustCannotFit
|
||||
}
|
||||
}
|
||||
return adjustNone
|
||||
}
|
||||
|
||||
return adjustNone
|
||||
}
|
||||
|
||||
func checkIntLimitation(value int, comparison string, values []string) bool {
|
||||
return applyIntLimitation(comparison, values, value, func(int) {}) == adjustNone
|
||||
}
|
||||
|
||||
// checkStringLimitation checks a string value against a limitation.
|
||||
// Only Equals and NotEquals comparisons are meaningful for strings.
|
||||
// LessThanEqual/GreaterThanEqual are not applicable and always pass.
|
||||
func checkStringLimitation(value string, comparison string, values []string) bool {
|
||||
switch comparison {
|
||||
case ComparisonEquals:
|
||||
for _, v := range values {
|
||||
if strings.EqualFold(value, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case ComparisonNotEquals:
|
||||
for _, v := range values {
|
||||
if strings.EqualFold(value, v) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseInt(s string) (int, bool) {
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil || v < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package core
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"mime"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@ -19,8 +20,7 @@ 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, mf *model.MediaFile, req Request) (*Stream, error)
|
||||
}
|
||||
|
||||
type TranscodingCache cache.FileCache
|
||||
@ -36,44 +36,44 @@ 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ms.DoStream(ctx, mf, reqFormat, reqBitRate, reqOffset)
|
||||
}
|
||||
|
||||
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
|
||||
func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req Request) (*Stream, error) {
|
||||
var format string
|
||||
var bitRate int
|
||||
var cached bool
|
||||
defer func() {
|
||||
log.Info(ctx, "Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached,
|
||||
"bitRate", bitRate, "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 +87,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 +108,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 +133,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 +161,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 +179,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 +196,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
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package core_test
|
||||
package stream_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/stream"
|
||||
"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 stream.MediaStreamer
|
||||
var ds model.DataStore
|
||||
ffmpeg := tests.NewMockFFmpeg("fake data")
|
||||
ctx := log.NewContext(context.TODO())
|
||||
@ -29,44 +29,45 @@ 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 := stream.NewTranscodingCache()
|
||||
Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue())
|
||||
streamer = core.NewMediaStreamer(ds, ffmpeg, testCache)
|
||||
streamer = stream.NewMediaStreamer(ds, ffmpeg, testCache)
|
||||
})
|
||||
AfterEach(func() {
|
||||
_ = os.RemoveAll(conf.Server.CacheFolder)
|
||||
})
|
||||
|
||||
Context("NewStream", func() {
|
||||
var mf *model.MediaFile
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
mf, err = ds.MediaFile(ctx).Get("123")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
It("returns a seekable stream if format is 'raw'", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "raw", 0, 0)
|
||||
s, err := streamer.NewStream(ctx, mf, stream.Request{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, mf, stream.Request{})
|
||||
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, mf, stream.Request{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, mf, stream.Request{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, mf, stream.Request{Format: "mp3", BitRate: 32})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
17
core/stream/stream_suite_test.go
Normal file
17
core/stream/stream_suite_test.go
Normal file
@ -0,0 +1,17 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestStream(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Stream Suite")
|
||||
}
|
||||
148
core/stream/token.go
Normal file
148
core/stream/token.go
Normal file
@ -0,0 +1,148 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
const tokenTTL = 48 * time.Hour
|
||||
|
||||
// params contains the parameters extracted from a transcode token.
|
||||
// TargetBitrate is in kilobits per second (kbps).
|
||||
type params struct {
|
||||
MediaID string
|
||||
DirectPlay bool
|
||||
TargetFormat string
|
||||
TargetBitrate int
|
||||
TargetChannels int
|
||||
TargetSampleRate int
|
||||
TargetBitDepth int
|
||||
SourceUpdatedAt time.Time
|
||||
}
|
||||
|
||||
// toClaimsMap converts a Decision into a JWT claims map for token encoding.
|
||||
// Only non-zero transcode fields are included.
|
||||
func (d *TranscodeDecision) toClaimsMap() map[string]any {
|
||||
m := map[string]any{
|
||||
"mid": d.MediaID,
|
||||
"ua": d.SourceUpdatedAt.Truncate(time.Second).Unix(),
|
||||
jwt.ExpirationKey: time.Now().Add(tokenTTL).UTC().Unix(),
|
||||
}
|
||||
if d.CanDirectPlay {
|
||||
m["dp"] = true
|
||||
}
|
||||
if d.CanTranscode && d.TargetFormat != "" {
|
||||
m["f"] = d.TargetFormat
|
||||
if d.TargetBitrate != 0 {
|
||||
m["b"] = d.TargetBitrate
|
||||
}
|
||||
if d.TargetChannels != 0 {
|
||||
m["ch"] = d.TargetChannels
|
||||
}
|
||||
if d.TargetSampleRate != 0 {
|
||||
m["sr"] = d.TargetSampleRate
|
||||
}
|
||||
if d.TargetBitDepth != 0 {
|
||||
m["bd"] = d.TargetBitDepth
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// paramsFromToken extracts and validates Params from a parsed JWT token.
|
||||
// Returns an error if required claims (media ID, source timestamp) are missing.
|
||||
func paramsFromToken(token jwt.Token) (*params, error) {
|
||||
var p params
|
||||
var mid string
|
||||
if err := token.Get("mid", &mid); err == nil {
|
||||
p.MediaID = mid
|
||||
}
|
||||
if p.MediaID == "" {
|
||||
return nil, fmt.Errorf("%w: missing media ID", ErrTokenInvalid)
|
||||
}
|
||||
|
||||
var dp bool
|
||||
if err := token.Get("dp", &dp); err == nil {
|
||||
p.DirectPlay = dp
|
||||
}
|
||||
|
||||
ua := getIntClaim(token, "ua")
|
||||
if ua != 0 {
|
||||
p.SourceUpdatedAt = time.Unix(int64(ua), 0)
|
||||
}
|
||||
if p.SourceUpdatedAt.IsZero() {
|
||||
return nil, fmt.Errorf("%w: missing source timestamp", ErrTokenInvalid)
|
||||
}
|
||||
|
||||
var f string
|
||||
if err := token.Get("f", &f); err == nil {
|
||||
p.TargetFormat = f
|
||||
}
|
||||
p.TargetBitrate = getIntClaim(token, "b")
|
||||
p.TargetChannels = getIntClaim(token, "ch")
|
||||
p.TargetSampleRate = getIntClaim(token, "sr")
|
||||
p.TargetBitDepth = getIntClaim(token, "bd")
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// getIntClaim extracts an int claim from a JWT token, handling the case where
|
||||
// the value may be stored as int64 or float64 (common in JSON-based JWT libraries).
|
||||
func getIntClaim(token jwt.Token, key string) int {
|
||||
var v int
|
||||
if err := token.Get(key, &v); err == nil {
|
||||
return v
|
||||
}
|
||||
var v64 int64
|
||||
if err := token.Get(key, &v64); err == nil {
|
||||
return int(v64)
|
||||
}
|
||||
var f float64
|
||||
if err := token.Get(key, &f); err == nil {
|
||||
return int(f)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *deciderService) CreateTranscodeParams(decision *TranscodeDecision) (string, error) {
|
||||
return auth.EncodeToken(decision.toClaimsMap())
|
||||
}
|
||||
|
||||
func (s *deciderService) parseTranscodeParams(tokenStr string) (*params, error) {
|
||||
token, err := auth.DecodeAndVerifyToken(tokenStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return paramsFromToken(token)
|
||||
}
|
||||
|
||||
func (s *deciderService) ResolveRequestFromToken(ctx context.Context, token string, mf *model.MediaFile, offset int) (Request, error) {
|
||||
p, err := s.parseTranscodeParams(token)
|
||||
if err != nil {
|
||||
return Request{}, errors.Join(ErrTokenInvalid, err)
|
||||
}
|
||||
if p.MediaID != mf.ID {
|
||||
return Request{}, fmt.Errorf("%w: token mediaID %q does not match %q", ErrTokenInvalid, p.MediaID, mf.ID)
|
||||
}
|
||||
if !mf.UpdatedAt.Truncate(time.Second).Equal(p.SourceUpdatedAt) {
|
||||
log.Info(ctx, "Transcode token is stale", "mediaID", mf.ID,
|
||||
"tokenUpdatedAt", p.SourceUpdatedAt, "fileUpdatedAt", mf.UpdatedAt)
|
||||
return Request{}, ErrTokenStale
|
||||
}
|
||||
|
||||
req := Request{Offset: offset}
|
||||
if !p.DirectPlay && p.TargetFormat != "" {
|
||||
req.Format = p.TargetFormat
|
||||
req.BitRate = p.TargetBitrate
|
||||
req.SampleRate = p.TargetSampleRate
|
||||
req.BitDepth = p.TargetBitDepth
|
||||
req.Channels = p.TargetChannels
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
256
core/stream/token_test.go
Normal file
256
core/stream/token_test.go
Normal file
@ -0,0 +1,256 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Token", func() {
|
||||
var (
|
||||
ds *tests.MockDataStore
|
||||
ff *tests.MockFFmpeg
|
||||
svc TranscodeDecider
|
||||
ctx context.Context
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
ds = &tests.MockDataStore{
|
||||
MockedProperty: &tests.MockedPropertyRepo{},
|
||||
MockedTranscoding: &tests.MockTranscodingRepo{},
|
||||
}
|
||||
ff = tests.NewMockFFmpeg("")
|
||||
auth.Init(ds)
|
||||
svc = NewTranscodeDecider(ds, ff)
|
||||
})
|
||||
|
||||
Describe("Token round-trip", func() {
|
||||
var (
|
||||
sourceTime time.Time
|
||||
impl *deciderService
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
sourceTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC)
|
||||
impl = svc.(*deciderService)
|
||||
})
|
||||
|
||||
It("creates and parses a direct play token", func() {
|
||||
decision := &TranscodeDecision{
|
||||
MediaID: "media-123",
|
||||
CanDirectPlay: true,
|
||||
SourceUpdatedAt: sourceTime,
|
||||
}
|
||||
token, err := svc.CreateTranscodeParams(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(token).ToNot(BeEmpty())
|
||||
|
||||
params, err := impl.parseTranscodeParams(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params.MediaID).To(Equal("media-123"))
|
||||
Expect(params.DirectPlay).To(BeTrue())
|
||||
Expect(params.TargetFormat).To(BeEmpty())
|
||||
Expect(params.SourceUpdatedAt.Unix()).To(Equal(sourceTime.Unix()))
|
||||
})
|
||||
|
||||
It("creates and parses a transcode token with kbps bitrate", func() {
|
||||
decision := &TranscodeDecision{
|
||||
MediaID: "media-456",
|
||||
CanDirectPlay: false,
|
||||
CanTranscode: true,
|
||||
TargetFormat: "mp3",
|
||||
TargetBitrate: 256, // kbps
|
||||
TargetChannels: 2,
|
||||
SourceUpdatedAt: sourceTime,
|
||||
}
|
||||
token, err := svc.CreateTranscodeParams(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
params, err := impl.parseTranscodeParams(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params.MediaID).To(Equal("media-456"))
|
||||
Expect(params.DirectPlay).To(BeFalse())
|
||||
Expect(params.TargetFormat).To(Equal("mp3"))
|
||||
Expect(params.TargetBitrate).To(Equal(256)) // kbps
|
||||
Expect(params.TargetChannels).To(Equal(2))
|
||||
Expect(params.SourceUpdatedAt.Unix()).To(Equal(sourceTime.Unix()))
|
||||
})
|
||||
|
||||
It("creates and parses a transcode token with sample rate", func() {
|
||||
decision := &TranscodeDecision{
|
||||
MediaID: "media-789",
|
||||
CanDirectPlay: false,
|
||||
CanTranscode: true,
|
||||
TargetFormat: "flac",
|
||||
TargetBitrate: 0,
|
||||
TargetChannels: 2,
|
||||
TargetSampleRate: 48000,
|
||||
SourceUpdatedAt: sourceTime,
|
||||
}
|
||||
token, err := svc.CreateTranscodeParams(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
params, err := impl.parseTranscodeParams(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params.MediaID).To(Equal("media-789"))
|
||||
Expect(params.DirectPlay).To(BeFalse())
|
||||
Expect(params.TargetFormat).To(Equal("flac"))
|
||||
Expect(params.TargetSampleRate).To(Equal(48000))
|
||||
Expect(params.TargetChannels).To(Equal(2))
|
||||
})
|
||||
|
||||
It("creates and parses a transcode token with bit depth", func() {
|
||||
decision := &TranscodeDecision{
|
||||
MediaID: "media-bd",
|
||||
CanDirectPlay: false,
|
||||
CanTranscode: true,
|
||||
TargetFormat: "flac",
|
||||
TargetBitrate: 0,
|
||||
TargetChannels: 2,
|
||||
TargetBitDepth: 24,
|
||||
SourceUpdatedAt: sourceTime,
|
||||
}
|
||||
token, err := svc.CreateTranscodeParams(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
params, err := impl.parseTranscodeParams(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params.MediaID).To(Equal("media-bd"))
|
||||
Expect(params.TargetBitDepth).To(Equal(24))
|
||||
})
|
||||
|
||||
It("omits bit depth from token when 0", func() {
|
||||
decision := &TranscodeDecision{
|
||||
MediaID: "media-nobd",
|
||||
CanDirectPlay: false,
|
||||
CanTranscode: true,
|
||||
TargetFormat: "mp3",
|
||||
TargetBitrate: 256,
|
||||
TargetBitDepth: 0,
|
||||
SourceUpdatedAt: sourceTime,
|
||||
}
|
||||
token, err := svc.CreateTranscodeParams(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
params, err := impl.parseTranscodeParams(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params.TargetBitDepth).To(Equal(0))
|
||||
})
|
||||
|
||||
It("omits sample rate from token when 0", func() {
|
||||
decision := &TranscodeDecision{
|
||||
MediaID: "media-100",
|
||||
CanDirectPlay: false,
|
||||
CanTranscode: true,
|
||||
TargetFormat: "mp3",
|
||||
TargetBitrate: 256,
|
||||
TargetSampleRate: 0,
|
||||
SourceUpdatedAt: sourceTime,
|
||||
}
|
||||
token, err := svc.CreateTranscodeParams(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
params, err := impl.parseTranscodeParams(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params.TargetSampleRate).To(Equal(0))
|
||||
})
|
||||
|
||||
It("truncates SourceUpdatedAt to seconds", func() {
|
||||
timeWithNanos := time.Date(2025, 6, 15, 10, 30, 0, 123456789, time.UTC)
|
||||
decision := &TranscodeDecision{
|
||||
MediaID: "media-trunc",
|
||||
CanDirectPlay: true,
|
||||
SourceUpdatedAt: timeWithNanos,
|
||||
}
|
||||
token, err := svc.CreateTranscodeParams(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
params, err := impl.parseTranscodeParams(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params.SourceUpdatedAt.Unix()).To(Equal(timeWithNanos.Truncate(time.Second).Unix()))
|
||||
})
|
||||
|
||||
It("rejects an invalid token", func() {
|
||||
_, err := impl.parseTranscodeParams("invalid-token")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ResolveRequestFromToken", func() {
|
||||
var sourceTime time.Time
|
||||
|
||||
BeforeEach(func() {
|
||||
sourceTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC)
|
||||
})
|
||||
|
||||
createTokenForMedia := func(mediaID string, updatedAt time.Time) string {
|
||||
decision := &TranscodeDecision{
|
||||
MediaID: mediaID,
|
||||
CanDirectPlay: true,
|
||||
SourceUpdatedAt: updatedAt,
|
||||
}
|
||||
token, err := svc.CreateTranscodeParams(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return token
|
||||
}
|
||||
|
||||
It("returns stream request for valid token", func() {
|
||||
mf := &model.MediaFile{ID: "song-1", UpdatedAt: sourceTime}
|
||||
token := createTokenForMedia("song-1", sourceTime)
|
||||
|
||||
req, err := svc.ResolveRequestFromToken(ctx, token, mf, 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(req.Format).To(BeEmpty()) // direct play has no target format
|
||||
})
|
||||
|
||||
It("returns ErrTokenInvalid for invalid token", func() {
|
||||
mf := &model.MediaFile{ID: "song-1", UpdatedAt: sourceTime}
|
||||
_, err := svc.ResolveRequestFromToken(ctx, "bad-token", mf, 0)
|
||||
Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error())))
|
||||
})
|
||||
|
||||
It("returns ErrTokenInvalid when mediaID does not match token", func() {
|
||||
mf := &model.MediaFile{ID: "song-2", UpdatedAt: sourceTime}
|
||||
token := createTokenForMedia("song-1", sourceTime)
|
||||
|
||||
_, err := svc.ResolveRequestFromToken(ctx, token, mf, 0)
|
||||
Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error())))
|
||||
})
|
||||
|
||||
It("returns ErrTokenStale when media file has changed", func() {
|
||||
newTime := sourceTime.Add(1 * time.Hour)
|
||||
mf := &model.MediaFile{ID: "song-1", UpdatedAt: newTime}
|
||||
token := createTokenForMedia("song-1", sourceTime)
|
||||
|
||||
_, err := svc.ResolveRequestFromToken(ctx, token, mf, 0)
|
||||
Expect(err).To(MatchError(ErrTokenStale))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("paramsFromToken", func() {
|
||||
It("returns error when media ID is missing", func() {
|
||||
tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token, _, err := tokenAuth.Encode(map[string]any{"ua": int64(1700000000)})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = paramsFromToken(token)
|
||||
Expect(err).To(MatchError(ContainSubstring("missing media ID")))
|
||||
})
|
||||
|
||||
It("returns error when source timestamp is missing", func() {
|
||||
tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token, _, err := tokenAuth.Encode(map[string]any{"mid": "song-5"})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = paramsFromToken(token)
|
||||
Expect(err).To(MatchError(ContainSubstring("missing source timestamp")))
|
||||
})
|
||||
})
|
||||
})
|
||||
132
core/stream/types.go
Normal file
132
core/stream/types.go
Normal file
@ -0,0 +1,132 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTokenInvalid = errors.New("invalid or expired transcode token")
|
||||
ErrTokenStale = errors.New("transcode token is stale: media file has changed")
|
||||
)
|
||||
|
||||
// TranscodeOptions controls optional behavior of MakeTranscodeDecision.
|
||||
type TranscodeOptions struct {
|
||||
// SkipProbe prevents MakeTranscodeDecision from running ffprobe on the media file.
|
||||
// When true, source stream details are derived from tag metadata only.
|
||||
SkipProbe bool
|
||||
}
|
||||
|
||||
// Request contains the resolved parameters for creating a media stream.
|
||||
type Request struct {
|
||||
Format string
|
||||
BitRate int // kbps
|
||||
SampleRate int
|
||||
BitDepth int
|
||||
Channels int
|
||||
Offset int // seconds
|
||||
}
|
||||
|
||||
// ClientInfo represents client playback capabilities.
|
||||
// All bitrate values are in kilobits per second (kbps)
|
||||
type ClientInfo struct {
|
||||
Name string
|
||||
Platform string
|
||||
MaxAudioBitrate int
|
||||
MaxTranscodingAudioBitrate int
|
||||
DirectPlayProfiles []DirectPlayProfile
|
||||
TranscodingProfiles []Profile
|
||||
CodecProfiles []CodecProfile
|
||||
}
|
||||
|
||||
// DirectPlayProfile describes a format the client can play directly
|
||||
type DirectPlayProfile struct {
|
||||
Containers []string
|
||||
AudioCodecs []string
|
||||
Protocols []string
|
||||
MaxAudioChannels int
|
||||
}
|
||||
|
||||
// Profile describes a transcoding target the client supports
|
||||
type Profile struct {
|
||||
Container string
|
||||
AudioCodec string
|
||||
Protocol string
|
||||
MaxAudioChannels int
|
||||
}
|
||||
|
||||
// CodecProfile describes codec-specific limitations
|
||||
type CodecProfile struct {
|
||||
Type string
|
||||
Name string
|
||||
Limitations []Limitation
|
||||
}
|
||||
|
||||
// Limitation describes a specific codec limitation
|
||||
type Limitation struct {
|
||||
Name string
|
||||
Comparison string
|
||||
Values []string
|
||||
Required bool
|
||||
}
|
||||
|
||||
// Protocol values (OpenSubsonic spec enum)
|
||||
const (
|
||||
ProtocolHTTP = "http"
|
||||
ProtocolHLS = "hls"
|
||||
)
|
||||
|
||||
// Comparison operators (OpenSubsonic spec enum)
|
||||
const (
|
||||
ComparisonEquals = "Equals"
|
||||
ComparisonNotEquals = "NotEquals"
|
||||
ComparisonLessThanEqual = "LessThanEqual"
|
||||
ComparisonGreaterThanEqual = "GreaterThanEqual"
|
||||
)
|
||||
|
||||
// Limitation names (OpenSubsonic spec enum)
|
||||
const (
|
||||
LimitationAudioChannels = "audioChannels"
|
||||
LimitationAudioBitrate = "audioBitrate"
|
||||
LimitationAudioProfile = "audioProfile"
|
||||
LimitationAudioSamplerate = "audioSamplerate"
|
||||
LimitationAudioBitdepth = "audioBitdepth"
|
||||
)
|
||||
|
||||
// Codec profile types (OpenSubsonic spec enum)
|
||||
const (
|
||||
CodecProfileTypeAudio = "AudioCodec"
|
||||
)
|
||||
|
||||
// TranscodeDecision represents the internal decision result.
|
||||
// All bitrate values are in kilobits per second (kbps).
|
||||
type TranscodeDecision struct {
|
||||
MediaID string
|
||||
CanDirectPlay bool
|
||||
CanTranscode bool
|
||||
TranscodeReasons []string
|
||||
ErrorReason string
|
||||
TargetFormat string
|
||||
TargetBitrate int
|
||||
TargetChannels int
|
||||
TargetSampleRate int
|
||||
TargetBitDepth int
|
||||
SourceStream Details
|
||||
SourceUpdatedAt time.Time
|
||||
TranscodeStream *Details
|
||||
}
|
||||
|
||||
// Details describes audio stream properties.
|
||||
// Bitrate is in kilobits per second (kbps).
|
||||
type Details struct {
|
||||
Container string
|
||||
Codec string
|
||||
Profile string // Audio profile (e.g., "LC", "HE-AACv2"). Populated from ffprobe data.
|
||||
Bitrate int
|
||||
SampleRate int
|
||||
BitDepth int
|
||||
Channels int
|
||||
Duration float32
|
||||
Size int64
|
||||
IsLossless bool
|
||||
}
|
||||
@ -5,15 +5,17 @@ import (
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
)
|
||||
|
||||
var Set = wire.NewSet(
|
||||
NewMediaStreamer,
|
||||
GetTranscodingCache,
|
||||
stream.NewMediaStreamer,
|
||||
stream.GetTranscodingCache,
|
||||
NewArchiver,
|
||||
NewPlayers,
|
||||
NewShare,
|
||||
@ -21,6 +23,7 @@ var Set = wire.NewSet(
|
||||
NewLibrary,
|
||||
NewUser,
|
||||
NewMaintenance,
|
||||
stream.NewTranscodeDecider,
|
||||
agents.GetAgents,
|
||||
external.NewProvider,
|
||||
wire.Bind(new(external.Agents), new(*agents.Agents)),
|
||||
@ -28,4 +31,5 @@ var Set = wire.NewSet(
|
||||
scrobbler.GetPlayTracker,
|
||||
playback.GetInstance,
|
||||
metrics.GetInstance,
|
||||
lyrics.NewLyrics,
|
||||
)
|
||||
|
||||
@ -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;
|
||||
22
db/migrations/20260228172956_add_playlist_image_file.go
Normal file
22
db/migrations/20260228172956_add_playlist_image_file.go
Normal 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
|
||||
}
|
||||
30
db/migrations/20260302021413_rename_playlist_image_fields.go
Normal file
30
db/migrations/20260302021413_rename_playlist_image_fields.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
28
db/migrations/20260309120007_fix_probe_data_null.go
Normal file
28
db/migrations/20260309120007_fix_probe_data_null.go
Normal 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
|
||||
}
|
||||
41
db/migrations/20260309203355_ensure_default_transcodings.go
Normal file
41
db/migrations/20260309203355_ensure_default_transcodings.go
Normal 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
41
go.mod
@ -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
86
go.sum
@ -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=
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,16 @@ 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 {
|
||||
@ -128,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:
|
||||
@ -153,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
|
||||
@ -174,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
|
||||
}
|
||||
|
||||
@ -181,6 +181,28 @@ var _ = Describe("Criteria", func() {
|
||||
})
|
||||
})
|
||||
|
||||
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{
|
||||
@ -263,6 +285,126 @@ var _ = Describe("Criteria", func() {
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
@ -60,6 +60,7 @@ var fieldMap = map[string]*mappedField{
|
||||
"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},
|
||||
@ -121,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.
|
||||
|
||||
@ -178,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}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -35,6 +35,7 @@ type AudioProperties struct {
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
Channels int
|
||||
Codec string
|
||||
}
|
||||
|
||||
type Date string
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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"
|
||||
@ -203,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
|
||||
@ -242,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 {
|
||||
@ -303,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
|
||||
@ -353,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
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@ -743,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 {
|
||||
|
||||
@ -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"
|
||||
@ -135,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,
|
||||
@ -513,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
|
||||
}
|
||||
|
||||
@ -512,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 {
|
||||
@ -543,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))
|
||||
|
||||
@ -560,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"))
|
||||
@ -580,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")
|
||||
|
||||
@ -688,7 +688,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
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())
|
||||
})
|
||||
@ -742,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())
|
||||
})
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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"
|
||||
@ -99,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,
|
||||
@ -162,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")
|
||||
@ -231,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.
|
||||
@ -371,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
|
||||
@ -428,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
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@ package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -248,22 +248,36 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
|
||||
|
||||
// Conditionally join album/artist annotation tables only when referenced by criteria or sort
|
||||
requiredJoins := rules.RequiredJoins()
|
||||
if requiredJoins.Has(criteria.JoinAlbumAnnotation) {
|
||||
sq = sq.LeftJoin("annotation AS album_annotation ON ("+
|
||||
"album_annotation.item_id = media_file.album_id"+
|
||||
" AND album_annotation.item_type = 'album'"+
|
||||
" AND album_annotation.user_id = ?)", usr.ID)
|
||||
}
|
||||
if requiredJoins.Has(criteria.JoinArtistAnnotation) {
|
||||
sq = sq.LeftJoin("annotation AS artist_annotation ON ("+
|
||||
"artist_annotation.item_id = media_file.artist_id"+
|
||||
" AND artist_annotation.item_type = 'artist'"+
|
||||
" AND artist_annotation.user_id = ?)", usr.ID)
|
||||
}
|
||||
sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, usr.ID)
|
||||
|
||||
// Only include media files from libraries the user has access to
|
||||
sq = r.applyLibraryFilter(sq, "media_file")
|
||||
|
||||
// Resolve percentage-based limit to an absolute number before applying criteria
|
||||
if rules.IsPercentageLimit() {
|
||||
// Use only expression-based joins for the COUNT query (sort joins are unnecessary)
|
||||
exprJoins := rules.ExpressionJoins()
|
||||
countSq := Select("count(*) as count").From("media_file").
|
||||
LeftJoin("annotation on ("+
|
||||
"annotation.item_id = media_file.id"+
|
||||
" AND annotation.item_type = 'media_file'"+
|
||||
" AND annotation.user_id = ?)", usr.ID)
|
||||
countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, usr.ID)
|
||||
countSq = r.applyLibraryFilter(countSq, "media_file")
|
||||
countSq = countSq.Where(rules)
|
||||
|
||||
var res struct{ Count int64 }
|
||||
err = r.queryOne(countSq, &res)
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Error counting matching tracks for percentage limit", "playlist", pls.Name, "id", pls.ID, err)
|
||||
return false
|
||||
}
|
||||
resolvedLimit := rules.EffectiveLimit(res.Count)
|
||||
log.Debug(r.ctx, "Resolved percentage limit", "playlist", pls.Name, "percent", rules.LimitPercent, "totalMatching", res.Count, "resolvedLimit", resolvedLimit)
|
||||
rules.Limit = resolvedLimit
|
||||
rules.LimitPercent = 0
|
||||
}
|
||||
|
||||
// Apply the criteria rules
|
||||
sq = r.addCriteria(sq, rules)
|
||||
insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq)
|
||||
@ -296,6 +310,22 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins criteria.JoinType, userID string) SelectBuilder {
|
||||
if joins.Has(criteria.JoinAlbumAnnotation) {
|
||||
sq = sq.LeftJoin("annotation AS album_annotation ON ("+
|
||||
"album_annotation.item_id = media_file.album_id"+
|
||||
" AND album_annotation.item_type = 'album'"+
|
||||
" AND album_annotation.user_id = ?)", userID)
|
||||
}
|
||||
if joins.Has(criteria.JoinArtistAnnotation) {
|
||||
sq = sq.LeftJoin("annotation AS artist_annotation ON ("+
|
||||
"artist_annotation.item_id = media_file.artist_id"+
|
||||
" AND artist_annotation.item_type = 'artist'"+
|
||||
" AND artist_annotation.user_id = ?)", userID)
|
||||
}
|
||||
return sq
|
||||
}
|
||||
|
||||
func (r *playlistRepository) addCriteria(sql SelectBuilder, c criteria.Criteria) SelectBuilder {
|
||||
sql = sql.Where(c)
|
||||
if c.Limit > 0 {
|
||||
|
||||
@ -31,6 +31,14 @@ func (r *pluginRepository) isPermitted() bool {
|
||||
return user.IsAdmin
|
||||
}
|
||||
|
||||
func (r *pluginRepository) ClearErrors() error {
|
||||
if !r.isPermitted() {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
_, err := r.db.NewQuery("UPDATE plugin SET last_error = '' WHERE last_error != ''").Execute()
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *pluginRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
if !r.isPermitted() {
|
||||
return 0, rest.ErrPermissionDenied
|
||||
@ -79,8 +87,8 @@ func (r *pluginRepository) Put(plugin *model.Plugin) error {
|
||||
|
||||
// Upsert using INSERT ... ON CONFLICT for atomic operation
|
||||
_, err := r.db.NewQuery(`
|
||||
INSERT INTO plugin (id, path, manifest, config, users, all_users, libraries, all_libraries, enabled, last_error, sha256, created_at, updated_at)
|
||||
VALUES ({:id}, {:path}, {:manifest}, {:config}, {:users}, {:all_users}, {:libraries}, {:all_libraries}, {:enabled}, {:last_error}, {:sha256}, {:created_at}, {:updated_at})
|
||||
INSERT INTO plugin (id, path, manifest, config, users, all_users, libraries, all_libraries, allow_write_access, enabled, last_error, sha256, created_at, updated_at)
|
||||
VALUES ({:id}, {:path}, {:manifest}, {:config}, {:users}, {:all_users}, {:libraries}, {:all_libraries}, {:allow_write_access}, {:enabled}, {:last_error}, {:sha256}, {:created_at}, {:updated_at})
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
path = excluded.path,
|
||||
manifest = excluded.manifest,
|
||||
@ -89,24 +97,26 @@ func (r *pluginRepository) Put(plugin *model.Plugin) error {
|
||||
all_users = excluded.all_users,
|
||||
libraries = excluded.libraries,
|
||||
all_libraries = excluded.all_libraries,
|
||||
allow_write_access = excluded.allow_write_access,
|
||||
enabled = excluded.enabled,
|
||||
last_error = excluded.last_error,
|
||||
sha256 = excluded.sha256,
|
||||
updated_at = excluded.updated_at
|
||||
`).Bind(dbx.Params{
|
||||
"id": plugin.ID,
|
||||
"path": plugin.Path,
|
||||
"manifest": plugin.Manifest,
|
||||
"config": plugin.Config,
|
||||
"users": plugin.Users,
|
||||
"all_users": plugin.AllUsers,
|
||||
"libraries": plugin.Libraries,
|
||||
"all_libraries": plugin.AllLibraries,
|
||||
"enabled": plugin.Enabled,
|
||||
"last_error": plugin.LastError,
|
||||
"sha256": plugin.SHA256,
|
||||
"created_at": time.Now(),
|
||||
"updated_at": plugin.UpdatedAt,
|
||||
"id": plugin.ID,
|
||||
"path": plugin.Path,
|
||||
"manifest": plugin.Manifest,
|
||||
"config": plugin.Config,
|
||||
"users": plugin.Users,
|
||||
"all_users": plugin.AllUsers,
|
||||
"libraries": plugin.Libraries,
|
||||
"all_libraries": plugin.AllLibraries,
|
||||
"allow_write_access": plugin.AllowWriteAccess,
|
||||
"enabled": plugin.Enabled,
|
||||
"last_error": plugin.LastError,
|
||||
"sha256": plugin.SHA256,
|
||||
"created_at": time.Now(),
|
||||
"updated_at": plugin.UpdatedAt,
|
||||
}).Execute()
|
||||
return err
|
||||
}
|
||||
|
||||
@ -175,6 +175,30 @@ var _ = Describe("PluginRepository", func() {
|
||||
Expect(err.Error()).To(ContainSubstring("ID cannot be empty"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ClearErrors", func() {
|
||||
It("clears last_error on all plugins with errors", func() {
|
||||
_ = repo.Put(&model.Plugin{ID: "ok-plugin", Path: "/plugins/ok.wasm", Manifest: "{}", SHA256: "h1"})
|
||||
_ = repo.Put(&model.Plugin{ID: "err-plugin-1", Path: "/plugins/e1.wasm", Manifest: "{}", SHA256: "h2", LastError: "incompatible version"})
|
||||
_ = repo.Put(&model.Plugin{ID: "err-plugin-2", Path: "/plugins/e2.wasm", Manifest: "{}", SHA256: "h3", LastError: "missing export"})
|
||||
|
||||
err := repo.ClearErrors()
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
all, err := repo.GetAll()
|
||||
Expect(err).To(BeNil())
|
||||
for _, p := range all {
|
||||
Expect(p.LastError).To(BeEmpty(), "plugin %s should have no error", p.ID)
|
||||
}
|
||||
})
|
||||
|
||||
It("succeeds when no plugins have errors", func() {
|
||||
_ = repo.Put(&model.Plugin{ID: "clean-plugin", Path: "/plugins/c.wasm", Manifest: "{}", SHA256: "h1"})
|
||||
|
||||
err := repo.ClearErrors()
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Regular User", func() {
|
||||
|
||||
@ -109,12 +109,10 @@ func booleanFilter(field string, value any) Sqlizer {
|
||||
func fullTextFilter(tableName string, mbidFields ...string) func(string, any) Sqlizer {
|
||||
return func(field string, value any) Sqlizer {
|
||||
v := strings.ToLower(value.(string))
|
||||
searchExpr := getSearchExpr()
|
||||
cond := cmp.Or(
|
||||
return cmp.Or[Sqlizer](
|
||||
mbidExpr(tableName, v, mbidFields...),
|
||||
searchExpr(tableName, v),
|
||||
getSearchStrategy(tableName, v),
|
||||
)
|
||||
return cond
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -102,11 +102,11 @@ var _ = Describe("sqlRestful", func() {
|
||||
uuid := "550e8400-e29b-41d4-a716-446655440000"
|
||||
result := noMbidFilter("search", uuid)
|
||||
|
||||
// mbidExpr with no fields returns nil, so cmp.Or falls back to fullTextExpr
|
||||
expected := squirrel.And{
|
||||
squirrel.Like{"test_table.full_text": "% 550e8400-e29b-41d4-a716-446655440000%"},
|
||||
}
|
||||
Expect(result).To(Equal(expected))
|
||||
// mbidExpr with no fields returns nil, so cmp.Or falls back to search strategy
|
||||
sql, args, err := result.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
||||
Expect(args).To(ContainElement("% 550e8400-e29b-41d4-a716-446655440000%"))
|
||||
})
|
||||
})
|
||||
|
||||
@ -114,26 +114,25 @@ var _ = Describe("sqlRestful", func() {
|
||||
It("returns full text search condition only", func() {
|
||||
result := filter("search", "beatles")
|
||||
|
||||
// mbidExpr returns nil for non-UUIDs, so fullTextExpr result is returned directly
|
||||
expected := squirrel.And{
|
||||
squirrel.Like{"test_table.full_text": "% beatles%"},
|
||||
}
|
||||
Expect(result).To(Equal(expected))
|
||||
// mbidExpr returns nil for non-UUIDs, so search strategy result is returned directly
|
||||
sql, args, err := result.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
||||
Expect(args).To(ContainElement("% beatles%"))
|
||||
})
|
||||
|
||||
It("handles multi-word search terms", func() {
|
||||
result := filter("search", "the beatles abbey road")
|
||||
|
||||
// Should return And condition directly
|
||||
andCondition, ok := result.(squirrel.And)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(andCondition).To(HaveLen(4))
|
||||
|
||||
// Check that all words are present (order may vary)
|
||||
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% the%"}))
|
||||
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% beatles%"}))
|
||||
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% abbey%"}))
|
||||
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% road%"}))
|
||||
sql, args, err := result.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// All words should be present as LIKE conditions
|
||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
||||
Expect(args).To(HaveLen(4))
|
||||
Expect(args).To(ContainElement("% the%"))
|
||||
Expect(args).To(ContainElement("% beatles%"))
|
||||
Expect(args).To(ContainElement("% abbey%"))
|
||||
Expect(args).To(ContainElement("% road%"))
|
||||
})
|
||||
})
|
||||
|
||||
@ -142,26 +141,48 @@ var _ = Describe("sqlRestful", func() {
|
||||
conf.Server.Search.FullString = false
|
||||
result := filter("search", "test query")
|
||||
|
||||
andCondition, ok := result.(squirrel.And)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(andCondition).To(HaveLen(2))
|
||||
|
||||
// Check that all words are present with leading space (order may vary)
|
||||
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% test%"}))
|
||||
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% query%"}))
|
||||
sql, args, err := result.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
||||
Expect(args).To(HaveLen(2))
|
||||
Expect(args).To(ContainElement("% test%"))
|
||||
Expect(args).To(ContainElement("% query%"))
|
||||
})
|
||||
|
||||
It("uses no separator with SearchFullString=true", func() {
|
||||
conf.Server.Search.FullString = true
|
||||
result := filter("search", "test query")
|
||||
|
||||
andCondition, ok := result.(squirrel.And)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(andCondition).To(HaveLen(2))
|
||||
sql, args, err := result.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
||||
Expect(args).To(HaveLen(2))
|
||||
Expect(args).To(ContainElement("%test%"))
|
||||
Expect(args).To(ContainElement("%query%"))
|
||||
})
|
||||
})
|
||||
|
||||
// Check that all words are present without leading space (order may vary)
|
||||
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "%test%"}))
|
||||
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "%query%"}))
|
||||
Context("single-character queries (regression: must not be rejected)", func() {
|
||||
It("returns valid filter for single-char query with legacy backend", func() {
|
||||
conf.Server.Search.Backend = "legacy"
|
||||
result := filter("search", "a")
|
||||
Expect(result).ToNot(BeNil(), "single-char REST filter must not be dropped")
|
||||
sql, args, err := result.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("LIKE"))
|
||||
Expect(args).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns valid filter for single-char query with FTS backend", func() {
|
||||
conf.Server.Search.Backend = "fts"
|
||||
conf.Server.Search.FullString = false
|
||||
ftsFilter := fullTextFilter(tableName, mbidFields...)
|
||||
result := ftsFilter("search", "a")
|
||||
Expect(result).ToNot(BeNil(), "single-char REST filter must not be dropped")
|
||||
sql, args, err := result.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("MATCH"))
|
||||
Expect(args).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
@ -179,10 +200,10 @@ var _ = Describe("sqlRestful", func() {
|
||||
It("handles special characters that are sanitized", func() {
|
||||
result := filter("search", "don't")
|
||||
|
||||
expected := squirrel.And{
|
||||
squirrel.Like{"test_table.full_text": "% dont%"}, // str.SanitizeStrings removes quotes
|
||||
}
|
||||
Expect(result).To(Equal(expected))
|
||||
sql, args, err := result.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
||||
Expect(args).To(ContainElement("% dont%"))
|
||||
})
|
||||
|
||||
It("returns nil for single quote (SQL injection protection)", func() {
|
||||
@ -206,31 +227,30 @@ var _ = Describe("sqlRestful", func() {
|
||||
result := filter("search", "550e8400-invalid-uuid")
|
||||
|
||||
// Should return full text filter since UUID is invalid
|
||||
expected := squirrel.And{
|
||||
squirrel.Like{"test_table.full_text": "% 550e8400-invalid-uuid%"},
|
||||
}
|
||||
Expect(result).To(Equal(expected))
|
||||
sql, args, err := result.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
||||
Expect(args).To(ContainElement("% 550e8400-invalid-uuid%"))
|
||||
})
|
||||
|
||||
It("handles empty mbid fields array", func() {
|
||||
emptyMbidFilter := fullTextFilter(tableName, []string{}...)
|
||||
result := emptyMbidFilter("search", "test")
|
||||
|
||||
// mbidExpr with empty fields returns nil, so cmp.Or falls back to fullTextExpr
|
||||
expected := squirrel.And{
|
||||
squirrel.Like{"test_table.full_text": "% test%"},
|
||||
}
|
||||
Expect(result).To(Equal(expected))
|
||||
// mbidExpr with empty fields returns nil, so search strategy result is returned directly
|
||||
sql, args, err := result.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
||||
Expect(args).To(ContainElement("% test%"))
|
||||
})
|
||||
|
||||
It("converts value to lowercase before processing", func() {
|
||||
result := filter("search", "TEST")
|
||||
|
||||
// The function converts to lowercase internally
|
||||
expected := squirrel.And{
|
||||
squirrel.Like{"test_table.full_text": "% test%"},
|
||||
}
|
||||
Expect(result).To(Equal(expected))
|
||||
sql, args, err := result.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
||||
Expect(args).To(ContainElement("% test%"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -6,7 +6,6 @@ import (
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
@ -16,57 +15,71 @@ func formatFullText(text ...string) string {
|
||||
return " " + fullText
|
||||
}
|
||||
|
||||
// searchExprFunc is the function signature for search expression builders.
|
||||
type searchExprFunc func(tableName string, query string) Sqlizer
|
||||
|
||||
// getSearchExpr returns the active search expression function based on config.
|
||||
// It falls back to legacySearchExpr when Search.FullString is enabled, because
|
||||
// FTS5 is token-based and cannot match substrings within words.
|
||||
// CJK queries are routed to likeSearchExpr, since FTS5's unicode61 tokenizer
|
||||
// cannot segment CJK text.
|
||||
func getSearchExpr() searchExprFunc {
|
||||
if conf.Server.Search.Backend == "legacy" || conf.Server.Search.FullString {
|
||||
return legacySearchExpr
|
||||
}
|
||||
return func(tableName, query string) Sqlizer {
|
||||
if containsCJK(query) {
|
||||
return likeSearchExpr(tableName, query)
|
||||
}
|
||||
return ftsSearchExpr(tableName, query)
|
||||
}
|
||||
// searchConfig holds per-repository constants for doSearch.
|
||||
type searchConfig struct {
|
||||
NaturalOrder string // ORDER BY for empty-query results (e.g. "album.rowid")
|
||||
OrderBy []string // ORDER BY for text search results (e.g. ["name"])
|
||||
MBIDFields []string // columns to match when query is a UUID
|
||||
// LibraryFilter overrides the default applyLibraryFilter for FTS Phase 1.
|
||||
// Needed when library access requires a junction table (e.g. artist → library_artist).
|
||||
LibraryFilter func(sq SelectBuilder) SelectBuilder
|
||||
}
|
||||
|
||||
// doSearch performs a full-text search with the specified parameters.
|
||||
// The naturalOrder is used to sort results when no full-text filter is applied. It is useful for cases like
|
||||
// OpenSubsonic, where an empty search query should return all results in a natural order. Normally the parameter
|
||||
// should be `tableName + ".rowid"`, but some repositories (ex: artist) may use a different natural order.
|
||||
func (r sqlRepository) doSearch(sq SelectBuilder, q string, offset, size int, results any, naturalOrder string, orderBys ...string) error {
|
||||
// searchStrategy defines how to execute a text search against a repository table.
|
||||
// options carries filters and pagination that must reach all query phases,
|
||||
// including FTS Phase 1 which builds its own query outside sq.
|
||||
type searchStrategy interface {
|
||||
Sqlizer
|
||||
execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error
|
||||
}
|
||||
|
||||
// getSearchStrategy returns the appropriate search strategy based on config and query content.
|
||||
// Returns nil when the query produces no searchable tokens.
|
||||
func getSearchStrategy(tableName, query string) searchStrategy {
|
||||
if conf.Server.Search.Backend == "legacy" || conf.Server.Search.FullString {
|
||||
return newLegacySearch(tableName, query)
|
||||
}
|
||||
if containsCJK(query) {
|
||||
return newLikeSearch(tableName, query)
|
||||
}
|
||||
return newFTSSearch(tableName, query)
|
||||
}
|
||||
|
||||
// doSearch dispatches a search query: empty → natural order, UUID → MBID match,
|
||||
// otherwise delegates to getSearchStrategy. sq must already have LIMIT/OFFSET set
|
||||
// via newSelect(options...). options is forwarded so FTS Phase 1 can apply the same
|
||||
// filters and pagination independently.
|
||||
func (r sqlRepository) doSearch(sq SelectBuilder, q string, results any, cfg searchConfig, options model.QueryOptions) error {
|
||||
q = strings.TrimSpace(q)
|
||||
q = strings.TrimSuffix(q, "*")
|
||||
|
||||
sq = sq.Where(Eq{r.tableName + ".missing": false})
|
||||
|
||||
// Empty query (OpenSubsonic `search3?query=""`) — return all in natural order.
|
||||
if q == "" || q == `""` {
|
||||
sq = sq.OrderBy(cfg.NaturalOrder)
|
||||
return r.queryAll(sq, results, options)
|
||||
}
|
||||
|
||||
// MBID search: if query is a valid UUID, search by MBID fields instead
|
||||
if uuid.Validate(q) == nil && len(cfg.MBIDFields) > 0 {
|
||||
sq = sq.Where(mbidExpr(r.tableName, q, cfg.MBIDFields...))
|
||||
return r.queryAll(sq, results)
|
||||
}
|
||||
|
||||
// Min-length guard: single-character queries are too broad for search3.
|
||||
// This check lives here (not in the strategies) so that fullTextFilter
|
||||
// (REST filter path) can still use single-character queries.
|
||||
if len(q) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
searchExpr := getSearchExpr()
|
||||
filter := searchExpr(r.tableName, q)
|
||||
if filter != nil {
|
||||
sq = sq.Where(filter)
|
||||
sq = sq.OrderBy(orderBys...)
|
||||
} else {
|
||||
// This is to speed up the results of `search3?query=""`, for OpenSubsonic
|
||||
// If the filter is empty, we sort by the specified natural order.
|
||||
sq = sq.OrderBy(naturalOrder)
|
||||
strategy := getSearchStrategy(r.tableName, q)
|
||||
if strategy == nil {
|
||||
return nil
|
||||
}
|
||||
sq = sq.Where(Eq{r.tableName + ".missing": false})
|
||||
sq = sq.Limit(uint64(size)).Offset(uint64(offset))
|
||||
return r.queryAll(sq, results, model.QueryOptions{Offset: offset})
|
||||
}
|
||||
|
||||
func (r sqlRepository) searchByMBID(sq SelectBuilder, mbid string, mbidFields []string, results any) error {
|
||||
sq = sq.Where(mbidExpr(r.tableName, mbid, mbidFields...))
|
||||
sq = sq.Where(Eq{r.tableName + ".missing": false})
|
||||
|
||||
return r.queryAll(sq, results)
|
||||
return strategy.execute(r, sq, results, cfg, options)
|
||||
}
|
||||
|
||||
func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer {
|
||||
@ -80,24 +93,3 @@ func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer {
|
||||
}
|
||||
return Or(cond)
|
||||
}
|
||||
|
||||
// legacySearchExpr generates LIKE-based search filters against the full_text column.
|
||||
// This is the original search implementation, used when Search.Backend="legacy".
|
||||
func legacySearchExpr(tableName string, s string) Sqlizer {
|
||||
q := str.SanitizeStrings(s)
|
||||
if q == "" {
|
||||
log.Trace("Search using legacy backend, query is empty", "table", tableName)
|
||||
return nil
|
||||
}
|
||||
var sep string
|
||||
if !conf.Server.Search.FullString {
|
||||
sep = " "
|
||||
}
|
||||
parts := strings.Split(q, " ")
|
||||
filters := And{}
|
||||
for _, part := range parts {
|
||||
filters = append(filters, Like{tableName + ".full_text": "%" + sep + part + "%"})
|
||||
}
|
||||
log.Trace("Search using legacy backend", "query", filters, "table", tableName)
|
||||
return filters
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// containsCJK returns true if the string contains any CJK (Chinese/Japanese/Korean) characters.
|
||||
@ -187,75 +188,235 @@ func buildFTS5Query(userInput string) string {
|
||||
return result
|
||||
}
|
||||
|
||||
// likeSearchColumns defines the core columns to search with LIKE queries.
|
||||
// These are the primary user-visible fields for each entity type.
|
||||
// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input).
|
||||
var likeSearchColumns = map[string][]string{
|
||||
"media_file": {"title", "album", "artist", "album_artist"},
|
||||
"album": {"name", "album_artist"},
|
||||
"artist": {"name"},
|
||||
// ftsColumn pairs an FTS5 column name with its BM25 relevance weight.
|
||||
type ftsColumn struct {
|
||||
Name string
|
||||
Weight float64
|
||||
}
|
||||
|
||||
// likeSearchExpr generates LIKE-based search filters against core columns.
|
||||
// Each word in the query must match at least one column (AND between words),
|
||||
// and each word can match any column (OR within a word).
|
||||
// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input).
|
||||
func likeSearchExpr(tableName string, s string) Sqlizer {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
log.Trace("Search using LIKE backend, query is empty", "table", tableName)
|
||||
return nil
|
||||
}
|
||||
columns, ok := likeSearchColumns[tableName]
|
||||
if !ok {
|
||||
log.Trace("Search using LIKE backend, couldn't find columns for this table", "table", tableName)
|
||||
return nil
|
||||
}
|
||||
words := strings.Fields(s)
|
||||
wordFilters := And{}
|
||||
for _, word := range words {
|
||||
colFilters := Or{}
|
||||
for _, col := range columns {
|
||||
colFilters = append(colFilters, Like{tableName + "." + col: "%" + word + "%"})
|
||||
// ftsColumnDefs defines FTS5 columns and their BM25 relevance weights.
|
||||
// The order MUST match the column order in the FTS5 table definition (see migrations).
|
||||
// All columns are both searched and ranked. When adding indexed-but-not-searched
|
||||
// columns in the future, use Weight: 0 to exclude from the search column filter.
|
||||
var ftsColumnDefs = map[string][]ftsColumn{
|
||||
"media_file": {
|
||||
{"title", 10.0},
|
||||
{"album", 5.0},
|
||||
{"artist", 3.0},
|
||||
{"album_artist", 3.0},
|
||||
{"sort_title", 1.0},
|
||||
{"sort_album_name", 1.0},
|
||||
{"sort_artist_name", 1.0},
|
||||
{"sort_album_artist_name", 1.0},
|
||||
{"disc_subtitle", 1.0},
|
||||
{"search_participants", 2.0},
|
||||
{"search_normalized", 1.0},
|
||||
},
|
||||
"album": {
|
||||
{"name", 10.0},
|
||||
{"sort_album_name", 1.0},
|
||||
{"album_artist", 3.0},
|
||||
{"search_participants", 2.0},
|
||||
{"discs", 1.0},
|
||||
{"catalog_num", 1.0},
|
||||
{"album_version", 1.0},
|
||||
{"search_normalized", 1.0},
|
||||
},
|
||||
"artist": {
|
||||
{"name", 10.0},
|
||||
{"sort_artist_name", 1.0},
|
||||
{"search_normalized", 1.0},
|
||||
},
|
||||
}
|
||||
|
||||
// ftsColumnFilters and ftsBM25Weights are precomputed from ftsColumnDefs at init time
|
||||
// to avoid per-query allocations.
|
||||
var (
|
||||
ftsColumnFilters = map[string]string{}
|
||||
ftsBM25Weights = map[string]string{}
|
||||
)
|
||||
|
||||
func init() {
|
||||
for table, cols := range ftsColumnDefs {
|
||||
var names []string
|
||||
weights := make([]string, len(cols))
|
||||
for i, c := range cols {
|
||||
if c.Weight > 0 {
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
weights[i] = fmt.Sprintf("%.1f", c.Weight)
|
||||
}
|
||||
wordFilters = append(wordFilters, colFilters)
|
||||
ftsColumnFilters[table] = "{" + strings.Join(names, " ") + "}"
|
||||
ftsBM25Weights[table] = strings.Join(weights, ", ")
|
||||
}
|
||||
log.Trace("Search using LIKE backend", "query", wordFilters, "table", tableName)
|
||||
return wordFilters
|
||||
}
|
||||
|
||||
// ftsSearchColumns defines which FTS5 columns are included in general search.
|
||||
// Columns not listed here are indexed but not searched by default,
|
||||
// enabling future additions (comments, lyrics, bios) without affecting general search.
|
||||
var ftsSearchColumns = map[string]string{
|
||||
"media_file": "{title album artist album_artist sort_title sort_album_name sort_artist_name sort_album_artist_name disc_subtitle search_participants search_normalized}",
|
||||
"album": "{name sort_album_name album_artist search_participants discs catalog_num album_version search_normalized}",
|
||||
"artist": "{name sort_artist_name search_normalized}",
|
||||
// ftsSearch implements searchStrategy using FTS5 full-text search with BM25 ranking.
|
||||
type ftsSearch struct {
|
||||
tableName string
|
||||
ftsTable string
|
||||
matchExpr string
|
||||
rankExpr string
|
||||
}
|
||||
|
||||
// ftsSearchExpr generates an FTS5 MATCH-based search filter.
|
||||
// If the query produces no FTS tokens (e.g., punctuation-only like "!!!!!!!"),
|
||||
// it falls back to LIKE-based search.
|
||||
func ftsSearchExpr(tableName string, s string) Sqlizer {
|
||||
q := buildFTS5Query(s)
|
||||
if q == "" {
|
||||
s = strings.TrimSpace(strings.ReplaceAll(s, `"`, ""))
|
||||
if s != "" {
|
||||
log.Trace("Search using LIKE fallback for non-tokenizable query", "table", tableName, "query", s)
|
||||
return likeSearchExpr(tableName, s)
|
||||
// ToSql returns a single-query fallback for the REST filter path (no two-phase split).
|
||||
func (s *ftsSearch) ToSql() (string, []interface{}, error) {
|
||||
sql := s.tableName + ".rowid IN (SELECT rowid FROM " + s.ftsTable + " WHERE " + s.ftsTable + " MATCH ?)"
|
||||
return sql, []interface{}{s.matchExpr}, nil
|
||||
}
|
||||
|
||||
// execute runs a two-phase FTS5 search:
|
||||
// - Phase 1: lightweight rowid query (main table + FTS + library filter) for ranking and pagination.
|
||||
// - Phase 2: full SELECT with all JOINs, scoped to Phase 1's rowid set.
|
||||
//
|
||||
// Complex ORDER BY (function calls, aggregations) are dropped from Phase 1.
|
||||
func (s *ftsSearch) execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error {
|
||||
qualifiedOrderBys := []string{s.rankExpr}
|
||||
for _, ob := range cfg.OrderBy {
|
||||
if qualified := qualifyOrderBy(s.tableName, ob); qualified != "" {
|
||||
qualifiedOrderBys = append(qualifiedOrderBys, qualified)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1: fresh query — must set LIMIT/OFFSET from options explicitly.
|
||||
// Mirror applyOptions behavior: Max=0 means no limit, not LIMIT 0.
|
||||
rowidQuery := Select(s.tableName+".rowid").
|
||||
From(s.tableName).
|
||||
Join(s.ftsTable+" ON "+s.ftsTable+".rowid = "+s.tableName+".rowid AND "+s.ftsTable+" MATCH ?", s.matchExpr).
|
||||
Where(Eq{s.tableName + ".missing": false}).
|
||||
OrderBy(qualifiedOrderBys...)
|
||||
if options.Max > 0 {
|
||||
rowidQuery = rowidQuery.Limit(uint64(options.Max))
|
||||
}
|
||||
if options.Offset > 0 {
|
||||
rowidQuery = rowidQuery.Offset(uint64(options.Offset))
|
||||
}
|
||||
|
||||
// Library filter + musicFolderId must be applied here, before pagination.
|
||||
if cfg.LibraryFilter != nil {
|
||||
rowidQuery = cfg.LibraryFilter(rowidQuery)
|
||||
} else {
|
||||
rowidQuery = r.applyLibraryFilter(rowidQuery)
|
||||
}
|
||||
if options.Filters != nil {
|
||||
rowidQuery = rowidQuery.Where(options.Filters)
|
||||
}
|
||||
|
||||
rowidSQL, rowidArgs, err := rowidQuery.ToSql()
|
||||
if err != nil {
|
||||
return fmt.Errorf("building FTS rowid query: %w", err)
|
||||
}
|
||||
|
||||
// Phase 2: strip LIMIT/OFFSET from sq (Phase 1 handled pagination),
|
||||
// join on the ranked rowid set to hydrate with full columns.
|
||||
sq = sq.RemoveLimit().RemoveOffset()
|
||||
rankedSubquery := fmt.Sprintf(
|
||||
"(SELECT rowid as _rid, row_number() OVER () AS _rn FROM (%s)) AS _ranked",
|
||||
rowidSQL,
|
||||
)
|
||||
sq = sq.Join(rankedSubquery+" ON "+s.tableName+".rowid = _ranked._rid", rowidArgs...)
|
||||
sq = sq.OrderBy("_ranked._rn")
|
||||
return r.queryAll(sq, dest)
|
||||
}
|
||||
|
||||
// qualifyOrderBy prepends tableName to a simple column name. Returns empty string for
|
||||
// complex expressions (function calls, aggregations) that can't be used in Phase 1.
|
||||
func qualifyOrderBy(tableName, orderBy string) string {
|
||||
orderBy = strings.TrimSpace(orderBy)
|
||||
if orderBy == "" || strings.ContainsAny(orderBy, "(,") {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Fields(orderBy)
|
||||
if !strings.Contains(parts[0], ".") {
|
||||
parts[0] = tableName + "." + parts[0]
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// ftsQueryDegraded returns true when the FTS query lost significant discriminating
|
||||
// content compared to the original input. This happens when special characters that
|
||||
// are part of the entity name (e.g., "1+", "C++", "!!!", "C#") get stripped by FTS
|
||||
// tokenization, leaving only very short/broad tokens. Also detects quoted phrases
|
||||
// that would be degraded by FTS5's unicode61 tokenizer (e.g., "1+" → token "1").
|
||||
func ftsQueryDegraded(original, ftsQuery string) bool {
|
||||
original = strings.TrimSpace(original)
|
||||
if original == "" || ftsQuery == "" {
|
||||
return false
|
||||
}
|
||||
// Strip quotes from original for comparison — we want the raw content
|
||||
stripped := strings.ReplaceAll(original, `"`, "")
|
||||
// Extract the alphanumeric content from the original query
|
||||
alphaNum := fts5PunctStrip.ReplaceAllString(stripped, "")
|
||||
// If the original is entirely alphanumeric, nothing was stripped — not degraded
|
||||
if len(alphaNum) == len(stripped) {
|
||||
return false
|
||||
}
|
||||
// Check if all effective FTS tokens are very short (≤2 chars).
|
||||
// Short tokens with prefix matching are too broad when special chars were stripped.
|
||||
// For quoted phrases, extract the content and check the tokens inside.
|
||||
tokens := strings.Fields(ftsQuery)
|
||||
for _, t := range tokens {
|
||||
t = strings.TrimSuffix(t, "*")
|
||||
// Skip internal phrase placeholders
|
||||
if strings.HasPrefix(t, "\x00") {
|
||||
return false
|
||||
}
|
||||
// For OR groups from processPunctuatedWords (e.g., ("a ha" OR aha*)),
|
||||
// the punctuated word was already handled meaningfully — not degraded.
|
||||
if strings.HasPrefix(t, "(") {
|
||||
return false
|
||||
}
|
||||
// For quoted phrases, check the tokens inside as FTS5 will tokenize them
|
||||
if strings.HasPrefix(t, `"`) {
|
||||
// Extract content between quotes
|
||||
inner := strings.Trim(t, `"`)
|
||||
innerAlpha := fts5PunctStrip.ReplaceAllString(inner, " ")
|
||||
for _, it := range strings.Fields(innerAlpha) {
|
||||
if len(it) > 2 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(t) > 2 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// newFTSSearch creates an FTS5 search strategy. Falls back to LIKE search if the
|
||||
// query produces no FTS tokens (e.g., punctuation-only like "!!!!!!!") or if FTS
|
||||
// tokenization stripped significant content from the query (e.g., "1+" → "1*").
|
||||
// Returns nil when the query produces no searchable tokens at all.
|
||||
func newFTSSearch(tableName, query string) searchStrategy {
|
||||
q := buildFTS5Query(query)
|
||||
if q == "" || ftsQueryDegraded(query, q) {
|
||||
// Fallback: try LIKE search with the raw query
|
||||
cleaned := strings.TrimSpace(strings.ReplaceAll(query, `"`, ""))
|
||||
if cleaned != "" {
|
||||
log.Trace("Search using LIKE fallback for non-tokenizable query", "table", tableName, "query", cleaned)
|
||||
return newLikeSearch(tableName, cleaned)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ftsTable := tableName + "_fts"
|
||||
matchExpr := q
|
||||
if cols, ok := ftsSearchColumns[tableName]; ok {
|
||||
if cols, ok := ftsColumnFilters[tableName]; ok {
|
||||
matchExpr = cols + " : (" + q + ")"
|
||||
}
|
||||
|
||||
filter := Expr(
|
||||
tableName+".rowid IN (SELECT rowid FROM "+ftsTable+" WHERE "+ftsTable+" MATCH ?)",
|
||||
matchExpr,
|
||||
)
|
||||
log.Trace("Search using FTS5 backend", "table", tableName, "query", q, "filter", filter)
|
||||
return filter
|
||||
rankExpr := ftsTable + ".rank"
|
||||
if weights, ok := ftsBM25Weights[tableName]; ok {
|
||||
rankExpr = "bm25(" + ftsTable + ", " + weights + ")"
|
||||
}
|
||||
|
||||
s := &ftsSearch{
|
||||
tableName: tableName,
|
||||
ftsTable: ftsTable,
|
||||
matchExpr: matchExpr,
|
||||
rankExpr: rankExpr,
|
||||
}
|
||||
log.Trace("Search using FTS5 backend", "table", tableName, "query", q, "filter", s)
|
||||
return s
|
||||
}
|
||||
|
||||
@ -3,8 +3,6 @@ package persistence
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@ -52,6 +50,23 @@ var _ = DescribeTable("buildFTS5Query",
|
||||
Entry("returns empty string for empty quoted phrase", `""`, ""),
|
||||
)
|
||||
|
||||
var _ = DescribeTable("ftsQueryDegraded",
|
||||
func(original, ftsQuery string, expected bool) {
|
||||
Expect(ftsQueryDegraded(original, ftsQuery)).To(Equal(expected))
|
||||
},
|
||||
Entry("not degraded for empty original", "", "1*", false),
|
||||
Entry("not degraded for empty ftsQuery", "1+", "", false),
|
||||
Entry("not degraded for purely alphanumeric query", "beatles", "beatles*", false),
|
||||
Entry("not degraded when long tokens remain", "test^val", "test* val*", false),
|
||||
Entry("not degraded for quoted phrase with long tokens", `"the beatles"`, `"the beatles"`, false),
|
||||
Entry("degraded for quoted phrase with only short tokens after tokenizer strips special chars", `"1+"`, `"1+"`, true),
|
||||
Entry("not degraded for quoted phrase with meaningful content", `"C++ programming"`, `"C++ programming"`, false),
|
||||
Entry("degraded when special chars stripped leaving short token", "1+", "1*", true),
|
||||
Entry("degraded when special chars stripped leaving two short tokens", "C# 1", "C* 1*", true),
|
||||
Entry("not degraded when at least one long token remains", "1+ beatles", "1* beatles*", false),
|
||||
Entry("not degraded for OR groups from processPunctuatedWords", "AC/DC", `("AC DC" OR ACDC*)`, false),
|
||||
)
|
||||
|
||||
var _ = DescribeTable("normalizeForFTS",
|
||||
func(expected string, values ...string) {
|
||||
Expect(normalizeForFTS(values...)).To(Equal(expected))
|
||||
@ -81,133 +96,211 @@ var _ = DescribeTable("containsCJK",
|
||||
Entry("detects single CJK character", "a曲b", true),
|
||||
)
|
||||
|
||||
var _ = Describe("likeSearchExpr", func() {
|
||||
It("returns nil for empty query", func() {
|
||||
Expect(likeSearchExpr("media_file", "")).To(BeNil())
|
||||
var _ = DescribeTable("qualifyOrderBy",
|
||||
func(tableName, orderBy, expected string) {
|
||||
Expect(qualifyOrderBy(tableName, orderBy)).To(Equal(expected))
|
||||
},
|
||||
Entry("returns empty string for empty input", "artist", "", ""),
|
||||
Entry("qualifies simple column with table name", "artist", "name", "artist.name"),
|
||||
Entry("qualifies column with direction", "artist", "name desc", "artist.name desc"),
|
||||
Entry("preserves already-qualified column", "artist", "artist.name", "artist.name"),
|
||||
Entry("preserves already-qualified column with direction", "artist", "artist.name desc", "artist.name desc"),
|
||||
Entry("returns empty for function call expression", "artist", "sum(json_extract(stats, '$.total.m')) desc", ""),
|
||||
Entry("returns empty for expression with comma", "artist", "a, b", ""),
|
||||
Entry("qualifies media_file column", "media_file", "title", "media_file.title"),
|
||||
)
|
||||
|
||||
var _ = Describe("ftsColumnDefs helpers", func() {
|
||||
Describe("ftsColumnFilters", func() {
|
||||
It("returns column filter for media_file", func() {
|
||||
Expect(ftsColumnFilters).To(HaveKeyWithValue("media_file",
|
||||
"{title album artist album_artist sort_title sort_album_name sort_artist_name sort_album_artist_name disc_subtitle search_participants search_normalized}",
|
||||
))
|
||||
})
|
||||
|
||||
It("returns column filter for album", func() {
|
||||
Expect(ftsColumnFilters).To(HaveKeyWithValue("album",
|
||||
"{name sort_album_name album_artist search_participants discs catalog_num album_version search_normalized}",
|
||||
))
|
||||
})
|
||||
|
||||
It("returns column filter for artist", func() {
|
||||
Expect(ftsColumnFilters).To(HaveKeyWithValue("artist",
|
||||
"{name sort_artist_name search_normalized}",
|
||||
))
|
||||
})
|
||||
|
||||
It("has no entry for unknown table", func() {
|
||||
Expect(ftsColumnFilters).ToNot(HaveKey("unknown"))
|
||||
})
|
||||
})
|
||||
|
||||
It("returns nil for whitespace-only query", func() {
|
||||
Expect(likeSearchExpr("media_file", " ")).To(BeNil())
|
||||
Describe("ftsBM25Weights", func() {
|
||||
It("returns weight CSV for media_file", func() {
|
||||
Expect(ftsBM25Weights).To(HaveKeyWithValue("media_file",
|
||||
"10.0, 5.0, 3.0, 3.0, 1.0, 1.0, 1.0, 1.0, 1.0, 2.0, 1.0",
|
||||
))
|
||||
})
|
||||
|
||||
It("returns weight CSV for album", func() {
|
||||
Expect(ftsBM25Weights).To(HaveKeyWithValue("album",
|
||||
"10.0, 1.0, 3.0, 2.0, 1.0, 1.0, 1.0, 1.0",
|
||||
))
|
||||
})
|
||||
|
||||
It("returns weight CSV for artist", func() {
|
||||
Expect(ftsBM25Weights).To(HaveKeyWithValue("artist",
|
||||
"10.0, 1.0, 1.0",
|
||||
))
|
||||
})
|
||||
|
||||
It("has no entry for unknown table", func() {
|
||||
Expect(ftsBM25Weights).ToNot(HaveKey("unknown"))
|
||||
})
|
||||
})
|
||||
|
||||
It("generates LIKE filters against core columns for single CJK word", func() {
|
||||
expr := likeSearchExpr("media_file", "周杰伦")
|
||||
sql, args, err := expr.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should have OR between columns for the single word
|
||||
Expect(sql).To(ContainSubstring("OR"))
|
||||
Expect(sql).To(ContainSubstring("media_file.title LIKE"))
|
||||
Expect(sql).To(ContainSubstring("media_file.album LIKE"))
|
||||
Expect(sql).To(ContainSubstring("media_file.artist LIKE"))
|
||||
Expect(sql).To(ContainSubstring("media_file.album_artist LIKE"))
|
||||
Expect(args).To(HaveLen(4))
|
||||
for _, arg := range args {
|
||||
Expect(arg).To(Equal("%周杰伦%"))
|
||||
It("has definitions for all known tables", func() {
|
||||
for _, table := range []string{"media_file", "album", "artist"} {
|
||||
Expect(ftsColumnDefs).To(HaveKey(table))
|
||||
Expect(ftsColumnDefs[table]).ToNot(BeEmpty())
|
||||
}
|
||||
})
|
||||
|
||||
It("generates AND of OR groups for multi-word query", func() {
|
||||
expr := likeSearchExpr("media_file", "周杰伦 greatest")
|
||||
sql, args, err := expr.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Two groups AND'd together, each with 4 columns OR'd
|
||||
Expect(sql).To(ContainSubstring("AND"))
|
||||
Expect(args).To(HaveLen(8))
|
||||
})
|
||||
|
||||
It("uses correct columns for album table", func() {
|
||||
expr := likeSearchExpr("album", "周杰伦")
|
||||
sql, args, err := expr.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("album.name LIKE"))
|
||||
Expect(sql).To(ContainSubstring("album.album_artist LIKE"))
|
||||
Expect(args).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("uses correct columns for artist table", func() {
|
||||
expr := likeSearchExpr("artist", "周杰伦")
|
||||
sql, args, err := expr.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("artist.name LIKE"))
|
||||
Expect(args).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("returns nil for unknown table", func() {
|
||||
Expect(likeSearchExpr("unknown_table", "周杰伦")).To(BeNil())
|
||||
It("has matching column count between filter and weights", func() {
|
||||
for table, cols := range ftsColumnDefs {
|
||||
// Column filter only includes Weight > 0 columns
|
||||
filterCount := 0
|
||||
for _, c := range cols {
|
||||
if c.Weight > 0 {
|
||||
filterCount++
|
||||
}
|
||||
}
|
||||
// For now, all columns have Weight > 0, so filter count == total count
|
||||
Expect(filterCount).To(Equal(len(cols)), "table %s: all columns should have positive weights", table)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("ftsSearchExpr", func() {
|
||||
var _ = Describe("newFTSSearch", func() {
|
||||
It("returns nil for empty query", func() {
|
||||
Expect(ftsSearchExpr("media_file", "")).To(BeNil())
|
||||
Expect(newFTSSearch("media_file", "")).To(BeNil())
|
||||
})
|
||||
|
||||
It("generates rowid IN subquery with MATCH and column filter", func() {
|
||||
expr := ftsSearchExpr("media_file", "beatles")
|
||||
sql, args, err := expr.ToSql()
|
||||
It("returns non-nil for single-character query", func() {
|
||||
strategy := newFTSSearch("media_file", "a")
|
||||
Expect(strategy).ToNot(BeNil(), "single-char queries must not be rejected; min-length is enforced in doSearch, not here")
|
||||
sql, _, err := strategy.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("MATCH"))
|
||||
})
|
||||
|
||||
It("returns ftsSearch with correct table names and MATCH expression", func() {
|
||||
strategy := newFTSSearch("media_file", "beatles")
|
||||
fts, ok := strategy.(*ftsSearch)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(fts.tableName).To(Equal("media_file"))
|
||||
Expect(fts.ftsTable).To(Equal("media_file_fts"))
|
||||
Expect(fts.matchExpr).To(HavePrefix("{title album artist album_artist"))
|
||||
Expect(fts.matchExpr).To(ContainSubstring("beatles*"))
|
||||
})
|
||||
|
||||
It("ToSql generates rowid IN subquery with MATCH (fallback path)", func() {
|
||||
strategy := newFTSSearch("media_file", "beatles")
|
||||
sql, args, err := strategy.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("media_file.rowid IN"))
|
||||
Expect(sql).To(ContainSubstring("media_file_fts"))
|
||||
Expect(sql).To(ContainSubstring("MATCH"))
|
||||
Expect(args).To(HaveLen(1))
|
||||
Expect(args[0]).To(HavePrefix("{title album artist album_artist"))
|
||||
Expect(args[0]).To(ContainSubstring("beatles*"))
|
||||
})
|
||||
|
||||
It("generates correct FTS table name per entity", func() {
|
||||
for _, table := range []string{"media_file", "album", "artist"} {
|
||||
expr := ftsSearchExpr(table, "test")
|
||||
sql, _, err := expr.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring(table + ".rowid IN"))
|
||||
Expect(sql).To(ContainSubstring(table + "_fts"))
|
||||
strategy := newFTSSearch(table, "test")
|
||||
fts, ok := strategy.(*ftsSearch)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(fts.tableName).To(Equal(table))
|
||||
Expect(fts.ftsTable).To(Equal(table + "_fts"))
|
||||
}
|
||||
})
|
||||
|
||||
It("builds bm25() rank expression with column weights", func() {
|
||||
strategy := newFTSSearch("media_file", "beatles")
|
||||
fts, ok := strategy.(*ftsSearch)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(fts.rankExpr).To(HavePrefix("bm25(media_file_fts,"))
|
||||
Expect(fts.rankExpr).To(ContainSubstring("10.0"))
|
||||
|
||||
strategy = newFTSSearch("artist", "beatles")
|
||||
fts, ok = strategy.(*ftsSearch)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(fts.rankExpr).To(HavePrefix("bm25(artist_fts,"))
|
||||
})
|
||||
|
||||
It("falls back to ftsTable.rank for unknown tables", func() {
|
||||
strategy := newFTSSearch("unknown_table", "test")
|
||||
fts, ok := strategy.(*ftsSearch)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(fts.rankExpr).To(Equal("unknown_table_fts.rank"))
|
||||
})
|
||||
|
||||
It("wraps query with column filter for known tables", func() {
|
||||
expr := ftsSearchExpr("artist", "Beatles")
|
||||
_, args, err := expr.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(args[0]).To(Equal("{name sort_artist_name search_normalized} : (Beatles*)"))
|
||||
strategy := newFTSSearch("artist", "Beatles")
|
||||
fts, ok := strategy.(*ftsSearch)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(fts.matchExpr).To(Equal("{name sort_artist_name search_normalized} : (Beatles*)"))
|
||||
})
|
||||
|
||||
It("passes query without column filter for unknown tables", func() {
|
||||
expr := ftsSearchExpr("unknown_table", "test")
|
||||
_, args, err := expr.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(args[0]).To(Equal("test*"))
|
||||
strategy := newFTSSearch("unknown_table", "test")
|
||||
fts, ok := strategy.(*ftsSearch)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(fts.matchExpr).To(Equal("test*"))
|
||||
})
|
||||
|
||||
It("preserves phrase queries inside column filter", func() {
|
||||
expr := ftsSearchExpr("media_file", `"the beatles"`)
|
||||
_, args, err := expr.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(args[0]).To(ContainSubstring(`"the beatles"`))
|
||||
strategy := newFTSSearch("media_file", `"the beatles"`)
|
||||
fts, ok := strategy.(*ftsSearch)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(fts.matchExpr).To(ContainSubstring(`"the beatles"`))
|
||||
})
|
||||
|
||||
It("preserves prefix queries inside column filter", func() {
|
||||
expr := ftsSearchExpr("media_file", "beat*")
|
||||
_, args, err := expr.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(args[0]).To(ContainSubstring("beat*"))
|
||||
strategy := newFTSSearch("media_file", "beat*")
|
||||
fts, ok := strategy.(*ftsSearch)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(fts.matchExpr).To(ContainSubstring("beat*"))
|
||||
})
|
||||
|
||||
It("falls back to LIKE search for punctuation-only query", func() {
|
||||
expr := ftsSearchExpr("media_file", "!!!!!!!")
|
||||
Expect(expr).ToNot(BeNil())
|
||||
sql, args, err := expr.ToSql()
|
||||
strategy := newFTSSearch("media_file", "!!!!!!!")
|
||||
Expect(strategy).ToNot(BeNil())
|
||||
_, ok := strategy.(*ftsSearch)
|
||||
Expect(ok).To(BeFalse(), "punctuation-only should fall back to LIKE, not FTS")
|
||||
sql, args, err := strategy.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("LIKE"))
|
||||
Expect(args).To(ContainElement("%!!!!!!!%"))
|
||||
})
|
||||
|
||||
It("falls back to LIKE search for degraded query (special chars stripped leaving short tokens)", func() {
|
||||
strategy := newFTSSearch("album", "1+")
|
||||
Expect(strategy).ToNot(BeNil())
|
||||
_, ok := strategy.(*ftsSearch)
|
||||
Expect(ok).To(BeFalse(), "degraded query should fall back to LIKE, not FTS")
|
||||
sql, args, err := strategy.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("LIKE"))
|
||||
Expect(args).To(ContainElement("%1+%"))
|
||||
})
|
||||
|
||||
It("returns nil for empty string even with LIKE fallback", func() {
|
||||
Expect(ftsSearchExpr("media_file", "")).To(BeNil())
|
||||
Expect(ftsSearchExpr("media_file", " ")).To(BeNil())
|
||||
Expect(newFTSSearch("media_file", "")).To(BeNil())
|
||||
Expect(newFTSSearch("media_file", " ")).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns nil for empty quoted phrase", func() {
|
||||
Expect(ftsSearchExpr("media_file", `""`)).To(BeNil())
|
||||
Expect(newFTSSearch("media_file", `""`)).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
@ -229,7 +322,7 @@ var _ = Describe("FTS5 Integration Search", func() {
|
||||
|
||||
Describe("MediaFile search", func() {
|
||||
It("finds media files by title", func() {
|
||||
results, err := mr.Search("Radioactivity", 0, 10)
|
||||
results, err := mr.Search("Radioactivity", model.QueryOptions{Max: 10})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].Title).To(Equal("Radioactivity"))
|
||||
@ -237,7 +330,7 @@ var _ = Describe("FTS5 Integration Search", func() {
|
||||
})
|
||||
|
||||
It("finds media files by artist name", func() {
|
||||
results, err := mr.Search("Beatles", 0, 10)
|
||||
results, err := mr.Search("Beatles", model.QueryOptions{Max: 10})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(3))
|
||||
for _, r := range results {
|
||||
@ -248,7 +341,7 @@ var _ = Describe("FTS5 Integration Search", func() {
|
||||
|
||||
Describe("Album search", func() {
|
||||
It("finds albums by name", func() {
|
||||
results, err := alr.Search("Sgt Peppers", 0, 10)
|
||||
results, err := alr.Search("Sgt Peppers", model.QueryOptions{Max: 10})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].Name).To(Equal("Sgt Peppers"))
|
||||
@ -256,7 +349,7 @@ var _ = Describe("FTS5 Integration Search", func() {
|
||||
})
|
||||
|
||||
It("finds albums with multi-word search", func() {
|
||||
results, err := alr.Search("Abbey Road", 0, 10)
|
||||
results, err := alr.Search("Abbey Road", model.QueryOptions{Max: 10})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(2))
|
||||
})
|
||||
@ -264,7 +357,7 @@ var _ = Describe("FTS5 Integration Search", func() {
|
||||
|
||||
Describe("Artist search", func() {
|
||||
It("finds artists by name", func() {
|
||||
results, err := arr.Search("Kraftwerk", 0, 10)
|
||||
results, err := arr.Search("Kraftwerk", model.QueryOptions{Max: 10})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].Name).To(Equal("Kraftwerk"))
|
||||
@ -274,7 +367,7 @@ var _ = Describe("FTS5 Integration Search", func() {
|
||||
|
||||
Describe("CJK search", func() {
|
||||
It("finds media files by CJK title", func() {
|
||||
results, err := mr.Search("プラチナ", 0, 10)
|
||||
results, err := mr.Search("プラチナ", model.QueryOptions{Max: 10})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].Title).To(Equal("プラチナ・ジェット"))
|
||||
@ -282,14 +375,14 @@ var _ = Describe("FTS5 Integration Search", func() {
|
||||
})
|
||||
|
||||
It("finds media files by CJK artist name", func() {
|
||||
results, err := mr.Search("シートベルツ", 0, 10)
|
||||
results, err := mr.Search("シートベルツ", model.QueryOptions{Max: 10})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].Artist).To(Equal("シートベルツ"))
|
||||
})
|
||||
|
||||
It("finds albums by CJK artist name", func() {
|
||||
results, err := alr.Search("シートベルツ", 0, 10)
|
||||
results, err := alr.Search("シートベルツ", model.QueryOptions{Max: 10})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].Name).To(Equal("COWBOY BEBOP"))
|
||||
@ -297,7 +390,7 @@ var _ = Describe("FTS5 Integration Search", func() {
|
||||
})
|
||||
|
||||
It("finds artists by CJK name", func() {
|
||||
results, err := arr.Search("シートベルツ", 0, 10)
|
||||
results, err := arr.Search("シートベルツ", model.QueryOptions{Max: 10})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].Name).To(Equal("シートベルツ"))
|
||||
@ -307,7 +400,7 @@ var _ = Describe("FTS5 Integration Search", func() {
|
||||
|
||||
Describe("Album version search", func() {
|
||||
It("finds albums by version tag via FTS", func() {
|
||||
results, err := alr.Search("Deluxe", 0, 10)
|
||||
results, err := alr.Search("Deluxe", model.QueryOptions{Max: 10})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].ID).To(Equal(albumWithVersion.ID))
|
||||
@ -316,7 +409,7 @@ var _ = Describe("FTS5 Integration Search", func() {
|
||||
|
||||
Describe("Punctuation-only search", func() {
|
||||
It("finds media files with punctuation-only title", func() {
|
||||
results, err := mr.Search("!!!!!!!", 0, 10)
|
||||
results, err := mr.Search("!!!!!!!", model.QueryOptions{Max: 10})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].Title).To(Equal("!!!!!!!"))
|
||||
@ -324,15 +417,19 @@ var _ = Describe("FTS5 Integration Search", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Legacy backend fallback", func() {
|
||||
It("returns results using legacy LIKE-based search when configured", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Search.Backend = "legacy"
|
||||
|
||||
results, err := mr.Search("Radioactivity", 0, 10)
|
||||
Describe("Single-character search (doSearch min-length guard)", func() {
|
||||
It("returns empty results for single-char query via Search", func() {
|
||||
results, err := mr.Search("a", model.QueryOptions{Max: 10})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].Title).To(Equal("Radioactivity"))
|
||||
Expect(results).To(BeEmpty(), "doSearch should reject single-char queries")
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Max=0 means no limit (regression: must not produce LIMIT 0)", func() {
|
||||
It("returns results with Max=0", func() {
|
||||
results, err := mr.Search("Beatles", model.QueryOptions{Max: 0})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).ToNot(BeEmpty(), "Max=0 should mean no limit, not LIMIT 0")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
106
persistence/sql_search_like.go
Normal file
106
persistence/sql_search_like.go
Normal file
@ -0,0 +1,106 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
// likeSearch implements searchStrategy using LIKE-based SQL filters.
|
||||
// Used for legacy full_text searches, CJK fallback, and punctuation-only fallback.
|
||||
type likeSearch struct {
|
||||
filter Sqlizer
|
||||
}
|
||||
|
||||
func (s *likeSearch) ToSql() (string, []interface{}, error) {
|
||||
return s.filter.ToSql()
|
||||
}
|
||||
|
||||
func (s *likeSearch) execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error {
|
||||
sq = sq.Where(s.filter)
|
||||
sq = sq.OrderBy(cfg.OrderBy...)
|
||||
return r.queryAll(sq, dest, options)
|
||||
}
|
||||
|
||||
// newLegacySearch creates a LIKE search against the full_text column.
|
||||
// Returns nil when the query produces no searchable tokens.
|
||||
func newLegacySearch(tableName, query string) searchStrategy {
|
||||
filter := legacySearchExpr(tableName, query)
|
||||
if filter == nil {
|
||||
return nil
|
||||
}
|
||||
return &likeSearch{filter: filter}
|
||||
}
|
||||
|
||||
// newLikeSearch creates a LIKE search against core entity columns (CJK, punctuation fallback).
|
||||
// No minimum length is enforced, since single CJK characters are meaningful words.
|
||||
// Returns nil when the query produces no searchable tokens.
|
||||
func newLikeSearch(tableName, query string) searchStrategy {
|
||||
filter := likeSearchExpr(tableName, query)
|
||||
if filter == nil {
|
||||
return nil
|
||||
}
|
||||
return &likeSearch{filter: filter}
|
||||
}
|
||||
|
||||
// legacySearchExpr generates LIKE-based search filters against the full_text column.
|
||||
// This is the original search implementation, used when Search.Backend="legacy".
|
||||
func legacySearchExpr(tableName string, s string) Sqlizer {
|
||||
q := str.SanitizeStrings(s)
|
||||
if q == "" {
|
||||
log.Trace("Search using legacy backend, query is empty", "table", tableName)
|
||||
return nil
|
||||
}
|
||||
var sep string
|
||||
if !conf.Server.Search.FullString {
|
||||
sep = " "
|
||||
}
|
||||
parts := strings.Split(q, " ")
|
||||
filters := And{}
|
||||
for _, part := range parts {
|
||||
filters = append(filters, Like{tableName + ".full_text": "%" + sep + part + "%"})
|
||||
}
|
||||
log.Trace("Search using legacy backend", "query", filters, "table", tableName)
|
||||
return filters
|
||||
}
|
||||
|
||||
// likeSearchColumns defines the core columns to search with LIKE queries.
|
||||
// These are the primary user-visible fields for each entity type.
|
||||
// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input).
|
||||
var likeSearchColumns = map[string][]string{
|
||||
"media_file": {"title", "album", "artist", "album_artist"},
|
||||
"album": {"name", "album_artist"},
|
||||
"artist": {"name"},
|
||||
}
|
||||
|
||||
// likeSearchExpr generates LIKE-based search filters against core columns.
|
||||
// Each word in the query must match at least one column (AND between words),
|
||||
// and each word can match any column (OR within a word).
|
||||
// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input).
|
||||
func likeSearchExpr(tableName string, s string) Sqlizer {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
log.Trace("Search using LIKE backend, query is empty", "table", tableName)
|
||||
return nil
|
||||
}
|
||||
columns, ok := likeSearchColumns[tableName]
|
||||
if !ok {
|
||||
log.Trace("Search using LIKE backend, couldn't find columns for this table", "table", tableName)
|
||||
return nil
|
||||
}
|
||||
words := strings.Fields(s)
|
||||
wordFilters := And{}
|
||||
for _, word := range words {
|
||||
colFilters := Or{}
|
||||
for _, col := range columns {
|
||||
colFilters = append(colFilters, Like{tableName + "." + col: "%" + word + "%"})
|
||||
}
|
||||
wordFilters = append(wordFilters, colFilters)
|
||||
}
|
||||
log.Trace("Search using LIKE backend", "query", wordFilters, "table", tableName)
|
||||
return wordFilters
|
||||
}
|
||||
134
persistence/sql_search_like_test.go
Normal file
134
persistence/sql_search_like_test.go
Normal file
@ -0,0 +1,134 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("newLegacySearch", func() {
|
||||
It("returns non-nil for single-character query", func() {
|
||||
strategy := newLegacySearch("media_file", "a")
|
||||
Expect(strategy).ToNot(BeNil(), "single-char queries must not be rejected; min-length is enforced in doSearch, not here")
|
||||
sql, _, err := strategy.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("LIKE"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("legacySearchExpr", func() {
|
||||
It("returns nil for empty query", func() {
|
||||
Expect(legacySearchExpr("media_file", "")).To(BeNil())
|
||||
})
|
||||
|
||||
It("generates LIKE filter for single word", func() {
|
||||
expr := legacySearchExpr("media_file", "beatles")
|
||||
sql, args, err := expr.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("media_file.full_text LIKE"))
|
||||
Expect(args).To(ContainElement("% beatles%"))
|
||||
})
|
||||
|
||||
It("generates AND of LIKE filters for multiple words", func() {
|
||||
expr := legacySearchExpr("media_file", "abbey road")
|
||||
sql, args, err := expr.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("AND"))
|
||||
Expect(args).To(HaveLen(2))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("likeSearchExpr", func() {
|
||||
It("returns nil for empty query", func() {
|
||||
Expect(likeSearchExpr("media_file", "")).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns nil for whitespace-only query", func() {
|
||||
Expect(likeSearchExpr("media_file", " ")).To(BeNil())
|
||||
})
|
||||
|
||||
It("generates LIKE filters against core columns for single CJK word", func() {
|
||||
expr := likeSearchExpr("media_file", "周杰伦")
|
||||
sql, args, err := expr.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should have OR between columns for the single word
|
||||
Expect(sql).To(ContainSubstring("OR"))
|
||||
Expect(sql).To(ContainSubstring("media_file.title LIKE"))
|
||||
Expect(sql).To(ContainSubstring("media_file.album LIKE"))
|
||||
Expect(sql).To(ContainSubstring("media_file.artist LIKE"))
|
||||
Expect(sql).To(ContainSubstring("media_file.album_artist LIKE"))
|
||||
Expect(args).To(HaveLen(4))
|
||||
for _, arg := range args {
|
||||
Expect(arg).To(Equal("%周杰伦%"))
|
||||
}
|
||||
})
|
||||
|
||||
It("generates AND of OR groups for multi-word query", func() {
|
||||
expr := likeSearchExpr("media_file", "周杰伦 greatest")
|
||||
sql, args, err := expr.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Two groups AND'd together, each with 4 columns OR'd
|
||||
Expect(sql).To(ContainSubstring("AND"))
|
||||
Expect(args).To(HaveLen(8))
|
||||
})
|
||||
|
||||
It("uses correct columns for album table", func() {
|
||||
expr := likeSearchExpr("album", "周杰伦")
|
||||
sql, args, err := expr.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("album.name LIKE"))
|
||||
Expect(sql).To(ContainSubstring("album.album_artist LIKE"))
|
||||
Expect(args).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("uses correct columns for artist table", func() {
|
||||
expr := likeSearchExpr("artist", "周杰伦")
|
||||
sql, args, err := expr.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("artist.name LIKE"))
|
||||
Expect(args).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("returns nil for unknown table", func() {
|
||||
Expect(likeSearchExpr("unknown_table", "周杰伦")).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Legacy Integration Search", func() {
|
||||
var mr model.MediaFileRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Search.Backend = "legacy"
|
||||
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, adminUser)
|
||||
conn := GetDBXBuilder()
|
||||
mr = NewMediaFileRepository(ctx, conn)
|
||||
})
|
||||
|
||||
It("returns results using legacy LIKE-based search", func() {
|
||||
results, err := mr.Search("Radioactivity", model.QueryOptions{Max: 10})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].Title).To(Equal("Radioactivity"))
|
||||
})
|
||||
|
||||
It("returns empty results for single-char query (doSearch min-length guard)", func() {
|
||||
results, err := mr.Search("a", model.QueryOptions{Max: 10})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(BeEmpty(), "doSearch should reject single-char queries")
|
||||
})
|
||||
|
||||
It("returns results with Max=0 (regression: must not produce LIMIT 0)", func() {
|
||||
results, err := mr.Search("Beatles", model.QueryOptions{Max: 0})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).ToNot(BeEmpty(), "Max=0 should mean no limit, not LIMIT 0")
|
||||
})
|
||||
})
|
||||
@ -14,98 +14,99 @@ var _ = Describe("sqlRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("legacySearchExpr", func() {
|
||||
It("returns nil for empty query", func() {
|
||||
Expect(legacySearchExpr("media_file", "")).To(BeNil())
|
||||
})
|
||||
|
||||
It("generates LIKE filter for single word", func() {
|
||||
expr := legacySearchExpr("media_file", "beatles")
|
||||
sql, args, err := expr.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("media_file.full_text LIKE"))
|
||||
Expect(args).To(ContainElement("% beatles%"))
|
||||
})
|
||||
|
||||
It("generates AND of LIKE filters for multiple words", func() {
|
||||
expr := legacySearchExpr("media_file", "abbey road")
|
||||
sql, args, err := expr.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("AND"))
|
||||
Expect(args).To(HaveLen(2))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("getSearchExpr", func() {
|
||||
It("returns ftsSearchExpr by default", func() {
|
||||
Describe("getSearchStrategy", func() {
|
||||
It("returns FTS strategy by default", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Search.Backend = "fts"
|
||||
conf.Server.Search.FullString = false
|
||||
|
||||
expr := getSearchExpr()("media_file", "test")
|
||||
sql, _, err := expr.ToSql()
|
||||
strategy := getSearchStrategy("media_file", "test")
|
||||
Expect(strategy).ToNot(BeNil())
|
||||
sql, _, err := strategy.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("MATCH"))
|
||||
})
|
||||
|
||||
It("returns legacySearchExpr when SearchBackend is legacy", func() {
|
||||
It("returns legacy LIKE strategy when SearchBackend is legacy", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Search.Backend = "legacy"
|
||||
conf.Server.Search.FullString = false
|
||||
|
||||
expr := getSearchExpr()("media_file", "test")
|
||||
sql, _, err := expr.ToSql()
|
||||
strategy := getSearchStrategy("media_file", "test")
|
||||
Expect(strategy).ToNot(BeNil())
|
||||
sql, _, err := strategy.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("LIKE"))
|
||||
})
|
||||
|
||||
It("falls back to legacySearchExpr when SearchFullString is enabled", func() {
|
||||
It("falls back to legacy LIKE strategy when SearchFullString is enabled", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Search.Backend = "fts"
|
||||
conf.Server.Search.FullString = true
|
||||
|
||||
expr := getSearchExpr()("media_file", "test")
|
||||
sql, _, err := expr.ToSql()
|
||||
strategy := getSearchStrategy("media_file", "test")
|
||||
Expect(strategy).ToNot(BeNil())
|
||||
sql, _, err := strategy.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("LIKE"))
|
||||
})
|
||||
|
||||
It("routes CJK queries to likeSearchExpr instead of ftsSearchExpr", func() {
|
||||
It("routes CJK queries to LIKE strategy instead of FTS", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Search.Backend = "fts"
|
||||
conf.Server.Search.FullString = false
|
||||
|
||||
expr := getSearchExpr()("media_file", "周杰伦")
|
||||
sql, _, err := expr.ToSql()
|
||||
strategy := getSearchStrategy("media_file", "周杰伦")
|
||||
Expect(strategy).ToNot(BeNil())
|
||||
sql, _, err := strategy.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// CJK should use LIKE, not MATCH
|
||||
Expect(sql).To(ContainSubstring("LIKE"))
|
||||
Expect(sql).NotTo(ContainSubstring("MATCH"))
|
||||
})
|
||||
|
||||
It("routes non-CJK queries to ftsSearchExpr", func() {
|
||||
It("routes non-CJK queries to FTS strategy", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Search.Backend = "fts"
|
||||
conf.Server.Search.FullString = false
|
||||
|
||||
expr := getSearchExpr()("media_file", "beatles")
|
||||
sql, _, err := expr.ToSql()
|
||||
strategy := getSearchStrategy("media_file", "beatles")
|
||||
Expect(strategy).ToNot(BeNil())
|
||||
sql, _, err := strategy.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("MATCH"))
|
||||
})
|
||||
|
||||
It("returns non-nil for single-character query (no min-length in strategy)", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Search.Backend = "fts"
|
||||
conf.Server.Search.FullString = false
|
||||
|
||||
strategy := getSearchStrategy("media_file", "a")
|
||||
Expect(strategy).ToNot(BeNil(), "single-char queries must be accepted by strategies (min-length is enforced in doSearch)")
|
||||
})
|
||||
|
||||
It("returns non-nil for single-character query with legacy backend", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Search.Backend = "legacy"
|
||||
conf.Server.Search.FullString = false
|
||||
|
||||
strategy := getSearchStrategy("media_file", "a")
|
||||
Expect(strategy).ToNot(BeNil(), "single-char queries must be accepted by legacy strategy (min-length is enforced in doSearch)")
|
||||
})
|
||||
|
||||
It("uses legacy for CJK when SearchBackend is legacy", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Search.Backend = "legacy"
|
||||
conf.Server.Search.FullString = false
|
||||
|
||||
expr := getSearchExpr()("media_file", "周杰伦")
|
||||
sql, _, err := expr.ToSql()
|
||||
strategy := getSearchStrategy("media_file", "周杰伦")
|
||||
Expect(strategy).ToNot(BeNil())
|
||||
sql, _, err := strategy.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Legacy should still use full_text column LIKE
|
||||
Expect(sql).To(ContainSubstring("LIKE"))
|
||||
Expect(sql).To(ContainSubstring("full_text"))
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
26
plugins/capabilities/lyrics.go
Normal file
26
plugins/capabilities/lyrics.go
Normal file
@ -0,0 +1,26 @@
|
||||
package capabilities
|
||||
|
||||
// Lyrics provides lyrics for a given track from external sources.
|
||||
//
|
||||
//nd:capability name=lyrics required=true
|
||||
type Lyrics interface {
|
||||
//nd:export name=nd_lyrics_get_lyrics
|
||||
GetLyrics(GetLyricsRequest) (GetLyricsResponse, error)
|
||||
}
|
||||
|
||||
// GetLyricsRequest contains the track information for lyrics lookup.
|
||||
type GetLyricsRequest struct {
|
||||
Track TrackInfo `json:"track"`
|
||||
}
|
||||
|
||||
// GetLyricsResponse contains the lyrics returned by the plugin.
|
||||
type GetLyricsResponse struct {
|
||||
Lyrics []LyricsText `json:"lyrics"`
|
||||
}
|
||||
|
||||
// LyricsText represents a single set of lyrics in raw text format.
|
||||
// Text can be plain text or LRC format — Navidrome will parse it.
|
||||
type LyricsText struct {
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
115
plugins/capabilities/lyrics.yaml
Normal file
115
plugins/capabilities/lyrics.yaml
Normal file
@ -0,0 +1,115 @@
|
||||
version: v1-draft
|
||||
exports:
|
||||
nd_lyrics_get_lyrics:
|
||||
input:
|
||||
$ref: '#/components/schemas/GetLyricsRequest'
|
||||
contentType: application/json
|
||||
output:
|
||||
$ref: '#/components/schemas/GetLyricsResponse'
|
||||
contentType: application/json
|
||||
components:
|
||||
schemas:
|
||||
ArtistRef:
|
||||
description: ArtistRef is a reference to an artist with name and optional MBID.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: ID is the internal Navidrome artist ID (if known).
|
||||
name:
|
||||
type: string
|
||||
description: Name is the artist name.
|
||||
mbid:
|
||||
type: string
|
||||
description: MBID is the MusicBrainz ID for the artist.
|
||||
required:
|
||||
- name
|
||||
GetLyricsRequest:
|
||||
description: GetLyricsRequest contains the track information for lyrics lookup.
|
||||
properties:
|
||||
track:
|
||||
$ref: '#/components/schemas/TrackInfo'
|
||||
required:
|
||||
- track
|
||||
GetLyricsResponse:
|
||||
description: GetLyricsResponse contains the lyrics returned by the plugin.
|
||||
properties:
|
||||
lyrics:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/LyricsText'
|
||||
required:
|
||||
- lyrics
|
||||
LyricsText:
|
||||
description: |-
|
||||
LyricsText represents a single set of lyrics in raw text format.
|
||||
Text can be plain text or LRC format — Navidrome will parse it.
|
||||
properties:
|
||||
lang:
|
||||
type: string
|
||||
text:
|
||||
type: string
|
||||
required:
|
||||
- text
|
||||
TrackInfo:
|
||||
description: TrackInfo contains track metadata.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: ID is the internal Navidrome track ID.
|
||||
title:
|
||||
type: string
|
||||
description: Title is the track title.
|
||||
album:
|
||||
type: string
|
||||
description: Album is the album name.
|
||||
artist:
|
||||
type: string
|
||||
description: Artist is the formatted artist name for display (e.g., "Artist1 • Artist2").
|
||||
albumArtist:
|
||||
type: string
|
||||
description: AlbumArtist is the formatted album artist name for display.
|
||||
artists:
|
||||
type: array
|
||||
description: Artists is the list of track artists.
|
||||
items:
|
||||
$ref: '#/components/schemas/ArtistRef'
|
||||
albumArtists:
|
||||
type: array
|
||||
description: AlbumArtists is the list of album artists.
|
||||
items:
|
||||
$ref: '#/components/schemas/ArtistRef'
|
||||
duration:
|
||||
type: number
|
||||
format: float
|
||||
description: Duration is the track duration in seconds.
|
||||
trackNumber:
|
||||
type: integer
|
||||
format: int32
|
||||
description: TrackNumber is the track number on the album.
|
||||
discNumber:
|
||||
type: integer
|
||||
format: int32
|
||||
description: DiscNumber is the disc number.
|
||||
mbzRecordingId:
|
||||
type: string
|
||||
description: MBZRecordingID is the MusicBrainz recording ID.
|
||||
mbzAlbumId:
|
||||
type: string
|
||||
description: MBZAlbumID is the MusicBrainz album/release ID.
|
||||
mbzReleaseGroupId:
|
||||
type: string
|
||||
description: MBZReleaseGroupID is the MusicBrainz release group ID.
|
||||
mbzReleaseTrackId:
|
||||
type: string
|
||||
description: MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
required:
|
||||
- id
|
||||
- title
|
||||
- album
|
||||
- artist
|
||||
- albumArtist
|
||||
- artists
|
||||
- albumArtists
|
||||
- duration
|
||||
- trackNumber
|
||||
- discNumber
|
||||
@ -38,7 +38,7 @@ type ArtistRef struct {
|
||||
MBID string `json:"mbid,omitempty"`
|
||||
}
|
||||
|
||||
// TrackInfo contains track metadata for scrobbling.
|
||||
// TrackInfo contains track metadata.
|
||||
type TrackInfo struct {
|
||||
// ID is the internal Navidrome track ID.
|
||||
ID string `json:"id"`
|
||||
|
||||
@ -77,7 +77,7 @@ components:
|
||||
- track
|
||||
- timestamp
|
||||
TrackInfo:
|
||||
description: TrackInfo contains track metadata for scrobbling.
|
||||
description: TrackInfo contains track metadata.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user