mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Merge branch 'master' into subsonic-folder
This commit is contained in:
commit
b376d6cb11
75
.github/workflows/pipeline.yml
vendored
75
.github/workflows/pipeline.yml
vendored
@ -120,6 +120,79 @@ jobs:
|
|||||||
go build -o ndpgen .
|
go build -o ndpgen .
|
||||||
./ndpgen --help
|
./ndpgen --help
|
||||||
|
|
||||||
|
go-windows:
|
||||||
|
name: Test Go code (Windows)
|
||||||
|
runs-on: windows-2022
|
||||||
|
env:
|
||||||
|
FFMPEG_VERSION: "7.1"
|
||||||
|
FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- uses: msys2/setup-msys2@v2
|
||||||
|
with:
|
||||||
|
msystem: MINGW64
|
||||||
|
install: mingw-w64-x86_64-gcc
|
||||||
|
update: false
|
||||||
|
|
||||||
|
- name: Add mingw64 to PATH
|
||||||
|
shell: bash
|
||||||
|
run: echo "C:/msys64/mingw64/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Cache ffmpeg
|
||||||
|
id: ffmpeg-cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: C:\ffmpeg
|
||||||
|
key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64
|
||||||
|
|
||||||
|
- name: Download ffmpeg
|
||||||
|
if: steps.ffmpeg-cache.outputs.cache-hit != 'true'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$asset = "ffmpeg-n${env:FFMPEG_VERSION}-latest-win64-gpl-${env:FFMPEG_VERSION}"
|
||||||
|
$url = "https://github.com/${env:FFMPEG_REPOSITORY}/releases/download/latest/$asset.zip"
|
||||||
|
Invoke-WebRequest -Uri $url -OutFile ffmpeg.zip
|
||||||
|
Expand-Archive ffmpeg.zip -DestinationPath C:\ffmpeg-extracted
|
||||||
|
New-Item -ItemType Directory -Force -Path C:\ffmpeg\bin | Out-Null
|
||||||
|
Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffmpeg.exe" C:\ffmpeg\bin
|
||||||
|
Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffprobe.exe" C:\ffmpeg\bin
|
||||||
|
|
||||||
|
- name: Add ffmpeg to PATH
|
||||||
|
shell: bash
|
||||||
|
run: echo "C:/ffmpeg/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Verify toolchain
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
go version
|
||||||
|
where.exe gcc
|
||||||
|
gcc --version
|
||||||
|
ffmpeg -version
|
||||||
|
ffprobe -version
|
||||||
|
|
||||||
|
- name: Download dependencies
|
||||||
|
shell: bash
|
||||||
|
run: go mod download
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
CGO_ENABLED: "1"
|
||||||
|
run: go test -shuffle=on -tags netgo,sqlite_fts5 ./... -v
|
||||||
|
|
||||||
|
- name: Test ndpgen
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
cd plugins\cmd\ndpgen
|
||||||
|
go test -shuffle=on -v
|
||||||
|
go build -o ndpgen.exe .
|
||||||
|
.\ndpgen.exe --help
|
||||||
|
|
||||||
js:
|
js:
|
||||||
name: Test JS code
|
name: Test JS code
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -184,7 +257,7 @@ jobs:
|
|||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
needs: [js, go, go-lint, i18n-lint, git-version, check-push-enabled]
|
needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled]
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
|
platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
|
||||||
|
|||||||
6
Makefile
6
Makefile
@ -75,8 +75,8 @@ test-i18n: ##@Development Validate all translations files
|
|||||||
|
|
||||||
install-golangci-lint: ##@Development Install golangci-lint if not present
|
install-golangci-lint: ##@Development Install golangci-lint if not present
|
||||||
@INSTALL=false; \
|
@INSTALL=false; \
|
||||||
if PATH=$$PATH:./bin which golangci-lint > /dev/null 2>&1; then \
|
if PATH=./bin:$$PATH which golangci-lint > /dev/null 2>&1; then \
|
||||||
CURRENT_VERSION=$$(PATH=$$PATH:./bin golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \
|
CURRENT_VERSION=$$(PATH=./bin:$$PATH golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \
|
||||||
REQUIRED_VERSION=$$(echo "$(GOLANGCI_LINT_VERSION)" | sed 's/^v//'); \
|
REQUIRED_VERSION=$$(echo "$(GOLANGCI_LINT_VERSION)" | sed 's/^v//'); \
|
||||||
if [ "$$CURRENT_VERSION" != "$$REQUIRED_VERSION" ]; then \
|
if [ "$$CURRENT_VERSION" != "$$REQUIRED_VERSION" ]; then \
|
||||||
echo "Found golangci-lint $$CURRENT_VERSION, but $$REQUIRED_VERSION is required. Reinstalling..."; \
|
echo "Found golangci-lint $$CURRENT_VERSION, but $$REQUIRED_VERSION is required. Reinstalling..."; \
|
||||||
@ -93,7 +93,7 @@ install-golangci-lint: ##@Development Install golangci-lint if not present
|
|||||||
.PHONY: install-golangci-lint
|
.PHONY: install-golangci-lint
|
||||||
|
|
||||||
lint: install-golangci-lint ##@Development Lint Go code
|
lint: install-golangci-lint ##@Development Lint Go code
|
||||||
PATH=$$PATH:./bin golangci-lint run --timeout 5m
|
PATH=./bin:$$PATH golangci-lint run --timeout 5m
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
|
|
||||||
lintall: lint ##@Development Lint Go and JS code
|
lintall: lint ##@Development Lint Go and JS code
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
"github.com/navidrome/navidrome/utils"
|
"github.com/navidrome/navidrome/utils"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
@ -213,6 +214,7 @@ var _ = Describe("Extractor", func() {
|
|||||||
// Only run permission tests if we are not root
|
// Only run permission tests if we are not root
|
||||||
RegularUserContext("when run without root privileges", func() {
|
RegularUserContext("when run without root privileges", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
tests.SkipOnWindows("uses Unix file permission bits")
|
||||||
// Use root fs for absolute paths in temp directory
|
// Use root fs for absolute paths in temp directory
|
||||||
e = &extractor{fs: os.DirFS("/")}
|
e = &extractor{fs: os.DirFS("/")}
|
||||||
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
|
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
|
||||||
|
|||||||
@ -27,6 +27,7 @@ type configOptions struct {
|
|||||||
Address string
|
Address string
|
||||||
Port int
|
Port int
|
||||||
UnixSocketPerm string
|
UnixSocketPerm string
|
||||||
|
EnforceNonRootUser bool
|
||||||
MusicFolder string
|
MusicFolder string
|
||||||
DataFolder string
|
DataFolder string
|
||||||
CacheFolder string
|
CacheFolder string
|
||||||
@ -60,8 +61,8 @@ type configOptions struct {
|
|||||||
SmartPlaylistRefreshDelay time.Duration
|
SmartPlaylistRefreshDelay time.Duration
|
||||||
AutoTranscodeDownload bool
|
AutoTranscodeDownload bool
|
||||||
DefaultDownsamplingFormat string
|
DefaultDownsamplingFormat string
|
||||||
Search searchOptions `json:",omitzero"`
|
Search searchOptions `json:",omitzero"`
|
||||||
SimilarSongsMatchThreshold int
|
Matcher matcherOptions `json:",omitzero"`
|
||||||
RecentlyAddedByModTime bool
|
RecentlyAddedByModTime bool
|
||||||
PreferSortTags bool
|
PreferSortTags bool
|
||||||
IgnoredArticles string
|
IgnoredArticles string
|
||||||
@ -262,6 +263,11 @@ type searchOptions struct {
|
|||||||
FullString bool
|
FullString bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type matcherOptions struct {
|
||||||
|
PreferStarred bool
|
||||||
|
FuzzyThreshold int
|
||||||
|
}
|
||||||
|
|
||||||
// logFatal prints a fatal error message to stderr and exits.
|
// logFatal prints a fatal error message to stderr and exits.
|
||||||
// Overridden in tests to allow testing fatal paths.
|
// Overridden in tests to allow testing fatal paths.
|
||||||
var logFatal = func(args ...any) {
|
var logFatal = func(args ...any) {
|
||||||
@ -269,6 +275,12 @@ var logFatal = func(args ...any) {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var getEUID = os.Geteuid
|
||||||
|
|
||||||
|
var currentGOOS = func() string {
|
||||||
|
return runtime.GOOS
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
Server = &configOptions{}
|
Server = &configOptions{}
|
||||||
hooks []func()
|
hooks []func()
|
||||||
@ -292,12 +304,18 @@ func Load(noConfigDump bool) {
|
|||||||
mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||||
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||||
mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality")
|
mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality")
|
||||||
|
mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
|
||||||
|
|
||||||
err := viper.Unmarshal(&Server)
|
err := viper.Unmarshal(&Server)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logFatal("Error parsing config:", err)
|
logFatal("Error parsing config:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate non-root user early, before any filesystem operations
|
||||||
|
if err := validateEnforceNonRootUser(); err != nil {
|
||||||
|
logFatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
|
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logFatal("Error creating data path:", err)
|
logFatal("Error creating data path:", err)
|
||||||
@ -425,6 +443,7 @@ func Load(noConfigDump bool) {
|
|||||||
logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||||
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||||
logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality")
|
logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality")
|
||||||
|
logDeprecatedOptions("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
|
||||||
|
|
||||||
// Removed options
|
// Removed options
|
||||||
logRemovedOptions("Spotify.ID", "Spotify.Secret")
|
logRemovedOptions("Spotify.ID", "Spotify.Secret")
|
||||||
@ -593,6 +612,18 @@ func validateMaxImageUploadSize() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateEnforceNonRootUser() error {
|
||||||
|
if !Server.EnforceNonRootUser || currentGOOS() == "windows" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if getEUID() == 0 {
|
||||||
|
return fmt.Errorf("EnforceNonRootUser is enabled but Navidrome is running as root")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateScanSchedule() error {
|
func validateScanSchedule() error {
|
||||||
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
|
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
|
||||||
Server.Scanner.Schedule = ""
|
Server.Scanner.Schedule = ""
|
||||||
@ -692,6 +723,7 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("address", "0.0.0.0")
|
viper.SetDefault("address", "0.0.0.0")
|
||||||
viper.SetDefault("port", 4533)
|
viper.SetDefault("port", 4533)
|
||||||
viper.SetDefault("unixsocketperm", "0660")
|
viper.SetDefault("unixsocketperm", "0660")
|
||||||
|
viper.SetDefault("enforcenonrootuser", false)
|
||||||
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
|
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
|
||||||
viper.SetDefault("baseurl", "")
|
viper.SetDefault("baseurl", "")
|
||||||
viper.SetDefault("tlscert", "")
|
viper.SetDefault("tlscert", "")
|
||||||
@ -717,7 +749,8 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
|
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
|
||||||
viper.SetDefault("search.fullstring", false)
|
viper.SetDefault("search.fullstring", false)
|
||||||
viper.SetDefault("search.backend", "fts")
|
viper.SetDefault("search.backend", "fts")
|
||||||
viper.SetDefault("similarsongsmatchthreshold", 85)
|
viper.SetDefault("matcher.preferstarred", true)
|
||||||
|
viper.SetDefault("matcher.fuzzythreshold", 85)
|
||||||
viper.SetDefault("recentlyaddedbymodtime", false)
|
viper.SetDefault("recentlyaddedbymodtime", false)
|
||||||
viper.SetDefault("prefersorttags", false)
|
viper.SetDefault("prefersorttags", false)
|
||||||
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
|
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
|
||||||
|
|||||||
@ -250,6 +250,49 @@ var _ = Describe("Configuration", func() {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("EnforceNonRootUser", func() {
|
||||||
|
It("defaults to false", func() {
|
||||||
|
conf.Load(true)
|
||||||
|
|
||||||
|
Expect(conf.Server.EnforceNonRootUser).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("allows startup for non-root users when enabled", func() {
|
||||||
|
DeferCleanup(conf.SetRuntimeInfoForTest("linux", 1000))
|
||||||
|
viper.Set("enforcenonrootuser", true)
|
||||||
|
|
||||||
|
conf.Load(true)
|
||||||
|
|
||||||
|
Expect(conf.Server.EnforceNonRootUser).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("exits when enabled and running as root without having created a data folder", func() {
|
||||||
|
// Create a path that doesn't exist yet
|
||||||
|
tempBase := GinkgoT().TempDir()
|
||||||
|
nonExistentDataFolder := filepath.Join(tempBase, "nonexistent", "data")
|
||||||
|
DeferCleanup(conf.SetRuntimeInfoForTest("linux", 0))
|
||||||
|
viper.Set("enforcenonrootuser", true)
|
||||||
|
viper.Set("datafolder", nonExistentDataFolder)
|
||||||
|
|
||||||
|
// Attempt to load config as root user - should fail before creating directories
|
||||||
|
Expect(func() {
|
||||||
|
conf.Load(true)
|
||||||
|
}).To(PanicWith(ContainSubstring("EnforceNonRootUser is enabled but Navidrome is running as root")))
|
||||||
|
|
||||||
|
// Verify that the data folder was NOT created
|
||||||
|
Expect(nonExistentDataFolder).ToNot(BeAnExistingFile())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("is a no-op on non-unix platforms", func() {
|
||||||
|
DeferCleanup(conf.SetRuntimeInfoForTest("windows", 0))
|
||||||
|
viper.Set("enforcenonrootuser", true)
|
||||||
|
|
||||||
|
conf.Load(true)
|
||||||
|
|
||||||
|
Expect(conf.Server.EnforceNonRootUser).To(BeTrue())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
DescribeTable("should load configuration from",
|
DescribeTable("should load configuration from",
|
||||||
func(format string) {
|
func(format string) {
|
||||||
filename := filepath.Join("testdata", "cfg."+format)
|
filename := filepath.Join("testdata", "cfg."+format)
|
||||||
|
|||||||
@ -16,6 +16,17 @@ var ToPascalCase = toPascalCase
|
|||||||
|
|
||||||
var ValidateMaxImageUploadSize = validateMaxImageUploadSize
|
var ValidateMaxImageUploadSize = validateMaxImageUploadSize
|
||||||
|
|
||||||
|
func SetRuntimeInfoForTest(goos string, euid int) func() {
|
||||||
|
oldGOOS := currentGOOS
|
||||||
|
oldEUID := getEUID
|
||||||
|
currentGOOS = func() string { return goos }
|
||||||
|
getEUID = func() int { return euid }
|
||||||
|
return func() {
|
||||||
|
currentGOOS = oldGOOS
|
||||||
|
getEUID = oldEUID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func SetLogFatal(f func(...any)) func() {
|
func SetLogFatal(f func(...any)) func() {
|
||||||
old := logFatal
|
old := logFatal
|
||||||
logFatal = f
|
logFatal = f
|
||||||
|
|||||||
@ -7,12 +7,11 @@ import (
|
|||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
"image/png"
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
_ "github.com/gen2brain/webp"
|
_ "github.com/gen2brain/webp"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
@ -81,6 +80,7 @@ var _ = Describe("Artwork", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
It("returns embed cover", func() {
|
It("returns embed cover", func() {
|
||||||
|
tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)")
|
||||||
aw, err := newAlbumArtworkReader(ctx, aw, alOnlyEmbed.CoverArtID(), nil)
|
aw, err := newAlbumArtworkReader(ctx, aw, alOnlyEmbed.CoverArtID(), nil)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
_, path, err := aw.Reader(ctx)
|
_, path, err := aw.Reader(ctx)
|
||||||
@ -104,6 +104,7 @@ var _ = Describe("Artwork", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
It("returns external cover", func() {
|
It("returns external cover", func() {
|
||||||
|
tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)")
|
||||||
folderRepo.result = []model.Folder{{
|
folderRepo.result = []model.Folder{{
|
||||||
Path: "tests/fixtures/artist/an-album",
|
Path: "tests/fixtures/artist/an-album",
|
||||||
ImageFiles: []string{"front.png"},
|
ImageFiles: []string{"front.png"},
|
||||||
@ -134,6 +135,7 @@ var _ = Describe("Artwork", func() {
|
|||||||
})
|
})
|
||||||
DescribeTable("CoverArtPriority",
|
DescribeTable("CoverArtPriority",
|
||||||
func(priority string, expected string) {
|
func(priority string, expected string) {
|
||||||
|
tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)")
|
||||||
conf.Server.CoverArtPriority = priority
|
conf.Server.CoverArtPriority = priority
|
||||||
aw, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil)
|
aw, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
@ -146,6 +148,51 @@ var _ = Describe("Artwork", func() {
|
|||||||
Entry(nil, " embedded , front.* , cover.*,folder.*", "tests/fixtures/artist/an-album/test.mp3"),
|
Entry(nil, " embedded , front.* , cover.*,folder.*", "tests/fixtures/artist/an-album/test.mp3"),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
Context("LastUpdated", func() {
|
||||||
|
// Regression test for #5377: LastUpdated feeds the HTTP Last-Modified header.
|
||||||
|
// It must return max(album.UpdatedAt, ImagesUpdatedAt) so browsers revalidate
|
||||||
|
// cached cover art when only the image file changes.
|
||||||
|
now := time.Now().Truncate(time.Second)
|
||||||
|
DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt",
|
||||||
|
func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) {
|
||||||
|
album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt}
|
||||||
|
folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}}
|
||||||
|
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album})
|
||||||
|
|
||||||
|
ar, err := newAlbumArtworkReader(ctx, aw, album.CoverArtID(), nil)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(ar.LastUpdated()).To(Equal(expected))
|
||||||
|
},
|
||||||
|
Entry("album newer than images", now, now.Add(-1*time.Hour), now),
|
||||||
|
Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)),
|
||||||
|
Entry("equal timestamps", now, now, now),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
Describe("discArtworkReader", func() {
|
||||||
|
Context("LastUpdated", func() {
|
||||||
|
// Regression test for #5377: same bug as albumArtworkReader — disc covers
|
||||||
|
// must also revalidate when the image file changes, not only when media files do.
|
||||||
|
now := time.Now().Truncate(time.Second)
|
||||||
|
DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt",
|
||||||
|
func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) {
|
||||||
|
album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt}
|
||||||
|
folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}}
|
||||||
|
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album})
|
||||||
|
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||||
|
{ID: "mf1", AlbumID: "al1", DiscNumber: 1, Path: "tests/fixtures/test.mp3"},
|
||||||
|
})
|
||||||
|
|
||||||
|
artID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("al1", 1), nil)
|
||||||
|
dr, err := newDiscArtworkReader(ctx, aw, artID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(dr.LastUpdated()).To(Equal(expected))
|
||||||
|
},
|
||||||
|
Entry("album newer than images", now, now.Add(-1*time.Hour), now),
|
||||||
|
Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)),
|
||||||
|
Entry("equal timestamps", now, now, now),
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
Describe("artistArtworkReader", func() {
|
Describe("artistArtworkReader", func() {
|
||||||
Context("Multiple covers", func() {
|
Context("Multiple covers", func() {
|
||||||
@ -166,6 +213,7 @@ var _ = Describe("Artwork", func() {
|
|||||||
})
|
})
|
||||||
DescribeTable("ArtistArtPriority",
|
DescribeTable("ArtistArtPriority",
|
||||||
func(priority string, expected string) {
|
func(priority string, expected string) {
|
||||||
|
tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)")
|
||||||
conf.Server.ArtistArtPriority = priority
|
conf.Server.ArtistArtPriority = priority
|
||||||
aw, err := newArtistArtworkReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
|
aw, err := newArtistArtworkReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
@ -203,6 +251,7 @@ var _ = Describe("Artwork", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
It("returns embed cover", func() {
|
It("returns embed cover", func() {
|
||||||
|
tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)")
|
||||||
aw, err := newMediafileArtworkReader(ctx, aw, mfWithEmbed.CoverArtID())
|
aw, err := newMediafileArtworkReader(ctx, aw, mfWithEmbed.CoverArtID())
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
_, path, err := aw.Reader(ctx)
|
_, path, err := aw.Reader(ctx)
|
||||||
@ -210,6 +259,7 @@ var _ = Describe("Artwork", func() {
|
|||||||
Expect(path).To(Equal("tests/fixtures/test.mp3"))
|
Expect(path).To(Equal("tests/fixtures/test.mp3"))
|
||||||
})
|
})
|
||||||
It("returns embed cover if successfully extracted by ffmpeg", func() {
|
It("returns embed cover if successfully extracted by ffmpeg", func() {
|
||||||
|
tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)")
|
||||||
aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID())
|
aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID())
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
r, path, err := aw.Reader(ctx)
|
r, path, err := aw.Reader(ctx)
|
||||||
|
|||||||
@ -72,7 +72,7 @@ func (a *albumArtworkReader) Key() string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
func (a *albumArtworkReader) LastUpdated() time.Time {
|
func (a *albumArtworkReader) LastUpdated() time.Time {
|
||||||
return a.album.UpdatedAt
|
return a.lastUpdate
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
@ -61,6 +62,7 @@ var _ = Describe("artistArtworkReader", func() {
|
|||||||
|
|
||||||
When("artist has only one album", func() {
|
When("artist has only one album", func() {
|
||||||
It("returns the parent folder", func() {
|
It("returns the parent folder", func() {
|
||||||
|
tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)")
|
||||||
paths = []string{
|
paths = []string{
|
||||||
filepath.FromSlash("/music/artist/album1"),
|
filepath.FromSlash("/music/artist/album1"),
|
||||||
}
|
}
|
||||||
@ -86,6 +88,7 @@ var _ = Describe("artistArtworkReader", func() {
|
|||||||
|
|
||||||
When("the album paths contain same prefix", func() {
|
When("the album paths contain same prefix", func() {
|
||||||
It("returns the common prefix", func() {
|
It("returns the common prefix", func() {
|
||||||
|
tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)")
|
||||||
paths = []string{
|
paths = []string{
|
||||||
filepath.FromSlash("/music/artist/album1"),
|
filepath.FromSlash("/music/artist/album1"),
|
||||||
filepath.FromSlash("/music/artist/album2"),
|
filepath.FromSlash("/music/artist/album2"),
|
||||||
|
|||||||
@ -116,7 +116,7 @@ func (d *discArtworkReader) Key() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *discArtworkReader) LastUpdated() time.Time {
|
func (d *discArtworkReader) LastUpdated() time.Time {
|
||||||
return d.album.UpdatedAt
|
return d.lastUpdate
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *discArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
func (d *discArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||||
|
|||||||
@ -41,6 +41,7 @@ var _ = Describe("common.go", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("returns the absolute path when library exists", func() {
|
It("returns the absolute path when library exists", func() {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-core)")
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
abs := AbsolutePath(ctx, ds, libId, path)
|
abs := AbsolutePath(ctx, ds, libId, path)
|
||||||
Expect(abs).To(Equal("/library/root/music/file.mp3"))
|
Expect(abs).To(Equal("/library/root/music/file.mp3"))
|
||||||
|
|||||||
2
core/external/provider_topsongs_test.go
vendored
2
core/external/provider_topsongs_test.go
vendored
@ -30,7 +30,7 @@ var _ = Describe("Provider - TopSongs", func() {
|
|||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
// Disable fuzzy matching for these tests to avoid unexpected GetAll calls
|
// Disable fuzzy matching for these tests to avoid unexpected GetAll calls
|
||||||
conf.Server.SimilarSongsMatchThreshold = 100
|
conf.Server.Matcher.FuzzyThreshold = 100
|
||||||
|
|
||||||
ctx = GinkgoT().Context()
|
ctx = GinkgoT().Context()
|
||||||
|
|
||||||
|
|||||||
23
core/external/provider_updateartistinfo_test.go
vendored
23
core/external/provider_updateartistinfo_test.go
vendored
@ -105,6 +105,29 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
|
|||||||
ag.AssertExpectations(GinkgoT())
|
ag.AssertExpectations(GinkgoT())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("preserves decoded plain text in biography storage", func() {
|
||||||
|
originalArtist := &model.Artist{
|
||||||
|
ID: "ar-encoded-bio",
|
||||||
|
Name: "Encoded Bio Artist",
|
||||||
|
}
|
||||||
|
mockArtistRepo.SetData(model.Artists{*originalArtist})
|
||||||
|
|
||||||
|
expectedMBID := "mbid-encoded-bio"
|
||||||
|
expectedBio := "R&B"
|
||||||
|
|
||||||
|
ag.On("GetArtistMBID", ctx, "ar-encoded-bio", "Encoded Bio Artist").Return(expectedMBID, nil).Once()
|
||||||
|
ag.On("GetArtistImages", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return(nil, nil).Maybe()
|
||||||
|
ag.On("GetArtistBiography", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return(expectedBio, nil).Once()
|
||||||
|
ag.On("GetArtistURL", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return("", nil).Maybe()
|
||||||
|
ag.On("GetSimilarArtists", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID, 100).Return(nil, nil).Maybe()
|
||||||
|
|
||||||
|
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-encoded-bio", 10, false)
|
||||||
|
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(updatedArtist).NotTo(BeNil())
|
||||||
|
Expect(updatedArtist.Biography).To(Equal("R&B"))
|
||||||
|
})
|
||||||
|
|
||||||
It("returns cached info when artist exists and info is not expired", func() {
|
It("returns cached info when artist exists and info is not expired", func() {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
originalArtist := &model.Artist{
|
originalArtist := &model.Artist{
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/core/lyrics"
|
"github.com/navidrome/navidrome/core/lyrics"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
"github.com/navidrome/navidrome/utils"
|
"github.com/navidrome/navidrome/utils"
|
||||||
"github.com/navidrome/navidrome/utils/gg"
|
"github.com/navidrome/navidrome/utils/gg"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
@ -93,6 +94,7 @@ var _ = Describe("sources", func() {
|
|||||||
var accessForbiddenFile string
|
var accessForbiddenFile string
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
tests.SkipOnWindows("uses Unix file permission bits")
|
||||||
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
|
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
|
||||||
|
|
||||||
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
|
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
|
||||||
|
|||||||
@ -46,18 +46,20 @@ func New(ds model.DataStore) *Matcher {
|
|||||||
// # Fuzzy Matching Details
|
// # Fuzzy Matching Details
|
||||||
//
|
//
|
||||||
// For title+artist matching, the algorithm uses Jaro-Winkler similarity (threshold configurable
|
// For title+artist matching, the algorithm uses Jaro-Winkler similarity (threshold configurable
|
||||||
// via SimilarSongsMatchThreshold, default 85%). Matches are ranked by:
|
// via Matcher.FuzzyThreshold, default 85%). Matches are ranked by:
|
||||||
//
|
//
|
||||||
// 1. Title similarity (Jaro-Winkler score, 0.0-1.0)
|
// 1. Title similarity (Jaro-Winkler score, 0.0-1.0)
|
||||||
// 2. Duration proximity (closer duration = higher score, 1.0 if unknown)
|
// 2. Duration proximity (closer duration = higher score, 1.0 if unknown)
|
||||||
// 3. Specificity level (0-5, based on metadata precision):
|
// 3. Preferred track flag (enabled by Matcher.PreferStarred; prioritized when the track is
|
||||||
|
// starred or has rating >= 4)
|
||||||
|
// 4. Specificity level (0-5, based on metadata precision):
|
||||||
// - Level 5: Title + Artist MBID + Album MBID (most specific)
|
// - Level 5: Title + Artist MBID + Album MBID (most specific)
|
||||||
// - Level 4: Title + Artist MBID + Album name (fuzzy)
|
// - Level 4: Title + Artist MBID + Album name (fuzzy)
|
||||||
// - Level 3: Title + Artist name + Album name (fuzzy)
|
// - Level 3: Title + Artist name + Album name (fuzzy)
|
||||||
// - Level 2: Title + Artist MBID
|
// - Level 2: Title + Artist MBID
|
||||||
// - Level 1: Title + Artist name
|
// - Level 1: Title + Artist name
|
||||||
// - Level 0: Title only
|
// - Level 0: Title only
|
||||||
// 4. Album similarity (Jaro-Winkler, as final tiebreaker)
|
// 5. Album similarity (Jaro-Winkler, as final tiebreaker)
|
||||||
//
|
//
|
||||||
// # Examples
|
// # Examples
|
||||||
//
|
//
|
||||||
@ -250,6 +252,7 @@ type songQuery struct {
|
|||||||
type matchScore struct {
|
type matchScore struct {
|
||||||
titleSimilarity float64
|
titleSimilarity float64
|
||||||
durationProximity float64
|
durationProximity float64
|
||||||
|
preferredMatch bool
|
||||||
albumSimilarity float64
|
albumSimilarity float64
|
||||||
specificityLevel int
|
specificityLevel int
|
||||||
}
|
}
|
||||||
@ -262,6 +265,9 @@ func (s matchScore) betterThan(other matchScore) bool {
|
|||||||
if s.durationProximity != other.durationProximity {
|
if s.durationProximity != other.durationProximity {
|
||||||
return s.durationProximity > other.durationProximity
|
return s.durationProximity > other.durationProximity
|
||||||
}
|
}
|
||||||
|
if s.preferredMatch != other.preferredMatch {
|
||||||
|
return s.preferredMatch
|
||||||
|
}
|
||||||
if s.specificityLevel != other.specificityLevel {
|
if s.specificityLevel != other.specificityLevel {
|
||||||
return s.specificityLevel > other.specificityLevel
|
return s.specificityLevel > other.specificityLevel
|
||||||
}
|
}
|
||||||
@ -322,7 +328,7 @@ func (m *Matcher) loadTracksByTitleAndArtist(ctx context.Context, songs []agents
|
|||||||
return map[string]model.MediaFile{}, nil
|
return map[string]model.MediaFile{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
threshold := float64(conf.Server.SimilarSongsMatchThreshold) / 100.0
|
threshold := float64(conf.Server.Matcher.FuzzyThreshold) / 100.0
|
||||||
|
|
||||||
byArtist := map[string][]songQuery{}
|
byArtist := map[string][]songQuery{}
|
||||||
for _, q := range queries {
|
for _, q := range queries {
|
||||||
@ -393,6 +399,7 @@ func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, t
|
|||||||
score := matchScore{
|
score := matchScore{
|
||||||
titleSimilarity: titleSim,
|
titleSimilarity: titleSim,
|
||||||
durationProximity: durationProximity(q.durationMs, t.mf.Duration),
|
durationProximity: durationProximity(q.durationMs, t.mf.Duration),
|
||||||
|
preferredMatch: conf.Server.Matcher.PreferStarred && isPreferredTrack(t.mf),
|
||||||
albumSimilarity: albumSim,
|
albumSimilarity: albumSim,
|
||||||
specificityLevel: computeSpecificityLevel(q, t, threshold),
|
specificityLevel: computeSpecificityLevel(q, t, threshold),
|
||||||
}
|
}
|
||||||
@ -406,6 +413,10 @@ func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, t
|
|||||||
return bestMatch, found
|
return bestMatch, found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isPreferredTrack(mf *model.MediaFile) bool {
|
||||||
|
return mf.Starred || mf.Rating >= 4
|
||||||
|
}
|
||||||
|
|
||||||
// buildTitleQueries converts agent songs into normalized songQuery structs for title+artist matching.
|
// buildTitleQueries converts agent songs into normalized songQuery structs for title+artist matching.
|
||||||
func (m *Matcher) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery {
|
func (m *Matcher) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery {
|
||||||
var queries []songQuery
|
var queries []songQuery
|
||||||
|
|||||||
@ -78,7 +78,7 @@ var _ = Describe("Matcher", func() {
|
|||||||
Describe("MatchSongsToLibrary", func() {
|
Describe("MatchSongsToLibrary", func() {
|
||||||
Context("matching by direct ID", func() {
|
Context("matching by direct ID", func() {
|
||||||
It("matches songs with an ID field to MediaFiles by ID", func() {
|
It("matches songs with an ID field to MediaFiles by ID", func() {
|
||||||
conf.Server.SimilarSongsMatchThreshold = 100
|
conf.Server.Matcher.FuzzyThreshold = 100
|
||||||
songs := []agents.Song{
|
songs := []agents.Song{
|
||||||
{ID: "track-1", Name: "Some Song", Artist: "Some Artist"},
|
{ID: "track-1", Name: "Some Song", Artist: "Some Artist"},
|
||||||
}
|
}
|
||||||
@ -96,7 +96,7 @@ var _ = Describe("Matcher", func() {
|
|||||||
|
|
||||||
Context("matching by MBID", func() {
|
Context("matching by MBID", func() {
|
||||||
It("matches songs with MBID to tracks with matching mbz_recording_id", func() {
|
It("matches songs with MBID to tracks with matching mbz_recording_id", func() {
|
||||||
conf.Server.SimilarSongsMatchThreshold = 100
|
conf.Server.Matcher.FuzzyThreshold = 100
|
||||||
songs := []agents.Song{
|
songs := []agents.Song{
|
||||||
{Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"},
|
{Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"},
|
||||||
}
|
}
|
||||||
@ -115,7 +115,7 @@ var _ = Describe("Matcher", func() {
|
|||||||
|
|
||||||
Context("matching by ISRC", func() {
|
Context("matching by ISRC", func() {
|
||||||
It("matches songs with ISRC to tracks with matching ISRC tag", func() {
|
It("matches songs with ISRC to tracks with matching ISRC tag", func() {
|
||||||
conf.Server.SimilarSongsMatchThreshold = 100
|
conf.Server.Matcher.FuzzyThreshold = 100
|
||||||
songs := []agents.Song{
|
songs := []agents.Song{
|
||||||
{Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"},
|
{Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"},
|
||||||
}
|
}
|
||||||
@ -134,7 +134,7 @@ var _ = Describe("Matcher", func() {
|
|||||||
|
|
||||||
Context("fuzzy title+artist matching", func() {
|
Context("fuzzy title+artist matching", func() {
|
||||||
It("matches songs by title and artist name", func() {
|
It("matches songs by title and artist name", func() {
|
||||||
conf.Server.SimilarSongsMatchThreshold = 100
|
conf.Server.Matcher.FuzzyThreshold = 100
|
||||||
songs := []agents.Song{
|
songs := []agents.Song{
|
||||||
{Name: "Enjoy the Silence", Artist: "Depeche Mode"},
|
{Name: "Enjoy the Silence", Artist: "Depeche Mode"},
|
||||||
}
|
}
|
||||||
@ -149,7 +149,7 @@ var _ = Describe("Matcher", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("matches songs with fuzzy title similarity", func() {
|
It("matches songs with fuzzy title similarity", func() {
|
||||||
conf.Server.SimilarSongsMatchThreshold = 85
|
conf.Server.Matcher.FuzzyThreshold = 85
|
||||||
songs := []agents.Song{
|
songs := []agents.Song{
|
||||||
{Name: "Bohemian Rhapsody", Artist: "Queen"},
|
{Name: "Bohemian Rhapsody", Artist: "Queen"},
|
||||||
}
|
}
|
||||||
@ -164,7 +164,7 @@ var _ = Describe("Matcher", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("does not match completely different titles", func() {
|
It("does not match completely different titles", func() {
|
||||||
conf.Server.SimilarSongsMatchThreshold = 85
|
conf.Server.Matcher.FuzzyThreshold = 85
|
||||||
songs := []agents.Song{
|
songs := []agents.Song{
|
||||||
{Name: "Yesterday", Artist: "The Beatles"},
|
{Name: "Yesterday", Artist: "The Beatles"},
|
||||||
}
|
}
|
||||||
@ -180,7 +180,7 @@ var _ = Describe("Matcher", func() {
|
|||||||
|
|
||||||
Context("deduplication", func() {
|
Context("deduplication", func() {
|
||||||
It("removes duplicates when different input songs match the same library track", func() {
|
It("removes duplicates when different input songs match the same library track", func() {
|
||||||
conf.Server.SimilarSongsMatchThreshold = 85
|
conf.Server.Matcher.FuzzyThreshold = 85
|
||||||
songs := []agents.Song{
|
songs := []agents.Song{
|
||||||
{Name: "Bohemian Rhapsody (Live)", Artist: "Queen"},
|
{Name: "Bohemian Rhapsody (Live)", Artist: "Queen"},
|
||||||
{Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"},
|
{Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"},
|
||||||
@ -196,7 +196,7 @@ var _ = Describe("Matcher", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("preserves duplicates when identical input songs match the same library track", func() {
|
It("preserves duplicates when identical input songs match the same library track", func() {
|
||||||
conf.Server.SimilarSongsMatchThreshold = 85
|
conf.Server.Matcher.FuzzyThreshold = 85
|
||||||
songs := []agents.Song{
|
songs := []agents.Song{
|
||||||
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
|
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
|
||||||
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
|
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
|
||||||
@ -215,7 +215,7 @@ var _ = Describe("Matcher", func() {
|
|||||||
|
|
||||||
Context("priority ordering", func() {
|
Context("priority ordering", func() {
|
||||||
It("prefers ID match over MBID match", func() {
|
It("prefers ID match over MBID match", func() {
|
||||||
conf.Server.SimilarSongsMatchThreshold = 100
|
conf.Server.Matcher.FuzzyThreshold = 100
|
||||||
// Song has both ID and MBID set. The matcher should resolve via ID
|
// Song has both ID and MBID set. The matcher should resolve via ID
|
||||||
// and short-circuit the MBID phase entirely, so no MBID fetch should
|
// and short-circuit the MBID phase entirely, so no MBID fetch should
|
||||||
// occur even though an mbz_recording_id exists in the input.
|
// occur even though an mbz_recording_id exists in the input.
|
||||||
@ -236,7 +236,7 @@ var _ = Describe("Matcher", func() {
|
|||||||
|
|
||||||
Context("count limit", func() {
|
Context("count limit", func() {
|
||||||
It("returns at most 'count' results", func() {
|
It("returns at most 'count' results", func() {
|
||||||
conf.Server.SimilarSongsMatchThreshold = 100
|
conf.Server.Matcher.FuzzyThreshold = 100
|
||||||
songs := []agents.Song{
|
songs := []agents.Song{
|
||||||
{Name: "Song A", Artist: "Artist"},
|
{Name: "Song A", Artist: "Artist"},
|
||||||
{Name: "Song B", Artist: "Artist"},
|
{Name: "Song B", Artist: "Artist"},
|
||||||
@ -265,7 +265,7 @@ var _ = Describe("Matcher", func() {
|
|||||||
|
|
||||||
Describe("specificity level matching", func() {
|
Describe("specificity level matching", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
conf.Server.SimilarSongsMatchThreshold = 100
|
conf.Server.Matcher.FuzzyThreshold = 100
|
||||||
})
|
})
|
||||||
|
|
||||||
It("matches by title + artist MBID + album MBID (highest priority)", func() {
|
It("matches by title + artist MBID + album MBID (highest priority)", func() {
|
||||||
@ -396,7 +396,7 @@ var _ = Describe("Matcher", func() {
|
|||||||
Describe("fuzzy matching thresholds", func() {
|
Describe("fuzzy matching thresholds", func() {
|
||||||
Context("with default threshold (85%)", func() {
|
Context("with default threshold (85%)", func() {
|
||||||
It("matches songs with remastered suffix", func() {
|
It("matches songs with remastered suffix", func() {
|
||||||
conf.Server.SimilarSongsMatchThreshold = 85
|
conf.Server.Matcher.FuzzyThreshold = 85
|
||||||
|
|
||||||
songs := []agents.Song{
|
songs := []agents.Song{
|
||||||
{Name: "Paranoid Android", Artist: "Radiohead"},
|
{Name: "Paranoid Android", Artist: "Radiohead"},
|
||||||
@ -415,7 +415,7 @@ var _ = Describe("Matcher", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("matches songs with live suffix", func() {
|
It("matches songs with live suffix", func() {
|
||||||
conf.Server.SimilarSongsMatchThreshold = 85
|
conf.Server.Matcher.FuzzyThreshold = 85
|
||||||
|
|
||||||
songs := []agents.Song{
|
songs := []agents.Song{
|
||||||
{Name: "Bohemian Rhapsody", Artist: "Queen"},
|
{Name: "Bohemian Rhapsody", Artist: "Queen"},
|
||||||
@ -436,7 +436,7 @@ var _ = Describe("Matcher", func() {
|
|||||||
|
|
||||||
Context("with threshold set to 100 (exact match only)", func() {
|
Context("with threshold set to 100 (exact match only)", func() {
|
||||||
It("only matches exact titles", func() {
|
It("only matches exact titles", func() {
|
||||||
conf.Server.SimilarSongsMatchThreshold = 100
|
conf.Server.Matcher.FuzzyThreshold = 100
|
||||||
|
|
||||||
songs := []agents.Song{
|
songs := []agents.Song{
|
||||||
{Name: "Paranoid Android", Artist: "Radiohead"},
|
{Name: "Paranoid Android", Artist: "Radiohead"},
|
||||||
@ -456,7 +456,7 @@ var _ = Describe("Matcher", func() {
|
|||||||
|
|
||||||
Context("with lower threshold (75%)", func() {
|
Context("with lower threshold (75%)", func() {
|
||||||
It("matches more aggressively", func() {
|
It("matches more aggressively", func() {
|
||||||
conf.Server.SimilarSongsMatchThreshold = 75
|
conf.Server.Matcher.FuzzyThreshold = 75
|
||||||
|
|
||||||
songs := []agents.Song{
|
songs := []agents.Song{
|
||||||
{Name: "Song", Artist: "Artist"},
|
{Name: "Song", Artist: "Artist"},
|
||||||
@ -478,7 +478,8 @@ var _ = Describe("Matcher", func() {
|
|||||||
|
|
||||||
Describe("fuzzy album matching", func() {
|
Describe("fuzzy album matching", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
conf.Server.SimilarSongsMatchThreshold = 85
|
conf.Server.Matcher.FuzzyThreshold = 85
|
||||||
|
conf.Server.Matcher.PreferStarred = false
|
||||||
})
|
})
|
||||||
|
|
||||||
It("matches album with (Remaster) suffix", func() {
|
It("matches album with (Remaster) suffix", func() {
|
||||||
@ -540,11 +541,53 @@ var _ = Describe("Matcher", func() {
|
|||||||
Expect(result).To(HaveLen(1))
|
Expect(result).To(HaveLen(1))
|
||||||
Expect(result[0].ID).To(Equal("exact"))
|
Expect(result[0].ID).To(Equal("exact"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("prefers starred songs over better album match when enabled", func() {
|
||||||
|
conf.Server.Matcher.PreferStarred = true
|
||||||
|
songs := []agents.Song{
|
||||||
|
{Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
|
||||||
|
}
|
||||||
|
albumMatch := model.MediaFile{
|
||||||
|
ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator",
|
||||||
|
}
|
||||||
|
starredTrack := model.MediaFile{
|
||||||
|
ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Starred: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
setupTitleOnlyExpectations(model.MediaFiles{albumMatch, starredTrack})
|
||||||
|
|
||||||
|
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(result).To(HaveLen(1))
|
||||||
|
Expect(result[0].ID).To(Equal("starred"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("prefers 4-star songs over better album match when enabled", func() {
|
||||||
|
conf.Server.Matcher.PreferStarred = true
|
||||||
|
songs := []agents.Song{
|
||||||
|
{Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
|
||||||
|
}
|
||||||
|
albumMatch := model.MediaFile{
|
||||||
|
ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator",
|
||||||
|
}
|
||||||
|
ratedTrack := model.MediaFile{
|
||||||
|
ID: "rated", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Rating: 4},
|
||||||
|
}
|
||||||
|
|
||||||
|
setupTitleOnlyExpectations(model.MediaFiles{albumMatch, ratedTrack})
|
||||||
|
|
||||||
|
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(result).To(HaveLen(1))
|
||||||
|
Expect(result[0].ID).To(Equal("rated"))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("duration matching", func() {
|
Describe("duration matching", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
conf.Server.SimilarSongsMatchThreshold = 100
|
conf.Server.Matcher.FuzzyThreshold = 100
|
||||||
})
|
})
|
||||||
|
|
||||||
It("prefers tracks with matching duration", func() {
|
It("prefers tracks with matching duration", func() {
|
||||||
@ -678,7 +721,7 @@ var _ = Describe("Matcher", func() {
|
|||||||
|
|
||||||
Describe("deduplication edge cases", func() {
|
Describe("deduplication edge cases", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
conf.Server.SimilarSongsMatchThreshold = 85
|
conf.Server.Matcher.FuzzyThreshold = 85
|
||||||
})
|
})
|
||||||
|
|
||||||
It("handles mixed scenario with both identical and different input songs", func() {
|
It("handles mixed scenario with both identical and different input songs", func() {
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
@ -199,6 +200,7 @@ var _ = Describe("MPV", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("executes MPV command and captures arguments correctly", func() {
|
It("executes MPV command and captures arguments correctly", func() {
|
||||||
|
tests.SkipOnWindows("mpv binary not available in CI (#TBD-mpv-windows)")
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@ -226,6 +228,7 @@ var _ = Describe("MPV", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("handles file paths with spaces", func() {
|
It("handles file paths with spaces", func() {
|
||||||
|
tests.SkipOnWindows("mpv binary not available in CI (#TBD-mpv-windows)")
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@ -253,6 +256,7 @@ var _ = Describe("MPV", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("passes all snapcast arguments correctly", func() {
|
It("passes all snapcast arguments correctly", func() {
|
||||||
|
tests.SkipOnWindows("mpv binary not available in CI (#TBD-mpv-windows)")
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
|||||||
@ -183,6 +183,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("rejects #EXTALBUMARTURL with absolute path outside library boundaries", func() {
|
It("rejects #EXTALBUMARTURL with absolute path outside library boundaries", func() {
|
||||||
|
tests.SkipOnWindows("relies on Unix /etc filesystem")
|
||||||
tmpDir := GinkgoT().TempDir()
|
tmpDir := GinkgoT().TempDir()
|
||||||
|
|
||||||
m3u := "#EXTALBUMARTURL:/etc/passwd\ntest.mp3\n"
|
m3u := "#EXTALBUMARTURL:/etc/passwd\ntest.mp3\n"
|
||||||
@ -320,6 +321,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
|
Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
|
||||||
})
|
})
|
||||||
It("returns an error if the playlist is not well-formed", func() {
|
It("returns an error if the playlist is not well-formed", func() {
|
||||||
|
tests.SkipOnWindows("line-ending differences affect JSON error offset")
|
||||||
_, err := ps.ImportFile(ctx, folder, "invalid_json.nsp")
|
_, err := ps.ImportFile(ctx, folder, "invalid_json.nsp")
|
||||||
Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
|
Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
|
||||||
})
|
})
|
||||||
@ -347,6 +349,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
|
|
||||||
DescribeTable("Playlist filename Unicode normalization (regression fix-playlist-filename-normalization)",
|
DescribeTable("Playlist filename Unicode normalization (regression fix-playlist-filename-normalization)",
|
||||||
func(storedForm, filesystemForm string) {
|
func(storedForm, filesystemForm string) {
|
||||||
|
tests.SkipOnWindows("/tmp hardcoded in test")
|
||||||
// Use Polish characters that decompose: ó (U+00F3) -> o + combining acute (U+006F + U+0301)
|
// Use Polish characters that decompose: ó (U+00F3) -> o + combining acute (U+006F + U+0301)
|
||||||
plsNameNFC := "Piosenki_Polskie_zółć" // NFC form (composed)
|
plsNameNFC := "Piosenki_Polskie_zółć" // NFC form (composed)
|
||||||
plsNameNFD := norm.NFD.String(plsNameNFC)
|
plsNameNFD := norm.NFD.String(plsNameNFC)
|
||||||
@ -821,6 +824,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("returns true if folder is in PlaylistsPath", func() {
|
It("returns true if folder is in PlaylistsPath", func() {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)")
|
||||||
conf.Server.PlaylistsPath = "other/**:playlists/**"
|
conf.Server.PlaylistsPath = "other/**:playlists/**"
|
||||||
Expect(playlists.InPath(folder)).To(BeTrue())
|
Expect(playlists.InPath(folder)).To(BeTrue())
|
||||||
})
|
})
|
||||||
|
|||||||
@ -15,6 +15,7 @@ var _ = Describe("libraryMatcher", func() {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)")
|
||||||
mockLibRepo = &tests.MockLibraryRepo{}
|
mockLibRepo = &tests.MockLibraryRepo{}
|
||||||
ds = &tests.MockDataStore{
|
ds = &tests.MockDataStore{
|
||||||
MockedLibrary: mockLibRepo,
|
MockedLibrary: mockLibRepo,
|
||||||
@ -196,6 +197,7 @@ var _ = Describe("pathResolver", func() {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)")
|
||||||
mockLibRepo = &tests.MockLibraryRepo{}
|
mockLibRepo = &tests.MockLibraryRepo{}
|
||||||
ds = &tests.MockDataStore{
|
ds = &tests.MockDataStore{
|
||||||
MockedLibrary: mockLibRepo,
|
MockedLibrary: mockLibRepo,
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/core/storage"
|
"github.com/navidrome/navidrome/core/storage"
|
||||||
"github.com/navidrome/navidrome/model/metadata"
|
"github.com/navidrome/navidrome/model/metadata"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
@ -44,6 +45,10 @@ var _ = Describe("LocalStorage", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
Describe("newLocalStorage", func() {
|
Describe("newLocalStorage", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
|
||||||
|
})
|
||||||
|
|
||||||
Context("with valid path", func() {
|
Context("with valid path", func() {
|
||||||
It("should create a localStorage instance with correct path", func() {
|
It("should create a localStorage instance with correct path", func() {
|
||||||
u, err := url.Parse("file://" + tempDir)
|
u, err := url.Parse("file://" + tempDir)
|
||||||
@ -166,6 +171,10 @@ var _ = Describe("LocalStorage", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
Describe("localStorage.FS", func() {
|
Describe("localStorage.FS", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
|
||||||
|
})
|
||||||
|
|
||||||
Context("with existing directory", func() {
|
Context("with existing directory", func() {
|
||||||
It("should return a localFS instance", func() {
|
It("should return a localFS instance", func() {
|
||||||
u, err := url.Parse("file://" + tempDir)
|
u, err := url.Parse("file://" + tempDir)
|
||||||
@ -199,6 +208,7 @@ var _ = Describe("LocalStorage", func() {
|
|||||||
var testFile string
|
var testFile string
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
|
||||||
// Create a test file
|
// Create a test file
|
||||||
testFile = filepath.Join(tempDir, "test.mp3")
|
testFile = filepath.Join(tempDir, "test.mp3")
|
||||||
err := os.WriteFile(testFile, []byte("test data"), 0600)
|
err := os.WriteFile(testFile, []byte("test data"), 0600)
|
||||||
@ -380,6 +390,7 @@ var _ = Describe("LocalStorage", func() {
|
|||||||
|
|
||||||
Describe("Storage registration", func() {
|
Describe("Storage registration", func() {
|
||||||
It("should register localStorage for file scheme", func() {
|
It("should register localStorage for file scheme", func() {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
|
||||||
// This tests the init() function indirectly
|
// This tests the init() function indirectly
|
||||||
storage, err := storage.For("file://" + tempDir)
|
storage, err := storage.For("file://" + tempDir)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
@ -54,6 +55,7 @@ var _ = Describe("Storage", func() {
|
|||||||
Expect(s.(*fakeLocalStorage).u.Path).To(Equal("/tmp"))
|
Expect(s.(*fakeLocalStorage).u.Path).To(Equal("/tmp"))
|
||||||
})
|
})
|
||||||
It("should return a file implementation for a relative folder", func() {
|
It("should return a file implementation for a relative folder", func() {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage)")
|
||||||
s, err := For("tmp")
|
s, err := For("tmp")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
cwd, _ := os.Getwd()
|
cwd, _ := os.Getwd()
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/id"
|
"github.com/navidrome/navidrome/model/id"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
@ -92,6 +93,7 @@ var _ = Describe("Folder", func() {
|
|||||||
|
|
||||||
When("the folder has multiple subdirs", func() {
|
When("the folder has multiple subdirs", func() {
|
||||||
It("should return the correct folder ID", func() {
|
It("should return the correct folder ID", func() {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)")
|
||||||
folderPath := filepath.FromSlash("/music/rock/metal")
|
folderPath := filepath.FromSlash("/music/rock/metal")
|
||||||
expectedID := id.NewHash("1:rock/metal")
|
expectedID := id.NewHash("1:rock/metal")
|
||||||
Expect(model.FolderID(lib, folderPath)).To(Equal(expectedID))
|
Expect(model.FolderID(lib, folderPath)).To(Equal(expectedID))
|
||||||
@ -101,6 +103,7 @@ var _ = Describe("Folder", func() {
|
|||||||
|
|
||||||
Describe("NewFolder", func() {
|
Describe("NewFolder", func() {
|
||||||
It("should create a new SubFolder with the correct attributes", func() {
|
It("should create a new SubFolder with the correct attributes", func() {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)")
|
||||||
folderPath := filepath.FromSlash("rock/metal")
|
folderPath := filepath.FromSlash("rock/metal")
|
||||||
folder := model.NewFolder(lib, folderPath)
|
folder := model.NewFolder(lib, folderPath)
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
. "github.com/navidrome/navidrome/model"
|
. "github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
@ -22,7 +23,7 @@ var _ = Describe("MediaFiles", func() {
|
|||||||
SortAlbumName: "SortAlbumName", SortArtistName: "SortArtistName", SortAlbumArtistName: "SortAlbumArtistName",
|
SortAlbumName: "SortAlbumName", SortArtistName: "SortArtistName", SortAlbumArtistName: "SortAlbumArtistName",
|
||||||
OrderAlbumName: "OrderAlbumName", OrderAlbumArtistName: "OrderAlbumArtistName",
|
OrderAlbumName: "OrderAlbumName", OrderAlbumArtistName: "OrderAlbumArtistName",
|
||||||
MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment",
|
MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment",
|
||||||
MbzReleaseGroupID: "MbzReleaseGroupID", Compilation: false, CatalogNum: "", Path: "/music1/file1.mp3", FolderID: "Folder1",
|
MbzReleaseGroupID: "MbzReleaseGroupID", Compilation: false, CatalogNum: "", Path: "music1/file1.mp3", FolderID: "Folder1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "2", Album: "Album", ArtistID: "ArtistID", Artist: "Artist", AlbumArtistID: "AlbumArtistID", AlbumArtist: "AlbumArtist", AlbumID: "AlbumID",
|
ID: "2", Album: "Album", ArtistID: "ArtistID", Artist: "Artist", AlbumArtistID: "AlbumArtistID", AlbumArtist: "AlbumArtist", AlbumID: "AlbumID",
|
||||||
@ -30,7 +31,7 @@ var _ = Describe("MediaFiles", func() {
|
|||||||
OrderAlbumName: "OrderAlbumName", OrderArtistName: "OrderArtistName", OrderAlbumArtistName: "OrderAlbumArtistName",
|
OrderAlbumName: "OrderAlbumName", OrderArtistName: "OrderArtistName", OrderAlbumArtistName: "OrderAlbumArtistName",
|
||||||
MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment",
|
MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment",
|
||||||
MbzReleaseGroupID: "MbzReleaseGroupID",
|
MbzReleaseGroupID: "MbzReleaseGroupID",
|
||||||
Compilation: true, CatalogNum: "CatalogNum", HasCoverArt: true, Path: "/music2/file2.mp3", FolderID: "Folder2",
|
Compilation: true, CatalogNum: "CatalogNum", HasCoverArt: true, Path: "music2/file2.mp3", FolderID: "Folder2",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -51,7 +52,7 @@ var _ = Describe("MediaFiles", func() {
|
|||||||
Expect(album.MbzReleaseGroupID).To(Equal("MbzReleaseGroupID"))
|
Expect(album.MbzReleaseGroupID).To(Equal("MbzReleaseGroupID"))
|
||||||
Expect(album.CatalogNum).To(Equal("CatalogNum"))
|
Expect(album.CatalogNum).To(Equal("CatalogNum"))
|
||||||
Expect(album.Compilation).To(BeTrue())
|
Expect(album.Compilation).To(BeTrue())
|
||||||
Expect(album.EmbedArtPath).To(Equal("/music2/file2.mp3"))
|
Expect(album.EmbedArtPath).To(Equal("music2/file2.mp3"))
|
||||||
Expect(album.FolderIDs).To(ConsistOf("Folder1", "Folder2"))
|
Expect(album.FolderIDs).To(ConsistOf("Folder1", "Folder2"))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -447,6 +448,9 @@ var _ = Describe("MediaFiles", func() {
|
|||||||
|
|
||||||
DescribeTable("generates correct output",
|
DescribeTable("generates correct output",
|
||||||
func(absolutePaths bool, expectedContent string) {
|
func(absolutePaths bool, expectedContent string) {
|
||||||
|
if absolutePaths {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)")
|
||||||
|
}
|
||||||
result := mfs.ToM3U8("Multi Track", absolutePaths)
|
result := mfs.ToM3U8("Multi Track", absolutePaths)
|
||||||
Expect(result).To(Equal(expectedContent))
|
Expect(result).To(Equal(expectedContent))
|
||||||
},
|
},
|
||||||
@ -467,6 +471,7 @@ var _ = Describe("MediaFiles", func() {
|
|||||||
|
|
||||||
Context("path variations", func() {
|
Context("path variations", func() {
|
||||||
It("handles different path structures", func() {
|
It("handles different path structures", func() {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)")
|
||||||
mfs = MediaFiles{
|
mfs = MediaFiles{
|
||||||
{Title: "Root", Artist: "Artist", Duration: 60, Path: "song.mp3", LibraryPath: "/lib"},
|
{Title: "Root", Artist: "Artist", Duration: 60, Path: "song.mp3", LibraryPath: "/lib"},
|
||||||
{Title: "Nested", Artist: "Artist", Duration: 60, Path: "deep/nested/song.mp3", LibraryPath: "/lib"},
|
{Title: "Nested", Artist: "Artist", Duration: 60, Path: "deep/nested/song.mp3", LibraryPath: "/lib"},
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
@ -79,6 +80,7 @@ var _ = Describe("getPID", func() {
|
|||||||
})
|
})
|
||||||
When("field is folder", func() {
|
When("field is folder", func() {
|
||||||
It("should return the pid", func() {
|
It("should return the pid", func() {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-metadata)")
|
||||||
spec := "folder|title"
|
spec := "folder|title"
|
||||||
md.tags = map[model.TagName][]string{"title": {"title"}}
|
md.tags = map[model.TagName][]string{"title": {"title"}}
|
||||||
mf.Path = "/path/to/file.mp3"
|
mf.Path = "/path/to/file.mp3"
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package model_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
@ -27,6 +28,7 @@ var _ = Describe("Playlist", func() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
It("generates the correct M3U format", func() {
|
It("generates the correct M3U format", func() {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)")
|
||||||
expected := `#EXTM3U
|
expected := `#EXTM3U
|
||||||
#PLAYLIST:Mellow sunset
|
#PLAYLIST:Mellow sunset
|
||||||
#EXTINF:378,Morcheeba feat. Kurt Wagner - What New York Couples Fight About
|
#EXTINF:378,Morcheeba feat. Kurt Wagner - What New York Couples Fight About
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
@ -99,6 +100,7 @@ var _ = Describe("FolderRepository", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("includes all child folders when querying parent", func() {
|
It("includes all child folders when querying parent", func() {
|
||||||
|
tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)")
|
||||||
// Create a parent folder with multiple children
|
// Create a parent folder with multiple children
|
||||||
parent := model.NewFolder(testLib, "TestParent/Music")
|
parent := model.NewFolder(testLib, "TestParent/Music")
|
||||||
child1 := model.NewFolder(testLib, "TestParent/Music/Rock/Queen")
|
child1 := model.NewFolder(testLib, "TestParent/Music/Rock/Queen")
|
||||||
@ -120,6 +122,7 @@ var _ = Describe("FolderRepository", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("excludes children from other libraries", func() {
|
It("excludes children from other libraries", func() {
|
||||||
|
tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)")
|
||||||
// Create parent in testLib
|
// Create parent in testLib
|
||||||
parent := model.NewFolder(testLib, "TestIsolation/Parent")
|
parent := model.NewFolder(testLib, "TestIsolation/Parent")
|
||||||
child := model.NewFolder(testLib, "TestIsolation/Parent/Child")
|
child := model.NewFolder(testLib, "TestIsolation/Parent/Child")
|
||||||
@ -145,6 +148,7 @@ var _ = Describe("FolderRepository", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("excludes missing children when querying parent", func() {
|
It("excludes missing children when querying parent", func() {
|
||||||
|
tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)")
|
||||||
// Create parent and children, mark one as missing
|
// Create parent and children, mark one as missing
|
||||||
parent := model.NewFolder(testLib, "TestMissingChild/Parent")
|
parent := model.NewFolder(testLib, "TestMissingChild/Parent")
|
||||||
child1 := model.NewFolder(testLib, "TestMissingChild/Parent/Child1")
|
child1 := model.NewFolder(testLib, "TestMissingChild/Parent/Child1")
|
||||||
@ -165,6 +169,7 @@ var _ = Describe("FolderRepository", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("handles mix of existing and non-existing target paths", func() {
|
It("handles mix of existing and non-existing target paths", func() {
|
||||||
|
tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)")
|
||||||
// Create folders for one path but not the other
|
// Create folders for one path but not the other
|
||||||
existingParent := model.NewFolder(testLib, "TestMixed/Exists")
|
existingParent := model.NewFolder(testLib, "TestMixed/Exists")
|
||||||
existingChild := model.NewFolder(testLib, "TestMixed/Exists/Child")
|
existingChild := model.NewFolder(testLib, "TestMixed/Exists/Child")
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package persistence
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
@ -64,6 +65,11 @@ var _ = Describe("LibraryRepository", func() {
|
|||||||
originalID := lib.ID
|
originalID := lib.ID
|
||||||
originalCreatedAt := lib.CreatedAt
|
originalCreatedAt := lib.CreatedAt
|
||||||
|
|
||||||
|
// Ensure the update's timestamp is strictly greater than the
|
||||||
|
// create's timestamp on platforms with coarse clock resolution
|
||||||
|
// (Windows' time.Now() is millisecond-granular).
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
|
||||||
// Now update it
|
// Now update it
|
||||||
lib.Name = "Updated Library"
|
lib.Name = "Updated Library"
|
||||||
lib.Path = "/music/updated"
|
lib.Path = "/music/updated"
|
||||||
|
|||||||
@ -48,10 +48,10 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
var mp3File, flacFile1, flacFile2, flacUpperFile model.MediaFile
|
var mp3File, flacFile1, flacFile2, flacUpperFile model.MediaFile
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
mp3File = model.MediaFile{ID: "suffix-mp3", LibraryID: 1, Suffix: "mp3", Path: "/test/file.mp3"}
|
mp3File = model.MediaFile{ID: "suffix-mp3", LibraryID: 1, Suffix: "mp3", Path: "test/file.mp3"}
|
||||||
flacFile1 = model.MediaFile{ID: "suffix-flac1", LibraryID: 1, Suffix: "flac", Path: "/test/file1.flac"}
|
flacFile1 = model.MediaFile{ID: "suffix-flac1", LibraryID: 1, Suffix: "flac", Path: "test/file1.flac"}
|
||||||
flacFile2 = model.MediaFile{ID: "suffix-flac2", LibraryID: 1, Suffix: "flac", Path: "/test/file2.flac"}
|
flacFile2 = model.MediaFile{ID: "suffix-flac2", LibraryID: 1, Suffix: "flac", Path: "test/file2.flac"}
|
||||||
flacUpperFile = model.MediaFile{ID: "suffix-FLAC", LibraryID: 1, Suffix: "FLAC", Path: "/test/file.FLAC"}
|
flacUpperFile = model.MediaFile{ID: "suffix-FLAC", LibraryID: 1, Suffix: "FLAC", Path: "test/file.FLAC"}
|
||||||
|
|
||||||
Expect(mr.Put(&mp3File)).To(Succeed())
|
Expect(mr.Put(&mp3File)).To(Succeed())
|
||||||
Expect(mr.Put(&flacFile1)).To(Succeed())
|
Expect(mr.Put(&flacFile1)).To(Succeed())
|
||||||
@ -109,7 +109,7 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
Describe("Put CreatedAt behavior (#5050)", func() {
|
Describe("Put CreatedAt behavior (#5050)", func() {
|
||||||
It("sets CreatedAt to now when inserting a new file with zero CreatedAt", func() {
|
It("sets CreatedAt to now when inserting a new file with zero CreatedAt", func() {
|
||||||
before := time.Now().Add(-time.Second)
|
before := time.Now().Add(-time.Second)
|
||||||
newFile := model.MediaFile{ID: id.NewRandom(), LibraryID: 1, Path: "/test/created-at-zero.mp3"}
|
newFile := model.MediaFile{ID: id.NewRandom(), LibraryID: 1, Path: "test/created-at-zero.mp3"}
|
||||||
Expect(mr.Put(&newFile)).To(Succeed())
|
Expect(mr.Put(&newFile)).To(Succeed())
|
||||||
|
|
||||||
retrieved, err := mr.Get(newFile.ID)
|
retrieved, err := mr.Get(newFile.ID)
|
||||||
@ -124,7 +124,7 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
newFile := model.MediaFile{
|
newFile := model.MediaFile{
|
||||||
ID: id.NewRandom(),
|
ID: id.NewRandom(),
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
Path: "/test/created-at-preserved.mp3",
|
Path: "test/created-at-preserved.mp3",
|
||||||
CreatedAt: originalTime,
|
CreatedAt: originalTime,
|
||||||
}
|
}
|
||||||
Expect(mr.Put(&newFile)).To(Succeed())
|
Expect(mr.Put(&newFile)).To(Succeed())
|
||||||
@ -142,7 +142,7 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
newFile := model.MediaFile{
|
newFile := model.MediaFile{
|
||||||
ID: fileID,
|
ID: fileID,
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
Path: "/test/created-at-update.mp3",
|
Path: "test/created-at-update.mp3",
|
||||||
Title: "Original Title",
|
Title: "Original Title",
|
||||||
CreatedAt: originalTime,
|
CreatedAt: originalTime,
|
||||||
}
|
}
|
||||||
@ -152,7 +152,7 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
updatedFile := model.MediaFile{
|
updatedFile := model.MediaFile{
|
||||||
ID: fileID,
|
ID: fileID,
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
Path: "/test/created-at-update.mp3",
|
Path: "test/created-at-update.mp3",
|
||||||
Title: "Updated Title",
|
Title: "Updated Title",
|
||||||
// CreatedAt is zero - should NOT overwrite the stored value
|
// CreatedAt is zero - should NOT overwrite the stored value
|
||||||
}
|
}
|
||||||
@ -231,7 +231,7 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
|
|
||||||
It("returns 0 when no ratings exist", func() {
|
It("returns 0 when no ratings exist", func() {
|
||||||
newID := id.NewRandom()
|
newID := id.NewRandom()
|
||||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/no-rating.mp3"})).To(Succeed())
|
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/no-rating.mp3"})).To(Succeed())
|
||||||
|
|
||||||
mf, err := mr.Get(newID)
|
mf, err := mr.Get(newID)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
@ -242,7 +242,7 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
|
|
||||||
It("returns the user's rating as average when only one user rated", func() {
|
It("returns the user's rating as average when only one user rated", func() {
|
||||||
newID := id.NewRandom()
|
newID := id.NewRandom()
|
||||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/single-rating.mp3"})).To(Succeed())
|
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/single-rating.mp3"})).To(Succeed())
|
||||||
Expect(mr.SetRating(5, newID)).To(Succeed())
|
Expect(mr.SetRating(5, newID)).To(Succeed())
|
||||||
|
|
||||||
mf, err := mr.Get(newID)
|
mf, err := mr.Get(newID)
|
||||||
@ -255,7 +255,7 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
|
|
||||||
It("calculates average across multiple users", func() {
|
It("calculates average across multiple users", func() {
|
||||||
newID := id.NewRandom()
|
newID := id.NewRandom()
|
||||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/multi-rating.mp3"})).To(Succeed())
|
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/multi-rating.mp3"})).To(Succeed())
|
||||||
|
|
||||||
Expect(mr.SetRating(3, newID)).To(Succeed())
|
Expect(mr.SetRating(3, newID)).To(Succeed())
|
||||||
|
|
||||||
@ -273,7 +273,7 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
|
|
||||||
It("excludes zero ratings from average calculation", func() {
|
It("excludes zero ratings from average calculation", func() {
|
||||||
newID := id.NewRandom()
|
newID := id.NewRandom()
|
||||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/zero-excluded.mp3"})).To(Succeed())
|
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/zero-excluded.mp3"})).To(Succeed())
|
||||||
|
|
||||||
Expect(mr.SetRating(4, newID)).To(Succeed())
|
Expect(mr.SetRating(4, newID)).To(Succeed())
|
||||||
|
|
||||||
@ -343,19 +343,19 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
ID: id.NewRandom(),
|
ID: id.NewRandom(),
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
Title: "Old Song",
|
Title: "Old Song",
|
||||||
Path: "/test/old.mp3",
|
Path: "test/old.mp3",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: id.NewRandom(),
|
ID: id.NewRandom(),
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
Title: "Middle Song",
|
Title: "Middle Song",
|
||||||
Path: "/test/middle.mp3",
|
Path: "test/middle.mp3",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: id.NewRandom(),
|
ID: id.NewRandom(),
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
Title: "New Song",
|
Title: "New Song",
|
||||||
Path: "/test/new.mp3",
|
Path: "test/new.mp3",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -486,7 +486,7 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
var mfWithoutAnnotation model.MediaFile
|
var mfWithoutAnnotation model.MediaFile
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
mfWithoutAnnotation = model.MediaFile{ID: "no-annotation-file", LibraryID: 1, Path: "/test/no-annotation.mp3", Title: "No Annotation"}
|
mfWithoutAnnotation = model.MediaFile{ID: "no-annotation-file", LibraryID: 1, Path: "test/no-annotation.mp3", Title: "No Annotation"}
|
||||||
Expect(mr.Put(&mfWithoutAnnotation)).To(Succeed())
|
Expect(mr.Put(&mfWithoutAnnotation)).To(Succeed())
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -566,7 +566,7 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
MbzRecordingID: "550e8400-e29b-41d4-a716-446655440020", // Valid UUID v4
|
MbzRecordingID: "550e8400-e29b-41d4-a716-446655440020", // Valid UUID v4
|
||||||
MbzReleaseTrackID: "550e8400-e29b-41d4-a716-446655440021", // Valid UUID v4
|
MbzReleaseTrackID: "550e8400-e29b-41d4-a716-446655440021", // Valid UUID v4
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
Path: "/test/path/test.mp3",
|
Path: "test/path/test.mp3",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert the test media file into the database
|
// Insert the test media file into the database
|
||||||
@ -608,7 +608,7 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
Title: "Test Missing MBID MediaFile",
|
Title: "Test Missing MBID MediaFile",
|
||||||
MbzRecordingID: "550e8400-e29b-41d4-a716-446655440022",
|
MbzRecordingID: "550e8400-e29b-41d4-a716-446655440022",
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
Path: "/test/path/missing.mp3",
|
Path: "test/path/missing.mp3",
|
||||||
Missing: true,
|
Missing: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -77,14 +77,14 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
albumSgtPeppers = al(model.Album{ID: "101", Name: "Sgt Peppers", AlbumArtist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967})
|
albumSgtPeppers = al(model.Album{ID: "101", Name: "Sgt Peppers", AlbumArtist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", EmbedArtPath: p("beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967})
|
||||||
albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969})
|
albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969})
|
||||||
albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("/kraft/radio/radio.mp3"), SongCount: 2})
|
albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("kraft/radio/radio.mp3"), SongCount: 2})
|
||||||
albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("/test/multi/disc1/track1.mp3"), SongCount: 4})
|
albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("test/multi/disc1/track1.mp3"), SongCount: 4})
|
||||||
albumCJK = al(model.Album{ID: "105", Name: "COWBOY BEBOP", AlbumArtist: "シートベルツ", OrderAlbumName: "cowboy bebop", AlbumArtistID: "4", EmbedArtPath: p("/seatbelts/cowboy-bebop/track1.mp3"), SongCount: 1})
|
albumCJK = al(model.Album{ID: "105", Name: "COWBOY BEBOP", AlbumArtist: "シートベルツ", OrderAlbumName: "cowboy bebop", AlbumArtistID: "4", EmbedArtPath: p("seatbelts/cowboy-bebop/track1.mp3"), SongCount: 1})
|
||||||
albumWithVersion = alWithTags(model.Album{ID: "106", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/2/come together.mp3"), SongCount: 1, MaxYear: 2019},
|
albumWithVersion = alWithTags(model.Album{ID: "106", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("beatles/2/come together.mp3"), SongCount: 1, MaxYear: 2019},
|
||||||
model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}})
|
model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}})
|
||||||
albumPunctuation = al(model.Album{ID: "107", Name: "Things Fall Apart", AlbumArtist: "The Roots", OrderAlbumName: "things fall apart", AlbumArtistID: "5", EmbedArtPath: p("/roots/things/track1.mp3"), SongCount: 1})
|
albumPunctuation = al(model.Album{ID: "107", Name: "Things Fall Apart", AlbumArtist: "The Roots", OrderAlbumName: "things fall apart", AlbumArtistID: "5", EmbedArtPath: p("roots/things/track1.mp3"), SongCount: 1})
|
||||||
testAlbums = model.Albums{
|
testAlbums = model.Albums{
|
||||||
albumSgtPeppers,
|
albumSgtPeppers,
|
||||||
albumAbbeyRoad,
|
albumAbbeyRoad,
|
||||||
@ -97,12 +97,12 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
songDayInALife = mf(model.MediaFile{ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Path: p("/beatles/1/sgt/a day.mp3")})
|
songDayInALife = mf(model.MediaFile{ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Path: p("beatles/1/sgt/a day.mp3")})
|
||||||
songComeTogether = mf(model.MediaFile{ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Path: p("/beatles/1/come together.mp3")})
|
songComeTogether = mf(model.MediaFile{ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Path: p("beatles/1/come together.mp3")})
|
||||||
songRadioactivity = mf(model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Path: p("/kraft/radio/radio.mp3")})
|
songRadioactivity = mf(model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Path: p("kraft/radio/radio.mp3")})
|
||||||
songAntenna = mf(model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk",
|
songAntenna = mf(model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk",
|
||||||
AlbumID: "103",
|
AlbumID: "103",
|
||||||
Path: p("/kraft/radio/antenna.mp3"),
|
Path: p("kraft/radio/antenna.mp3"),
|
||||||
RGAlbumGain: gg.P(1.0), RGAlbumPeak: gg.P(2.0), RGTrackGain: gg.P(3.0), RGTrackPeak: gg.P(4.0),
|
RGAlbumGain: gg.P(1.0), RGAlbumPeak: gg.P(2.0), RGTrackGain: gg.P(3.0), RGTrackPeak: gg.P(4.0),
|
||||||
})
|
})
|
||||||
songAntennaWithLyrics = mf(model.MediaFile{
|
songAntennaWithLyrics = mf(model.MediaFile{
|
||||||
@ -115,13 +115,13 @@ var (
|
|||||||
})
|
})
|
||||||
songAntenna2 = mf(model.MediaFile{ID: "1006", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103"})
|
songAntenna2 = mf(model.MediaFile{ID: "1006", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103"})
|
||||||
// Multi-disc album tracks (intentionally out of order to test sorting)
|
// Multi-disc album tracks (intentionally out of order to test sorting)
|
||||||
songDisc2Track11 = mf(model.MediaFile{ID: "2001", Title: "Disc 2 Track 11", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 11, Path: p("/test/multi/disc2/track11.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
songDisc2Track11 = mf(model.MediaFile{ID: "2001", Title: "Disc 2 Track 11", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 11, Path: p("test/multi/disc2/track11.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||||
songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("/test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||||
songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("/test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||||
songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("/test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||||
songCJK = mf(model.MediaFile{ID: "3001", Title: "プラチナ・ジェット", ArtistID: "4", Artist: "シートベルツ", AlbumID: "105", Album: "COWBOY BEBOP", Path: p("/seatbelts/cowboy-bebop/track1.mp3")})
|
songCJK = mf(model.MediaFile{ID: "3001", Title: "プラチナ・ジェット", ArtistID: "4", Artist: "シートベルツ", AlbumID: "105", Album: "COWBOY BEBOP", Path: p("seatbelts/cowboy-bebop/track1.mp3")})
|
||||||
songVersioned = mf(model.MediaFile{ID: "3002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "106", Album: "Abbey Road", Path: p("/beatles/2/come together.mp3")})
|
songVersioned = mf(model.MediaFile{ID: "3002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "106", Album: "Abbey Road", Path: p("beatles/2/come together.mp3")})
|
||||||
songPunctuation = mf(model.MediaFile{ID: "3003", Title: "!!!!!!!", ArtistID: "5", Artist: "The Roots", AlbumID: "107", Album: "Things Fall Apart", Path: p("/roots/things/track1.mp3")})
|
songPunctuation = mf(model.MediaFile{ID: "3003", Title: "!!!!!!!", ArtistID: "5", Artist: "The Roots", AlbumID: "107", Album: "Things Fall Apart", Path: p("roots/things/track1.mp3")})
|
||||||
testSongs = model.MediaFiles{
|
testSongs = model.MediaFiles{
|
||||||
songDayInALife,
|
songDayInALife,
|
||||||
songComeTogether,
|
songComeTogether,
|
||||||
|
|||||||
@ -408,7 +408,7 @@ var _ = Describe("PlaylistRepository", func() {
|
|||||||
ArtistID: "1",
|
ArtistID: "1",
|
||||||
Album: "Test Album",
|
Album: "Test Album",
|
||||||
AlbumID: "101",
|
AlbumID: "101",
|
||||||
Path: "/test/grouping/song1.mp3",
|
Path: "test/grouping/song1.mp3",
|
||||||
Tags: model.Tags{
|
Tags: model.Tags{
|
||||||
"grouping": []string{"My Crate"},
|
"grouping": []string{"My Crate"},
|
||||||
},
|
},
|
||||||
@ -426,7 +426,7 @@ var _ = Describe("PlaylistRepository", func() {
|
|||||||
ArtistID: "1",
|
ArtistID: "1",
|
||||||
Album: "Test Album",
|
Album: "Test Album",
|
||||||
AlbumID: "101",
|
AlbumID: "101",
|
||||||
Path: "/test/grouping/song2.mp3",
|
Path: "test/grouping/song2.mp3",
|
||||||
Tags: model.Tags{},
|
Tags: model.Tags{},
|
||||||
Participants: model.Participants{},
|
Participants: model.Participants{},
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
@ -614,7 +614,7 @@ var _ = Describe("PlaylistRepository", func() {
|
|||||||
ArtistID: "1",
|
ArtistID: "1",
|
||||||
Album: "Test Album",
|
Album: "Test Album",
|
||||||
AlbumID: "101",
|
AlbumID: "101",
|
||||||
Path: "/music/lib1/song.mp3",
|
Path: "lib1/song.mp3",
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
Participants: model.Participants{},
|
Participants: model.Participants{},
|
||||||
Tags: model.Tags{},
|
Tags: model.Tags{},
|
||||||
@ -630,7 +630,7 @@ var _ = Describe("PlaylistRepository", func() {
|
|||||||
ArtistID: "1",
|
ArtistID: "1",
|
||||||
Album: "Test Album",
|
Album: "Test Album",
|
||||||
AlbumID: "101",
|
AlbumID: "101",
|
||||||
Path: uniqueLibPath + "/song.mp3",
|
Path: "lib2/song.mp3",
|
||||||
LibraryID: lib2ID,
|
LibraryID: lib2ID,
|
||||||
Participants: model.Participants{},
|
Participants: model.Participants{},
|
||||||
Tags: model.Tags{},
|
Tags: model.Tags{},
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
//go:build !windows
|
|
||||||
|
|
||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
//go:build !windows
|
|
||||||
|
|
||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
//go:build !windows
|
|
||||||
|
|
||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
23
plugins/plugins_suite_windows_test.go
Normal file
23
plugins/plugins_suite_windows_test.go
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Runs the subset of plugin specs compiled on Windows (files without the
|
||||||
|
// //go:build !windows tag): capabilities, manager_cache, manager_plugin,
|
||||||
|
// manifest, package. WASM-runtime-dependent specs live in !windows-tagged
|
||||||
|
// files and aren't reached here.
|
||||||
|
func TestPlugins(t *testing.T) {
|
||||||
|
tests.Init(t, false)
|
||||||
|
log.SetLevel(log.LevelFatal)
|
||||||
|
RegisterFailHandler(Fail)
|
||||||
|
RunSpecs(t, "Plugins Suite")
|
||||||
|
}
|
||||||
@ -111,6 +111,7 @@ var _ = Describe("phasePlaylists", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("reports an error if there is an error reading files", func() {
|
It("reports an error if there is an error reading files", func() {
|
||||||
|
tests.SkipOnWindows("relies on Unix /etc filesystem")
|
||||||
progress := make(chan *ProgressInfo)
|
progress := make(chan *ProgressInfo)
|
||||||
state.progress = progress
|
state.progress = progress
|
||||||
folder := &model.Folder{Path: "/invalid/path"}
|
folder := &model.Folder{Path: "/invalid/path"}
|
||||||
|
|||||||
@ -43,6 +43,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
BeforeAll(func() {
|
BeforeAll(func() {
|
||||||
|
tests.SkipOnWindows("SQLite file lock blocks TempDir cleanup (#TBD-path-sep-scanner)")
|
||||||
ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true})
|
ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true})
|
||||||
tmpDir := GinkgoT().TempDir()
|
tmpDir := GinkgoT().TempDir()
|
||||||
conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner-multilibrary.db?_journal_mode=WAL")
|
conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner-multilibrary.db?_journal_mode=WAL")
|
||||||
|
|||||||
@ -34,6 +34,7 @@ var _ = Describe("ScanFolders", Ordered, func() {
|
|||||||
var fsys storagetest.FakeFS
|
var fsys storagetest.FakeFS
|
||||||
|
|
||||||
BeforeAll(func() {
|
BeforeAll(func() {
|
||||||
|
tests.SkipOnWindows("SQLite file lock blocks TempDir cleanup (#TBD-path-sep-scanner)")
|
||||||
ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true})
|
ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true})
|
||||||
tmpDir := GinkgoT().TempDir()
|
tmpDir := GinkgoT().TempDir()
|
||||||
conf.Server.DbPath = filepath.Join(tmpDir, "test-selective-scan.db?_journal_mode=WAL")
|
conf.Server.DbPath = filepath.Join(tmpDir, "test-selective-scan.db?_journal_mode=WAL")
|
||||||
|
|||||||
@ -168,6 +168,7 @@ var _ = Describe("Scanner", Ordered, func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("should update the album", func() {
|
It("should update the album", func() {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
|
||||||
Expect(runScanner(ctx, true)).To(Succeed())
|
Expect(runScanner(ctx, true)).To(Succeed())
|
||||||
|
|
||||||
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.name": "Help!"}})
|
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.name": "Help!"}})
|
||||||
@ -268,6 +269,7 @@ var _ = Describe("Scanner", Ordered, func() {
|
|||||||
var beatlesMBID = uuid.NewString()
|
var beatlesMBID = uuid.NewString()
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
|
||||||
By("Having two MP3 albums")
|
By("Having two MP3 albums")
|
||||||
beatles := _t{
|
beatles := _t{
|
||||||
"artist": "The Beatles",
|
"artist": "The Beatles",
|
||||||
@ -872,6 +874,7 @@ var _ = Describe("Scanner", Ordered, func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("should update artist stats during quick scans when new albums are added", func() {
|
It("should update artist stats during quick scans when new albums are added", func() {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
|
||||||
// Don't use the mocked artist repo for this test - we need the real one
|
// Don't use the mocked artist repo for this test - we need the real one
|
||||||
ds.MockedArtist = nil
|
ds.MockedArtist = nil
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/core/storage"
|
"github.com/navidrome/navidrome/core/storage"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
@ -229,6 +230,7 @@ var _ = Describe("walk_dir_tree", func() {
|
|||||||
|
|
||||||
Context("with symlinks enabled", func() {
|
Context("with symlinks enabled", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
tests.SkipOnWindows("symlink semantics")
|
||||||
conf.Server.Scanner.FollowSymlinks = true
|
conf.Server.Scanner.FollowSymlinks = true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -389,6 +389,7 @@ var _ = Describe("Watcher", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("should NOT send notification when nested ignored folder is deleted", func() {
|
It("should NOT send notification when nested ignored folder is deleted", func() {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
|
||||||
startEventProcessing()
|
startEventProcessing()
|
||||||
|
|
||||||
// Simulate deletion of music/rock/artist/temp (matches **/temp)
|
// Simulate deletion of music/rock/artist/temp (matches **/temp)
|
||||||
@ -402,6 +403,7 @@ var _ = Describe("Watcher", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("should send notification for non-ignored nested folder", func() {
|
It("should send notification for non-ignored nested folder", func() {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
|
||||||
startEventProcessing()
|
startEventProcessing()
|
||||||
|
|
||||||
// Simulate change in music/rock/artist (doesn't match any pattern)
|
// Simulate change in music/rock/artist (doesn't match any pattern)
|
||||||
@ -426,6 +428,7 @@ var _ = Describe("Watcher", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("should NOT send notification for file changes in ignored folders", func() {
|
It("should NOT send notification for file changes in ignored folders", func() {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
|
||||||
startEventProcessing()
|
startEventProcessing()
|
||||||
|
|
||||||
// Simulate file change in rock/_TEMP/file.mp3
|
// Simulate file change in rock/_TEMP/file.mp3
|
||||||
@ -464,11 +467,13 @@ var _ = Describe("resolveFolderPath", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("walks up to parent directory when given a file path", func() {
|
It("walks up to parent directory when given a file path", func() {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
|
||||||
result := resolveFolderPath(mockFS, "artist1/album1/track1.mp3")
|
result := resolveFolderPath(mockFS, "artist1/album1/track1.mp3")
|
||||||
Expect(result).To(Equal("artist1/album1"))
|
Expect(result).To(Equal("artist1/album1"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("walks up multiple levels if needed", func() {
|
It("walks up multiple levels if needed", func() {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
|
||||||
result := resolveFolderPath(mockFS, "artist1/album1/nonexistent/file.mp3")
|
result := resolveFolderPath(mockFS, "artist1/album1/nonexistent/file.mp3")
|
||||||
Expect(result).To(Equal("artist1/album1"))
|
Expect(result).To(Equal("artist1/album1"))
|
||||||
})
|
})
|
||||||
@ -489,6 +494,7 @@ var _ = Describe("resolveFolderPath", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("handles nested file paths correctly", func() {
|
It("handles nested file paths correctly", func() {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
|
||||||
result := resolveFolderPath(mockFS, "artist1/album2/song.flac")
|
result := resolveFolderPath(mockFS, "artist1/album2/song.flac")
|
||||||
Expect(result).To(Equal("artist1/album2"))
|
Expect(result).To(Equal("artist1/album2"))
|
||||||
})
|
})
|
||||||
|
|||||||
@ -470,6 +470,13 @@ var _ = BeforeSuite(func() {
|
|||||||
Expect(os.WriteFile(snapshotPath, data, 0600)).To(Succeed())
|
Expect(os.WriteFile(snapshotPath, data, 0600)).To(Succeed())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Close the database before the suite's TempDir cleanup runs. Required on
|
||||||
|
// Windows where open SQLite handles hold file locks that block temp-dir
|
||||||
|
// removal; harmless on other OSes.
|
||||||
|
var _ = AfterSuite(func() {
|
||||||
|
db.Close(ctx)
|
||||||
|
})
|
||||||
|
|
||||||
// setupTestDB restores the database from the golden snapshot and creates the
|
// setupTestDB restores the database from the golden snapshot and creates the
|
||||||
// Subsonic Router. Call this from BeforeEach/BeforeAll in each test container.
|
// Subsonic Router. Call this from BeforeEach/BeforeAll in each test container.
|
||||||
func setupTestDB() {
|
func setupTestDB() {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/resources"
|
"github.com/navidrome/navidrome/resources"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
@ -16,6 +17,7 @@ import (
|
|||||||
var _ = Describe("Translations", func() {
|
var _ = Describe("Translations", func() {
|
||||||
Describe("I18n files", func() {
|
Describe("I18n files", func() {
|
||||||
It("contains only valid json language files", func() {
|
It("contains only valid json language files", func() {
|
||||||
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-nativeapi)")
|
||||||
fsys := resources.FS()
|
fsys := resources.FS()
|
||||||
dir, _ := fsys.Open(consts.I18nFolder)
|
dir, _ := fsys.Open(consts.I18nFolder)
|
||||||
files, _ := dir.(fs.ReadDirFile).ReadDir(-1)
|
files, _ := dir.(fs.ReadDirFile).ReadDir(-1)
|
||||||
|
|||||||
@ -45,7 +45,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
|
|||||||
"variousArtistsId": consts.VariousArtistsID,
|
"variousArtistsId": consts.VariousArtistsID,
|
||||||
"baseURL": str.SanitizeText(strings.TrimSuffix(conf.Server.BasePath, "/")),
|
"baseURL": str.SanitizeText(strings.TrimSuffix(conf.Server.BasePath, "/")),
|
||||||
"loginBackgroundURL": str.SanitizeText(conf.Server.UILoginBackgroundURL),
|
"loginBackgroundURL": str.SanitizeText(conf.Server.UILoginBackgroundURL),
|
||||||
"welcomeMessage": str.SanitizeText(conf.Server.UIWelcomeMessage),
|
"welcomeMessage": str.SanitizeHTML(conf.Server.UIWelcomeMessage),
|
||||||
"maxSidebarPlaylists": conf.Server.MaxSidebarPlaylists,
|
"maxSidebarPlaylists": conf.Server.MaxSidebarPlaylists,
|
||||||
"enableTranscodingConfig": conf.Server.EnableTranscodingConfig,
|
"enableTranscodingConfig": conf.Server.EnableTranscodingConfig,
|
||||||
"enableDownloads": conf.Server.EnableDownloads,
|
"enableDownloads": conf.Server.EnableDownloads,
|
||||||
|
|||||||
@ -108,6 +108,18 @@ var _ = Describe("serveIndex", func() {
|
|||||||
Entry("extAuthLogoutURL", func() { conf.Server.ExtAuth.LogoutURL = "https://auth.example.com/logout" }, "extAuthLogoutURL", "https://auth.example.com/logout"),
|
Entry("extAuthLogoutURL", func() { conf.Server.ExtAuth.LogoutURL = "https://auth.example.com/logout" }, "extAuthLogoutURL", "https://auth.example.com/logout"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
It("sanitizes entity-encoded welcomeMessage as html", func() {
|
||||||
|
conf.Server.UIWelcomeMessage = `<img src=x onerror=alert(1)><b>Hello</b>`
|
||||||
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
serveIndex(ds, fs, nil)(w, r)
|
||||||
|
|
||||||
|
config := extractAppConfig(w.Body.String())
|
||||||
|
Expect(config).To(HaveKey("welcomeMessage"))
|
||||||
|
Expect(config["welcomeMessage"]).To(Equal(`<img src="x"><b>Hello</b>`))
|
||||||
|
})
|
||||||
|
|
||||||
DescribeTable("sets other UI configuration values",
|
DescribeTable("sets other UI configuration values",
|
||||||
func(configKey string, expectedValueFunc func() any) {
|
func(configKey string, expectedValueFunc func() any) {
|
||||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||||
|
|||||||
@ -30,6 +30,7 @@ var _ = Describe("createUnixSocketFile", func() {
|
|||||||
|
|
||||||
When("unixSocketPerm is valid", func() {
|
When("unixSocketPerm is valid", func() {
|
||||||
It("updates the permission of the unix socket file and returns nil", func() {
|
It("updates the permission of the unix socket file and returns nil", func() {
|
||||||
|
tests.SkipOnWindows("uses Unix file permission bits")
|
||||||
_, err := createUnixSocketFile(socketPath, "0777")
|
_, err := createUnixSocketFile(socketPath, "0777")
|
||||||
fileInfo, _ := os.Stat(socketPath)
|
fileInfo, _ := os.Stat(socketPath)
|
||||||
actualPermission := fileInfo.Mode().Perm()
|
actualPermission := fileInfo.Mode().Perm()
|
||||||
@ -50,6 +51,7 @@ var _ = Describe("createUnixSocketFile", func() {
|
|||||||
|
|
||||||
When("file already exists", func() {
|
When("file already exists", func() {
|
||||||
It("recreates the file as a socket with the right permissions", func() {
|
It("recreates the file as a socket with the right permissions", func() {
|
||||||
|
tests.SkipOnWindows("uses Unix file permission bits")
|
||||||
_, err := os.Create(socketPath)
|
_, err := os.Create(socketPath)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(os.Chmod(socketPath, os.FileMode(0777))).To(Succeed())
|
Expect(os.Chmod(socketPath, os.FileMode(0777))).To(Succeed())
|
||||||
|
|||||||
@ -32,7 +32,9 @@ var _ = Describe("MediaAnnotationController", func() {
|
|||||||
|
|
||||||
Describe("Scrobble", func() {
|
Describe("Scrobble", func() {
|
||||||
It("submit all scrobbles with only the id", func() {
|
It("submit all scrobbles with only the id", func() {
|
||||||
submissionTime := time.Now()
|
// Back-date the baseline so the assertion still passes on platforms
|
||||||
|
// with millisecond clock resolution (e.g. Windows).
|
||||||
|
submissionTime := time.Now().Add(-time.Second)
|
||||||
r := newGetRequest("id=12", "id=34")
|
r := newGetRequest("id=12", "id=34")
|
||||||
|
|
||||||
_, err := router.Scrobble(r)
|
_, err := router.Scrobble(r)
|
||||||
|
|||||||
@ -4,14 +4,25 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model/id"
|
"github.com/navidrome/navidrome/model/id"
|
||||||
|
"github.com/onsi/ginkgo/v2"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/sirupsen/logrus/hooks/test"
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SkipOnWindows marks the current spec (or surrounding BeforeEach) as skipped
|
||||||
|
// when running on Windows. The reason is included in the Ginkgo output so the
|
||||||
|
// backlog of Windows-skipped tests stays auditable.
|
||||||
|
func SkipOnWindows(reason string) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
ginkgo.Skip("not supported on Windows: " + reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type testingT interface {
|
type testingT interface {
|
||||||
TempDir() string
|
TempDir() string
|
||||||
}
|
}
|
||||||
@ -20,10 +31,20 @@ func TempFileName(t testingT, prefix, suffix string) string {
|
|||||||
return filepath.Join(t.TempDir(), prefix+id.NewRandom()+suffix)
|
return filepath.Join(t.TempDir(), prefix+id.NewRandom()+suffix)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TempFile creates an empty file in t.TempDir() and returns the closed handle.
|
||||||
|
// The handle is returned for backward compatibility, but is already closed so
|
||||||
|
// callers don't need to. On Windows, leaving the handle open would hold a file
|
||||||
|
// lock and block Ginkgo's TempDir cleanup.
|
||||||
func TempFile(t testingT, prefix, suffix string) (*os.File, string, error) {
|
func TempFile(t testingT, prefix, suffix string) (*os.File, string, error) {
|
||||||
name := TempFileName(t, prefix, suffix)
|
name := TempFileName(t, prefix, suffix)
|
||||||
f, err := os.Create(name)
|
f, err := os.Create(name)
|
||||||
return f, name, err
|
if err != nil {
|
||||||
|
return nil, name, err
|
||||||
|
}
|
||||||
|
if cerr := f.Close(); cerr != nil {
|
||||||
|
return f, name, cerr
|
||||||
|
}
|
||||||
|
return f, name, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearDB deletes all tables and data from the database
|
// ClearDB deletes all tables and data from the database
|
||||||
|
|||||||
@ -192,6 +192,10 @@ var _ = Describe("FileExists", func() {
|
|||||||
filePath := tempFile.Name()
|
filePath := tempFile.Name()
|
||||||
Expect(utils.FileExists(filePath)).To(BeTrue())
|
Expect(utils.FileExists(filePath)).To(BeTrue())
|
||||||
|
|
||||||
|
// Close the file before removing it. On Windows, an open handle
|
||||||
|
// holds a file lock and os.Remove fails; closing first makes the
|
||||||
|
// test cross-platform.
|
||||||
|
Expect(tempFile.Close()).To(Succeed())
|
||||||
err := os.Remove(filePath)
|
err := os.Remove(filePath)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
tempFile = nil // Prevent cleanup attempt
|
tempFile = nil // Prevent cleanup attempt
|
||||||
|
|||||||
@ -38,11 +38,20 @@ func SanitizeStrings(text ...string) string {
|
|||||||
|
|
||||||
var policy = bluemonday.UGCPolicy()
|
var policy = bluemonday.UGCPolicy()
|
||||||
|
|
||||||
|
// SanitizeText unescapes the input string before sanitizing it as text.
|
||||||
|
// This should be used for fields rendered as plain text in the UI (e.g. lyrics, song titles, artist names)
|
||||||
func SanitizeText(text string) string {
|
func SanitizeText(text string) string {
|
||||||
s := policy.Sanitize(text)
|
s := policy.Sanitize(text)
|
||||||
return html.UnescapeString(s)
|
return html.UnescapeString(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SanitizeHTML unescapes the input string before sanitizing it as HTML.
|
||||||
|
// This should be used for fields rendered as HTML by clients (e.g. biographies, welcome messages)
|
||||||
|
// to prevent XSS bypasses via entity-encoded tags.
|
||||||
|
func SanitizeHTML(text string) string {
|
||||||
|
return policy.Sanitize(html.UnescapeString(text))
|
||||||
|
}
|
||||||
|
|
||||||
func SanitizeFieldForSorting(originalValue string) string {
|
func SanitizeFieldForSorting(originalValue string) string {
|
||||||
v := strings.TrimSpace(sanitize.Accents(originalValue))
|
v := strings.TrimSpace(sanitize.Accents(originalValue))
|
||||||
return Clear(strings.ToLower(v))
|
return Clear(strings.ToLower(v))
|
||||||
|
|||||||
@ -64,6 +64,35 @@ var _ = Describe("Sanitize Strings", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("SanitizeText", func() {
|
||||||
|
It("preserves decoded plaintext", func() {
|
||||||
|
Expect(str.SanitizeText("Tom & Jerry")).To(Equal("Tom & Jerry"))
|
||||||
|
Expect(str.SanitizeText("Tom & Jerry")).To(Equal("Tom & Jerry"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("keeps entity-encoded html readable", func() {
|
||||||
|
Expect(str.SanitizeText(`<b>ok</b>`)).To(Equal("<b>ok</b>"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("SanitizeHTML", func() {
|
||||||
|
It("removes dangerous content from raw html", func() {
|
||||||
|
sanitized := str.SanitizeHTML(`<img src=x onerror=alert(1)><script>alert(2)</script><b>ok</b>`)
|
||||||
|
|
||||||
|
Expect(sanitized).To(ContainSubstring("<b>ok</b>"))
|
||||||
|
Expect(sanitized).ToNot(ContainSubstring("onerror"))
|
||||||
|
Expect(sanitized).ToNot(ContainSubstring("<script"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("removes dangerous content from entity-encoded html", func() {
|
||||||
|
sanitized := str.SanitizeHTML(`<img src=x onerror=alert(1)><script>alert(2)</script><b>ok</b>`)
|
||||||
|
|
||||||
|
Expect(sanitized).To(ContainSubstring("<b>ok</b>"))
|
||||||
|
Expect(sanitized).ToNot(ContainSubstring("onerror"))
|
||||||
|
Expect(sanitized).ToNot(ContainSubstring("<script"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Describe("SanitizeFieldForSortingNoArticle", func() {
|
Describe("SanitizeFieldForSortingNoArticle", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
conf.Server.IgnoredArticles = "The O"
|
conf.Server.IgnoredArticles = "The O"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user