Merge branch 'master' into subsonic-folder

This commit is contained in:
Patrik Wallström 2026-03-19 21:30:29 +01:00 committed by GitHub
commit cf85e5f5b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
56 changed files with 1753 additions and 263 deletions

1
.gitignore vendored
View File

@ -38,3 +38,4 @@ AGENTS.md
*.ndp *.ndp
openspec/ openspec/
go.work* go.work*
.worktrees/

View File

@ -233,6 +233,39 @@ get-music: ##@Development Download some free music from Navidrome's demo instanc
.PHONY: get-music .PHONY: get-music
##########################################
#### Worktrees
WORKTREES_DIR := .worktrees
wt: check_go_env ##@Worktrees Create and setup a git worktree. Usage: make wt name=feature-name [go=1]
@if [ -z "${name}" ]; then echo "Usage: make wt name=<branch-name> [go=1]"; exit 1; fi
@mkdir -p $(WORKTREES_DIR)
@echo "Creating worktree for branch '${name}'..."
@git worktree add $(WORKTREES_DIR)/${name} -b ${name} 2>/dev/null || \
git worktree add $(WORKTREES_DIR)/${name} ${name}
@if [ -n "${go}" ]; then \
./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name} --go-only; \
else \
./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name}; \
fi
@echo "\nWorktree ready at $(WORKTREES_DIR)/${name}"
@echo " cd $(WORKTREES_DIR)/${name}"
.PHONY: wt
rm-wt: ##@Worktrees Remove a git worktree. Usage: make rm-wt name=feature-name
@if [ -z "${name}" ]; then echo "Usage: make rm-wt name=<branch-name>"; exit 1; fi
@if [ ! -d "$(WORKTREES_DIR)/${name}" ]; then echo "Worktree '${name}' not found in $(WORKTREES_DIR)/"; exit 1; fi
@echo "Removing worktree '${name}'..."
@git worktree remove --force $(WORKTREES_DIR)/${name}
@echo "Worktree '${name}' removed."
@echo "Note: branch '${name}' still exists. Delete it with: git branch -D ${name}"
.PHONY: rm-wt
ls-wt: ##@Worktrees List all active git worktrees
@git worktree list
.PHONY: ls-wt
########################################## ##########################################
#### Miscellaneous #### Miscellaneous

View File

@ -58,7 +58,20 @@ func (e extractor) Version() string {
return "unknown" return "unknown"
} }
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) { func (e extractor) extractMetadata(filePath string) (info *metadata.Info, err error) {
// Recover from panics in the WASM runtime that can occur during any taglib
// operation (opening, reading tags, or reading properties). This catches crashes
// from malformed files or WASM runtime issues (e.g., wazero mmap failures on
// hardened systems with MemoryDenyWriteExecute=true).
debug.SetPanicOnFault(true)
defer func() {
if r := recover(); r != nil {
log.Error("gotaglib: WASM runtime panic reading file. Skipping", "filePath", filePath, "panic", r)
debug.PrintStack()
err = fmt.Errorf("WASM runtime panic: %v", r)
}
}()
f, close, err := e.openFile(filePath) f, close, err := e.openFile(filePath)
if err != nil { if err != nil {
log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err) log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err)
@ -112,16 +125,6 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
// openFile opens the file at filePath using the extractor's filesystem. // openFile opens the file at filePath using the extractor's filesystem.
// It returns a TagLib File handle and a cleanup function to close resources. // It returns a TagLib File handle and a cleanup function to close resources.
func (e extractor) openFile(filePath string) (f *taglib.File, closeFunc func(), err error) { func (e extractor) openFile(filePath string) (f *taglib.File, closeFunc func(), err error) {
// Recover from panics in the WASM runtime (e.g., wazero failing to mmap executable memory
// on hardened systems like NixOS with MemoryDenyWriteExecute=true)
debug.SetPanicOnFault(true)
defer func() {
if r := recover(); r != nil {
log.Error("WASM runtime panic: This may be caused by a hardened system that blocks executable memory mapping.", "file", filePath, "panic", r)
err = fmt.Errorf("WASM runtime panic (hardened system?): %v", r)
}
}()
// Open the file from the filesystem // Open the file from the filesystem
file, err := e.fs.Open(filePath) file, err := e.fs.Open(filePath)
if err != nil { if err != nil {

View File

@ -71,6 +71,7 @@ const (
PlaceholderAlbumArt = "album-placeholder.webp" PlaceholderAlbumArt = "album-placeholder.webp"
PlaceholderAvatar = "logo-192x192.png" PlaceholderAvatar = "logo-192x192.png"
UICoverArtSize = 300 UICoverArtSize = 300
UIThumbnailSize = 80
DefaultUIVolume = 100 DefaultUIVolume = 100
DefaultUISearchDebounceMs = 200 DefaultUISearchDebounceMs = 200
@ -107,6 +108,7 @@ const (
const ( const (
EntityArtist = "artist" EntityArtist = "artist"
EntityPlaylist = "playlist" EntityPlaylist = "playlist"
EntityRadio = "radio"
) )
const ( const (

View File

@ -126,6 +126,8 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
artReader, err = newDiscArtworkReader(ctx, a, artID) artReader, err = newDiscArtworkReader(ctx, a, artID)
case model.KindFolderArtwork: case model.KindFolderArtwork:
artReader, err = newFolderArtworkReader(ctx, a, artID) artReader, err = newFolderArtworkReader(ctx, a, artID)
case model.KindRadioArtwork:
artReader, err = newRadioArtworkReader(ctx, a, artID)
default: default:
return nil, ErrUnavailable return nil, ErrUnavailable
} }

View File

@ -142,14 +142,15 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro
ctx, cancel := context.WithTimeout(ctx, 10*time.Second) ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel() defer cancel()
r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize, true) for _, size := range []int{consts.UICoverArtSize, consts.UIThumbnailSize} {
if err != nil { r, _, err := a.artwork.Get(ctx, id, size, true)
return fmt.Errorf("caching id='%s': %w", id, err) if err != nil {
} return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err)
defer r.Close() }
_, err = io.Copy(io.Discard, r) defer r.Close()
if err != nil { if _, err = io.Copy(io.Discard, r); err != nil {
return err return err
}
} }
return nil return nil
} }

View File

@ -6,11 +6,13 @@ import (
"fmt" "fmt"
"io" "io"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache" "github.com/navidrome/navidrome/utils/cache"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
@ -173,20 +175,42 @@ var _ = Describe("CacheWarmer", func() {
return len(cw.buffer) return len(cw.buffer)
}).Should(Equal(0)) }).Should(Equal(0))
}) })
It("pre-caches both UICoverArtSize and UIThumbnailSize", func() {
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
cw.PreCache(model.MustParseArtworkID("al-1"))
Eventually(func() []int {
return aw.getCachedSizes()
}).Should(ContainElements(consts.UICoverArtSize, consts.UIThumbnailSize))
})
}) })
}) })
type mockArtwork struct { type mockArtwork struct {
err error err error
mu sync.Mutex
cachedSizes []int
} }
func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) { func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) {
if m.err != nil { if m.err != nil {
return nil, time.Time{}, m.err return nil, time.Time{}, m.err
} }
m.mu.Lock()
m.cachedSizes = append(m.cachedSizes, size)
m.mu.Unlock()
return io.NopCloser(strings.NewReader("test")), time.Now(), nil return io.NopCloser(strings.NewReader("test")), time.Now(), nil
} }
func (m *mockArtwork) getCachedSizes() []int {
m.mu.Lock()
defer m.mu.Unlock()
result := make([]int, len(m.cachedSizes))
copy(result, m.cachedSizes)
return result
}
func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) { func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) {
return m.Get(ctx, model.ArtworkID{}, size, square) return m.Get(ctx, model.ArtworkID{}, size, square)
} }

View File

@ -0,0 +1,40 @@
package artwork
import (
"context"
"io"
"time"
"github.com/navidrome/navidrome/model"
)
type radioArtworkReader struct {
cacheKey
a *artwork
radio model.Radio
}
func newRadioArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*radioArtworkReader, error) {
r, err := artwork.ds.Radio(ctx).Get(artID.ID)
if err != nil {
return nil, err
}
a := &radioArtworkReader{a: artwork, radio: *r}
a.cacheKey.artID = artID
a.cacheKey.lastUpdate = r.UpdatedAt
return a, nil
}
func (a *radioArtworkReader) LastUpdated() time.Time {
return a.lastUpdate
}
func (a *radioArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
return selectImageReader(ctx, a.artID,
a.fromRadioUploadedImage(),
)
}
func (a *radioArtworkReader) fromRadioUploadedImage() sourceFunc {
return fromLocalFile(a.radio.UploadedImagePath())
}

View File

@ -0,0 +1,84 @@
package artwork
import (
"context"
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("radioArtworkReader", func() {
var (
tempDir string
reader *radioArtworkReader
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tempDir = GinkgoT().TempDir()
conf.Server.DataFolder = tempDir
Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "radio"), 0755)).To(Succeed())
reader = &radioArtworkReader{}
})
Describe("fromRadioUploadedImage", func() {
When("radio has an uploaded image", func() {
It("returns the uploaded image", func() {
imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg")
Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed())
reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"}
sf := reader.fromRadioUploadedImage()
r, path, err := sf()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
Expect(path).To(Equal(imgPath))
r.Close()
})
})
When("radio has no uploaded image", func() {
It("returns nil reader (falls through)", func() {
reader.radio = model.Radio{ID: "rd-1"}
sf := reader.fromRadioUploadedImage()
r, path, err := sf()
Expect(err).ToNot(HaveOccurred())
Expect(r).To(BeNil())
Expect(path).To(BeEmpty())
})
})
})
Describe("Reader", func() {
When("radio has an uploaded image", func() {
It("returns the image reader", func() {
imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg")
Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed())
reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"}
reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"}
r, _, err := reader.Reader(context.Background())
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
r.Close()
})
})
When("radio has no uploaded image", func() {
It("returns ErrUnavailable", func() {
reader.radio = model.Radio{ID: "rd-1"}
reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"}
r, _, err := reader.Reader(context.Background())
Expect(err).To(MatchError(ErrUnavailable))
Expect(r).To(BeNil())
})
})
})
})

View File

