mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Merge branch 'master' into subsonic-folder
This commit is contained in:
commit
cf85e5f5b8
3
.gitignore
vendored
3
.gitignore
vendored
@ -37,4 +37,5 @@ AGENTS.md
|
||||
*.wasm
|
||||
*.ndp
|
||||
openspec/
|
||||
go.work*
|
||||
go.work*
|
||||
.worktrees/
|
||||
33
Makefile
33
Makefile
@ -233,6 +233,39 @@ get-music: ##@Development Download some free music from Navidrome's demo instanc
|
||||
.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
|
||||
|
||||
|
||||
@ -58,7 +58,20 @@ func (e extractor) Version() string {
|
||||
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)
|
||||
if err != nil {
|
||||
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.
|
||||
// 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) {
|
||||
// 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
|
||||
file, err := e.fs.Open(filePath)
|
||||
if err != nil {
|
||||
|
||||
@ -71,6 +71,7 @@ const (
|
||||
PlaceholderAlbumArt = "album-placeholder.webp"
|
||||
PlaceholderAvatar = "logo-192x192.png"
|
||||
UICoverArtSize = 300
|
||||
UIThumbnailSize = 80
|
||||
DefaultUIVolume = 100
|
||||
DefaultUISearchDebounceMs = 200
|
||||
|
||||
@ -107,6 +108,7 @@ const (
|
||||
const (
|
||||
EntityArtist = "artist"
|
||||
EntityPlaylist = "playlist"
|
||||
EntityRadio = "radio"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@ -126,6 +126,8 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
|
||||
artReader, err = newDiscArtworkReader(ctx, a, artID)
|
||||
case model.KindFolderArtwork:
|
||||
artReader, err = newFolderArtworkReader(ctx, a, artID)
|
||||
case model.KindRadioArtwork:
|
||||
artReader, err = newRadioArtworkReader(ctx, a, artID)
|
||||
default:
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
|
||||
@ -142,14 +142,15 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("caching id='%s': %w", id, err)
|
||||
}
|
||||
defer r.Close()
|
||||
_, err = io.Copy(io.Discard, r)
|
||||
if err != nil {
|
||||
return err
|
||||
for _, size := range []int{consts.UICoverArtSize, consts.UIThumbnailSize} {
|
||||
r, _, err := a.artwork.Get(ctx, id, size, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err)
|
||||
}
|
||||
defer r.Close()
|
||||
if _, err = io.Copy(io.Discard, r); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -6,11 +6,13 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@ -173,20 +175,42 @@ var _ = Describe("CacheWarmer", func() {
|
||||
return len(cw.buffer)
|
||||
}).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 {
|
||||
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) {
|
||||
if m.err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
return m.Get(ctx, model.ArtworkID{}, size, square)
|
||||
}
|
||||
|
||||
40
core/artwork/reader_radio.go
Normal file
40
core/artwork/reader_radio.go
Normal 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())
|
||||
}
|
||||
84
core/artwork/reader_radio_test.go
Normal file
84
core/artwork/reader_radio_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,7 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@ -258,10 +259,11 @@ func (e *ffmpeg) start(ctx context.Context, args []string, input ...io.Reader) (
|
||||
|
||||
type ffCmd struct {
|
||||
*io.PipeReader
|
||||
out *io.PipeWriter
|
||||
args []string
|
||||
cmd *exec.Cmd
|
||||
input io.Reader // optional stdin source
|
||||
out *io.PipeWriter
|
||||
args []string
|
||||
cmd *exec.Cmd
|
||||
input io.Reader // optional stdin source
|
||||
stderr *bytes.Buffer
|
||||
}
|
||||
|
||||
func (j *ffCmd) start(ctx context.Context) error {
|
||||
@ -270,10 +272,12 @@ func (j *ffCmd) start(ctx context.Context) error {
|
||||
if j.input != nil {
|
||||
cmd.Stdin = j.input
|
||||
}
|
||||
j.stderr = &bytes.Buffer{}
|
||||
stderrWriter := &limitedWriter{buf: j.stderr, limit: 4096}
|
||||
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stderr = io.MultiWriter(os.Stderr, stderrWriter)
|
||||
} else {
|
||||
cmd.Stderr = io.Discard
|
||||
cmd.Stderr = stderrWriter
|
||||
}
|
||||
j.cmd = cmd
|
||||
|
||||
@ -287,7 +291,11 @@ func (j *ffCmd) wait() {
|
||||
if err := j.cmd.Wait(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
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 {
|
||||
_ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err))
|
||||
}
|
||||
@ -296,6 +304,26 @@ func (j *ffCmd) wait() {
|
||||
_ = 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.
|
||||
var formatCodecMap = map[string]string{
|
||||
"mp3": "libmp3lame",
|
||||
|
||||
@ -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() {
|
||||
var longRunningCmd string
|
||||
BeforeEach(func() {
|
||||
|
||||
@ -5,8 +5,9 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@ -17,6 +18,7 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var format string
|
||||
var bitRate int
|
||||
@ -133,14 +138,59 @@ func (s *Stream) EstimatedContentLength() int {
|
||||
return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
|
||||
}
|
||||
|
||||
// NewTestStream creates a Stream for testing purposes.
|
||||
func NewTestStream(mf *model.MediaFile, format string, bitRate int) *Stream {
|
||||
// Serve writes the stream to the HTTP response. For seekable streams it uses http.ServeContent
|
||||
// (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{
|
||||
ctx: context.Background(),
|
||||
mf: mf,
|
||||
format: format,
|
||||
bitRate: bitRate,
|
||||
ReadCloser: io.NopCloser(strings.NewReader("")),
|
||||
ReadCloser: r,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
22
db/migrations/20260318182414_add_radio_uploaded_image.go
Normal file
22
db/migrations/20260318182414_add_radio_uploaded_image.go
Normal 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
22
go.mod
@ -36,7 +36,7 @@ require (
|
||||
github.com/kardianos/service v1.2.4
|
||||
github.com/kr/pretty v0.3.1
|
||||
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/mileusna/useragent v1.3.5
|
||||
github.com/onsi/ginkgo/v2 v2.28.1
|
||||
@ -58,12 +58,12 @@ require (
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
|
||||
go.senan.xyz/taglib v0.11.1
|
||||
go.uber.org/goleak v1.3.0
|
||||
golang.org/x/image v0.36.0
|
||||
golang.org/x/net v0.51.0
|
||||
golang.org/x/image v0.37.0
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/term v0.40.0
|
||||
golang.org/x/text v0.34.0
|
||||
golang.org/x/term v0.41.0
|
||||
golang.org/x/text v0.35.0
|
||||
golang.org/x/time v0.15.0
|
||||
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/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // 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/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // 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/google/go-cmp v0.7.0 // 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.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/ini.v1 v1.67.1 // indirect
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||
|
||||
44
go.sum
44
go.sum
@ -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/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/ebitengine/purego v0.8.3 h1:K+0AjQp63JEZTEMZiwsI9g0+hAMNohwUOtY0RPGexmc=
|
||||
github.com/ebitengine/purego v0.8.3/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||
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/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA=
|
||||
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/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
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.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
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/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
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/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-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||
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/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
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/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||
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.8.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.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
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-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
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.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.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
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-20220722155255-886fb9371eb4/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/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0=
|
||||
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
|
||||
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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
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.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
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.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
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.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
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.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
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/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
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.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.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
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=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
|
||||
@ -24,6 +24,7 @@ var (
|
||||
KindPlaylistArtwork = Kind{"pl", "playlist"}
|
||||
KindDiscArtwork = Kind{"dc", "disc"}
|
||||
KindFolderArtwork = Kind{"fo", "folder"}
|
||||
KindRadioArtwork = Kind{"ra", "radio"}
|
||||
)
|
||||
|
||||
var artworkKindMap = map[string]Kind{
|
||||
@ -33,6 +34,7 @@ var artworkKindMap = map[string]Kind{
|
||||
KindPlaylistArtwork.prefix: KindPlaylistArtwork,
|
||||
KindDiscArtwork.prefix: KindDiscArtwork,
|
||||
KindFolderArtwork.prefix: KindFolderArtwork,
|
||||
KindRadioArtwork.prefix: KindRadioArtwork,
|
||||
}
|
||||
|
||||
type ArtworkID struct {
|
||||
@ -147,5 +149,13 @@ func artworkIDFromFolder(f Folder) ArtworkID {
|
||||
Kind: KindFolderArtwork,
|
||||
ID: f.ID,
|
||||
LastUpdate: f.ImagesUpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func artworkIDFromRadio(r Radio) ArtworkID {
|
||||
return ArtworkID{
|
||||
Kind: KindRadioArtwork,
|
||||
ID: r.ID,
|
||||
LastUpdate: r.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,5 +26,9 @@ func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) {
|
||||
if err == nil {
|
||||
return mf, nil
|
||||
}
|
||||
r, err := ds.Radio(ctx).Get(id)
|
||||
if err == nil {
|
||||
return r, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -1,14 +1,27 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
)
|
||||
|
||||
type Radio struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
StreamUrl string `structs:"stream_url" json:"streamUrl"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
HomePageUrl string `structs:"home_page_url" json:"homePageUrl"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
ID string `structs:"id" json:"id"`
|
||||
StreamUrl string `structs:"stream_url" json:"streamUrl"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
HomePageUrl string `structs:"home_page_url" json:"homePageUrl"`
|
||||
UploadedImage string `structs:"uploaded_image" json:"uploadedImage,omitempty"`
|
||||
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
|
||||
@ -19,5 +32,5 @@ type RadioRepository interface {
|
||||
Delete(id string) error
|
||||
Get(id string) (*Radio, error)
|
||||
GetAll(options ...QueryOptions) (Radios, error)
|
||||
Put(u *Radio) error
|
||||
Put(u *Radio, colsToUpdate ...string) error
|
||||
}
|
||||
|
||||
42
model/radio_test.go
Normal file
42
model/radio_test.go
Normal 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")))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -4,7 +4,9 @@ import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
@ -12,6 +14,7 @@ import (
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
@ -315,7 +318,19 @@ func (r *artistRepository) GetIndex(includeMissing bool, libraryIds []int, roles
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("purging empty artists: %w", err)
|
||||
@ -323,6 +338,19 @@ func (r *artistRepository) purgeEmpty() error {
|
||||
if c > 0 {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@ -3,11 +3,14 @@ package persistence
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"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.
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"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)
|
||||
for i := range testSongs {
|
||||
err := mr.Put(&testSongs[i])
|
||||
|
||||
@ -58,34 +58,20 @@ func (r *radioRepository) GetAll(options ...model.QueryOptions) (model.Radios, e
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *radioRepository) Put(radio *model.Radio) error {
|
||||
func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error {
|
||||
if !r.isPermitted() {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
|
||||
var values map[string]any
|
||||
|
||||
radio.UpdatedAt = time.Now()
|
||||
|
||||
if radio.ID == "" {
|
||||
radio.CreatedAt = time.Now()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
values["created_at"] = time.Now()
|
||||
insert := Insert(r.tableName).SetMap(values)
|
||||
_, err := r.executeSQL(insert)
|
||||
if len(colsToUpdate) > 0 {
|
||||
colsToUpdate = append(colsToUpdate, "UpdatedAt")
|
||||
}
|
||||
_, err := r.put(radio.ID, radio, colsToUpdate...)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
723
resources/i18n/sk.json
Normal file
723
resources/i18n/sk.json
Normal 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
57
scripts/setup-worktree.sh
Executable 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"
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
@ -287,18 +288,28 @@ func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool
|
||||
// spyStreamer captures the Request passed to NewStream for test assertions,
|
||||
// then returns a minimal fake Stream so the handler completes without error.
|
||||
type spyStreamer struct {
|
||||
LastRequest stream.Request
|
||||
LastMediaFile *model.MediaFile
|
||||
LastRequest stream.Request
|
||||
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) {
|
||||
s.LastRequest = req
|
||||
s.LastMediaFile = mf
|
||||
if s.SimulateError != nil {
|
||||
return nil, s.SimulateError
|
||||
}
|
||||
format := req.Format
|
||||
if format == "" || format == "raw" {
|
||||
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.
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -124,4 +127,56 @@ var _ = Describe("stream.view (legacy streaming)", Ordered, func() {
|
||||
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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@ -602,6 +603,36 @@ var _ = Describe("Transcode Endpoints", Ordered, func() {
|
||||
mf.UpdatedAt = originalUpdatedAt
|
||||
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() {
|
||||
|
||||
@ -71,7 +71,7 @@ func (api *Router) routes() http.Handler {
|
||||
api.R(r, "/genre", model.Genre{}, false)
|
||||
api.R(r, "/player", model.Player{}, true)
|
||||
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)
|
||||
if conf.Server.EnableSharing {
|
||||
api.RX(r, "/share", api.share.NewRepository, true)
|
||||
|
||||
70
server/nativeapi/radios.go
Normal file
70
server/nativeapi/radios.go
Normal 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")
|
||||
})
|
||||
}
|
||||
@ -2,7 +2,6 @@ package public
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"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-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32))
|
||||
|
||||
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 := 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
n, err := stream.Serve(ctx, w, r)
|
||||
if err != nil || n == 0 {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -103,7 +103,7 @@ func (api *Router) UpdateInternetRadio(r *http.Request) (*responses.Subsonic, er
|
||||
Name: name,
|
||||
}
|
||||
|
||||
err = api.ds.Radio(ctx).Put(radio)
|
||||
err = api.ds.Radio(ctx).Put(radio, "StreamUrl", "HomePageUrl", "Name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
"id": "1",
|
||||
"name": "album",
|
||||
"artist": "artist",
|
||||
"duration": 292,
|
||||
"genre": "rock",
|
||||
"userRating": 4,
|
||||
"genres": [
|
||||
|
||||
@ -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">
|
||||
<album id="1" name="album" artist="artist" genre="rock" userRating="4" musicBrainzId="1234" isCompilation="true" sortName="sorted album" displayArtist="artist1 & 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 & artist2" explicitStatus="clean" version="Deluxe Edition">
|
||||
<genres name="rock"></genres>
|
||||
<genres name="progressive"></genres>
|
||||
<discTitles disc="1" title="disc 1"></discTitles>
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
"openSubsonic": true,
|
||||
"album": {
|
||||
"id": "",
|
||||
"name": ""
|
||||
"name": "",
|
||||
"duration": 0
|
||||
}
|
||||
}
|
||||
|
||||
@ -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">
|
||||
<album id="" name=""></album>
|
||||
<album id="" name="" duration="0"></album>
|
||||
</subsonic-response>
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
"album": {
|
||||
"id": "",
|
||||
"name": "",
|
||||
"duration": 0,
|
||||
"userRating": 0,
|
||||
"genres": [],
|
||||
"musicBrainzId": "",
|
||||
|
||||
@ -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">
|
||||
<album id="" name=""></album>
|
||||
<album id="" name="" duration="0"></album>
|
||||
</subsonic-response>
|
||||
|
||||
@ -250,7 +250,7 @@ type AlbumID3 struct {
|
||||
ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
|
||||
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,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"`
|
||||
Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"`
|
||||
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
|
||||
|
||||
@ -288,7 +288,7 @@ var _ = Describe("Responses", func() {
|
||||
Context("with data", func() {
|
||||
BeforeEach(func() {
|
||||
album := AlbumID3{
|
||||
Id: "1", Name: "album", Artist: "artist", Genre: "rock",
|
||||
Id: "1", Name: "album", Artist: "artist", Duration: 292, Genre: "rock",
|
||||
}
|
||||
album.OpenSubsonicAlbumID3 = &OpenSubsonicAlbumID3{
|
||||
Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}},
|
||||
|
||||
@ -1,15 +1,12 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@ -17,38 +14,6 @@ import (
|
||||
"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) {
|
||||
ctx := r.Context()
|
||||
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-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32))
|
||||
|
||||
api.serveStream(ctx, w, r, stream, id)
|
||||
|
||||
return nil, nil
|
||||
_, err = stream.Serve(ctx, w, r)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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())
|
||||
w.Header().Set("Content-Disposition", disposition)
|
||||
|
||||
api.serveStream(ctx, w, r, stream, id)
|
||||
return nil, nil
|
||||
_, err = stream.Serve(ctx, w, r)
|
||||
return nil, err
|
||||
case *model.Album:
|
||||
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:
|
||||
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:
|
||||
setHeaders(v.Name)
|
||||
err = api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w)
|
||||
return nil, api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w)
|
||||
default:
|
||||
err = model.ErrNotFound
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -395,7 +395,9 @@ func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (*
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -73,7 +73,7 @@ func (m *MockedRadioRepo) GetAll(qo ...model.QueryOptions) (model.Radios, error)
|
||||
return m.All, nil
|
||||
}
|
||||
|
||||
func (m *MockedRadioRepo) Put(radio *model.Radio) error {
|
||||
func (m *MockedRadioRepo) Put(radio *model.Radio, _ ...string) error {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@ -29,6 +29,7 @@ import {
|
||||
RatingField,
|
||||
SizeField,
|
||||
useAlbumsPerPage,
|
||||
useImageLoadingState,
|
||||
} from '../common'
|
||||
import config from '../config'
|
||||
import { formatFullDate, intersperse } from '../utils'
|
||||
@ -220,11 +221,17 @@ const AlbumDetails = (props) => {
|
||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg'))
|
||||
const classes = useStyles()
|
||||
const [isLightboxOpen, setLightboxOpen] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [albumInfo, setAlbumInfo] = useState()
|
||||
const [imageLoading, setImageLoading] = useState(false)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const {
|
||||
imageLoading,
|
||||
imageError,
|
||||
isLightboxOpen,
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
handleOpenLightbox,
|
||||
handleCloseLightbox,
|
||||
} = useImageLoadingState(record.id)
|
||||
|
||||
let notes = albumInfo?.notes || record.notes
|
||||
|
||||
@ -247,33 +254,9 @@ const AlbumDetails = (props) => {
|
||||
})
|
||||
}, [record])
|
||||
|
||||
// Reset image state when album changes
|
||||
useEffect(() => {
|
||||
setImageLoading(true)
|
||||
setImageError(false)
|
||||
}, [record.id])
|
||||
|
||||
const imageUrl = subsonic.getCoverArtUrl(record, 300)
|
||||
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 (
|
||||
<Card className={classes.root}>
|
||||
<div className={classes.cardContents}>
|
||||
|
||||
@ -94,6 +94,11 @@ const useStyles = makeStyles(
|
||||
)
|
||||
|
||||
const useCoverStyles = makeStyles({
|
||||
coverContainer: {
|
||||
width: '100%',
|
||||
aspectRatio: '1',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
cover: {
|
||||
display: 'inline-block',
|
||||
width: '100%',
|
||||
@ -150,7 +155,7 @@ const Cover = withContentRect('bounds')(({
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={measureRef}>
|
||||
<div ref={measureRef} className={classes.coverContainer}>
|
||||
<div ref={dragAlbumRef}>
|
||||
<img
|
||||
key={record.id} // Force re-render when record changes
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
ReferenceArrayInput,
|
||||
ReferenceInput,
|
||||
SearchInput,
|
||||
useListContext,
|
||||
usePermissions,
|
||||
useRefresh,
|
||||
useTranslate,
|
||||
@ -174,6 +175,14 @@ const AlbumListTitle = ({ albumListType }) => {
|
||||
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 AlbumList = (props) => {
|
||||
@ -234,7 +243,7 @@ const AlbumList = (props) => {
|
||||
actions={<AlbumListActions />}
|
||||
filters={<AlbumFilter />}
|
||||
perPage={perPage}
|
||||
pagination={<Pagination rowsPerPageOptions={perPageOptions} />}
|
||||
pagination={<AlbumListPagination rowsPerPageOptions={perPageOptions} />}
|
||||
title={<AlbumListTitle albumListType={albumListType} />}
|
||||
>
|
||||
{albumView.grid ? (
|
||||
|
||||
@ -6,13 +6,17 @@ import CardContent from '@material-ui/core/CardContent'
|
||||
import CardMedia from '@material-ui/core/CardMedia'
|
||||
import ArtistExternalLinks from './ArtistExternalLink'
|
||||
import config from '../config'
|
||||
import { LoveButton, RatingField, ImageUploadOverlay } from '../common'
|
||||
import {
|
||||
LoveButton,
|
||||
RatingField,
|
||||
ImageUploadOverlay,
|
||||
useImageLoadingState,
|
||||
} from '../common'
|
||||
import Lightbox from 'react-image-lightbox'
|
||||
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
||||
import AlbumInfo from '../album/AlbumInfo'
|
||||
import subsonic from '../subsonic'
|
||||
import { SafeHTML } from '../common/SafeHTML'
|
||||
import useArtistImageState from './useArtistImageState'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
@ -95,7 +99,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
|
||||
handleImageError,
|
||||
handleOpenLightbox,
|
||||
handleCloseLightbox,
|
||||
} = useArtistImageState(record.id)
|
||||
} = useImageLoadingState(record.id)
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
|
||||
@ -4,11 +4,15 @@ import { makeStyles } from '@material-ui/core/styles'
|
||||
import Card from '@material-ui/core/Card'
|
||||
import CardMedia from '@material-ui/core/CardMedia'
|
||||
import config from '../config'
|
||||
import { LoveButton, RatingField, ImageUploadOverlay } from '../common'
|
||||
import {
|
||||
LoveButton,
|
||||
RatingField,
|
||||
ImageUploadOverlay,
|
||||
useImageLoadingState,
|
||||
} from '../common'
|
||||
import Lightbox from 'react-image-lightbox'
|
||||
import subsonic from '../subsonic'
|
||||
import { SafeHTML } from '../common/SafeHTML'
|
||||
import useArtistImageState from './useArtistImageState'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
@ -97,7 +101,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
|
||||
handleImageError,
|
||||
handleOpenLightbox,
|
||||
handleCloseLightbox,
|
||||
} = useArtistImageState(record.id)
|
||||
} = useImageLoadingState(record.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@ -45,3 +45,4 @@ export * from './OverflowTooltip'
|
||||
export * from './useSearchRefocus'
|
||||
export * from './ImageUploadOverlay'
|
||||
export * from './CoverArtAvatar'
|
||||
export * from './useImageLoadingState'
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
/**
|
||||
* Manages image loading/error state and lightbox open/close for artist detail views.
|
||||
* Resets when record.id changes.
|
||||
* Manages image loading/error state and lightbox open/close.
|
||||
* Resets when recordId changes.
|
||||
*/
|
||||
const useArtistImageState = (recordId) => {
|
||||
const [imageLoading, setImageLoading] = useState(false)
|
||||
export const useImageLoadingState = (recordId) => {
|
||||
const [imageLoading, setImageLoading] = useState(true)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const [isLightboxOpen, setLightboxOpen] = useState(false)
|
||||
|
||||
@ -42,5 +42,3 @@ const useArtistImageState = (recordId) => {
|
||||
handleCloseLightbox,
|
||||
}
|
||||
}
|
||||
|
||||
export default useArtistImageState
|
||||
@ -24,6 +24,8 @@ DraggableTypes.ALL.push(
|
||||
DraggableTypes.ARTIST,
|
||||
)
|
||||
|
||||
export const RADIO_PLACEHOLDER_IMAGE = 'internet-radio-icon.svg'
|
||||
|
||||
export const DEFAULT_SHARE_BITRATE = 128
|
||||
|
||||
export const BITRATE_CHOICES = [
|
||||
|
||||
@ -7,7 +7,6 @@ import {
|
||||
} from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { useTranslate } from 'react-admin'
|
||||
import { useCallback, useState, useEffect } from 'react'
|
||||
import Lightbox from 'react-image-lightbox'
|
||||
import 'react-image-lightbox/style.css'
|
||||
import {
|
||||
@ -17,6 +16,7 @@ import {
|
||||
SizeField,
|
||||
isWritable,
|
||||
OverflowTooltip,
|
||||
useImageLoadingState,
|
||||
} from '../common'
|
||||
import subsonic from '../subsonic'
|
||||
|
||||
@ -96,37 +96,19 @@ const PlaylistDetails = (props) => {
|
||||
const translate = useTranslate()
|
||||
const classes = useStyles()
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg'))
|
||||
const [isLightboxOpen, setLightboxOpen] = useState(false)
|
||||
const [imageLoading, setImageLoading] = useState(false)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const {
|
||||
imageLoading,
|
||||
imageError,
|
||||
isLightboxOpen,
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
handleOpenLightbox,
|
||||
handleCloseLightbox,
|
||||
} = useImageLoadingState(record.id)
|
||||
|
||||
const imageUrl = subsonic.getCoverArtUrl(record, 300, true)
|
||||
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 (
|
||||
<Card className={classes.root}>
|
||||
<div className={classes.cardContents}>
|
||||
|
||||
@ -6,8 +6,37 @@ import {
|
||||
TextInput,
|
||||
useTranslate,
|
||||
} from 'react-admin'
|
||||
import { CardMedia } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
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 translate = useTranslate()
|
||||
@ -21,6 +50,7 @@ const RadioEdit = (props) => {
|
||||
return (
|
||||
<Edit title={<RadioTitle />} {...props}>
|
||||
<SimpleForm variant="outlined" {...props}>
|
||||
<RadioCoverArt />
|
||||
<TextInput source="name" validate={[required()]} />
|
||||
<TextInput
|
||||
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
|
||||
|
||||
@ -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 {
|
||||
CreateButton,
|
||||
@ -16,9 +16,11 @@ import {
|
||||
} from 'react-admin'
|
||||
import { List } from '../common'
|
||||
import { ToggleFieldsMenu, useSelectedFields } from '../common'
|
||||
import subsonic from '../subsonic'
|
||||
import { StreamField } from './StreamField'
|
||||
import { setTrack } from '../actions'
|
||||
import { songFromRadio } from './helper'
|
||||
import { RADIO_PLACEHOLDER_IMAGE } from '../consts'
|
||||
import { useDispatch } from 'react-redux'
|
||||
|
||||
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 classes = useStyles()
|
||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||
@ -80,6 +95,7 @@ const RadioList = ({ permissions, ...props }) => {
|
||||
const isAdmin = permissions === 'admin'
|
||||
|
||||
const toggleableFields = {
|
||||
coverArt: <CoverArtField source="id" sortable={false} />,
|
||||
name: <TextField source="name" />,
|
||||
homePageUrl: (
|
||||
<UrlField
|
||||
@ -97,7 +113,7 @@ const RadioList = ({ permissions, ...props }) => {
|
||||
const columns = useSelectedFields({
|
||||
resource: 'radio',
|
||||
columns: toggleableFields,
|
||||
defaultOff: ['createdAt'],
|
||||
defaultOff: ['streamUrl', 'createdAt'],
|
||||
})
|
||||
|
||||
const handleRowClick = async (id, basePath, record) => {
|
||||
@ -117,6 +133,7 @@ const RadioList = ({ permissions, ...props }) => {
|
||||
>
|
||||
{isXsmall ? (
|
||||
<SimpleList
|
||||
leftAvatar={(r) => <CoverArtField record={r} />}
|
||||
leftIcon={(r) => (
|
||||
<StreamField
|
||||
record={r}
|
||||
|
||||
@ -1,16 +1,24 @@
|
||||
import subsonic from '../subsonic'
|
||||
import { RADIO_PLACEHOLDER_IMAGE } from '../consts'
|
||||
|
||||
export async function songFromRadio(radio) {
|
||||
if (!radio) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let cover = 'internet-radio-icon.svg'
|
||||
try {
|
||||
const url = new URL(radio.homePageUrl ?? radio.streamUrl)
|
||||
url.pathname = '/favicon.ico'
|
||||
await resourceExists(url)
|
||||
cover = url.toString()
|
||||
} catch {
|
||||
// ignore
|
||||
let cover = RADIO_PLACEHOLDER_IMAGE
|
||||
if (radio.uploadedImage) {
|
||||
cover = subsonic.getCoverArtUrl(radio, 300, true)
|
||||
} else {
|
||||
// Try favicon as fallback
|
||||
try {
|
||||
const url = new URL(radio.homePageUrl ?? radio.streamUrl)
|
||||
url.pathname = '/favicon.ico'
|
||||
await resourceExists(url)
|
||||
cover = url.toString()
|
||||
} catch {
|
||||
// No cover available
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -86,6 +86,9 @@ const getCoverArtUrl = (record, size, square) => {
|
||||
} else if (record.sync !== undefined) {
|
||||
// This is a playlist
|
||||
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 {
|
||||
return baseUrl(url('getCoverArt', 'ar-' + record.id, options))
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user