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 .
|
||||
./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:
|
||||
name: Test JS code
|
||||
runs-on: ubuntu-latest
|
||||
@ -184,7 +257,7 @@ jobs:
|
||||
|
||||
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:
|
||||
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 ]
|
||||
|
||||
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=false; \
|
||||
if PATH=$$PATH:./bin 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); \
|
||||
if PATH=./bin:$$PATH which golangci-lint > /dev/null 2>&1; then \
|
||||
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//'); \
|
||||
if [ "$$CURRENT_VERSION" != "$$REQUIRED_VERSION" ]; then \
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
lintall: lint ##@Development Lint Go and JS code
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@ -213,6 +214,7 @@ var _ = Describe("Extractor", func() {
|
||||
// Only run permission tests if we are not root
|
||||
RegularUserContext("when run without root privileges", func() {
|
||||
BeforeEach(func() {
|
||||
tests.SkipOnWindows("uses Unix file permission bits")
|
||||
// Use root fs for absolute paths in temp directory
|
||||
e = &extractor{fs: os.DirFS("/")}
|
||||
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
|
||||
|
||||
@ -27,6 +27,7 @@ type configOptions struct {
|
||||
Address string
|
||||
Port int
|
||||
UnixSocketPerm string
|
||||
EnforceNonRootUser bool
|
||||
MusicFolder string
|
||||
DataFolder string
|
||||
CacheFolder string
|
||||
@ -60,8 +61,8 @@ type configOptions struct {
|
||||
SmartPlaylistRefreshDelay time.Duration
|
||||
AutoTranscodeDownload bool
|
||||
DefaultDownsamplingFormat string
|
||||
Search searchOptions `json:",omitzero"`
|
||||
SimilarSongsMatchThreshold int
|
||||
Search searchOptions `json:",omitzero"`
|
||||
Matcher matcherOptions `json:",omitzero"`
|
||||
RecentlyAddedByModTime bool
|
||||
PreferSortTags bool
|
||||
IgnoredArticles string
|
||||
@ -262,6 +263,11 @@ type searchOptions struct {
|
||||
FullString bool
|
||||
}
|
||||
|
||||
type matcherOptions struct {
|
||||
PreferStarred bool
|
||||
FuzzyThreshold int
|
||||
}
|
||||
|
||||
// logFatal prints a fatal error message to stderr and exits.
|
||||
// Overridden in tests to allow testing fatal paths.
|
||||
var logFatal = func(args ...any) {
|
||||
@ -269,6 +275,12 @@ var logFatal = func(args ...any) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var getEUID = os.Geteuid
|
||||
|
||||
var currentGOOS = func() string {
|
||||
return runtime.GOOS
|
||||
}
|
||||
|
||||
var (
|
||||
Server = &configOptions{}
|
||||
hooks []func()
|
||||
@ -292,12 +304,18 @@ func Load(noConfigDump bool) {
|
||||
mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality")
|
||||
mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
|
||||
|
||||
err := viper.Unmarshal(&Server)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
logFatal("Error creating data path:", err)
|
||||
@ -425,6 +443,7 @@ func Load(noConfigDump bool) {
|
||||
logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality")
|
||||
logDeprecatedOptions("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
|
||||
|
||||
// Removed options
|
||||
logRemovedOptions("Spotify.ID", "Spotify.Secret")
|
||||
@ -593,6 +612,18 @@ func validateMaxImageUploadSize() error {
|
||||
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 {
|
||||
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
|
||||
Server.Scanner.Schedule = ""
|
||||
@ -692,6 +723,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("address", "0.0.0.0")
|
||||
viper.SetDefault("port", 4533)
|
||||
viper.SetDefault("unixsocketperm", "0660")
|
||||
viper.SetDefault("enforcenonrootuser", false)
|
||||
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
|
||||
viper.SetDefault("baseurl", "")
|
||||
viper.SetDefault("tlscert", "")
|
||||
@ -717,7 +749,8 @@ func setViperDefaults() {
|
||||
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
|
||||
viper.SetDefault("search.fullstring", false)
|
||||
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("prefersorttags", false)
|
||||
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",
|
||||
func(format string) {
|
||||
filename := filepath.Join("testdata", "cfg."+format)
|
||||
|
||||
@ -16,6 +16,17 @@ var ToPascalCase = toPascalCase
|
||||
|
||||
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() {
|
||||
old := logFatal
|
||||
logFatal = f
|
||||
|
||||
@ -7,12 +7,11 @@ import (
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
_ "github.com/gen2brain/webp"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@ -81,6 +80,7 @@ var _ = Describe("Artwork", func() {
|
||||
})
|
||||
})
|
||||
It("returns embed cover", func() {
|
||||
tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)")
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alOnlyEmbed.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
@ -104,6 +104,7 @@ var _ = Describe("Artwork", func() {
|
||||
})
|
||||
})
|
||||
It("returns external cover", func() {
|
||||
tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)")
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"front.png"},
|
||||
@ -134,6 +135,7 @@ var _ = Describe("Artwork", func() {
|
||||
})
|
||||
DescribeTable("CoverArtPriority",
|
||||
func(priority string, expected string) {
|
||||
tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)")
|
||||
conf.Server.CoverArtPriority = priority
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil)
|
||||
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"),
|
||||
)
|
||||
})
|
||||
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() {
|
||||
Context("Multiple covers", func() {
|
||||
@ -166,6 +213,7 @@ var _ = Describe("Artwork", func() {
|
||||
})
|
||||
DescribeTable("ArtistArtPriority",
|
||||
func(priority string, expected string) {
|
||||
tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)")
|
||||
conf.Server.ArtistArtPriority = priority
|
||||
aw, err := newArtistArtworkReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@ -203,6 +251,7 @@ var _ = Describe("Artwork", func() {
|
||||
})
|
||||
})
|
||||
It("returns embed cover", func() {
|
||||
tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)")
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, mfWithEmbed.CoverArtID())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
@ -210,6 +259,7 @@ var _ = Describe("Artwork", func() {
|
||||
Expect(path).To(Equal("tests/fixtures/test.mp3"))
|
||||
})
|
||||
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())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
r, path, err := aw.Reader(ctx)
|
||||
|
||||
@ -72,7 +72,7 @@ func (a *albumArtworkReader) Key() string {
|
||||
)
|
||||
}
|
||||
func (a *albumArtworkReader) LastUpdated() time.Time {
|
||||
return a.album.UpdatedAt
|
||||
return a.lastUpdate
|
||||
}
|
||||
|
||||
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/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -61,6 +62,7 @@ var _ = Describe("artistArtworkReader", func() {
|
||||
|
||||
When("artist has only one album", func() {
|
||||
It("returns the parent folder", func() {
|
||||
tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)")
|
||||
paths = []string{
|
||||
filepath.FromSlash("/music/artist/album1"),
|
||||
}
|
||||
@ -86,6 +88,7 @@ var _ = Describe("artistArtworkReader", func() {
|
||||
|
||||
When("the album paths contain same prefix", func() {
|
||||
It("returns the common prefix", func() {
|
||||
tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)")
|
||||
paths = []string{
|
||||
filepath.FromSlash("/music/artist/album1"),
|
||||
filepath.FromSlash("/music/artist/album2"),
|
||||
|
||||
@ -116,7 +116,7 @@ func (d *discArtworkReader) Key() string {
|
||||
}
|
||||
|
||||
func (d *discArtworkReader) LastUpdated() time.Time {
|
||||
return d.album.UpdatedAt
|
||||
return d.lastUpdate
|
||||
}
|
||||
|
||||
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() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-core)")
|
||||
ctx := context.Background()
|
||||
abs := AbsolutePath(ctx, ds, libId, path)
|
||||
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() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
// Disable fuzzy matching for these tests to avoid unexpected GetAll calls
|
||||
conf.Server.SimilarSongsMatchThreshold = 100
|
||||
conf.Server.Matcher.FuzzyThreshold = 100
|
||||
|
||||
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())
|
||||
})
|
||||
|
||||
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() {
|
||||
now := time.Now()
|
||||
originalArtist := &model.Artist{
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@ -93,6 +94,7 @@ var _ = Describe("sources", func() {
|
||||
var accessForbiddenFile string
|
||||
|
||||
BeforeEach(func() {
|
||||
tests.SkipOnWindows("uses Unix file permission bits")
|
||||
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
|
||||
|
||||
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
|
||||
//
|
||||
// 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)
|
||||
// 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 4: Title + Artist MBID + Album name (fuzzy)
|
||||
// - Level 3: Title + Artist name + Album name (fuzzy)
|
||||
// - Level 2: Title + Artist MBID
|
||||
// - Level 1: Title + Artist name
|
||||
// - Level 0: Title only
|
||||
// 4. Album similarity (Jaro-Winkler, as final tiebreaker)
|
||||
// 5. Album similarity (Jaro-Winkler, as final tiebreaker)
|
||||
//
|
||||
// # Examples
|
||||
//
|
||||
@ -250,6 +252,7 @@ type songQuery struct {
|
||||
type matchScore struct {
|
||||
titleSimilarity float64
|
||||
durationProximity float64
|
||||
preferredMatch bool
|
||||
albumSimilarity float64
|
||||
specificityLevel int
|
||||
}
|
||||
@ -262,6 +265,9 @@ func (s matchScore) betterThan(other matchScore) bool {
|
||||
if s.durationProximity != other.durationProximity {
|
||||
return s.durationProximity > other.durationProximity
|
||||
}
|
||||
if s.preferredMatch != other.preferredMatch {
|
||||
return s.preferredMatch
|
||||
}
|
||||
if 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
|
||||
}
|
||||
|
||||
threshold := float64(conf.Server.SimilarSongsMatchThreshold) / 100.0
|
||||
threshold := float64(conf.Server.Matcher.FuzzyThreshold) / 100.0
|
||||
|
||||
byArtist := map[string][]songQuery{}
|
||||
for _, q := range queries {
|
||||
@ -393,6 +399,7 @@ func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, t
|
||||
score := matchScore{
|
||||
titleSimilarity: titleSim,
|
||||
durationProximity: durationProximity(q.durationMs, t.mf.Duration),
|
||||
preferredMatch: conf.Server.Matcher.PreferStarred && isPreferredTrack(t.mf),
|
||||
albumSimilarity: albumSim,
|
||||
specificityLevel: computeSpecificityLevel(q, t, threshold),
|
||||
}
|
||||
@ -406,6 +413,10 @@ func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, t
|
||||
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.
|
||||
func (m *Matcher) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery {
|
||||
var queries []songQuery
|
||||
|
||||
@ -78,7 +78,7 @@ var _ = Describe("Matcher", func() {
|
||||
Describe("MatchSongsToLibrary", func() {
|
||||
Context("matching by direct 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{
|
||||
{ID: "track-1", Name: "Some Song", Artist: "Some Artist"},
|
||||
}
|
||||
@ -96,7 +96,7 @@ var _ = Describe("Matcher", func() {
|
||||
|
||||
Context("matching by MBID", 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{
|
||||
{Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"},
|
||||
}
|
||||
@ -115,7 +115,7 @@ var _ = Describe("Matcher", func() {
|
||||
|
||||
Context("matching by ISRC", 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{
|
||||
{Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"},
|
||||
}
|
||||
@ -134,7 +134,7 @@ var _ = Describe("Matcher", func() {
|
||||
|
||||
Context("fuzzy title+artist matching", func() {
|
||||
It("matches songs by title and artist name", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 100
|
||||
conf.Server.Matcher.FuzzyThreshold = 100
|
||||
songs := []agents.Song{
|
||||
{Name: "Enjoy the Silence", Artist: "Depeche Mode"},
|
||||
}
|
||||
@ -149,7 +149,7 @@ var _ = Describe("Matcher", func() {
|
||||
})
|
||||
|
||||
It("matches songs with fuzzy title similarity", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
conf.Server.Matcher.FuzzyThreshold = 85
|
||||
songs := []agents.Song{
|
||||
{Name: "Bohemian Rhapsody", Artist: "Queen"},
|
||||
}
|
||||
@ -164,7 +164,7 @@ var _ = Describe("Matcher", func() {
|
||||
})
|
||||
|
||||
It("does not match completely different titles", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
conf.Server.Matcher.FuzzyThreshold = 85
|
||||
songs := []agents.Song{
|
||||
{Name: "Yesterday", Artist: "The Beatles"},
|
||||
}
|
||||
@ -180,7 +180,7 @@ var _ = Describe("Matcher", func() {
|
||||
|
||||
Context("deduplication", 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{
|
||||
{Name: "Bohemian Rhapsody (Live)", 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() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
conf.Server.Matcher.FuzzyThreshold = 85
|
||||
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"},
|
||||
@ -215,7 +215,7 @@ var _ = Describe("Matcher", func() {
|
||||
|
||||
Context("priority ordering", 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
|
||||
// and short-circuit the MBID phase entirely, so no MBID fetch should
|
||||
// occur even though an mbz_recording_id exists in the input.
|
||||
@ -236,7 +236,7 @@ var _ = Describe("Matcher", func() {
|
||||
|
||||
Context("count limit", func() {
|
||||
It("returns at most 'count' results", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 100
|
||||
conf.Server.Matcher.FuzzyThreshold = 100
|
||||
songs := []agents.Song{
|
||||
{Name: "Song A", Artist: "Artist"},
|
||||
{Name: "Song B", Artist: "Artist"},
|
||||
@ -265,7 +265,7 @@ var _ = Describe("Matcher", func() {
|
||||
|
||||
Describe("specificity level matching", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 100
|
||||
conf.Server.Matcher.FuzzyThreshold = 100
|
||||
})
|
||||
|
||||
It("matches by title + artist MBID + album MBID (highest priority)", func() {
|
||||
@ -396,7 +396,7 @@ var _ = Describe("Matcher", func() {
|
||||
Describe("fuzzy matching thresholds", func() {
|
||||
Context("with default threshold (85%)", func() {
|
||||
It("matches songs with remastered suffix", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
conf.Server.Matcher.FuzzyThreshold = 85
|
||||
|
||||
songs := []agents.Song{
|
||||
{Name: "Paranoid Android", Artist: "Radiohead"},
|
||||
@ -415,7 +415,7 @@ var _ = Describe("Matcher", func() {
|
||||
})
|
||||
|
||||
It("matches songs with live suffix", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
conf.Server.Matcher.FuzzyThreshold = 85
|
||||
|
||||
songs := []agents.Song{
|
||||
{Name: "Bohemian Rhapsody", Artist: "Queen"},
|
||||
@ -436,7 +436,7 @@ var _ = Describe("Matcher", func() {
|
||||
|
||||
Context("with threshold set to 100 (exact match only)", func() {
|
||||
It("only matches exact titles", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 100
|
||||
conf.Server.Matcher.FuzzyThreshold = 100
|
||||
|
||||
songs := []agents.Song{
|
||||
{Name: "Paranoid Android", Artist: "Radiohead"},
|
||||
@ -456,7 +456,7 @@ var _ = Describe("Matcher", func() {
|
||||
|
||||
Context("with lower threshold (75%)", func() {
|
||||
It("matches more aggressively", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 75
|
||||
conf.Server.Matcher.FuzzyThreshold = 75
|
||||
|
||||
songs := []agents.Song{
|
||||
{Name: "Song", Artist: "Artist"},
|
||||
@ -478,7 +478,8 @@ var _ = Describe("Matcher", func() {
|
||||
|
||||
Describe("fuzzy album matching", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
conf.Server.Matcher.FuzzyThreshold = 85
|
||||
conf.Server.Matcher.PreferStarred = false
|
||||
})
|
||||
|
||||
It("matches album with (Remaster) suffix", func() {
|
||||
@ -540,11 +541,53 @@ var _ = Describe("Matcher", func() {
|
||||
Expect(result).To(HaveLen(1))
|
||||
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() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 100
|
||||
conf.Server.Matcher.FuzzyThreshold = 100
|
||||
})
|
||||
|
||||
It("prefers tracks with matching duration", func() {
|
||||
@ -678,7 +721,7 @@ var _ = Describe("Matcher", func() {
|
||||
|
||||
Describe("deduplication edge cases", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
conf.Server.Matcher.FuzzyThreshold = 85
|
||||
})
|
||||
|
||||
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/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -199,6 +200,7 @@ var _ = Describe("MPV", 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)
|
||||
defer cancel()
|
||||
|
||||
@ -226,6 +228,7 @@ var _ = Describe("MPV", 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)
|
||||
defer cancel()
|
||||
|
||||
@ -253,6 +256,7 @@ var _ = Describe("MPV", 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)
|
||||
defer cancel()
|
||||
|
||||
|
||||
@ -183,6 +183,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
})
|
||||
|
||||
It("rejects #EXTALBUMARTURL with absolute path outside library boundaries", func() {
|
||||
tests.SkipOnWindows("relies on Unix /etc filesystem")
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
|
||||
m3u := "#EXTALBUMARTURL:/etc/passwd\ntest.mp3\n"
|
||||
@ -320,6 +321,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
|
||||
})
|
||||
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")
|
||||
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)",
|
||||
func(storedForm, filesystemForm string) {
|
||||
tests.SkipOnWindows("/tmp hardcoded in test")
|
||||
// Use Polish characters that decompose: ó (U+00F3) -> o + combining acute (U+006F + U+0301)
|
||||
plsNameNFC := "Piosenki_Polskie_zółć" // NFC form (composed)
|
||||
plsNameNFD := norm.NFD.String(plsNameNFC)
|
||||
@ -821,6 +824,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
})
|
||||
|
||||
It("returns true if folder is in PlaylistsPath", func() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)")
|
||||
conf.Server.PlaylistsPath = "other/**:playlists/**"
|
||||
Expect(playlists.InPath(folder)).To(BeTrue())
|
||||
})
|
||||
|
||||
@ -15,6 +15,7 @@ var _ = Describe("libraryMatcher", func() {
|
||||
ctx := context.Background()
|
||||
|
||||
BeforeEach(func() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)")
|
||||
mockLibRepo = &tests.MockLibraryRepo{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedLibrary: mockLibRepo,
|
||||
@ -196,6 +197,7 @@ var _ = Describe("pathResolver", func() {
|
||||
ctx := context.Background()
|
||||
|
||||
BeforeEach(func() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)")
|
||||
mockLibRepo = &tests.MockLibraryRepo{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedLibrary: mockLibRepo,
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/storage"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -44,6 +45,10 @@ var _ = Describe("LocalStorage", func() {
|
||||
})
|
||||
|
||||
Describe("newLocalStorage", func() {
|
||||
BeforeEach(func() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
|
||||
})
|
||||
|
||||
Context("with valid path", func() {
|
||||
It("should create a localStorage instance with correct path", func() {
|
||||
u, err := url.Parse("file://" + tempDir)
|
||||
@ -166,6 +171,10 @@ var _ = Describe("LocalStorage", func() {
|
||||
})
|
||||
|
||||
Describe("localStorage.FS", func() {
|
||||
BeforeEach(func() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
|
||||
})
|
||||
|
||||
Context("with existing directory", func() {
|
||||
It("should return a localFS instance", func() {
|
||||
u, err := url.Parse("file://" + tempDir)
|
||||
@ -199,6 +208,7 @@ var _ = Describe("LocalStorage", func() {
|
||||
var testFile string
|
||||
|
||||
BeforeEach(func() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
|
||||
// Create a test file
|
||||
testFile = filepath.Join(tempDir, "test.mp3")
|
||||
err := os.WriteFile(testFile, []byte("test data"), 0600)
|
||||
@ -380,6 +390,7 @@ var _ = Describe("LocalStorage", func() {
|
||||
|
||||
Describe("Storage registration", 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
|
||||
storage, err := storage.For("file://" + tempDir)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -54,6 +55,7 @@ var _ = Describe("Storage", func() {
|
||||
Expect(s.(*fakeLocalStorage).u.Path).To(Equal("/tmp"))
|
||||
})
|
||||
It("should return a file implementation for a relative folder", func() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage)")
|
||||
s, err := For("tmp")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
cwd, _ := os.Getwd()
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -92,6 +93,7 @@ var _ = Describe("Folder", func() {
|
||||
|
||||
When("the folder has multiple subdirs", func() {
|
||||
It("should return the correct folder ID", func() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)")
|
||||
folderPath := filepath.FromSlash("/music/rock/metal")
|
||||
expectedID := id.NewHash("1:rock/metal")
|
||||
Expect(model.FolderID(lib, folderPath)).To(Equal(expectedID))
|
||||
@ -101,6 +103,7 @@ var _ = Describe("Folder", func() {
|
||||
|
||||
Describe("NewFolder", 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")
|
||||
folder := model.NewFolder(lib, folderPath)
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
. "github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -22,7 +23,7 @@ var _ = Describe("MediaFiles", func() {
|
||||
SortAlbumName: "SortAlbumName", SortArtistName: "SortArtistName", SortAlbumArtistName: "SortAlbumArtistName",
|
||||
OrderAlbumName: "OrderAlbumName", OrderAlbumArtistName: "OrderAlbumArtistName",
|
||||
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",
|
||||
@ -30,7 +31,7 @@ var _ = Describe("MediaFiles", func() {
|
||||
OrderAlbumName: "OrderAlbumName", OrderArtistName: "OrderArtistName", OrderAlbumArtistName: "OrderAlbumArtistName",
|
||||
MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment",
|
||||
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.CatalogNum).To(Equal("CatalogNum"))
|
||||
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"))
|
||||
})
|
||||
})
|
||||
@ -447,6 +448,9 @@ var _ = Describe("MediaFiles", func() {
|
||||
|
||||
DescribeTable("generates correct output",
|
||||
func(absolutePaths bool, expectedContent string) {
|
||||
if absolutePaths {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)")
|
||||
}
|
||||
result := mfs.ToM3U8("Multi Track", absolutePaths)
|
||||
Expect(result).To(Equal(expectedContent))
|
||||
},
|
||||
@ -467,6 +471,7 @@ var _ = Describe("MediaFiles", func() {
|
||||
|
||||
Context("path variations", func() {
|
||||
It("handles different path structures", func() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)")
|
||||
mfs = MediaFiles{
|
||||
{Title: "Root", Artist: "Artist", Duration: 60, Path: "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/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -79,6 +80,7 @@ var _ = Describe("getPID", func() {
|
||||
})
|
||||
When("field is folder", func() {
|
||||
It("should return the pid", func() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-metadata)")
|
||||
spec := "folder|title"
|
||||
md.tags = map[model.TagName][]string{"title": {"title"}}
|
||||
mf.Path = "/path/to/file.mp3"
|
||||
|
||||
@ -2,6 +2,7 @@ package model_test
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -27,6 +28,7 @@ var _ = Describe("Playlist", func() {
|
||||
}
|
||||
})
|
||||
It("generates the correct M3U format", func() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)")
|
||||
expected := `#EXTM3U
|
||||
#PLAYLIST:Mellow sunset
|
||||
#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/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/pocketbase/dbx"
|
||||
@ -99,6 +100,7 @@ var _ = Describe("FolderRepository", 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
|
||||
parent := model.NewFolder(testLib, "TestParent/Music")
|
||||
child1 := model.NewFolder(testLib, "TestParent/Music/Rock/Queen")
|
||||
@ -120,6 +122,7 @@ var _ = Describe("FolderRepository", func() {
|
||||
})
|
||||
|
||||
It("excludes children from other libraries", func() {
|
||||
tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)")
|
||||
// Create parent in testLib
|
||||
parent := model.NewFolder(testLib, "TestIsolation/Parent")
|
||||
child := model.NewFolder(testLib, "TestIsolation/Parent/Child")
|
||||
@ -145,6 +148,7 @@ var _ = Describe("FolderRepository", 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
|
||||
parent := model.NewFolder(testLib, "TestMissingChild/Parent")
|
||||
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() {
|
||||
tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)")
|
||||
// Create folders for one path but not the other
|
||||
existingParent := model.NewFolder(testLib, "TestMixed/Exists")
|
||||
existingChild := model.NewFolder(testLib, "TestMixed/Exists/Child")
|
||||
|
||||
@ -2,6 +2,7 @@ package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@ -64,6 +65,11 @@ var _ = Describe("LibraryRepository", func() {
|
||||
originalID := lib.ID
|
||||
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
|
||||
lib.Name = "Updated Library"
|
||||
lib.Path = "/music/updated"
|
||||
|
||||
@ -48,10 +48,10 @@ var _ = Describe("MediaRepository", func() {
|
||||
var mp3File, flacFile1, flacFile2, flacUpperFile model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
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"}
|
||||
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"}
|
||||
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"}
|
||||
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"}
|
||||
|
||||
Expect(mr.Put(&mp3File)).To(Succeed())
|
||||
Expect(mr.Put(&flacFile1)).To(Succeed())
|
||||
@ -109,7 +109,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
Describe("Put CreatedAt behavior (#5050)", func() {
|
||||
It("sets CreatedAt to now when inserting a new file with zero CreatedAt", func() {
|
||||
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())
|
||||
|
||||
retrieved, err := mr.Get(newFile.ID)
|
||||
@ -124,7 +124,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
newFile := model.MediaFile{
|
||||
ID: id.NewRandom(),
|
||||
LibraryID: 1,
|
||||
Path: "/test/created-at-preserved.mp3",
|
||||
Path: "test/created-at-preserved.mp3",
|
||||
CreatedAt: originalTime,
|
||||
}
|
||||
Expect(mr.Put(&newFile)).To(Succeed())
|
||||
@ -142,7 +142,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
newFile := model.MediaFile{
|
||||
ID: fileID,
|
||||
LibraryID: 1,
|
||||
Path: "/test/created-at-update.mp3",
|
||||
Path: "test/created-at-update.mp3",
|
||||
Title: "Original Title",
|
||||
CreatedAt: originalTime,
|
||||
}
|
||||
@ -152,7 +152,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
updatedFile := model.MediaFile{
|
||||
ID: fileID,
|
||||
LibraryID: 1,
|
||||
Path: "/test/created-at-update.mp3",
|
||||
Path: "test/created-at-update.mp3",
|
||||
Title: "Updated Title",
|
||||
// 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() {
|
||||
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)
|
||||
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() {
|
||||
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())
|
||||
|
||||
mf, err := mr.Get(newID)
|
||||
@ -255,7 +255,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
|
||||
It("calculates average across multiple users", func() {
|
||||
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())
|
||||
|
||||
@ -273,7 +273,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
|
||||
It("excludes zero ratings from average calculation", func() {
|
||||
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())
|
||||
|
||||
@ -343,19 +343,19 @@ var _ = Describe("MediaRepository", func() {
|
||||
ID: id.NewRandom(),
|
||||
LibraryID: 1,
|
||||
Title: "Old Song",
|
||||
Path: "/test/old.mp3",
|
||||
Path: "test/old.mp3",
|
||||
},
|
||||
{
|
||||
ID: id.NewRandom(),
|
||||
LibraryID: 1,
|
||||
Title: "Middle Song",
|
||||
Path: "/test/middle.mp3",
|
||||
Path: "test/middle.mp3",
|
||||
},
|
||||
{
|
||||
ID: id.NewRandom(),
|
||||
LibraryID: 1,
|
||||
Title: "New Song",
|
||||
Path: "/test/new.mp3",
|
||||
Path: "test/new.mp3",
|
||||
},
|
||||
}
|
||||
|
||||
@ -486,7 +486,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
var mfWithoutAnnotation model.MediaFile
|
||||
|
||||
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())
|
||||
})
|
||||
|
||||
@ -566,7 +566,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
MbzRecordingID: "550e8400-e29b-41d4-a716-446655440020", // Valid UUID v4
|
||||
MbzReleaseTrackID: "550e8400-e29b-41d4-a716-446655440021", // Valid UUID v4
|
||||
LibraryID: 1,
|
||||
Path: "/test/path/test.mp3",
|
||||
Path: "test/path/test.mp3",
|
||||
}
|
||||
|
||||
// Insert the test media file into the database
|
||||
@ -608,7 +608,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
Title: "Test Missing MBID MediaFile",
|
||||
MbzRecordingID: "550e8400-e29b-41d4-a716-446655440022",
|
||||
LibraryID: 1,
|
||||
Path: "/test/path/missing.mp3",
|
||||
Path: "test/path/missing.mp3",
|
||||
Missing: true,
|
||||
}
|
||||
|
||||
|
||||
@ -77,14 +77,14 @@ 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})
|
||||
albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969})
|
||||
albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("/kraft/radio/radio.mp3"), SongCount: 2})
|
||||
albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("/test/multi/disc1/track1.mp3"), SongCount: 4})
|
||||
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},
|
||||
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})
|
||||
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})
|
||||
albumCJK = al(model.Album{ID: "105", Name: "COWBOY BEBOP", AlbumArtist: "シートベルツ", OrderAlbumName: "cowboy bebop", AlbumArtistID: "4", EmbedArtPath: p("seatbelts/cowboy-bebop/track1.mp3"), SongCount: 1})
|
||||
albumWithVersion = alWithTags(model.Album{ID: "106", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("beatles/2/come together.mp3"), SongCount: 1, MaxYear: 2019},
|
||||
model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}})
|
||||
albumPunctuation = al(model.Album{ID: "107", Name: "Things Fall Apart", AlbumArtist: "The Roots", OrderAlbumName: "things fall apart", AlbumArtistID: "5", EmbedArtPath: p("/roots/things/track1.mp3"), SongCount: 1})
|
||||
albumPunctuation = al(model.Album{ID: "107", Name: "Things Fall Apart", AlbumArtist: "The Roots", OrderAlbumName: "things fall apart", AlbumArtistID: "5", EmbedArtPath: p("roots/things/track1.mp3"), SongCount: 1})
|
||||
testAlbums = model.Albums{
|
||||
albumSgtPeppers,
|
||||
albumAbbeyRoad,
|
||||
@ -97,12 +97,12 @@ 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")})
|
||||
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")})
|
||||
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")})
|
||||
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",
|
||||
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),
|
||||
})
|
||||
songAntennaWithLyrics = mf(model.MediaFile{
|
||||
@ -115,13 +115,13 @@ var (
|
||||
})
|
||||
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)
|
||||
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"})
|
||||
songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("/test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||
songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("/test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||
songCJK = mf(model.MediaFile{ID: "3001", Title: "プラチナ・ジェット", ArtistID: "4", Artist: "シートベルツ", AlbumID: "105", Album: "COWBOY BEBOP", Path: p("/seatbelts/cowboy-bebop/track1.mp3")})
|
||||
songVersioned = mf(model.MediaFile{ID: "3002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "106", Album: "Abbey Road", Path: p("/beatles/2/come together.mp3")})
|
||||
songPunctuation = mf(model.MediaFile{ID: "3003", Title: "!!!!!!!", ArtistID: "5", Artist: "The Roots", AlbumID: "107", Album: "Things Fall Apart", Path: p("/roots/things/track1.mp3")})
|
||||
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"})
|
||||
songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||
songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||
songCJK = mf(model.MediaFile{ID: "3001", Title: "プラチナ・ジェット", ArtistID: "4", Artist: "シートベルツ", AlbumID: "105", Album: "COWBOY BEBOP", Path: p("seatbelts/cowboy-bebop/track1.mp3")})
|
||||
songVersioned = mf(model.MediaFile{ID: "3002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "106", Album: "Abbey Road", Path: p("beatles/2/come together.mp3")})
|
||||
songPunctuation = mf(model.MediaFile{ID: "3003", Title: "!!!!!!!", ArtistID: "5", Artist: "The Roots", AlbumID: "107", Album: "Things Fall Apart", Path: p("roots/things/track1.mp3")})
|
||||
testSongs = model.MediaFiles{
|
||||
songDayInALife,
|
||||
songComeTogether,
|
||||
|
||||
@ -408,7 +408,7 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
ArtistID: "1",
|
||||
Album: "Test Album",
|
||||
AlbumID: "101",
|
||||
Path: "/test/grouping/song1.mp3",
|
||||
Path: "test/grouping/song1.mp3",
|
||||
Tags: model.Tags{
|
||||
"grouping": []string{"My Crate"},
|
||||
},
|
||||
@ -426,7 +426,7 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
ArtistID: "1",
|
||||
Album: "Test Album",
|
||||
AlbumID: "101",
|
||||
Path: "/test/grouping/song2.mp3",
|
||||
Path: "test/grouping/song2.mp3",
|
||||
Tags: model.Tags{},
|
||||
Participants: model.Participants{},
|
||||
LibraryID: 1,
|
||||
@ -614,7 +614,7 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
ArtistID: "1",
|
||||
Album: "Test Album",
|
||||
AlbumID: "101",
|
||||
Path: "/music/lib1/song.mp3",
|
||||
Path: "lib1/song.mp3",
|
||||
LibraryID: 1,
|
||||
Participants: model.Participants{},
|
||||
Tags: model.Tags{},
|
||||
@ -630,7 +630,7 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
ArtistID: "1",
|
||||
Album: "Test Album",
|
||||
AlbumID: "101",
|
||||
Path: uniqueLibPath + "/song.mp3",
|
||||
Path: "lib2/song.mp3",
|
||||
LibraryID: lib2ID,
|
||||
Participants: model.Participants{},
|
||||
Tags: model.Tags{},
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
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() {
|
||||
tests.SkipOnWindows("relies on Unix /etc filesystem")
|
||||
progress := make(chan *ProgressInfo)
|
||||
state.progress = progress
|
||||
folder := &model.Folder{Path: "/invalid/path"}
|
||||
|
||||
@ -43,6 +43,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, 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})
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
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
|
||||
|
||||
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})
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
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() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
|
||||
Expect(runScanner(ctx, true)).To(Succeed())
|
||||
|
||||
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()
|
||||
|
||||
BeforeEach(func() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
|
||||
By("Having two MP3 albums")
|
||||
beatles := _t{
|
||||
"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() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
|
||||
// Don't use the mocked artist repo for this test - we need the real one
|
||||
ds.MockedArtist = nil
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/storage"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"golang.org/x/sync/errgroup"
|
||||
@ -229,6 +230,7 @@ var _ = Describe("walk_dir_tree", func() {
|
||||
|
||||
Context("with symlinks enabled", func() {
|
||||
BeforeEach(func() {
|
||||
tests.SkipOnWindows("symlink semantics")
|
||||
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() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
|
||||
startEventProcessing()
|
||||
|
||||
// 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() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
|
||||
startEventProcessing()
|
||||
|
||||
// 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() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
|
||||
startEventProcessing()
|
||||
|
||||
// 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() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
|
||||
result := resolveFolderPath(mockFS, "artist1/album1/track1.mp3")
|
||||
Expect(result).To(Equal("artist1/album1"))
|
||||
})
|
||||
|
||||
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")
|
||||
Expect(result).To(Equal("artist1/album1"))
|
||||
})
|
||||
@ -489,6 +494,7 @@ var _ = Describe("resolveFolderPath", func() {
|
||||
})
|
||||
|
||||
It("handles nested file paths correctly", func() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
|
||||
result := resolveFolderPath(mockFS, "artist1/album2/song.flac")
|
||||
Expect(result).To(Equal("artist1/album2"))
|
||||
})
|
||||
|
||||
@ -470,6 +470,13 @@ var _ = BeforeSuite(func() {
|
||||
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
|
||||
// Subsonic Router. Call this from BeforeEach/BeforeAll in each test container.
|
||||
func setupTestDB() {
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -16,6 +17,7 @@ import (
|
||||
var _ = Describe("Translations", func() {
|
||||
Describe("I18n files", func() {
|
||||
It("contains only valid json language files", func() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-nativeapi)")
|
||||
fsys := resources.FS()
|
||||
dir, _ := fsys.Open(consts.I18nFolder)
|
||||
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,
|
||||
"baseURL": str.SanitizeText(strings.TrimSuffix(conf.Server.BasePath, "/")),
|
||||
"loginBackgroundURL": str.SanitizeText(conf.Server.UILoginBackgroundURL),
|
||||
"welcomeMessage": str.SanitizeText(conf.Server.UIWelcomeMessage),
|
||||
"welcomeMessage": str.SanitizeHTML(conf.Server.UIWelcomeMessage),
|
||||
"maxSidebarPlaylists": conf.Server.MaxSidebarPlaylists,
|
||||
"enableTranscodingConfig": conf.Server.EnableTranscodingConfig,
|
||||
"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"),
|
||||
)
|
||||
|
||||
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",
|
||||
func(configKey string, expectedValueFunc func() any) {
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
|
||||
@ -30,6 +30,7 @@ var _ = Describe("createUnixSocketFile", func() {
|
||||
|
||||
When("unixSocketPerm is valid", 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")
|
||||
fileInfo, _ := os.Stat(socketPath)
|
||||
actualPermission := fileInfo.Mode().Perm()
|
||||
@ -50,6 +51,7 @@ var _ = Describe("createUnixSocketFile", func() {
|
||||
|
||||
When("file already exists", func() {
|
||||
It("recreates the file as a socket with the right permissions", func() {
|
||||
tests.SkipOnWindows("uses Unix file permission bits")
|
||||
_, err := os.Create(socketPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(os.Chmod(socketPath, os.FileMode(0777))).To(Succeed())
|
||||
|
||||
@ -32,7 +32,9 @@ var _ = Describe("MediaAnnotationController", func() {
|
||||
|
||||
Describe("Scrobble", 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")
|
||||
|
||||
_, err := router.Scrobble(r)
|
||||
|
||||
@ -4,14 +4,25 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
"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 {
|
||||
TempDir() string
|
||||
}
|
||||
@ -20,10 +31,20 @@ func TempFileName(t testingT, prefix, suffix string) string {
|
||||
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) {
|
||||
name := TempFileName(t, prefix, suffix)
|
||||
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
|
||||
|
||||
@ -192,6 +192,10 @@ var _ = Describe("FileExists", func() {
|
||||
filePath := tempFile.Name()
|
||||
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)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
tempFile = nil // Prevent cleanup attempt
|
||||
|
||||
@ -38,11 +38,20 @@ func SanitizeStrings(text ...string) string {
|
||||
|
||||
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 {
|
||||
s := policy.Sanitize(text)
|
||||
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 {
|
||||
v := strings.TrimSpace(sanitize.Accents(originalValue))
|
||||
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() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.IgnoredArticles = "The O"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user