@ -1,6 +1,7 @@
package ffmpeg package ffmpeg
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@ -258,10 +259,11 @@ func (e *ffmpeg) start(ctx context.Context, args []string, input ...io.Reader) (
type ffCmd struct { type ffCmd struct {
*io.PipeReader *io.PipeReader
out *io.PipeWriter out *io.PipeWriter
args []string args []string
cmd *exec.Cmd cmd *exec.Cmd
input io.Reader // optional stdin source input io.Reader // optional stdin source
stderr *bytes.Buffer
} }
func (j *ffCmd) start(ctx context.Context) error { func (j *ffCmd) start(ctx context.Context) error {
@ -270,10 +272,12 @@ func (j *ffCmd) start(ctx context.Context) error {
if j.input != nil { if j.input != nil {
cmd.Stdin = j.input cmd.Stdin = j.input
} }
j.stderr = &bytes.Buffer{}
stderrWriter := &limitedWriter{buf: j.stderr, limit: 4096}
if log.IsGreaterOrEqualTo(log.LevelTrace) { if log.IsGreaterOrEqualTo(log.LevelTrace) {
cmd.Stderr = os.Stderr cmd.Stderr = io.MultiWriter(os.Stderr, stderrWriter)
} else { } else {
cmd.Stderr = io.Discard cmd.Stderr = stderrWriter
} }
j.cmd = cmd j.cmd = cmd
@ -287,7 +291,11 @@ func (j *ffCmd) wait() {
if err := j.cmd.Wait(); err != nil { if err := j.cmd.Wait(); err != nil {
var exitErr *exec.ExitError var exitErr *exec.ExitError
if errors.As(err, &exitErr) { if errors.As(err, &exitErr) {
_ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode())) errMsg := fmt.Sprintf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode())
if stderrOutput := strings.TrimSpace(j.stderr.String()); stderrOutput != "" {
errMsg += ": " + stderrOutput
}
_ = j.out.CloseWithError(errors.New(errMsg))
} else { } else {
_ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err)) _ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err))
} }
@ -296,6 +304,26 @@ func (j *ffCmd) wait() {
_ = j.out.Close() _ = j.out.Close()
} }
// limitedWriter wraps a bytes.Buffer and stops writing once the limit is reached.
// Writes that would exceed the limit are silently discarded to prevent unbounded memory usage.
type limitedWriter struct {
buf *bytes.Buffer
limit int
}
func (w *limitedWriter) Write(p []byte) (int, error) {
n := len(p)
remaining := w.limit - w.buf.Len()
if remaining <= 0 {
return n, nil // Discard but report success to avoid breaking the writer
}
if len(p) > remaining {
p = p[:remaining]
}
w.buf.Write(p)
return n, nil // Always report full write to avoid ErrShortWrite from io.MultiWriter
}
// formatCodecMap maps target format to ffmpeg codec flag. // formatCodecMap maps target format to ffmpeg codec flag.
var formatCodecMap = map[string]string{ var formatCodecMap = map[string]string{
"mp3": "libmp3lame", "mp3": "libmp3lame",

View File

@ -604,6 +604,46 @@ var _ = Describe("ffmpeg", func() {
}) })
}) })
Context("stderr capture", func() {
BeforeEach(func() {
if runtime.GOOS == "windows" {
Skip("stderr capture tests use /bin/sh, skipping on Windows")
}
})
It("should include stderr in error when process fails", func() {
ff := &ffmpeg{}
ctx := GinkgoT().Context()
// Directly call start() with a bash command that writes to stderr and fails
args := []string{"/bin/sh", "-c", "echo 'codec not found: libopus' >&2; exit 1"}
stream, err := ff.start(ctx, args)
Expect(err).ToNot(HaveOccurred())
defer stream.Close()
buf := make([]byte, 1024)
_, err = stream.Read(buf)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("codec not found: libopus"))
})
It("should not include stderr in error when process succeeds", func() {
ff := &ffmpeg{}
ctx := GinkgoT().Context()
// Command that writes to stderr but exits successfully
args := []string{"/bin/sh", "-c", "echo 'warning: something' >&2; printf 'output'"}
stream, err := ff.start(ctx, args)
Expect(err).ToNot(HaveOccurred())
defer stream.Close()
buf := make([]byte, 1024)
n, err := stream.Read(buf)
Expect(err).ToNot(HaveOccurred())
Expect(string(buf[:n])).To(Equal("output"))
})
})
Context("with mock process behavior", func() { Context("with mock process behavior", func() {
var longRunningCmd string var longRunningCmd string
BeforeEach(func() { BeforeEach(func() {

View File

@ -5,8 +5,9 @@ import (
"fmt" "fmt"
"io" "io"
"mime" "mime"
"net/http"
"os" "os"
"strings" "strconv"
"sync" "sync"
"time" "time"
@ -17,6 +18,7 @@ import (
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/cache" "github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/req"
) )
type MediaStreamer interface { type MediaStreamer interface {
@ -51,6 +53,9 @@ func (j *streamJob) Key() string {
return fmt.Sprintf("%s.%s.%d.%d.%d.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.sampleRate, j.bitDepth, j.channels, j.format, j.offset) return fmt.Sprintf("%s.%s.%d.%d.%d.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.sampleRate, j.bitDepth, j.channels, j.format, j.offset)
} }
// NewStream creates a Stream for the given MediaFile and Request. It handles both raw streaming (no transcoding)
// and transcoded streaming based on the requested format and bitrate. It also logs detailed information about
// the streaming request and whether the transcoding result was served from cache or not.
func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req Request) (*Stream, error) { func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req Request) (*Stream, error) {
var format string var format string
var bitRate int var bitRate int
@ -133,14 +138,59 @@ func (s *Stream) EstimatedContentLength() int {
return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024) return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
} }
// NewTestStream creates a Stream for testing purposes. // Serve writes the stream to the HTTP response. For seekable streams it uses http.ServeContent
func NewTestStream(mf *model.MediaFile, format string, bitRate int) *Stream { // (supporting range requests). For non-seekable streams it writes directly and logs any errors.
// Returns the number of bytes written and an error only when io.Copy fails with 0 bytes written
// (meaning the HTTP 200 status has not been flushed yet and the caller can still send an error response).
// Empty output (0 bytes, no error) is logged but not treated as an error.
func (s *Stream) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request) (int64, error) {
if s.Seekable() {
http.ServeContent(w, r, s.Name(), s.ModTime(), s)
return -1, nil
}
w.Header().Set("Accept-Ranges", "none")
w.Header().Set("Content-Type", s.ContentType())
if req.Params(r).BoolOr("estimateContentLength", false) {
length := strconv.Itoa(s.EstimatedContentLength())
log.Trace(ctx, "Estimated content-length", "contentLength", length)
w.Header().Set("Content-Length", length)
}
if r.Method == http.MethodHead {
go func() { _, _ = io.Copy(io.Discard, s) }()
return 0, nil
}
id := s.mf.ID
c, err := io.Copy(w, s)
if err != nil {
log.Error(ctx, "Error sending transcoded file", "id", id, err)
if c == 0 {
w.Header().Del("Content-Length")
return 0, fmt.Errorf("sending transcoded file: %w", err)
}
return c, nil
}
if c == 0 {
log.Error(ctx, "Transcoding returned empty output, ffmpeg may have failed. "+
"Check that ffmpeg supports the requested codec. Enable Trace logging for ffmpeg stderr details",
"id", id, "format", s.ContentType())
} else {
log.Trace(ctx, "Success sending transcoded file", "id", id, "size", c)
}
return c, nil
}
// NewStream creates a non-seekable Stream from the given components.
func NewStream(mf *model.MediaFile, format string, bitRate int, r io.ReadCloser) *Stream {
return &Stream{ return &Stream{
ctx: context.Background(), ctx: context.Background(),
mf: mf, mf: mf,
format: format, format: format,
bitRate: bitRate, bitRate: bitRate,
ReadCloser: io.NopCloser(strings.NewReader("")), ReadCloser: r,
} }
} }

View File

@ -0,0 +1,22 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddRadioUploadedImage, downAddRadioUploadedImage)
}
func upAddRadioUploadedImage(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `ALTER TABLE radio ADD COLUMN uploaded_image VARCHAR(255) NOT NULL DEFAULT ''`)
return err
}
func downAddRadioUploadedImage(ctx context.Context, tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

22
go.mod
View File

@ -36,7 +36,7 @@ require (
github.com/kardianos/service v1.2.4 github.com/kardianos/service v1.2.4
github.com/kr/pretty v0.3.1 github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v3 v3.0.13 github.com/lestrrat-go/jwx/v3 v3.0.13
github.com/mattn/go-sqlite3 v1.14.34 github.com/mattn/go-sqlite3 v1.14.37
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.28.1 github.com/onsi/ginkgo/v2 v2.28.1
@ -58,12 +58,12 @@ require (
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
go.senan.xyz/taglib v0.11.1 go.senan.xyz/taglib v0.11.1
go.uber.org/goleak v1.3.0 go.uber.org/goleak v1.3.0
golang.org/x/image v0.36.0 golang.org/x/image v0.37.0
golang.org/x/net v0.51.0 golang.org/x/net v0.52.0
golang.org/x/sync v0.20.0 golang.org/x/sync v0.20.0
golang.org/x/sys v0.42.0 golang.org/x/sys v0.42.0
golang.org/x/term v0.40.0 golang.org/x/term v0.41.0
golang.org/x/text v0.34.0 golang.org/x/text v0.35.0
golang.org/x/time v0.15.0 golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@ -80,13 +80,13 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect
github.com/ebitengine/purego v0.8.3 // indirect github.com/ebitengine/purego v0.10.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
@ -134,10 +134,10 @@ require (
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.49.0 // indirect
golang.org/x/mod v0.33.0 // indirect golang.org/x/mod v0.34.0 // indirect
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
golang.org/x/tools v0.42.0 // indirect golang.org/x/tools v0.43.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.1 // indirect gopkg.in/ini.v1 v1.67.1 // indirect
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect

44
go.sum
View File

@ -56,8 +56,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY=
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q=
github.com/ebitengine/purego v0.8.3 h1:K+0AjQp63JEZTEMZiwsI9g0+hAMNohwUOtY0RPGexmc= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.8.3/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw=
github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
@ -96,8 +96,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg= github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg=
@ -177,8 +177,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
@ -319,19 +319,19 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -343,8 +343,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -372,8 +372,8 @@ golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0= golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@ -382,8 +382,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -394,8 +394,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -405,8 +405,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@ -24,6 +24,7 @@ var (
KindPlaylistArtwork = Kind{"pl", "playlist"} KindPlaylistArtwork = Kind{"pl", "playlist"}
KindDiscArtwork = Kind{"dc", "disc"} KindDiscArtwork = Kind{"dc", "disc"}
KindFolderArtwork = Kind{"fo", "folder"} KindFolderArtwork = Kind{"fo", "folder"}
KindRadioArtwork = Kind{"ra", "radio"}
) )
var artworkKindMap = map[string]Kind{ var artworkKindMap = map[string]Kind{
@ -33,6 +34,7 @@ var artworkKindMap = map[string]Kind{
KindPlaylistArtwork.prefix: KindPlaylistArtwork, KindPlaylistArtwork.prefix: KindPlaylistArtwork,
KindDiscArtwork.prefix: KindDiscArtwork, KindDiscArtwork.prefix: KindDiscArtwork,
KindFolderArtwork.prefix: KindFolderArtwork, KindFolderArtwork.prefix: KindFolderArtwork,
KindRadioArtwork.prefix: KindRadioArtwork,
} }
type ArtworkID struct { type ArtworkID struct {
@ -147,5 +149,13 @@ func artworkIDFromFolder(f Folder) ArtworkID {
Kind: KindFolderArtwork, Kind: KindFolderArtwork,
ID: f.ID, ID: f.ID,
LastUpdate: f.ImagesUpdatedAt, LastUpdate: f.ImagesUpdatedAt,
}
}
func artworkIDFromRadio(r Radio) ArtworkID {
return ArtworkID{
Kind: KindRadioArtwork,
ID: r.ID,
LastUpdate: r.UpdatedAt,
} }
} }

View File

@ -26,5 +26,9 @@ func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) {
if err == nil { if err == nil {
return mf, nil return mf, nil
} }
r, err := ds.Radio(ctx).Get(id)
if err == nil {
return r, nil
}
return nil, err return nil, err
} }

View File

@ -1,14 +1,27 @@
package model package model
import "time" import (
"time"
"github.com/navidrome/navidrome/consts"
)
type Radio struct { type Radio struct {
ID string `structs:"id" json:"id"` ID string `structs:"id" json:"id"`
StreamUrl string `structs:"stream_url" json:"streamUrl"` StreamUrl string `structs:"stream_url" json:"streamUrl"`
Name string `structs:"name" json:"name"` Name string `structs:"name" json:"name"`
HomePageUrl string `structs:"home_page_url" json:"homePageUrl"` HomePageUrl string `structs:"home_page_url" json:"homePageUrl"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"` UploadedImage string `structs:"uploaded_image" json:"uploadedImage,omitempty"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
}
func (r Radio) CoverArtID() ArtworkID {
return artworkIDFromRadio(r)
}
func (r Radio) UploadedImagePath() string {
return UploadedImagePath(consts.EntityRadio, r.UploadedImage)
} }
type Radios []Radio type Radios []Radio
@ -19,5 +32,5 @@ type RadioRepository interface {
Delete(id string) error Delete(id string) error
Get(id string) (*Radio, error) Get(id string) (*Radio, error)
GetAll(options ...QueryOptions) (Radios, error) GetAll(options ...QueryOptions) (Radios, error)
Put(u *Radio) error Put(u *Radio, colsToUpdate ...string) error
} }

42
model/radio_test.go Normal file
View File

@ -0,0 +1,42 @@
package model_test
import (
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Radio", func() {
Describe("CoverArtID", func() {
It("returns a radio artwork ID", func() {
now := time.Now()
r := model.Radio{ID: "rd-1", UpdatedAt: now}
artID := r.CoverArtID()
Expect(artID.Kind).To(Equal(model.KindRadioArtwork))
Expect(artID.ID).To(Equal("rd-1"))
Expect(artID.LastUpdate).To(Equal(now))
})
})
Describe("UploadedImagePath", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DataFolder = "/data"
})
It("returns empty string when no image uploaded", func() {
r := model.Radio{ID: "rd-1"}
Expect(r.UploadedImagePath()).To(BeEmpty())
})
It("returns full path when image is set", func() {
r := model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"}
Expect(r.UploadedImagePath()).To(Equal(filepath.Join("/data", "artwork", "radio", "rd-1_test.jpg")))
})
})
})

View File

@ -4,7 +4,9 @@ import (
"cmp" "cmp"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os"
"slices" "slices"
"strings" "strings"
"time" "time"
@ -12,6 +14,7 @@ import (
. "github.com/Masterminds/squirrel" . "github.com/Masterminds/squirrel"
"github.com/deluan/rest" "github.com/deluan/rest"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils"
@ -315,7 +318,19 @@ func (r *artistRepository) GetIndex(includeMissing bool, libraryIds []int, roles
} }
func (r *artistRepository) purgeEmpty() error { func (r *artistRepository) purgeEmpty() error {
del := Delete(r.tableName).Where("id not in (select artist_id from album_artists)") orphanFilter := "id not in (select artist_id from album_artists)"
// Collect uploaded image filenames before deleting
sel := Select("uploaded_image").From(r.tableName).
Where(orphanFilter).
Where("uploaded_image != ''")
var imageFiles []string
if err := r.queryAllSlice(sel, &imageFiles); err != nil && !errors.Is(err, model.ErrNotFound) {
return fmt.Errorf("collecting artist images for cleanup: %w", err)
}
// Delete orphan artists
del := Delete(r.tableName).Where(orphanFilter)
c, err := r.executeSQL(del) c, err := r.executeSQL(del)
if err != nil { if err != nil {
return fmt.Errorf("purging empty artists: %w", err) return fmt.Errorf("purging empty artists: %w", err)
@ -323,6 +338,19 @@ func (r *artistRepository) purgeEmpty() error {
if c > 0 { if c > 0 {
log.Debug(r.ctx, "Purged empty artists", "totalDeleted", c) log.Debug(r.ctx, "Purged empty artists", "totalDeleted", c)
} }
if len(imageFiles) == 0 {
return nil
}
// Best-effort cleanup of uploaded image files
log.Debug(r.ctx, "Cleaning up artist images", "totalImages", len(imageFiles))
for _, filename := range imageFiles {
path := model.UploadedImagePath(consts.EntityArtist, filename)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
log.Warn(r.ctx, "Failed to remove artist image during GC", "path", path, err)
}
}
return nil return nil
} }

View File

@ -3,11 +3,14 @@ package persistence
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"os"
"path/filepath"
"github.com/Masterminds/squirrel" "github.com/Masterminds/squirrel"
"github.com/deluan/rest" "github.com/deluan/rest"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils"
@ -829,6 +832,89 @@ var _ = Describe("ArtistRepository", func() {
}) })
}) })
}) })
Describe("purgeEmpty", func() {
var repo *artistRepository
var tmpDir string
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tmpDir = GinkgoT().TempDir()
conf.Server.DataFolder = tmpDir
ctx := request.WithUser(GinkgoT().Context(), adminUser)
repo = NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository)
})
// Helper to create an artist image file on disk and return its path
createImageFile := func(filename string) string {
dir := filepath.Join(tmpDir, consts.ArtworkFolder, consts.EntityArtist)
Expect(os.MkdirAll(dir, 0755)).To(Succeed())
path := filepath.Join(dir, filename)
Expect(os.WriteFile(path, []byte("fake image data"), 0600)).To(Succeed())
return path
}
It("removes uploaded image files for purged artists", func() {
// Create an orphan artist (not in album_artists) with an uploaded image
orphanArtist := model.Artist{ID: "orphan-with-image", Name: "Orphan Artist", UploadedImage: "orphan-with-image_Orphan_Artist.jpg"}
Expect(repo.Put(&orphanArtist)).To(Succeed())
imgPath := createImageFile("orphan-with-image_Orphan_Artist.jpg")
Expect(repo.purgeEmpty()).To(Succeed())
// Artist should be gone from DB
exists, err := repo.Exists("orphan-with-image")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
// Image file should be removed from disk
_, err = os.Stat(imgPath)
Expect(os.IsNotExist(err)).To(BeTrue())
})
It("handles missing image files gracefully", func() {
// Artist has UploadedImage set but no actual file on disk
orphanArtist := model.Artist{ID: "orphan-no-file", Name: "Ghost Image", UploadedImage: "orphan-no-file_Ghost_Image.jpg"}
Expect(repo.Put(&orphanArtist)).To(Succeed())
Expect(repo.purgeEmpty()).To(Succeed())
// Artist should be gone from DB
exists, err := repo.Exists("orphan-no-file")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
})
It("does not delete images for artists that are kept", func() {
// Create an artist with an uploaded image AND an album_artists entry so it won't be purged
keptArtist := model.Artist{ID: "kept-artist", Name: "Kept Artist", UploadedImage: "kept-artist_Kept_Artist.jpg"}
Expect(repo.Put(&keptArtist)).To(Succeed())
imgPath := createImageFile("kept-artist_Kept_Artist.jpg")
// Insert an album_artists record to keep this artist from being purged
_, err := repo.executeSQL(squirrel.Insert("album_artists").
SetMap(map[string]any{"album_id": "101", "artist_id": "kept-artist", "role": "artist", "sub_role": ""}))
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_, _ = repo.executeSQL(squirrel.Delete("album_artists").Where(squirrel.Eq{"artist_id": "kept-artist"}))
_ = repo.delete(squirrel.Eq{"id": "kept-artist"})
})
Expect(repo.purgeEmpty()).To(Succeed())
// Artist should still exist (check directly, bypassing library filter)
var ids []string
err = repo.queryAllSlice(squirrel.Select("id").From("artist").Where(squirrel.Eq{"id": "kept-artist"}), &ids)
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(HaveLen(1))
// Image file should still be on disk
_, err = os.Stat(imgPath)
Expect(err).ToNot(HaveOccurred())
})
})
}) })
// Helper function to create an artist with proper library association. // Helper function to create an artist with proper library association.

View File

@ -5,6 +5,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/Masterminds/squirrel"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/db"
@ -211,6 +212,27 @@ var _ = BeforeSuite(func() {
} }
} }
// Populate album_artists based on the AlbumArtistID relationships in testAlbums
artistIDs := map[string]bool{}
for _, a := range testArtists {
artistIDs[a.ID] = true
}
for i := range testAlbums {
a := testAlbums[i]
if a.AlbumArtistID == "" || !artistIDs[a.AlbumArtistID] {
continue
}
_, err := alr.executeSQL(squirrel.Insert("album_artists").SetMap(map[string]any{
"album_id": a.ID,
"artist_id": a.AlbumArtistID,
"role": "artist",
"sub_role": "",
}))
if err != nil {
panic(err)
}
}
mr := NewMediaFileRepository(ctx, conn) mr := NewMediaFileRepository(ctx, conn)
for i := range testSongs { for i := range testSongs {
err := mr.Put(&testSongs[i]) err := mr.Put(&testSongs[i])

View File

@ -58,34 +58,20 @@ func (r *radioRepository) GetAll(options ...model.QueryOptions) (model.Radios, e
return res, err return res, err
} }
func (r *radioRepository) Put(radio *model.Radio) error { func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error {
if !r.isPermitted() { if !r.isPermitted() {
return rest.ErrPermissionDenied return rest.ErrPermissionDenied
} }
var values map[string]any
radio.UpdatedAt = time.Now() radio.UpdatedAt = time.Now()
if radio.ID == "" { if radio.ID == "" {
radio.CreatedAt = time.Now() radio.CreatedAt = time.Now()
radio.ID = id.NewRandom() radio.ID = id.NewRandom()
values, _ = toSQLArgs(*radio)
} else {
values, _ = toSQLArgs(*radio)
update := Update(r.tableName).Where(Eq{"id": radio.ID}).SetMap(values)
count, err := r.executeSQL(update)
if err != nil {
return err
} else if count > 0 {
return nil
}
} }
if len(colsToUpdate) > 0 {
values["created_at"] = time.Now() colsToUpdate = append(colsToUpdate, "UpdatedAt")
insert := Insert(r.tableName).SetMap(values) }
_, err := r.executeSQL(insert) _, err := r.put(radio.ID, radio, colsToUpdate...)
return err return err
} }

723
resources/i18n/sk.json Normal file
View File

@ -0,0 +1,723 @@
{
"languageName": "Slovenčina",
"resources": {
"song": {
"name": "Skladba |||| Skladieb",
"fields": {
"albumArtist": "Interpret albumu",
"duration": "Dĺžka",
"trackNumber": "#",
"playCount": "Počet prehratí",
"title": "Názov",
"artist": "Interpret",
"composer": "Skladateľ",
"album": "Album",
"path": "Cesta k súboru",
"libraryName": "Knižnica",
"genre": "Žáner",
"compilation": "Kompilácia",
"year": "Rok",
"size": "Veľkosť súboru",
"updatedAt": "Nahrané",
"bitRate": "Prenosová rýchlosť",
"bitDepth": "Bitová hĺbka",
"sampleRate": "Vzorkovacia frekvencia",
"channels": "Kanály",
"disc": "Disk %{discNumber}",
"discSubtitle": "Podtitul disku",
"starred": "Obľúbené",
"comment": "Komentár",
"rating": "Hodnotenie",
"quality": "Kvalita",
"bpm": "BPM",
"playDate": "Naposledy prehraná skladba",
"createdAt": "Pridané",
"grouping": "Zoskupovanie",
"mood": "Nálada",
"participants": "Ďalší účastníci",
"tags": "Ďalšie značky",
"mappedTags": "Mapované značky",
"rawTags": "Nespracované značky",
"missing": "Chýbajúce"
},
"actions": {
"addToQueue": "Prehrať neskôr",
"playNow": "Prehrať teraz",
"addToPlaylist": "Pridať do zoznamu skladieb",
"showInPlaylist": "Zobraziť v zozname skladieb",
"shuffleAll": "Zamiešať všetko",
"download": "Stiahnuť",
"playNext": "Prehrať ako ďalšie",
"info": "Získať informácie",
"instantMix": "Okamžitý mix"
}
},
"album": {
"name": "Album |||| Albumy",
"fields": {
"albumArtist": "Interpret albumu",
"artist": "Interpret",
"duration": "Dĺžka",
"songCount": "Skladby",
"playCount": "Počet prehratí",
"size": "Veľkosť",
"name": "Názov",
"libraryName": "Knižnica",
"genre": "Žáner",
"compilation": "Kompilácia",
"year": "Rok",
"date": "Dátum záznamu",
"originalDate": "Pôvodné",
"releaseDate": "Vydané",
"releases": "Vydanie |||| Vydania",
"released": "Vydané",
"updatedAt": "Aktualizované",
"comment": "Komentár",
"rating": "Hodnotenie",
"createdAt": "Pridané",
"recordLabel": "Štítok",
"catalogNum": "Katalógové číslo",
"releaseType": "Typ vydania",
"grouping": "Zoskupovanie",
"media": "Médiá",
"mood": "Nálada",
"missing": "Chýbajúce"
},
"actions": {
"playAll": "Prehrať",
"playNext": "Prehrať ako ďalšie",
"addToQueue": "Prehrať neskôr",
"share": "Zdieľať",
"shuffle": "Zamiešať",
"addToPlaylist": "Pridať do zoznamu skladieb",
"download": "Stiahnuť",
"info": "Získať informácie"
},
"lists": {
"all": "Všetko",
"random": "Náhodné",
"recentlyAdded": "Nedávno pridané",
"recentlyPlayed": "Nedávno prehrané",
"mostPlayed": "Najviac prehrávané",
"starred": "Obľúbené",
"topRated": "Najlepšie hodnotené"
}
},
"artist": {
"name": "Interpret |||| Interpreti",
"fields": {
"name": "Názov",
"albumCount": "Počet albumov",
"songCount": "Počet skladieb",
"size": "Veľkosť",
"playCount": "Prehrania",
"rating": "Hodnotenie",
"genre": "Žáner",
"role": "Rola",
"missing": "Chýbajúci"
},
"roles": {
"albumartist": "Interpret albumu |||| Interpreti albumov",
"artist": "Interpret |||| Interpreti",
"composer": "Skladateľ |||| Skladatelia",
"conductor": "Dirigent |||| Dirigenti",
"lyricist": "Textár |||| Textári",
"arranger": "Aranžér |||| Aranžéri",
"producer": "Producent |||| Producenti",
"director": "Režisér |||| Režiséri",
"engineer": "Zvukový technik |||| Zvukoví technici",
"mixer": "Mixér |||| Mixéri",
"remixer": "Remixér |||| Remixéri",
"djmixer": "DJ Mixér |||| DJ Mixéri",
"performer": "Účinkujúci |||| Účinkujúci",
"maincredit": "Interpret albumu alebo interpret |||| Interpreti albumov alebo interpreti"
},
"actions": {
"topSongs": "Najpopulárnejšie skladby",
"shuffle": "Zamiešať",
"radio": "Rádio"
}
},
"user": {
"name": "Používateľ |||| Používatelia",
"fields": {
"userName": "Používateľské meno",
"isAdmin": "Správca",
"lastLoginAt": "Naposledy prihlásený",
"lastAccessAt": "Posledný Prístup",
"updatedAt": "Upravený",
"name": "Meno",
"password": "Heslo",
"createdAt": "Vytvorený",
"changePassword": "Zmeniť heslo?",
"currentPassword": "Súčastné heslo",
"newPassword": "Nové heslo",
"token": "Token",
"libraries": "Knižnice"
},
"helperTexts": {
"name": "Zmena mena sa zobrazí až po ďalšom prihlásení",
"libraries": "Vyberte konkrétne knižnice pre tohto používateľa alebo nechajte pole prázdne, ak chcete použiť predvolené knižnice"
},
"notifications": {
"created": "Používateľ vytvorený",
"updated": "Používateľ upravený",
"deleted": "Používateľ odstránený"
},
"validation": {
"librariesRequired": "Pre používateľov bez administrátorských práv musí byť vybratá aspoň jedna knižnica"
},
"message": {
"listenBrainzToken": "Vložte svoj používateľský ListenBrainz token.",
"clickHereForToken": "Kliknite sem pre získanie svojho tokenu",
"selectAllLibraries": "Vybrať všetky knižnice",
"adminAutoLibraries": "Administrátori majú automaticky prístup ku všetkým knižniciam"
}
},
"player": {
"name": "Prehrávač |||| Prehrávače",
"fields": {
"name": "Názov",
"transcodingId": "ID transkódovania",
"maxBitRate": "Max. prenosová rýchlosť",
"client": "Klient",
"userName": "Používateľské meno",
"lastSeen": "Naposledy videný",
"reportRealPath": "Skutočná cesta hlásenia",
"scrobbleEnabled": "Odosielať scrobbling na externé služby"
}
},
"transcoding": {
"name": "Transkódovanie |||| Transkódovania",
"fields": {
"name": "Názov",
"targetFormat": "Cieľový formát",
"defaultBitRate": "Predvolená prenosová rýchlosť",
"command": "Príkaz"
}
},
"playlist": {
"name": "Zoznam skladieb |||| Zoznamy skladieb",
"fields": {
"name": "Názov",
"duration": "Dĺžka",
"ownerName": "Autor",
"public": "Verejný",
"updatedAt": "Nahraný",
"createdAt": "Vytvorený",
"songCount": "Skladby",
"comment": "Komentár",
"sync": "Auto-import",
"path": "Importovať z"
},
"actions": {
"selectPlaylist": "Vybrať zoznam skladieb:",
"addNewPlaylist": "Vytvoriť \"%{name}\"",
"export": "Export",
"saveQueue": "Uložiť rad do zoznamu skladieb",
"makePublic": "Zverejniť",
"makePrivate": "Nastaviť ako súkromné",
"searchOrCreate": "Vyhľadajte zoznamy skladieb alebo napíšte pre vytvorenie nového...",
"pressEnterToCreate": "Stlačte Enter pre vytvorenie nového zoznamu skladieb",
"removeFromSelection": "Odstrániť z výberu"
},
"message": {
"duplicate_song": "Pridať duplicitné položky",
"song_exist": "Pridávate duplikát už existujúcej položky v zozname skladieb. Chcete pridať duplikát alebo ho preskočiť?",
"noPlaylistsFound": "Žiadne zoznamy skladieb sa nenašli",
"noPlaylists": "Žiadne zoznamy skladieb nie sú dostupné"
}
},
"radio": {
"name": "Rádio |||| Rádiá",
"fields": {
"name": "Názov",
"streamUrl": "URL streamu",
"homePageUrl": "URL stránky",
"updatedAt": "Nahrané",
"createdAt": "Vytvorené"
},
"actions": {
"playNow": "Spustiť"
}
},
"share": {
"name": "Zdieľanie |||| Zdieľania",
"fields": {
"username": "Zdieľané",
"url": "URL",
"description": "Popis",
"downloadable": "Povoliť sťahovanie?",
"contents": "Obsah",
"expiresAt": "Vyprší",
"lastVisitedAt": "Naposledy navštívené",
"visitCount": "Počet návštev",
"format": "Formát",
"maxBitRate": "Max. Bit Rate",
"updatedAt": "Nahrané",
"createdAt": "Vytvorené"
},
"notifications": {},
"actions": {}
},
"missing": {
"name": "Chýbajúci súbor |||| Chýbajúce súbory",
"empty": "Žiadne chýbajúce súbory",
"fields": {
"path": "Cesta",
"size": "Veľkosť",
"libraryName": "Knižnica",
"updatedAt": "Zmizol dňa"
},
"actions": {
"remove": "Odstrániť",
"remove_all": "Odstrániť všetky"
},
"notifications": {
"removed": "Chýbajúce súbory odstránené"
}
},
"library": {
"name": "Knižnica |||| Knižnice",
"fields": {
"name": "Názov",
"path": "Cesta",
"remotePath": "Vzdialená cesta",
"lastScanAt": "Posledný sken",
"songCount": "Skladby",
"albumCount": "Albumy",
"artistCount": "Interpreti",
"totalSongs": "Skladby",
"totalAlbums": "Albumy",
"totalArtists": "Interpreti",
"totalFolders": "Priečinky",
"totalFiles": "Súbory",
"totalMissingFiles": "Chýbajúce súbory",
"totalSize": "Celková veľkosť",
"totalDuration": "Dĺžka",
"defaultNewUsers": "Predvolené pre nových používateľov",
"createdAt": "Vytvorené",
"updatedAt": "Aktualizované"
},
"sections": {
"basic": "Základné informácie",
"statistics": "Štatistiky"
},
"actions": {
"scan": "Skenovať knižnicu",
"quickScan": "Rýchly sken",
"fullScan": "Úplný sken",
"manageUsers": "Spravovať prístup používateľov",
"viewDetails": "Zobraziť detaily"
},
"notifications": {
"created": "Knižnica úspešne vytvorená",
"updated": "Knižnica úspešne aktualizovaná",
"deleted": "Knižnica úspešne odstránená",
"scanStarted": "Skenovanie knižnice spustené",
"quickScanStarted": "Rýchly sken spustený",
"fullScanStarted": "Úplný sken spustený",
"scanError": "Chyba pri spustení skenu. Skontrolujte logy",
"scanCompleted": "Skenovanie knižnice dokončené"
},
"validation": {
"nameRequired": "Názov knižnice je povinný",
"pathRequired": "Cesta ku knižnici je povinná",
"pathNotDirectory": "Cesta ku knižnici musí byť priečinok",
"pathNotFound": "Cesta ku knižnici sa nenašla",
"pathNotAccessible": "Cesta ku knižnici nie je dostupná",
"pathInvalid": "Neplatná cesta ku knižnici"
},
"messages": {
"deleteConfirm": "Ste si istý, že chcete odstrániť túto knižnicu? Tým sa odstránia všetky súvisiace dáta a prístupy používateľov.",
"scanInProgress": "Skenovanie prebieha...",
"noLibrariesAssigned": "Tomuto používateľovi nie sú priradené žiadne knižnice"
}
},
"plugin": {
"name": "Plugin |||| Pluginy",
"fields": {
"id": "ID",
"name": "Názov",
"description": "Popis",
"version": "Verzia",
"author": "Autor",
"website": "Webová stránka",
"permissions": "Oprávnenia",
"enabled": "Povolený",
"status": "Stav",
"path": "Cesta",
"lastError": "Chyba",
"hasError": "Chyba",
"updatedAt": "Aktualizovaný",
"createdAt": "Nainštalovaný",
"configKey": "Kľúč",
"configValue": "Hodnota",
"allUsers": "Povoliť všetkých používateľov",
"selectedUsers": "Vybraní používatelia",
"allLibraries": "Povoliť všetky knižnice",
"selectedLibraries": "Vybrané knižnice",
"allowWriteAccess": "Povoliť prístup na zápis"
},
"sections": {
"status": "Stav",
"info": "Informácie o plugine",
"configuration": "Konfigurácia",
"manifest": "Manifest",
"usersPermission": "Oprávnenia používateľov",
"libraryPermission": "Oprávnenia knižnice"
},
"status": {
"enabled": "Povolený",
"disabled": "Zakázaný"
},
"actions": {
"enable": "Povoliť",
"disable": "Zakázať",
"disabledDueToError": "Opravte chybu pred povolením",
"disabledUsersRequired": "Vyberte používateľov pred povolením",
"disabledLibrariesRequired": "Vyberte knižnice pred povolením",
"addConfig": "Pridať konfiguráciu",
"rescan": "Znovu skenovať"
},
"notifications": {
"enabled": "Plugin povolený",
"disabled": "Plugin zakázaný",
"updated": "Plugin aktualizovaný",
"error": "Chyba pri aktualizácii pluginu"
},
"validation": {
"invalidJson": "Konfigurácia musí byť platný JSON"
},
"messages": {
"configHelp": "Nakonfigurujte plugin pomocou párov kľúč-hodnota. Nechajte prázdne, ak plugin nevyžaduje žiadnu konfiguráciu.",
"configValidationError": "Overenie konfigurácie zlyhalo:",
"schemaRenderError": "Nie je možné zobraziť konfiguračný formulár. Schéma pluginu môže byť neplatná.",
"clickPermissions": "Kliknite na oprávnenie pre detaily",
"noConfig": "Žiadna konfigurácia nastavená",
"allUsersHelp": "Keď je povolené, plugin bude mať prístup ku všetkým používateľom, vrátane tých vytvorených v budúcnosti.",
"noUsers": "Žiadni používatelia nevybraní",
"permissionReason": "Dôvod",
"usersRequired": "Tento plugin vyžaduje prístup k informáciám o používateľoch. Vyberte, ku ktorým používateľom má plugin prístup, alebo povolte 'Povoliť všetkých používateľov'.",
"allLibrariesHelp": "Keď je povolené, plugin bude mať prístup ku všetkým knižniciam, vrátane tých vytvorených v budúcnosti.",
"noLibraries": "Žiadne knižnice nevybrané",
"librariesRequired": "Tento plugin vyžaduje prístup k informáciám o knižniciach. Vyberte, ku ktorým knižniciam má plugin prístup, alebo povolte 'Povoliť všetky knižnice'.",
"allowWriteAccessHelp": "Keď je povolené, plugin môže upravovať súbory v adresároch knižníc. Predvolene majú pluginy prístup iba na čítanie.",
"requiredHosts": "Požadovaní hostitelia"
},
"placeholders": {
"configKey": "kľúč",
"configValue": "hodnota"
}
}
},
"ra": {
"auth": {
"welcome1": "Ďakujeme, že ste si nainštalovali Navidrome!",
"welcome2": "Najskôr vytvorte účet správcu",
"confirmPassword": "Potvrďte heslo",
"buttonCreateAdmin": "Vytvoriť správcu",
"auth_check_error": "Pre pokračovanie sa prosím prihláste",
"user_menu": "Profil",
"username": "Používateľské meno",
"password": "Heslo",
"sign_in": "Prihlásiť sa",
"sign_in_error": "Overenie zlyhalo, skúste to znova",
"logout": "Odhlásiť sa",
"insightsCollectionNote": "Navidrome zhromažďuje anonymné údaje\n o používaní, aby pomohol zlepšiť projekt.\nKliknite [sem] a dozviete sa viac a v prípade\npotreby sa odhláste."
},
"validation": {
"invalidChars": "Prosím, používajte iba písmená a čísla",
"passwordDoesNotMatch": "Heslá sa nezhodujú",
"required": "Povinné pole",
"minLength": "Musí obsahovať najmenej %{min} znakov",
"maxLength": "Môže obsahovať maximálne %{max} znakov",
"minValue": "Musí byť aspoň %{min}",
"maxValue": "Môže byť maximálne %{max}",
"number": "Musí byť číslo",
"email": "Musí byť platná e-mailová adresa",
"oneOf": "Musí spĺňať jedno z: %{options}",
"regex": "Musí byť v špecifickom formáte (regexp): %{pattern}",
"unique": "Musí byť jedinečný",
"url": "Musí byť platná URL"
},
"action": {
"add_filter": "Pridať filter",
"add": "Pridať",
"back": "Ísť späť",
"bulk_actions": "1 vybraná |||| %{smart_count} vybraných",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"cancel": "Zrušiť",
"clear_input_value": "Vymazať hodnotu",
"clone": "Klonovať",
"confirm": "Potvrdiť",
"create": "Vytvoriť",
"delete": "Vymazať",
"edit": "Upraviť",
"export": "Exportovať",
"list": "Zoznam",
"refresh": "Obnoviť",
"remove_filter": "Odstrániť filter",
"remove": "Odstrániť",
"save": "Uložiť",
"search": "Vyhľadať",
"show": "Zobraziť",
"sort": "Zoradiť",
"undo": "Vrátiť",
"expand": "Rozbaliť",
"close": "Zavrieť",
"open_menu": "Otvoriť ponuku",
"close_menu": "Zavrieť ponuku",
"unselect": "Zrušiť výber",
"skip": "Preskočiť",
"share": "Zdieľať",
"download": "Stiahnuť"
},
"boolean": {
"true": "Áno",
"false": "Nie"
},
"page": {
"create": "Vytvoriť %{name}",
"dashboard": "Dashboard",
"edit": "%{name} #%{id}",
"error": "Niečo sa pokazilo",
"list": "%{name}",
"loading": "Načítavanie",
"not_found": "Nenájdené",
"show": "%{name} #%{id}",
"empty": "Zatiaľ žiaden %{name}.",
"invite": "Chcete pridať nové?"
},
"input": {
"file": {
"upload_several": "Presuňte súbory pre nahranie alebo kliknite pre výber.",
"upload_single": "Presuňte súbor pre nahranie alebo kliknite pre jeho výber."
},
"image": {
"upload_several": "Presuňte obrázky pre nahranie alebo kliknite pre výber.",
"upload_single": "Presuňte obrázok pre nahranie alebo kliknite pre jeho výber."
},
"references": {
"all_missing": "Referencované dáta sa nenašli.",
"many_missing": "Aspoň jedna z referencií už nie je dostupná.",
"single_missing": "Referencia sa zdá byť nedostupná."
},
"password": {
"toggle_visible": "Skryť heslo",
"toggle_hidden": "Zobraziť heslo"
}
},
"message": {
"about": "O Navidrome",
"are_you_sure": "Ste si istý?",
"bulk_delete_content": "Ste si istý, že chcete vymazať %{name}? |||| Ste si istý, že chcete vymazať týchto %{smart_count} položiek?",
"bulk_delete_title": "Vymazať %{name} |||| Vymazať %{smart_count} %{name} položiek",
"delete_content": "Ste si istý, že chcete vymazať túto položku?",
"delete_title": "Vymazať %{name} #%{id}",
"details": "Detaily",
"error": "Vyskytla sa chyba klienta a vaša požiadavka nemohla byť splnená.",
"invalid_form": "Formulár nie je platný. Prosím skontrolujte ho.",
"loading": "Stránka sa načítava, prosím počkajte",
"no": "Nie",
"not_found": "Zadali ste nesprávnu adresu URL, alebo ste nasledovali nesprávny odkaz.",
"yes": "Áno",
"unsaved_changes": "Niektoré vaše zmeny neboli uložené. Ste si istí, že ich chcete ignorovať?"
},
"navigation": {
"no_results": "Nenašli sa žiadne výsledky",
"no_more_results": "Stránka číslo %{page} je mimo rozsah. Skúste predchádzajúcu.",
"page_out_of_boundaries": "Stránka číslo %{page} je mimo rozsah",
"page_out_from_end": "Nemožno ísť za poslednú stranu",
"page_out_from_begin": "Nemožno ísť pred prvú stranu",
"page_range_info": "%{offsetBegin}-%{offsetEnd} z %{total}",
"page_rows_per_page": "Položiek na stránke:",
"next": "Ďalší",
"prev": "Predchádzajúci",
"skip_nav": "Preskočiť na obsah"
},
"notification": {
"updated": "Prvok aktualizovaný |||| %{smart_count} prvkov aktualizovaných",
"created": "Prvok vytvorený",
"deleted": "Prvok vymazaný |||| %{smart_count} prvkov vymazaných",
"bad_item": "Nesprávny prvok",
"item_doesnt_exist": "Prvok neexistuje",
"http_error": "Chyba komunikácie servera",
"data_provider_error": "Chyba dataProvideru. Detaily nájdete v konzole.",
"i18n_error": "Nemožno načítať preklady pre vybraný jazyk",
"canceled": "Akcia zrušená",
"logged_out": "Vaša relácia skončila, prosím pripojte sa znova.",
"new_version": "Je dostupná nová verzia! Prosím obnovte toto okno."
},
"toggleFieldsMenu": {
"columnsToDisplay": "Stĺpce na zobrazenie",
"layout": "Rozloženie",
"grid": "Mriežka",
"table": "Tabuľka"
}
},
"message": {
"uploadCover": "Nahrať obrázok obalu",
"removeCover": "Odstrániť obrázok obalu",
"coverUploaded": "Obrázok obalu albumu aktualizovaný",
"coverRemoved": "Obrázok obalu albumu odstránený",
"coverUploadError": "Chyba pri nahrávaní obrázku obalu albumu",
"coverRemoveError": "Chyba pri odstraňovaní obrázku obalu albumu",
"note": "POZNÁMKA",
"transcodingDisabled": "Zmena nastavení transkódovania je vo webovom prostredí vypnutá z bezpečnostných dôvodov. Ak chcete zmeniť (upraviť alebo pridať) možnosti transkódovania, reštartujte server s možnosťou %{config}.",
"transcodingEnabled": "Navidrome práve beží s možnosťou %{config}, ktorá umožňuje spúšťanie systémových príkazov z nastavení transkódovania pomocou webového rozhrania. Odporúčame ju vypnúť z bezpečnostných dôvodov a používať ju iba pri úprave nastavení transkódovania.",
"songsAddedToPlaylist": "1 skladba pridaná do zoznamu skladieb |||| %{smart_count} skladieb pridaných do zoznamu skladieb",
"noSimilarSongsFound": "Nenašli sa žiadne podobné skladby",
"startingInstantMix": "Načítava sa Instant Mix...",
"noTopSongsFound": "Nenašli sa žiadne top skladby",
"noPlaylistsAvailable": "Žiadne nie sú dostupné",
"delete_user_title": "Odstrániť používateľa '%{name}'",
"delete_user_content": "Ste si istí, že chcete odstrániť tohto používateľa a všetky jeho dáta (vrátane zoznamov skladieb a nastavení)?",
"remove_missing_title": "Odstráňte chýbajúce súbory",
"remove_missing_content": "Naozaj chcete odstrániť vybraté chýbajúce súbory z databázy? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.",
"remove_all_missing_title": "Odstráňte všetky chýbajúce súbory",
"remove_all_missing_content": "Naozaj chcete z databázy odstrániť všetky chýbajúce súbory? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.",
"notifications_blocked": "Zablokovali ste si oznámenia pre túto stránku v nastaveniach vášho prehliadača",
"notifications_not_available": "Tento prehliadač nepodporuje oznámenia na ploche alebo nepristupujete k Navidrome cez https",
"lastfmLinkSuccess": "Last.fm úspešne pripojené a scrobbling zapnutý",
"lastfmLinkFailure": "Last.fm sa nepodarilo pripojiť",
"lastfmUnlinkSuccess": "Last.fm odpojené a scrobbling vypnutý",
"lastfmUnlinkFailure": "Last.fm sa nepodarilo odpojiť",
"listenBrainzLinkSuccess": "ListenBrainz úspešne pripojený a scrobbling zapnutý ako používateľ: %{user}",
"listenBrainzLinkFailure": "ListenBrainz sa nepodarilo pripojiť: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz odpojený a scrobbling vypnutý",
"listenBrainzUnlinkFailure": "ListenBrainz sa nepodarilo odpojiť",
"openIn": {
"lastfm": "Otvoriť na Last.fm",
"musicbrainz": "Otvoriť na MusicBrainz"
},
"lastfmLink": "Čítať ďalej...",
"shareOriginalFormat": "Zdieľať v pôvodnom formáte",
"shareDialogTitle": "Zdieľať %{resource} '%{name}'",
"shareBatchDialogTitle": "Zdieľať 1 %{resource} |||| Zdieľať %{smart_count} %{resource}",
"shareCopyToClipboard": "Skopírovať do schránky: Ctrl+C, Enter",
"shareSuccess": "URL skopírovaná do schránky: %{url}",
"shareFailure": "Chyba pri kopírovaní URL %{url} do schránky",
"downloadDialogTitle": "Stiahnuť %{resource} '%{name}' (%{size})",
"downloadOriginalFormat": "Stiahnuť v pôvodnom formáte"
},
"menu": {
"library": "Knižnica",
"librarySelector": {
"allLibraries": "Všetky knižnice (%{count})",
"multipleLibraries": "%{selected} z %{total} knižníc",
"selectLibraries": "Vyberte knižnice",
"none": "Žiadne"
},
"settings": "Nastavenia",
"version": "Verzia",
"theme": "Téma",
"personal": {
"name": "Osobné",
"options": {
"theme": "Téma",
"language": "Jazyk",
"defaultView": "Predvolená stránka",
"desktop_notifications": "Oznámenia na ploche",
"lastfmNotConfigured": "Kľúč API Last.fm nie je nakonfigurovaný",
"lastfmScrobbling": "Scrobblovať na Last.fm",
"listenBrainzScrobbling": "Scrobblovať na ListenBrainz",
"replaygain": "Mód ReplayGain",
"preAmp": "ReplayGain PreAmp (dB)",
"gain": {
"none": "Vypnuté",
"album": "Použiť Album Gain",
"track": "Použiť Track Gain"
}
}
},
"albumList": "Albumy",
"playlists": "Zoznamy skladieb",
"sharedPlaylists": "Zdieľané zoznamy skladieb",
"about": "O Navidrome"
},
"player": {
"playListsText": "Rad",
"openText": "Otvoriť",
"closeText": "Zavrieť",
"notContentText": "Žiadne skladby",
"clickToPlayText": "Kliknite pre prehranie",
"clickToPauseText": "Kliknite pre pozastavenie",
"nextTrackText": "Ďalšia skladba",
"previousTrackText": "Predchádzajúca skladba",
"reloadText": "Znovu načítať",
"volumeText": "Hlasitosť",
"toggleLyricText": "Prepnúť text",
"toggleMiniModeText": "Zmenšiť",
"destroyText": "Zničiť",
"downloadText": "Stiahnuť",
"removeAudioListsText": "Vymazať zoznam",
"clickToDeleteText": "Kliknite pre odstránenie %{name}",
"emptyLyricText": "Bez textu",
"playModeText": {
"order": "Po poradí",
"orderLoop": "Opakovať",
"singleLoop": "Opakovať raz",
"shufflePlay": "Zamiešať"
}
},
"about": {
"links": {
"homepage": "Domovská stránka",
"source": "Zdrojový kód",
"featureRequests": "Požiadavky na funkcie",
"lastInsightsCollection": "Posledný zber štatistík",
"insights": {
"disabled": "Zakázané",
"waiting": "Čakanie"
}
},
"tabs": {
"about": "O aplikácii",
"config": "Konfigurácia"
},
"config": {
"configName": "Názov konfigurácie",
"environmentVariable": "Premenná prostredia",
"currentValue": "Aktuálna hodnota",
"configurationFile": "Konfiguračný súbor",
"exportToml": "Exportovať konfiguráciu (TOML)",
"downloadToml": "Stiahnuť konfiguráciu (TOML)",
"exportSuccess": "Konfigurácia exportovaná do schránky vo formáte TOML",
"exportFailed": "Nepodarilo sa skopírovať konfiguráciu",
"devFlagsHeader": "Vývojové príznaky (môžu byť zmenené/odstránené)",
"devFlagsComment": "Toto sú experimentálne nastavenia a môžu byť odstránené v budúcich verziách"
}
},
"activity": {
"title": "Aktivita",
"totalScanned": "Naskenované priečinky",
"quickScan": "Rýchly sken",
"fullScan": "Úplný sken",
"selectiveScan": "Selektívne",
"serverUptime": "Doba od spustenia",
"serverDown": "OFFLINE",
"scanType": "Posledný Sken",
"status": "Chyba skenovania",
"elapsedTime": "Uplynutý čas"
},
"nowPlaying": {
"title": "Práve hrá",
"empty": "Nič sa neprehráva",
"minutesAgo": "pred %{smart_count} minútou |||| pred %{smart_count} minútami"
},
"help": {
"title": "Klávesové skratky Navidrome",
"hotkeys": {
"show_help": "Zobraziť túto nápovedu",
"toggle_menu": "Prepnúť bočné menu",
"toggle_play": "Prehrať / Pozastaviť",
"prev_song": "Predchádzajúca skladba",
"next_song": "Nasledujúca skladba",
"current_song": "Prejsť na aktuálnu skladbu",
"vol_up": "Zvýšiť hlasitosť",
"vol_down": "Znížiť hlasitosť",
"toggle_love": "Pridať túto skladbu do obľúbených"
}
}
}

57
scripts/setup-worktree.sh Executable file
View File

@ -0,0 +1,57 @@
#!/usr/bin/env bash
#
# Setup a git worktree for Navidrome development.
# This script is called automatically by `make worktree` and by Claude Code's
# worktree isolation, but can also be run standalone:
#
# ./scripts/setup-worktree.sh <worktree-path> [--go-only]
#
# Options:
# --go-only Skip frontend (npm) setup. Useful for agents working only on Go code.
#
set -euo pipefail
WORKTREE_PATH="${1:?Usage: $0 <worktree-path> [--go-only]}"
GO_ONLY="${2:-}"
# Resolve the main worktree root (where the original repo lives)
MAIN_WORKTREE="$(git -C "$WORKTREE_PATH" worktree list --porcelain | head -1 | sed 's/^worktree //')"
if [ ! -d "$WORKTREE_PATH" ]; then
echo "ERROR: Worktree path does not exist: $WORKTREE_PATH"
exit 1
fi
cd "$WORKTREE_PATH"
echo "==> Setting up worktree at $WORKTREE_PATH"
# 1. Download Go dependencies
echo "==> Downloading Go dependencies..."
go mod download
# 2. Install frontend dependencies (unless --go-only)
if [ "$GO_ONLY" != "--go-only" ]; then
echo "==> Installing frontend dependencies..."
(cd ui && npm ci --prefer-offline --no-audit 2>/dev/null || npm ci)
else
echo "==> Skipping frontend setup (--go-only)"
fi
# 3. Create required directories
mkdir -p data
# 4. Copy navidrome.toml from main worktree if it exists and not already present
if [ ! -f navidrome.toml ] && [ -f "$MAIN_WORKTREE/navidrome.toml" ]; then
echo "==> Copying navidrome.toml from main worktree..."
cp "$MAIN_WORKTREE/navidrome.toml" navidrome.toml
fi
# 5. Copy existing database from main worktree (already migrated and scanned)
# This is much faster than running migrations + a full scan from scratch.
if [ ! -f data/navidrome.db ] && [ -f "$MAIN_WORKTREE/data/navidrome.db" ]; then
echo "==> Copying database from main worktree (pre-migrated, pre-scanned)..."
cp "$MAIN_WORKTREE/data/navidrome.db" data/navidrome.db
fi
echo "==> Worktree setup complete: $WORKTREE_PATH"

View File

@ -11,6 +11,7 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"testing/fstest" "testing/fstest"
"time" "time"
@ -287,18 +288,28 @@ func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool
// spyStreamer captures the Request passed to NewStream for test assertions, // spyStreamer captures the Request passed to NewStream for test assertions,
// then returns a minimal fake Stream so the handler completes without error. // then returns a minimal fake Stream so the handler completes without error.
type spyStreamer struct { type spyStreamer struct {
LastRequest stream.Request LastRequest stream.Request
LastMediaFile *model.MediaFile LastMediaFile *model.MediaFile
SimulateError error // When set, NewStream returns this error
SimulateEmptyStream bool // When true, returns a 0-byte stream (simulates ffmpeg producing no output)
} }
func (s *spyStreamer) NewStream(_ context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) { func (s *spyStreamer) NewStream(_ context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) {
s.LastRequest = req s.LastRequest = req
s.LastMediaFile = mf s.LastMediaFile = mf
if s.SimulateError != nil {
return nil, s.SimulateError
}
format := req.Format format := req.Format
if format == "" || format == "raw" { if format == "" || format == "raw" {
format = mf.Suffix format = mf.Suffix
} }
return stream.NewTestStream(mf, format, req.BitRate), nil content := "fake audio data"
if s.SimulateEmptyStream {
content = ""
}
r := io.NopCloser(strings.NewReader(content))
return stream.NewStream(mf, format, req.BitRate, r), nil
} }
// noopFFmpeg implements ffmpeg.FFmpeg with no-op methods. // noopFFmpeg implements ffmpeg.FFmpeg with no-op methods.

View File

@ -1,9 +1,12 @@
package e2e package e2e
import ( import (
"encoding/json"
"errors"
"net/http" "net/http"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
) )
@ -124,4 +127,56 @@ var _ = Describe("stream.view (legacy streaming)", Ordered, func() {
Expect(streamerSpy.LastRequest.Offset).To(Equal(30)) Expect(streamerSpy.LastRequest.Offset).To(Equal(30))
}) })
}) })
Describe("stream creation failure", func() {
BeforeEach(func() {
streamerSpy.SimulateError = errors.New("ffmpeg exited with non-zero status code: 1: Unknown encoder 'libopus'")
})
AfterEach(func() {
streamerSpy.SimulateError = nil
})
It("returns a Subsonic error for stream endpoint", func() {
w := doRawReq("stream", "id", flacTrackID, "format", "opus")
Expect(w.Code).To(Equal(http.StatusOK)) // Subsonic errors are returned as 200
var wrapper responses.JsonWrapper
Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed())
Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed))
Expect(wrapper.Subsonic.Error).ToNot(BeNil())
})
It("returns a Subsonic error for download endpoint", func() {
conf.Server.EnableDownloads = true
w := doRawReq("download", "id", flacTrackID, "format", "opus")
Expect(w.Code).To(Equal(http.StatusOK))
var wrapper responses.JsonWrapper
Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed())
Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed))
Expect(wrapper.Subsonic.Error).ToNot(BeNil())
})
})
Describe("empty transcoded output", func() {
BeforeEach(func() {
streamerSpy.SimulateEmptyStream = true
})
AfterEach(func() {
streamerSpy.SimulateEmptyStream = false
})
It("returns 200 with empty body for stream endpoint", func() {
w := doRawReq("stream", "id", flacTrackID, "format", "opus")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Body.Len()).To(Equal(0))
})
It("returns 200 with empty body for download endpoint", func() {
conf.Server.EnableDownloads = true
w := doRawReq("download", "id", flacTrackID, "format", "opus")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Body.Len()).To(Equal(0))
})
})
}) })

View File

@ -1,6 +1,7 @@
package e2e package e2e
import ( import (
"errors"
"net/http" "net/http"
"time" "time"
@ -602,6 +603,36 @@ var _ = Describe("Transcode Endpoints", Ordered, func() {
mf.UpdatedAt = originalUpdatedAt mf.UpdatedAt = originalUpdatedAt
Expect(ds.MediaFile(ctx).Put(mf)).To(Succeed()) Expect(ds.MediaFile(ctx).Put(mf)).To(Succeed())
}) })
It("returns 500 when stream creation fails", func() {
// Get a valid decision token
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
token := resp.TranscodeDecision.TranscodeParams
Expect(token).ToNot(BeEmpty())
// Simulate streamer failure (e.g., ffmpeg missing codec)
streamerSpy.SimulateError = errors.New("ffmpeg exited with non-zero status code: 1: Unknown encoder 'libopus'")
defer func() { streamerSpy.SimulateError = nil }()
w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
It("returns 500 when transcoded stream is empty", func() {
// Get a valid decision token
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
token := resp.TranscodeDecision.TranscodeParams
Expect(token).ToNot(BeEmpty())
// Simulate ffmpeg producing 0 bytes
streamerSpy.SimulateEmptyStream = true
defer func() { streamerSpy.SimulateEmptyStream = false }()
w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
}) })
Describe("round-trip: decision then stream", func() { Describe("round-trip: decision then stream", func() {

View File

@ -71,7 +71,7 @@ func (api *Router) routes() http.Handler {
api.R(r, "/genre", model.Genre{}, false) api.R(r, "/genre", model.Genre{}, false)
api.R(r, "/player", model.Player{}, true) api.R(r, "/player", model.Player{}, true)
api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
api.R(r, "/radio", model.Radio{}, true) api.addRadioRoute(r)
api.R(r, "/tag", model.Tag{}, true) api.R(r, "/tag", model.Tag{}, true)
if conf.Server.EnableSharing { if conf.Server.EnableSharing {
api.RX(r, "/share", api.share.NewRepository, true) api.RX(r, "/share", api.share.NewRepository, true)

View File

@ -0,0 +1,70 @@
package nativeapi
import (
"context"
"errors"
"io"
"net/http"
"github.com/deluan/rest"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
)
func (api *Router) addRadioRoute(r chi.Router) {
constructor := func(ctx context.Context) rest.Repository {
return api.ds.Resource(ctx, model.Radio{})
}
r.Route("/radio", func(r chi.Router) {
r.Get("/", rest.GetAll(constructor))
r.Post("/", rest.Post(constructor))
r.Route("/{id}", func(r chi.Router) {
r.Use(server.URLParamsMiddleware)
r.Get("/", rest.Get(constructor))
r.Put("/", rest.Put(constructor))
r.Delete("/", rest.Delete(constructor))
r.Post("/image", api.uploadRadioImage())
r.Delete("/image", api.deleteRadioImage())
})
})
}
func (api *Router) uploadRadioImage() http.HandlerFunc {
return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error {
radioID := chi.URLParamFromCtx(ctx, "id")
radio, err := api.ds.Radio(ctx).Get(radioID)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return model.ErrNotFound
}
return err
}
oldPath := radio.UploadedImagePath()
filename, err := api.imgUpload.SetImage(ctx, consts.EntityRadio, radio.ID, radio.Name, oldPath, reader, ext)
if err != nil {
return err
}
radio.UploadedImage = filename
return api.ds.Radio(ctx).Put(radio, "UploadedImage")
})
}
func (api *Router) deleteRadioImage() http.HandlerFunc {
return handleImageDelete(func(ctx context.Context) error {
radioID := chi.URLParamFromCtx(ctx, "id")
radio, err := api.ds.Radio(ctx).Get(radioID)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return model.ErrNotFound
}
return err
}
if err := api.imgUpload.RemoveImage(ctx, radio.UploadedImagePath()); err != nil {
return err
}
radio.UploadedImage = ""
return api.ds.Radio(ctx).Put(radio, "UploadedImage")
})
}

View File

@ -2,7 +2,6 @@ package public
import ( import (
"errors" "errors"
"io"
"net/http" "net/http"
"strconv" "strconv"
@ -54,34 +53,9 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32)) w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32))
if stream.Seekable() { n, err := stream.Serve(ctx, w, r)
http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream) if err != nil || n == 0 {
} else { http.Error(w, "internal error", http.StatusInternalServerError)
// If the stream doesn't provide a size (i.e. is not seekable), we can't support ranges/content-length
w.Header().Set("Accept-Ranges", "none")
w.Header().Set("Content-Type", stream.ContentType())
estimateContentLength := p.BoolOr("estimateContentLength", false)
// if Client requests the estimated content-length, send it
if estimateContentLength {
length := strconv.Itoa(stream.EstimatedContentLength())
log.Trace(ctx, "Estimated content-length", "contentLength", length)
w.Header().Set("Content-Length", length)
}
if r.Method == http.MethodHead {
go func() { _, _ = io.Copy(io.Discard, stream) }()
} else {
c, err := io.Copy(w, stream)
if log.IsGreaterOrEqualTo(log.LevelDebug) {
if err != nil {
log.Error(ctx, "Error sending shared transcoded file", "id", info.id, err)
} else {
log.Trace(ctx, "Success sending shared transcode file", "id", info.id, "size", c)
}
}
}
} }
} }

View File

@ -103,7 +103,7 @@ func (api *Router) UpdateInternetRadio(r *http.Request) (*responses.Subsonic, er
Name: name, Name: name,
} }
err = api.ds.Radio(ctx).Put(radio) err = api.ds.Radio(ctx).Put(radio, "StreamUrl", "HomePageUrl", "Name")
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -8,6 +8,7 @@
"id": "1", "id": "1",
"name": "album", "name": "album",
"artist": "artist", "artist": "artist",
"duration": 292,
"genre": "rock", "genre": "rock",
"userRating": 4, "userRating": 4,
"genres": [ "genres": [

View File

@ -1,5 +1,5 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> <subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<album id="1" name="album" artist="artist" genre="rock" userRating="4" musicBrainzId="1234" isCompilation="true" sortName="sorted album" displayArtist="artist1 &amp; artist2" explicitStatus="clean" version="Deluxe Edition"> <album id="1" name="album" artist="artist" duration="292" genre="rock" userRating="4" musicBrainzId="1234" isCompilation="true" sortName="sorted album" displayArtist="artist1 &amp; artist2" explicitStatus="clean" version="Deluxe Edition">
<genres name="rock"></genres> <genres name="rock"></genres>
<genres name="progressive"></genres> <genres name="progressive"></genres>
<discTitles disc="1" title="disc 1"></discTitles> <discTitles disc="1" title="disc 1"></discTitles>

View File

@ -6,6 +6,7 @@
"openSubsonic": true, "openSubsonic": true,
"album": { "album": {
"id": "", "id": "",
"name": "" "name": "",
"duration": 0
} }
} }

View File

@ -1,3 +1,3 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> <subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<album id="" name=""></album> <album id="" name="" duration="0"></album>
</subsonic-response> </subsonic-response>

View File

@ -7,6 +7,7 @@
"album": { "album": {
"id": "", "id": "",
"name": "", "name": "",
"duration": 0,
"userRating": 0, "userRating": 0,
"genres": [], "genres": [],
"musicBrainzId": "", "musicBrainzId": "",

View File

@ -1,3 +1,3 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> <subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<album id="" name=""></album> <album id="" name="" duration="0"></album>
</subsonic-response> </subsonic-response>

View File

@ -250,7 +250,7 @@ type AlbumID3 struct {
ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"` ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"` SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
Duration int32 `xml:"duration,attr,omitempty" json:"duration,omitempty"` Duration int32 `xml:"duration,attr" json:"duration"`
PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"` PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"`
Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"` Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"`
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"` Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`

View File

@ -288,7 +288,7 @@ var _ = Describe("Responses", func() {
Context("with data", func() { Context("with data", func() {
BeforeEach(func() { BeforeEach(func() {
album := AlbumID3{ album := AlbumID3{
Id: "1", Name: "album", Artist: "artist", Genre: "rock", Id: "1", Name: "album", Artist: "artist", Duration: 292, Genre: "rock",
} }
album.OpenSubsonicAlbumID3 = &OpenSubsonicAlbumID3{ album.OpenSubsonicAlbumID3 = &OpenSubsonicAlbumID3{
Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}}, Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}},

View File

@ -1,15 +1,12 @@
package subsonic package subsonic
import ( import (
"context"
"fmt" "fmt"
"io"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/model/request"
@ -17,38 +14,6 @@ import (
"github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/req"
) )
func (api *Router) serveStream(ctx context.Context, w http.ResponseWriter, r *http.Request, stream *stream.Stream, id string) {
if stream.Seekable() {
http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream)
} else {
// If the stream doesn't provide a size (i.e. is not seekable), we can't support ranges/content-length
w.Header().Set("Accept-Ranges", "none")
w.Header().Set("Content-Type", stream.ContentType())
estimateContentLength := req.Params(r).BoolOr("estimateContentLength", false)
// if Client requests the estimated content-length, send it
if estimateContentLength {
length := strconv.Itoa(stream.EstimatedContentLength())
log.Trace(ctx, "Estimated content-length", "contentLength", length)
w.Header().Set("Content-Length", length)
}
if r.Method == http.MethodHead {
go func() { _, _ = io.Copy(io.Discard, stream) }()
} else {
c, err := io.Copy(w, stream)
if log.IsGreaterOrEqualTo(log.LevelDebug) {
if err != nil {
log.Error(ctx, "Error sending transcoded file", "id", id, err)
} else {
log.Trace(ctx, "Success sending transcode file", "id", id, "size", c)
}
}
}
}
}
func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
ctx := r.Context() ctx := r.Context()
p := req.Params(r) p := req.Params(r)
@ -81,9 +46,8 @@ func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Su
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32)) w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32))
api.serveStream(ctx, w, r, stream, id) _, err = stream.Serve(ctx, w, r)
return nil, err
return nil, nil
} }
func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
@ -151,20 +115,18 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses.
disposition := fmt.Sprintf("attachment; filename=\"%s\"", stream.Name()) disposition := fmt.Sprintf("attachment; filename=\"%s\"", stream.Name())
w.Header().Set("Content-Disposition", disposition) w.Header().Set("Content-Disposition", disposition)
api.serveStream(ctx, w, r, stream, id) _, err = stream.Serve(ctx, w, r)
return nil, nil return nil, err
case *model.Album: case *model.Album:
setHeaders(v.Name) setHeaders(v.Name)
err = api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w) return nil, api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w)
case *model.Artist: case *model.Artist:
setHeaders(v.Name) setHeaders(v.Name)
err = api.archiver.ZipArtist(ctx, id, format, maxBitRate, w) return nil, api.archiver.ZipArtist(ctx, id, format, maxBitRate, w)
case *model.Playlist: case *model.Playlist:
setHeaders(v.Name) setHeaders(v.Name)
err = api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w) return nil, api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w)
default: default:
err = model.ErrNotFound return nil, model.ErrNotFound
} }
return nil, err
} }

