mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Compare commits
16 Commits
0cdfff43c2
...
0e982602b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e982602b2 | ||
|
|
4f7dc105b0 | ||
|
|
e918e049e2 | ||
|
|
1e8d28ff46 | ||
|
|
a128b3cf98 | ||
|
|
290a9fdeaa | ||
|
|
58b5ed86df | ||
|
|
fe1cee0159 | ||
|
|
3dfaa8cca1 | ||
|
|
0a5abfc1b1 | ||
|
|
c501bc6996 | ||
|
|
0c71842b12 | ||
|
|
578375ca71 | ||
|
|
187eac268f | ||
|
|
f085446cc6 | ||
|
|
b12e3ff367 |
18
.github/workflows/pipeline.yml
vendored
18
.github/workflows/pipeline.yml
vendored
@ -217,7 +217,7 @@ jobs:
|
|||||||
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
|
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
|
||||||
|
|
||||||
- name: Upload Binaries
|
- name: Upload Binaries
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: navidrome-${{ env.PLATFORM }}
|
name: navidrome-${{ env.PLATFORM }}
|
||||||
path: ./output
|
path: ./output
|
||||||
@ -248,7 +248,7 @@ jobs:
|
|||||||
touch "/tmp/digests/${digest#sha256:}"
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v5
|
||||||
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||||
with:
|
with:
|
||||||
name: digests-${{ env.PLATFORM }}
|
name: digests-${{ env.PLATFORM }}
|
||||||
@ -267,7 +267,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@v5
|
uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
@ -320,7 +320,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- uses: actions/download-artifact@v5
|
- uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
path: ./binaries
|
path: ./binaries
|
||||||
pattern: navidrome-windows*
|
pattern: navidrome-windows*
|
||||||
@ -339,7 +339,7 @@ jobs:
|
|||||||
du -h binaries/msi/*.msi
|
du -h binaries/msi/*.msi
|
||||||
|
|
||||||
- name: Upload MSI files
|
- name: Upload MSI files
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: navidrome-windows-installers
|
name: navidrome-windows-installers
|
||||||
path: binaries/msi/*.msi
|
path: binaries/msi/*.msi
|
||||||
@ -357,7 +357,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
fetch-tags: true
|
fetch-tags: true
|
||||||
|
|
||||||
- uses: actions/download-artifact@v5
|
- uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
path: ./binaries
|
path: ./binaries
|
||||||
pattern: navidrome-*
|
pattern: navidrome-*
|
||||||
@ -383,7 +383,7 @@ jobs:
|
|||||||
rm ./dist/*.tar.gz ./dist/*.zip
|
rm ./dist/*.tar.gz ./dist/*.zip
|
||||||
|
|
||||||
- name: Upload all-packages artifact
|
- name: Upload all-packages artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: packages
|
name: packages
|
||||||
path: dist/navidrome_0*
|
path: dist/navidrome_0*
|
||||||
@ -406,13 +406,13 @@ jobs:
|
|||||||
item: ${{ fromJson(needs.release.outputs.package_list) }}
|
item: ${{ fromJson(needs.release.outputs.package_list) }}
|
||||||
steps:
|
steps:
|
||||||
- name: Download all-packages artifact
|
- name: Download all-packages artifact
|
||||||
uses: actions/download-artifact@v5
|
uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: packages
|
name: packages
|
||||||
path: ./dist
|
path: ./dist
|
||||||
|
|
||||||
- name: Upload all-packages artifact
|
- name: Upload all-packages artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: navidrome_linux_${{ matrix.item }}
|
name: navidrome_linux_${{ matrix.item }}
|
||||||
path: dist/navidrome_0*_linux_${{ matrix.item }}
|
path: dist/navidrome_0*_linux_${{ matrix.item }}
|
||||||
|
|||||||
@ -374,6 +374,7 @@ func init() {
|
|||||||
rootCmd.Flags().Duration("scaninterval", viper.GetDuration("scaninterval"), "how frequently to scan for changes in your music library")
|
rootCmd.Flags().Duration("scaninterval", viper.GetDuration("scaninterval"), "how frequently to scan for changes in your music library")
|
||||||
rootCmd.Flags().String("uiloginbackgroundurl", viper.GetString("uiloginbackgroundurl"), "URL to a backaground image used in the Login page")
|
rootCmd.Flags().String("uiloginbackgroundurl", viper.GetString("uiloginbackgroundurl"), "URL to a backaground image used in the Login page")
|
||||||
rootCmd.Flags().Bool("enabletranscodingconfig", viper.GetBool("enabletranscodingconfig"), "enables transcoding configuration in the UI")
|
rootCmd.Flags().Bool("enabletranscodingconfig", viper.GetBool("enabletranscodingconfig"), "enables transcoding configuration in the UI")
|
||||||
|
rootCmd.Flags().Bool("enabletranscodingcancellation", viper.GetBool("enabletranscodingcancellation"), "enables transcoding context cancellation")
|
||||||
rootCmd.Flags().String("transcodingcachesize", viper.GetString("transcodingcachesize"), "size of transcoding cache")
|
rootCmd.Flags().String("transcodingcachesize", viper.GetString("transcodingcachesize"), "size of transcoding cache")
|
||||||
rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache")
|
rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache")
|
||||||
rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized")
|
rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized")
|
||||||
@ -397,6 +398,7 @@ func init() {
|
|||||||
_ = viper.BindPFlag("prometheus.metricspath", rootCmd.Flags().Lookup("prometheus.metricspath"))
|
_ = viper.BindPFlag("prometheus.metricspath", rootCmd.Flags().Lookup("prometheus.metricspath"))
|
||||||
|
|
||||||
_ = viper.BindPFlag("enabletranscodingconfig", rootCmd.Flags().Lookup("enabletranscodingconfig"))
|
_ = viper.BindPFlag("enabletranscodingconfig", rootCmd.Flags().Lookup("enabletranscodingconfig"))
|
||||||
|
_ = viper.BindPFlag("enabletranscodingcancellation", rootCmd.Flags().Lookup("enabletranscodingcancellation"))
|
||||||
_ = viper.BindPFlag("transcodingcachesize", rootCmd.Flags().Lookup("transcodingcachesize"))
|
_ = viper.BindPFlag("transcodingcachesize", rootCmd.Flags().Lookup("transcodingcachesize"))
|
||||||
_ = viper.BindPFlag("imagecachesize", rootCmd.Flags().Lookup("imagecachesize"))
|
_ = viper.BindPFlag("imagecachesize", rootCmd.Flags().Lookup("imagecachesize"))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,6 +41,7 @@ type configOptions struct {
|
|||||||
UIWelcomeMessage string
|
UIWelcomeMessage string
|
||||||
MaxSidebarPlaylists int
|
MaxSidebarPlaylists int
|
||||||
EnableTranscodingConfig bool
|
EnableTranscodingConfig bool
|
||||||
|
EnableTranscodingCancellation bool
|
||||||
EnableDownloads bool
|
EnableDownloads bool
|
||||||
EnableExternalServices bool
|
EnableExternalServices bool
|
||||||
EnableInsightsCollector bool
|
EnableInsightsCollector bool
|
||||||
@ -489,6 +490,7 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("uiwelcomemessage", "")
|
viper.SetDefault("uiwelcomemessage", "")
|
||||||
viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists)
|
viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists)
|
||||||
viper.SetDefault("enabletranscodingconfig", false)
|
viper.SetDefault("enabletranscodingconfig", false)
|
||||||
|
viper.SetDefault("enabletranscodingcancellation", false)
|
||||||
viper.SetDefault("transcodingcachesize", "100MB")
|
viper.SetDefault("transcodingcachesize", "100MB")
|
||||||
viper.SetDefault("imagecachesize", "100MB")
|
viper.SetDefault("imagecachesize", "100MB")
|
||||||
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
|
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
|
||||||
|
|||||||
@ -112,7 +112,7 @@ func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error
|
|||||||
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
|
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
|
||||||
j := &ffCmd{args: args}
|
j := &ffCmd{args: args}
|
||||||
j.PipeReader, j.out = io.Pipe()
|
j.PipeReader, j.out = io.Pipe()
|
||||||
err := j.start()
|
err := j.start(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -127,8 +127,8 @@ type ffCmd struct {
|
|||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *ffCmd) start() error {
|
func (j *ffCmd) start(ctx context.Context) error {
|
||||||
cmd := exec.Command(j.args[0], j.args[1:]...) // #nosec
|
cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
|
||||||
cmd.Stdout = j.out
|
cmd.Stdout = j.out
|
||||||
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
package ffmpeg
|
package ffmpeg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"runtime"
|
||||||
|
sync "sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
@ -65,4 +69,98 @@ var _ = Describe("ffmpeg", func() {
|
|||||||
Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "ffmetadata"}))
|
Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "ffmetadata"}))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("FFmpeg", func() {
|
||||||
|
Context("when FFmpeg is available", func() {
|
||||||
|
var ff FFmpeg
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
ffOnce = sync.Once{}
|
||||||
|
ff = New()
|
||||||
|
// Skip if FFmpeg is not available
|
||||||
|
if !ff.IsAvailable() {
|
||||||
|
Skip("FFmpeg not available on this system")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should interrupt transcoding when context is cancelled", func() {
|
||||||
|
ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Use a command that generates audio indefinitely
|
||||||
|
// -f lavfi uses FFmpeg's built-in audio source
|
||||||
|
// -t 0 means no time limit (runs forever)
|
||||||
|
command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -f mp3 -"
|
||||||
|
|
||||||
|
// The input file is not used here, but we need to provide a valid path to the Transcode function
|
||||||
|
stream, err := ff.Transcode(ctx, command, "tests/fixtures/test.mp3", 128, 0)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
defer stream.Close()
|
||||||
|
|
||||||
|
// Read some data first to ensure FFmpeg is running
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
_, err = stream.Read(buf)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
// Cancel the context
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
// Next read should fail due to cancelled context
|
||||||
|
_, err = stream.Read(buf)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should handle immediate context cancellation", func() {
|
||||||
|
ctx, cancel := context.WithCancel(GinkgoT().Context())
|
||||||
|
cancel() // Cancel immediately
|
||||||
|
|
||||||
|
// This should fail immediately
|
||||||
|
_, err := ff.Transcode(ctx, "ffmpeg -i %s -f mp3 -", "tests/fixtures/test.mp3", 128, 0)
|
||||||
|
Expect(err).To(MatchError(context.Canceled))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("with mock process behavior", func() {
|
||||||
|
var longRunningCmd string
|
||||||
|
BeforeEach(func() {
|
||||||
|
// Use a long-running command for testing cancellation
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
// Use PowerShell's Start-Sleep
|
||||||
|
ffmpegPath = "powershell"
|
||||||
|
longRunningCmd = "powershell -Command Start-Sleep -Seconds 10"
|
||||||
|
default:
|
||||||
|
// Use sleep on Unix-like systems
|
||||||
|
ffmpegPath = "sleep"
|
||||||
|
longRunningCmd = "sleep 10"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should terminate the underlying process when context is cancelled", func() {
|
||||||
|
ff := New()
|
||||||
|
ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Start a process that will run for a while
|
||||||
|
stream, err := ff.Transcode(ctx, longRunningCmd, "tests/fixtures/test.mp3", 0, 0)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
defer stream.Close()
|
||||||
|
|
||||||
|
// Give the process time to start
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// Cancel the context
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
// Try to read from the stream, which should fail
|
||||||
|
buf := make([]byte, 100)
|
||||||
|
_, err = stream.Read(buf)
|
||||||
|
Expect(err).To(HaveOccurred(), "Expected stream to be closed due to process termination")
|
||||||
|
|
||||||
|
// Verify the stream is closed by attempting another read
|
||||||
|
_, err = stream.Read(buf)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -204,7 +204,20 @@ func NewTranscodingCache() TranscodingCache {
|
|||||||
log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
|
log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
|
||||||
return nil, os.ErrInvalid
|
return nil, os.ErrInvalid
|
||||||
}
|
}
|
||||||
out, err := job.ms.transcoder.Transcode(ctx, t.Command, job.filePath, job.bitRate, job.offset)
|
|
||||||
|
// Choose the appropriate context based on EnableTranscodingCancellation configuration.
|
||||||
|
// This is where we decide whether transcoding processes should be cancellable or not.
|
||||||
|
var transcodingCtx context.Context
|
||||||
|
if conf.Server.EnableTranscodingCancellation {
|
||||||
|
// Use the request context directly, allowing cancellation when client disconnects
|
||||||
|
transcodingCtx = ctx
|
||||||
|
} else {
|
||||||
|
// Use background context with request values preserved.
|
||||||
|
// This prevents cancellation but maintains request metadata (user, client, etc.)
|
||||||
|
transcodingCtx = request.AddValues(context.Background(), ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := job.ms.transcoder.Transcode(transcodingCtx, t.Command, job.filePath, job.bitRate, job.offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
|
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
|
||||||
return nil, os.ErrInvalid
|
return nil, os.ErrInvalid
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
. "github.com/navidrome/navidrome/utils/gg"
|
. "github.com/navidrome/navidrome/utils/gg"
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
"github.com/navidrome/navidrome/utils/slice"
|
||||||
|
"github.com/navidrome/navidrome/utils/str"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Share interface {
|
type Share interface {
|
||||||
@ -119,9 +120,8 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
|
|||||||
log.Error(r.ctx, "Invalid Resource ID", "id", firstId)
|
log.Error(r.ctx, "Invalid Resource ID", "id", firstId)
|
||||||
return "", model.ErrNotFound
|
return "", model.ErrNotFound
|
||||||
}
|
}
|
||||||
if len(s.Contents) > 30 {
|
|
||||||
s.Contents = s.Contents[:26] + "..."
|
s.Contents = str.TruncateRunes(s.Contents, 30, "...")
|
||||||
}
|
|
||||||
|
|
||||||
id, err = r.Persistable.Save(s)
|
id, err = r.Persistable.Save(s)
|
||||||
return id, err
|
return id, err
|
||||||
|
|||||||
@ -38,6 +38,38 @@ var _ = Describe("Share", func() {
|
|||||||
Expect(id).ToNot(BeEmpty())
|
Expect(id).ToNot(BeEmpty())
|
||||||
Expect(entity.ID).To(Equal(id))
|
Expect(entity.ID).To(Equal(id))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("does not truncate ASCII labels shorter than 30 characters", func() {
|
||||||
|
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"})
|
||||||
|
entity := &model.Share{Description: "test", ResourceIDs: "456"}
|
||||||
|
_, err := repo.Save(entity)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(entity.Contents).To(Equal("Example Media File"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("truncates ASCII labels longer than 30 characters", func() {
|
||||||
|
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "Example Media File But The Title Is Really Long For Testing Purposes"})
|
||||||
|
entity := &model.Share{Description: "test", ResourceIDs: "789"}
|
||||||
|
_, err := repo.Save(entity)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(entity.Contents).To(Equal("Example Media File But The ..."))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not truncate CJK labels shorter than 30 runes", func() {
|
||||||
|
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "青春コンプレックス"})
|
||||||
|
entity := &model.Share{Description: "test", ResourceIDs: "456"}
|
||||||
|
_, err := repo.Save(entity)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(entity.Contents).To(Equal("青春コンプレックス"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("truncates CJK labels longer than 30 runes", func() {
|
||||||
|
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "私の中の幻想的世界観及びその顕現を想起させたある現実での出来事に関する一考察"})
|
||||||
|
entity := &model.Share{Description: "test", ResourceIDs: "789"}
|
||||||
|
_, err := repo.Save(entity)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実で..."))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Update", func() {
|
Describe("Update", func() {
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE playqueue ADD COLUMN position_int integer;
|
||||||
|
UPDATE playqueue SET position_int = CAST(position as INTEGER) ;
|
||||||
|
ALTER TABLE playqueue DROP COLUMN position;
|
||||||
|
ALTER TABLE playqueue RENAME COLUMN position_int TO position;
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
13
go.mod
13
go.mod
@ -1,9 +1,13 @@
|
|||||||
module github.com/navidrome/navidrome
|
module github.com/navidrome/navidrome
|
||||||
|
|
||||||
go 1.25.3
|
go 1.25.4
|
||||||
|
|
||||||
// Fork to fix https://github.com/navidrome/navidrome/pull/3254
|
replace (
|
||||||
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
// Fork to fix https://github.com/navidrome/navidrome/issues/3254
|
||||||
|
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
||||||
|
// Using version from main that fixes https://github.com/navidrome/navidrome/issues/4396
|
||||||
|
github.com/tetratelabs/wazero v1.9.0 => github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Masterminds/squirrel v1.5.4
|
github.com/Masterminds/squirrel v1.5.4
|
||||||
@ -43,7 +47,7 @@ require (
|
|||||||
github.com/mattn/go-sqlite3 v1.14.32
|
github.com/mattn/go-sqlite3 v1.14.32
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27
|
github.com/microcosm-cc/bluemonday v1.0.27
|
||||||
github.com/mileusna/useragent v1.3.5
|
github.com/mileusna/useragent v1.3.5
|
||||||
github.com/onsi/ginkgo/v2 v2.27.1
|
github.com/onsi/ginkgo/v2 v2.27.2
|
||||||
github.com/onsi/gomega v1.38.2
|
github.com/onsi/gomega v1.38.2
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4
|
github.com/pelletier/go-toml/v2 v2.2.4
|
||||||
github.com/pocketbase/dbx v1.11.0
|
github.com/pocketbase/dbx v1.11.0
|
||||||
@ -124,7 +128,6 @@ require (
|
|||||||
github.com/stretchr/objx v0.5.2 // indirect
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
|||||||
12
go.sum
12
go.sum
@ -186,8 +186,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
|
|||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
|
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
|
||||||
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
|
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
|
||||||
github.com/onsi/ginkgo/v2 v2.27.1 h1:0LJC8MpUSQnfnp4n/3W3GdlmJP3ENGF0ZPzjQGLPP7s=
|
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
|
||||||
github.com/onsi/ginkgo/v2 v2.27.1/go.mod h1:wmy3vCqiBjirARfVhAqFpYt8uvX0yaFe+GudAqqcCqA=
|
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
@ -201,8 +201,6 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
|||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
|
||||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
|
||||||
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
|
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
|
||||||
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
|
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
|
||||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
@ -267,8 +265,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
|
github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684 h1:ugT1JTRsK1Jhn95BWilCugyZ1Svsyxm9xSiflOa2e7E=
|
||||||
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
|
github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
|
||||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
@ -286,8 +284,6 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
|||||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
|
||||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
|
|||||||
@ -55,6 +55,7 @@ var _ = Describe("AlbumRepository", func() {
|
|||||||
It("returns all records sorted", func() {
|
It("returns all records sorted", func() {
|
||||||
Expect(GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{
|
Expect(GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{
|
||||||
albumAbbeyRoad,
|
albumAbbeyRoad,
|
||||||
|
albumMultiDisc,
|
||||||
albumRadioactivity,
|
albumRadioactivity,
|
||||||
albumSgtPeppers,
|
albumSgtPeppers,
|
||||||
}))
|
}))
|
||||||
@ -64,6 +65,7 @@ var _ = Describe("AlbumRepository", func() {
|
|||||||
Expect(GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{
|
Expect(GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{
|
||||||
albumSgtPeppers,
|
albumSgtPeppers,
|
||||||
albumRadioactivity,
|
albumRadioactivity,
|
||||||
|
albumMultiDisc,
|
||||||
albumAbbeyRoad,
|
albumAbbeyRoad,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|||||||
@ -38,7 +38,7 @@ var _ = Describe("MediaRepository", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("counts the number of mediafiles in the DB", func() {
|
It("counts the number of mediafiles in the DB", func() {
|
||||||
Expect(mr.CountAll()).To(Equal(int64(6)))
|
Expect(mr.CountAll()).To(Equal(int64(10)))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns songs ordered by lyrics with a specific title/artist", func() {
|
It("returns songs ordered by lyrics with a specific title/artist", func() {
|
||||||
|
|||||||
@ -69,10 +69,12 @@ 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})
|
||||||
testAlbums = model.Albums{
|
testAlbums = model.Albums{
|
||||||
albumSgtPeppers,
|
albumSgtPeppers,
|
||||||
albumAbbeyRoad,
|
albumAbbeyRoad,
|
||||||
albumRadioactivity,
|
albumRadioactivity,
|
||||||
|
albumMultiDisc,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -94,13 +96,22 @@ var (
|
|||||||
Lyrics: `[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`,
|
Lyrics: `[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`,
|
||||||
})
|
})
|
||||||
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"})
|
||||||
testSongs = model.MediaFiles{
|
// 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"})
|
||||||
|
testSongs = model.MediaFiles{
|
||||||
songDayInALife,
|
songDayInALife,
|
||||||
songComeTogether,
|
songComeTogether,
|
||||||
songRadioactivity,
|
songRadioactivity,
|
||||||
songAntenna,
|
songAntenna,
|
||||||
songAntennaWithLyrics,
|
songAntennaWithLyrics,
|
||||||
songAntenna2,
|
songAntenna2,
|
||||||
|
songDisc2Track11,
|
||||||
|
songDisc1Track01,
|
||||||
|
songDisc2Track01,
|
||||||
|
songDisc1Track02,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -219,4 +219,37 @@ var _ = Describe("PlaylistRepository", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("Playlist Track Sorting", func() {
|
||||||
|
var testPlaylistID string
|
||||||
|
|
||||||
|
AfterEach(func() {
|
||||||
|
if testPlaylistID != "" {
|
||||||
|
Expect(repo.Delete(testPlaylistID)).To(BeNil())
|
||||||
|
testPlaylistID = ""
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
It("sorts tracks correctly by album (disc and track number)", func() {
|
||||||
|
By("creating a playlist with multi-disc album tracks in arbitrary order")
|
||||||
|
newPls := model.Playlist{Name: "Multi-Disc Test", OwnerID: "userid"}
|
||||||
|
// Add tracks in intentionally scrambled order
|
||||||
|
newPls.AddMediaFilesByID([]string{"2001", "2002", "2003", "2004"})
|
||||||
|
Expect(repo.Put(&newPls)).To(Succeed())
|
||||||
|
testPlaylistID = newPls.ID
|
||||||
|
|
||||||
|
By("retrieving tracks sorted by album")
|
||||||
|
tracksRepo := repo.Tracks(newPls.ID, false)
|
||||||
|
tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
By("verifying tracks are sorted by disc number then track number")
|
||||||
|
Expect(tracks).To(HaveLen(4))
|
||||||
|
// Expected order: Disc 1 Track 1, Disc 1 Track 2, Disc 2 Track 1, Disc 2 Track 11
|
||||||
|
Expect(tracks[0].MediaFileID).To(Equal("2002")) // Disc 1, Track 1
|
||||||
|
Expect(tracks[1].MediaFileID).To(Equal("2004")) // Disc 1, Track 2
|
||||||
|
Expect(tracks[2].MediaFileID).To(Equal("2003")) // Disc 2, Track 1
|
||||||
|
Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -55,7 +55,7 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
|
|||||||
"id": "playlist_tracks.id",
|
"id": "playlist_tracks.id",
|
||||||
"artist": "order_artist_name",
|
"artist": "order_artist_name",
|
||||||
"album_artist": "order_album_artist_name",
|
"album_artist": "order_album_artist_name",
|
||||||
"album": "order_album_name, order_album_artist_name",
|
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title",
|
||||||
"title": "order_title",
|
"title": "order_title",
|
||||||
// To make sure these fields will be whitelisted
|
// To make sure these fields will be whitelisted
|
||||||
"duration": "duration",
|
"duration": "duration",
|
||||||
|
|||||||
@ -57,6 +57,7 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository
|
|||||||
r.db = db
|
r.db = db
|
||||||
r.tableName = "user"
|
r.tableName = "user"
|
||||||
r.registerModel(&model.User{}, map[string]filterFunc{
|
r.registerModel(&model.User{}, map[string]filterFunc{
|
||||||
|
"id": idFilter(r.tableName),
|
||||||
"password": invalidFilter(ctx),
|
"password": invalidFilter(ctx),
|
||||||
"name": r.withTableName(startsWithFilter),
|
"name": r.withTableName(startsWithFilter),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -559,4 +559,15 @@ var _ = Describe("UserRepository", func() {
|
|||||||
Expect(user.Libraries[0].ID).To(Equal(1))
|
Expect(user.Libraries[0].ID).To(Equal(1))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("filters", func() {
|
||||||
|
It("qualifies id filter with table name", func() {
|
||||||
|
r := repo.(*userRepository)
|
||||||
|
qo := r.parseRestOptions(r.ctx, rest.QueryOptions{Filters: map[string]any{"id": "123"}})
|
||||||
|
sel := r.selectUserWithLibraries(qo)
|
||||||
|
query, _, err := r.toSQL(sel)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(query).To(ContainSubstring("user.id = {:p0}"))
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -95,7 +95,7 @@ export const formatFullDate = (date, locale) => {
|
|||||||
return new Date(date).toLocaleDateString(locale, options)
|
return new Date(date).toLocaleDateString(locale, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatNumber = (value) => {
|
export const formatNumber = (value, locale) => {
|
||||||
if (value === null || value === undefined) return '0'
|
if (value === null || value === undefined) return '0'
|
||||||
return value.toLocaleString()
|
return value.toLocaleString(locale)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -121,35 +121,35 @@ describe('formatDuration2', () => {
|
|||||||
|
|
||||||
describe('formatNumber', () => {
|
describe('formatNumber', () => {
|
||||||
it('handles null and undefined values', () => {
|
it('handles null and undefined values', () => {
|
||||||
expect(formatNumber(null)).toEqual('0')
|
expect(formatNumber(null, 'en-CA')).toEqual('0')
|
||||||
expect(formatNumber(undefined)).toEqual('0')
|
expect(formatNumber(undefined, 'en-CA')).toEqual('0')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('formats integers', () => {
|
it('formats integers', () => {
|
||||||
expect(formatNumber(0)).toEqual('0')
|
expect(formatNumber(0, 'en-CA')).toEqual('0')
|
||||||
expect(formatNumber(1)).toEqual('1')
|
expect(formatNumber(1, 'en-CA')).toEqual('1')
|
||||||
expect(formatNumber(123)).toEqual('123')
|
expect(formatNumber(123, 'en-CA')).toEqual('123')
|
||||||
expect(formatNumber(1000)).toEqual('1,000')
|
expect(formatNumber(1000, 'en-CA')).toEqual('1,000')
|
||||||
expect(formatNumber(1234567)).toEqual('1,234,567')
|
expect(formatNumber(1234567, 'en-CA')).toEqual('1,234,567')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('formats decimal numbers', () => {
|
it('formats decimal numbers', () => {
|
||||||
expect(formatNumber(123.45)).toEqual('123.45')
|
expect(formatNumber(123.45, 'en-CA')).toEqual('123.45')
|
||||||
expect(formatNumber(1234.567)).toEqual('1,234.567')
|
expect(formatNumber(1234.567, 'en-CA')).toEqual('1,234.567')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('formats negative numbers', () => {
|
it('formats negative numbers', () => {
|
||||||
expect(formatNumber(-123)).toEqual('-123')
|
expect(formatNumber(-123, 'en-CA')).toEqual('-123')
|
||||||
expect(formatNumber(-1234)).toEqual('-1,234')
|
expect(formatNumber(-1234, 'en-CA')).toEqual('-1,234')
|
||||||
expect(formatNumber(-123.45)).toEqual('-123.45')
|
expect(formatNumber(-123.45, 'en-CA')).toEqual('-123.45')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('formatFullDate', () => {
|
describe('formatFullDate', () => {
|
||||||
it('format dates', () => {
|
it('format dates', () => {
|
||||||
expect(formatFullDate('2011', 'en-US')).toEqual('2011')
|
expect(formatFullDate('2011', 'en-CA')).toEqual('2011')
|
||||||
expect(formatFullDate('2011-06', 'en-US')).toEqual('Jun 2011')
|
expect(formatFullDate('2011-06', 'en-CA')).toEqual('Jun 2011')
|
||||||
expect(formatFullDate('1985-01-01', 'en-US')).toEqual('Jan 1, 1985')
|
expect(formatFullDate('1985-01-01', 'en-CA')).toEqual('Jan 1, 1985')
|
||||||
expect(formatFullDate('199704')).toEqual('')
|
expect(formatFullDate('199704')).toEqual('')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package str
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
var utf8ToAscii = func() *strings.Replacer {
|
var utf8ToAscii = func() *strings.Replacer {
|
||||||
@ -39,3 +40,25 @@ func LongestCommonPrefix(list []string) string {
|
|||||||
}
|
}
|
||||||
return list[0]
|
return list[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TruncateRunes truncates a string to a maximum number of runes, adding a suffix if truncated.
|
||||||
|
// The suffix is included in the rune count, so if maxRunes is 30 and suffix is "...", the actual
|
||||||
|
// string content will be truncated to fit within the maxRunes limit including the suffix.
|
||||||
|
func TruncateRunes(s string, maxRunes int, suffix string) string {
|
||||||
|
if utf8.RuneCountInString(s) <= maxRunes {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
suffixRunes := utf8.RuneCountInString(suffix)
|
||||||
|
truncateAt := maxRunes - suffixRunes
|
||||||
|
if truncateAt < 0 {
|
||||||
|
truncateAt = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
runes := []rune(s)
|
||||||
|
if truncateAt >= len(runes) {
|
||||||
|
return s + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(runes[:truncateAt]) + suffix
|
||||||
|
}
|
||||||
|
|||||||
@ -31,6 +31,72 @@ var _ = Describe("String Utils", func() {
|
|||||||
Expect(str.LongestCommonPrefix(albums)).To(Equal("/artist/album"))
|
Expect(str.LongestCommonPrefix(albums)).To(Equal("/artist/album"))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("TruncateRunes", func() {
|
||||||
|
It("returns string unchanged if under max runes", func() {
|
||||||
|
Expect(str.TruncateRunes("hello", 10, "...")).To(Equal("hello"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns string unchanged if exactly at max runes", func() {
|
||||||
|
Expect(str.TruncateRunes("hello", 5, "...")).To(Equal("hello"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("truncates and adds suffix when over max runes", func() {
|
||||||
|
Expect(str.TruncateRunes("hello world", 8, "...")).To(Equal("hello..."))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles unicode characters correctly", func() {
|
||||||
|
// 6 emoji characters, maxRunes=5, suffix="..." (3 runes)
|
||||||
|
// So content gets 5-3=2 runes
|
||||||
|
Expect(str.TruncateRunes("😀😁😂😃😄😅", 5, "...")).To(Equal("😀😁..."))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles multi-byte UTF-8 characters", func() {
|
||||||
|
// Characters like é are single runes
|
||||||
|
Expect(str.TruncateRunes("Café au Lait", 5, "...")).To(Equal("Ca..."))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("works with empty suffix", func() {
|
||||||
|
Expect(str.TruncateRunes("hello world", 5, "")).To(Equal("hello"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("accounts for suffix length in truncation", func() {
|
||||||
|
// maxRunes=10, suffix="..." (3 runes) -> leaves 7 runes for content
|
||||||
|
result := str.TruncateRunes("hello world this is long", 10, "...")
|
||||||
|
Expect(result).To(Equal("hello w..."))
|
||||||
|
// Verify total rune count is <= maxRunes
|
||||||
|
runeCount := len([]rune(result))
|
||||||
|
Expect(runeCount).To(BeNumerically("<=", 10))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles very long suffix gracefully", func() {
|
||||||
|
// If suffix is longer than maxRunes, we still add it
|
||||||
|
// but the content will be truncated to 0
|
||||||
|
result := str.TruncateRunes("hello world", 5, "... (truncated)")
|
||||||
|
// Result will be just the suffix (since truncateAt=0)
|
||||||
|
Expect(result).To(Equal("... (truncated)"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles empty string", func() {
|
||||||
|
Expect(str.TruncateRunes("", 10, "...")).To(Equal(""))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("uses custom suffix", func() {
|
||||||
|
// maxRunes=11, suffix=" [...]" (6 runes) -> content gets 5 runes
|
||||||
|
// "hello world" is 11 runes exactly, so we need a longer string
|
||||||
|
Expect(str.TruncateRunes("hello world extra", 11, " [...]")).To(Equal("hello [...]"))
|
||||||
|
})
|
||||||
|
|
||||||
|
DescribeTable("truncates at rune boundaries (not byte boundaries)",
|
||||||
|
func(input string, maxRunes int, suffix string, expected string) {
|
||||||
|
Expect(str.TruncateRunes(input, maxRunes, suffix)).To(Equal(expected))
|
||||||
|
},
|
||||||
|
Entry("ASCII", "abcdefghij", 5, "...", "ab..."),
|
||||||
|
Entry("Mixed ASCII and Unicode", "ab😀cd", 4, ".", "ab😀."),
|
||||||
|
Entry("All emoji", "😀😁😂😃😄", 3, "…", "😀😁…"),
|
||||||
|
Entry("Japanese", "こんにちは世界", 3, "…", "こん…"),
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
var testPaths = []string{
|
var testPaths = []string{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user