View File

@ -395,7 +395,9 @@ func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (*
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")
api.serveStream(ctx, w, r, stream, mediaID) n, err := stream.Serve(ctx, w, r)
if err != nil || n == 0 {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return nil, nil return nil, nil
} }

View File

@ -73,7 +73,7 @@ func (m *MockedRadioRepo) GetAll(qo ...model.QueryOptions) (model.Radios, error)
return m.All, nil return m.All, nil
} }
func (m *MockedRadioRepo) Put(radio *model.Radio) error { func (m *MockedRadioRepo) Put(radio *model.Radio, _ ...string) error {
if m.Err { if m.Err {
return errors.New("error") return errors.New("error")
} }

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import {
Card, Card,
CardContent, CardContent,
@ -29,6 +29,7 @@ import {
RatingField, RatingField,
SizeField, SizeField,
useAlbumsPerPage, useAlbumsPerPage,
useImageLoadingState,
} from '../common' } from '../common'
import config from '../config' import config from '../config'
import { formatFullDate, intersperse } from '../utils' import { formatFullDate, intersperse } from '../utils'
@ -220,11 +221,17 @@ const AlbumDetails = (props) => {
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg')) const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg'))
const classes = useStyles() const classes = useStyles()
const [isLightboxOpen, setLightboxOpen] = useState(false)
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [albumInfo, setAlbumInfo] = useState() const [albumInfo, setAlbumInfo] = useState()
const [imageLoading, setImageLoading] = useState(false) const {
const [imageError, setImageError] = useState(false) imageLoading,
imageError,
isLightboxOpen,
handleImageLoad,
handleImageError,
handleOpenLightbox,
handleCloseLightbox,
} = useImageLoadingState(record.id)
let notes = albumInfo?.notes || record.notes let notes = albumInfo?.notes || record.notes
@ -247,33 +254,9 @@ const AlbumDetails = (props) => {
}) })
}, [record]) }, [record])
// Reset image state when album changes
useEffect(() => {
setImageLoading(true)
setImageError(false)
}, [record.id])
const imageUrl = subsonic.getCoverArtUrl(record, 300) const imageUrl = subsonic.getCoverArtUrl(record, 300)
const fullImageUrl = subsonic.getCoverArtUrl(record) const fullImageUrl = subsonic.getCoverArtUrl(record)
const handleImageLoad = useCallback(() => {
setImageLoading(false)
setImageError(false)
}, [])
const handleImageError = useCallback(() => {
setImageLoading(false)
setImageError(true)
}, [])
const handleOpenLightbox = useCallback(() => {
if (!imageError) {
setLightboxOpen(true)
}
}, [imageError])
const handleCloseLightbox = useCallback(() => setLightboxOpen(false), [])
return ( return (
<Card className={classes.root}> <Card className={classes.root}>
<div className={classes.cardContents}> <div className={classes.cardContents}>

View File

@ -94,6 +94,11 @@ const useStyles = makeStyles(
) )
const useCoverStyles = makeStyles({ const useCoverStyles = makeStyles({
coverContainer: {
width: '100%',
aspectRatio: '1',
overflow: 'hidden',
},
cover: { cover: {
display: 'inline-block', display: 'inline-block',
width: '100%', width: '100%',
@ -150,7 +155,7 @@ const Cover = withContentRect('bounds')(({
}, []) }, [])
return ( return (
<div ref={measureRef}> <div ref={measureRef} className={classes.coverContainer}>
<div ref={dragAlbumRef}> <div ref={dragAlbumRef}>
<img <img
key={record.id} // Force re-render when record changes key={record.id} // Force re-render when record changes

View File

@ -10,6 +10,7 @@ import {
ReferenceArrayInput, ReferenceArrayInput,
ReferenceInput, ReferenceInput,
SearchInput, SearchInput,
useListContext,
usePermissions, usePermissions,
useRefresh, useRefresh,
useTranslate, useTranslate,
@ -174,6 +175,14 @@ const AlbumListTitle = ({ albumListType }) => {
return <Title subTitle={title} args={{ smart_count: 2 }} /> return <Title subTitle={title} args={{ smart_count: 2 }} />
} }
const AlbumListPagination = (props) => {
const { loading } = useListContext()
if (loading) {
return null
}
return <Pagination {...props} />
}
const randomStartingSeed = Math.random().toString() const randomStartingSeed = Math.random().toString()
const AlbumList = (props) => { const AlbumList = (props) => {
@ -234,7 +243,7 @@ const AlbumList = (props) => {
actions={<AlbumListActions />} actions={<AlbumListActions />}
filters={<AlbumFilter />} filters={<AlbumFilter />}
perPage={perPage} perPage={perPage}
pagination={<Pagination rowsPerPageOptions={perPageOptions} />} pagination={<AlbumListPagination rowsPerPageOptions={perPageOptions} />}
title={<AlbumListTitle albumListType={albumListType} />} title={<AlbumListTitle albumListType={albumListType} />}
> >
{albumView.grid ? ( {albumView.grid ? (

View File

@ -6,13 +6,17 @@ import CardContent from '@material-ui/core/CardContent'
import CardMedia from '@material-ui/core/CardMedia' import CardMedia from '@material-ui/core/CardMedia'
import ArtistExternalLinks from './ArtistExternalLink' import ArtistExternalLinks from './ArtistExternalLink'
import config from '../config' import config from '../config'
import { LoveButton, RatingField, ImageUploadOverlay } from '../common' import {
LoveButton,
RatingField,
ImageUploadOverlay,
useImageLoadingState,
} from '../common'
import Lightbox from 'react-image-lightbox' import Lightbox from 'react-image-lightbox'
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
import AlbumInfo from '../album/AlbumInfo' import AlbumInfo from '../album/AlbumInfo'
import subsonic from '../subsonic' import subsonic from '../subsonic'
import { SafeHTML } from '../common/SafeHTML' import { SafeHTML } from '../common/SafeHTML'
import useArtistImageState from './useArtistImageState'
const useStyles = makeStyles( const useStyles = makeStyles(
(theme) => ({ (theme) => ({
@ -95,7 +99,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
handleImageError, handleImageError,
handleOpenLightbox, handleOpenLightbox,
handleCloseLightbox, handleCloseLightbox,
} = useArtistImageState(record.id) } = useImageLoadingState(record.id)
return ( return (
<div className={classes.root}> <div className={classes.root}>

View File

@ -4,11 +4,15 @@ import { makeStyles } from '@material-ui/core/styles'
import Card from '@material-ui/core/Card' import Card from '@material-ui/core/Card'
import CardMedia from '@material-ui/core/CardMedia' import CardMedia from '@material-ui/core/CardMedia'
import config from '../config' import config from '../config'
import { LoveButton, RatingField, ImageUploadOverlay } from '../common' import {
LoveButton,
RatingField,
ImageUploadOverlay,
useImageLoadingState,
} from '../common'
import Lightbox from 'react-image-lightbox' import Lightbox from 'react-image-lightbox'
import subsonic from '../subsonic' import subsonic from '../subsonic'
import { SafeHTML } from '../common/SafeHTML' import { SafeHTML } from '../common/SafeHTML'
import useArtistImageState from './useArtistImageState'
const useStyles = makeStyles( const useStyles = makeStyles(
(theme) => ({ (theme) => ({
@ -97,7 +101,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
handleImageError, handleImageError,
handleOpenLightbox, handleOpenLightbox,
handleCloseLightbox, handleCloseLightbox,
} = useArtistImageState(record.id) } = useImageLoadingState(record.id)
return ( return (
<> <>

View File

@ -45,3 +45,4 @@ export * from './OverflowTooltip'
export * from './useSearchRefocus' export * from './useSearchRefocus'
export * from './ImageUploadOverlay' export * from './ImageUploadOverlay'
export * from './CoverArtAvatar' export * from './CoverArtAvatar'
export * from './useImageLoadingState'

View File

@ -1,11 +1,11 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
/** /**
* Manages image loading/error state and lightbox open/close for artist detail views. * Manages image loading/error state and lightbox open/close.
* Resets when record.id changes. * Resets when recordId changes.
*/ */
const useArtistImageState = (recordId) => { export const useImageLoadingState = (recordId) => {
const [imageLoading, setImageLoading] = useState(false) const [imageLoading, setImageLoading] = useState(true)
const [imageError, setImageError] = useState(false) const [imageError, setImageError] = useState(false)
const [isLightboxOpen, setLightboxOpen] = useState(false) const [isLightboxOpen, setLightboxOpen] = useState(false)
@ -42,5 +42,3 @@ const useArtistImageState = (recordId) => {
handleCloseLightbox, handleCloseLightbox,
} }
} }
export default useArtistImageState

View File

@ -24,6 +24,8 @@ DraggableTypes.ALL.push(
DraggableTypes.ARTIST, DraggableTypes.ARTIST,
) )
export const RADIO_PLACEHOLDER_IMAGE = 'internet-radio-icon.svg'
export const DEFAULT_SHARE_BITRATE = 128 export const DEFAULT_SHARE_BITRATE = 128
export const BITRATE_CHOICES = [ export const BITRATE_CHOICES = [

View File

@ -7,7 +7,6 @@ import {
} from '@material-ui/core' } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import { useTranslate } from 'react-admin' import { useTranslate } from 'react-admin'
import { useCallback, useState, useEffect } from 'react'
import Lightbox from 'react-image-lightbox' import Lightbox from 'react-image-lightbox'
import 'react-image-lightbox/style.css' import 'react-image-lightbox/style.css'
import { import {
@ -17,6 +16,7 @@ import {
SizeField, SizeField,
isWritable, isWritable,
OverflowTooltip, OverflowTooltip,
useImageLoadingState,
} from '../common' } from '../common'
import subsonic from '../subsonic' import subsonic from '../subsonic'
@ -96,37 +96,19 @@ const PlaylistDetails = (props) => {
const translate = useTranslate() const translate = useTranslate()
const classes = useStyles() const classes = useStyles()
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg')) const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg'))
const [isLightboxOpen, setLightboxOpen] = useState(false) const {
const [imageLoading, setImageLoading] = useState(false) imageLoading,
const [imageError, setImageError] = useState(false) imageError,
isLightboxOpen,
handleImageLoad,
handleImageError,
handleOpenLightbox,
handleCloseLightbox,
} = useImageLoadingState(record.id)
const imageUrl = subsonic.getCoverArtUrl(record, 300, true) const imageUrl = subsonic.getCoverArtUrl(record, 300, true)
const fullImageUrl = subsonic.getCoverArtUrl(record) const fullImageUrl = subsonic.getCoverArtUrl(record)
// Reset image state when playlist changes
useEffect(() => {
setImageLoading(true)
setImageError(false)
}, [record.id])
const handleImageLoad = useCallback(() => {
setImageLoading(false)
setImageError(false)
}, [])
const handleImageError = useCallback(() => {
setImageLoading(false)
setImageError(true)
}, [])
const handleOpenLightbox = useCallback(() => {
if (!imageError) {
setLightboxOpen(true)
}
}, [imageError])
const handleCloseLightbox = useCallback(() => setLightboxOpen(false), [])
return ( return (
<Card className={classes.root}> <Card className={classes.root}>
<div className={classes.cardContents}> <div className={classes.cardContents}>

View File

@ -6,8 +6,37 @@ import {
TextInput, TextInput,
useTranslate, useTranslate,
} from 'react-admin' } from 'react-admin'
import { CardMedia } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import { urlValidate } from '../utils/validations' import { urlValidate } from '../utils/validations'
import { Title } from '../common' import { Title, ImageUploadOverlay, useImageLoadingState } from '../common'
import subsonic from '../subsonic'
import { RADIO_PLACEHOLDER_IMAGE } from '../consts'
const useStyles = makeStyles({
coverParent: {
display: 'inline-flex',
position: 'relative',
width: '8rem',
height: '8rem',
marginBottom: '1em',
},
cover: {
width: '8rem',
height: '8rem',
objectFit: 'cover',
cursor: 'pointer',
transition: 'opacity 0.3s ease-in-out',
},
coverLoading: {
opacity: 0.5,
},
placeholder: {
width: '8rem',
height: '8rem',
objectFit: 'contain',
},
})
const RadioTitle = ({ record }) => { const RadioTitle = ({ record }) => {
const translate = useTranslate() const translate = useTranslate()
@ -21,6 +50,7 @@ const RadioEdit = (props) => {
return ( return (
<Edit title={<RadioTitle />} {...props}> <Edit title={<RadioTitle />} {...props}>
<SimpleForm variant="outlined" {...props}> <SimpleForm variant="outlined" {...props}>
<RadioCoverArt />
<TextInput source="name" validate={[required()]} /> <TextInput source="name" validate={[required()]} />
<TextInput <TextInput
type="url" type="url"
@ -41,4 +71,39 @@ const RadioEdit = (props) => {
) )
} }
const RadioCoverArt = ({ record }) => {
const classes = useStyles()
const { imageLoading, handleImageLoad, handleImageError } =
useImageLoadingState(record?.id)
if (!record) return null
return (
<div className={classes.coverParent}>
{record.uploadedImage ? (
<CardMedia
component="img"
src={subsonic.getCoverArtUrl(record, 300, true)}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onLoad={handleImageLoad}
onError={handleImageError}
title={record.name}
alt={record.name}
/>
) : (
<img
src={RADIO_PLACEHOLDER_IMAGE}
className={classes.placeholder}
alt={record.name}
/>
)}
<ImageUploadOverlay
entityType="radio"
entityId={record.id}
hasUploadedImage={!!record.uploadedImage}
/>
</div>
)
}
export default RadioEdit export default RadioEdit

View File

@ -1,4 +1,4 @@
import { makeStyles, useMediaQuery } from '@material-ui/core' import { Avatar, makeStyles, useMediaQuery } from '@material-ui/core'
import React, { cloneElement } from 'react' import React, { cloneElement } from 'react'
import { import {
CreateButton, CreateButton,
@ -16,9 +16,11 @@ import {
} from 'react-admin' } from 'react-admin'
import { List } from '../common' import { List } from '../common'
import { ToggleFieldsMenu, useSelectedFields } from '../common' import { ToggleFieldsMenu, useSelectedFields } from '../common'
import subsonic from '../subsonic'
import { StreamField } from './StreamField' import { StreamField } from './StreamField'
import { setTrack } from '../actions' import { setTrack } from '../actions'
import { songFromRadio } from './helper' import { songFromRadio } from './helper'
import { RADIO_PLACEHOLDER_IMAGE } from '../consts'
import { useDispatch } from 'react-redux' import { useDispatch } from 'react-redux'
const useStyles = makeStyles({ const useStyles = makeStyles({
@ -73,6 +75,19 @@ const RadioListActions = ({
) )
} }
const avatarStyle = { width: 40, height: 40 }
const CoverArtField = ({ record }) => {
if (!record) return null
const src = record.uploadedImage
? subsonic.getCoverArtUrl(record, 40, true)
: RADIO_PLACEHOLDER_IMAGE
return (
<Avatar src={src} variant="rounded" style={avatarStyle} alt={record.name} />
)
}
CoverArtField.defaultProps = { label: '' }
const RadioList = ({ permissions, ...props }) => { const RadioList = ({ permissions, ...props }) => {
const classes = useStyles() const classes = useStyles()
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
@ -80,6 +95,7 @@ const RadioList = ({ permissions, ...props }) => {
const isAdmin = permissions === 'admin' const isAdmin = permissions === 'admin'
const toggleableFields = { const toggleableFields = {
coverArt: <CoverArtField source="id" sortable={false} />,
name: <TextField source="name" />, name: <TextField source="name" />,
homePageUrl: ( homePageUrl: (
<UrlField <UrlField
@ -97,7 +113,7 @@ const RadioList = ({ permissions, ...props }) => {
const columns = useSelectedFields({ const columns = useSelectedFields({
resource: 'radio', resource: 'radio',
columns: toggleableFields, columns: toggleableFields,
defaultOff: ['createdAt'], defaultOff: ['streamUrl', 'createdAt'],
}) })
const handleRowClick = async (id, basePath, record) => { const handleRowClick = async (id, basePath, record) => {
@ -117,6 +133,7 @@ const RadioList = ({ permissions, ...props }) => {
> >
{isXsmall ? ( {isXsmall ? (
<SimpleList <SimpleList
leftAvatar={(r) => <CoverArtField record={r} />}
leftIcon={(r) => ( leftIcon={(r) => (
<StreamField <StreamField
record={r} record={r}

View File

@ -1,16 +1,24 @@
import subsonic from '../subsonic'
import { RADIO_PLACEHOLDER_IMAGE } from '../consts'
export async function songFromRadio(radio) { export async function songFromRadio(radio) {
if (!radio) { if (!radio) {
return undefined return undefined
} }
let cover = 'internet-radio-icon.svg' let cover = RADIO_PLACEHOLDER_IMAGE
try { if (radio.uploadedImage) {
const url = new URL(radio.homePageUrl ?? radio.streamUrl) cover = subsonic.getCoverArtUrl(radio, 300, true)
url.pathname = '/favicon.ico' } else {
await resourceExists(url) // Try favicon as fallback
cover = url.toString() try {
} catch { const url = new URL(radio.homePageUrl ?? radio.streamUrl)
// ignore url.pathname = '/favicon.ico'
await resourceExists(url)
cover = url.toString()
} catch {
// No cover available
}
} }
return { return {

View File

@ -86,6 +86,9 @@ const getCoverArtUrl = (record, size, square) => {
} else if (record.sync !== undefined) { } else if (record.sync !== undefined) {
// This is a playlist // This is a playlist
return baseUrl(url('getCoverArt', 'pl-' + record.id, options)) return baseUrl(url('getCoverArt', 'pl-' + record.id, options))
} else if (record.streamUrl !== undefined) {
// This is a radio station
return baseUrl(url('getCoverArt', 'ra-' + record.id, options))
} else { } else {
return baseUrl(url('getCoverArt', 'ar-' + record.id, options)) return baseUrl(url('getCoverArt', 'ar-' + record.id, options))
} }