diff --git a/.gitignore b/.gitignore index db8c0abcf..73475a53a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ AGENTS.md *.wasm *.ndp openspec/ -go.work* \ No newline at end of file +go.work* +.worktrees/ \ No newline at end of file diff --git a/Makefile b/Makefile index c9c88f506..3bad5b620 100644 --- a/Makefile +++ b/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= [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="; 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 diff --git a/adapters/gotaglib/gotaglib.go b/adapters/gotaglib/gotaglib.go index 9b71cb462..7ea98a442 100644 --- a/adapters/gotaglib/gotaglib.go +++ b/adapters/gotaglib/gotaglib.go @@ -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 { diff --git a/consts/consts.go b/consts/consts.go index 9f4387ae6..6fb6c5dac 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -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 ( diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 652e823c0..979dc9012 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -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 } diff --git a/core/artwork/cache_warmer.go b/core/artwork/cache_warmer.go index f13820d00..83c98c806 100644 --- a/core/artwork/cache_warmer.go +++ b/core/artwork/cache_warmer.go @@ -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 } diff --git a/core/artwork/cache_warmer_test.go b/core/artwork/cache_warmer_test.go index abf4f259a..6ddda00d6 100644 --- a/core/artwork/cache_warmer_test.go +++ b/core/artwork/cache_warmer_test.go @@ -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) } diff --git a/core/artwork/reader_radio.go b/core/artwork/reader_radio.go new file mode 100644 index 000000000..22db6e302 --- /dev/null +++ b/core/artwork/reader_radio.go @@ -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()) +} diff --git a/core/artwork/reader_radio_test.go b/core/artwork/reader_radio_test.go new file mode 100644 index 000000000..1f5bc9084 --- /dev/null +++ b/core/artwork/reader_radio_test.go @@ -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()) + }) + }) + }) +}) diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 2b06250a1..33d6733c8 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -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", diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 23e419219..04663828f 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -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() { diff --git a/core/stream/media_streamer.go b/core/stream/media_streamer.go index 062a13884..de03b4d2f 100644 --- a/core/stream/media_streamer.go +++ b/core/stream/media_streamer.go @@ -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, } } diff --git a/db/migrations/20260318182414_add_radio_uploaded_image.go b/db/migrations/20260318182414_add_radio_uploaded_image.go new file mode 100644 index 000000000..e92a6d2ef --- /dev/null +++ b/db/migrations/20260318182414_add_radio_uploaded_image.go @@ -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 +} diff --git a/go.mod b/go.mod index 28fae78e3..487b57ef7 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index fd02f812e..d7b16d9d3 100644 --- a/go.sum +++ b/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= diff --git a/model/artwork_id.go b/model/artwork_id.go index ab4cd1aa8..8a463f173 100644 --- a/model/artwork_id.go +++ b/model/artwork_id.go @@ -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, } } diff --git a/model/get_entity.go b/model/get_entity.go index b50f336e6..c7577f093 100644 --- a/model/get_entity.go +++ b/model/get_entity.go @@ -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 } diff --git a/model/radio.go b/model/radio.go index 567d32e44..86f27c24c 100644 --- a/model/radio.go +++ b/model/radio.go @@ -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 } diff --git a/model/radio_test.go b/model/radio_test.go new file mode 100644 index 000000000..dc421454e --- /dev/null +++ b/model/radio_test.go @@ -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"))) + }) + }) +}) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index 7f3d61540..e75a0e58c 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -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 } diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index 90e449e8d..e2904466c 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -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. diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index 0ee1570a1..3ed443129 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -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]) diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go index 543b76c5e..a073643db 100644 --- a/persistence/radio_repository.go +++ b/persistence/radio_repository.go @@ -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 } diff --git a/resources/i18n/sk.json b/resources/i18n/sk.json new file mode 100644 index 000000000..af5afade7 --- /dev/null +++ b/resources/i18n/sk.json @@ -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" + } + } +} \ No newline at end of file diff --git a/scripts/setup-worktree.sh b/scripts/setup-worktree.sh new file mode 100755 index 000000000..65113f3b2 --- /dev/null +++ b/scripts/setup-worktree.sh @@ -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 [--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 [--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" diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index cb851debf..262a5ed36 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -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. diff --git a/server/e2e/subsonic_stream_test.go b/server/e2e/subsonic_stream_test.go index d144dc4eb..6a11c1740 100644 --- a/server/e2e/subsonic_stream_test.go +++ b/server/e2e/subsonic_stream_test.go @@ -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)) + }) + }) }) diff --git a/server/e2e/subsonic_transcode_test.go b/server/e2e/subsonic_transcode_test.go index a9a180dc8..f134448df 100644 --- a/server/e2e/subsonic_transcode_test.go +++ b/server/e2e/subsonic_transcode_test.go @@ -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() { diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 3ef00ebb1..669c4d7b5 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -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) diff --git a/server/nativeapi/radios.go b/server/nativeapi/radios.go new file mode 100644 index 000000000..701c6c926 --- /dev/null +++ b/server/nativeapi/radios.go @@ -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") + }) +} diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index 6cdf8b44a..daa09c375 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -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) } } diff --git a/server/subsonic/radio.go b/server/subsonic/radio.go index 9f2cd48f6..c66268344 100644 --- a/server/subsonic/radio.go +++ b/server/subsonic/radio.go @@ -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 } diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON index 6ed471e8b..07678407a 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON @@ -8,6 +8,7 @@ "id": "1", "name": "album", "artist": "artist", + "duration": 292, "genre": "rock", "userRating": 4, "genres": [ diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML index 67dcf6bd7..f7b23cb4e 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML @@ -1,5 +1,5 @@ - + diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON index fbeded48a..14e96939e 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON @@ -6,6 +6,7 @@ "openSubsonic": true, "album": { "id": "", - "name": "" + "name": "", + "duration": 0 } } diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML index 159967c1d..868265347 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML @@ -1,3 +1,3 @@ - + diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON index 758aef0cb..446368fa5 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON @@ -7,6 +7,7 @@ "album": { "id": "", "name": "", + "duration": 0, "userRating": 0, "genres": [], "musicBrainzId": "", diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML index 159967c1d..868265347 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML @@ -1,3 +1,3 @@ - + diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index b70f3b128..b9a39b6f9 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -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"` diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index ccf15afe3..15f2da9c6 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -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"}}, diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go index ebebb97f1..b49af2b24 100644 --- a/server/subsonic/stream.go +++ b/server/subsonic/stream.go @@ -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 } diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index 64e74d460..4e494b324 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -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 } diff --git a/tests/mock_radio_repository.go b/tests/mock_radio_repository.go index 279b735db..c50a529e5 100644 --- a/tests/mock_radio_repository.go +++ b/tests/mock_radio_repository.go @@ -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") } diff --git a/ui/src/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.jsx index bd6a41523..c5d9a7ac4 100644 --- a/ui/src/album/AlbumDetails.jsx +++ b/ui/src/album/AlbumDetails.jsx @@ -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 (
diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx index b7db39730..c0d88d50e 100644 --- a/ui/src/album/AlbumGridView.jsx +++ b/ui/src/album/AlbumGridView.jsx @@ -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 ( -
+
{ return } +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 ? ( diff --git a/ui/src/artist/DesktopArtistDetails.jsx b/ui/src/artist/DesktopArtistDetails.jsx index da8d06014..7052b1634 100644 --- a/ui/src/artist/DesktopArtistDetails.jsx +++ b/ui/src/artist/DesktopArtistDetails.jsx @@ -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}> diff --git a/ui/src/artist/MobileArtistDetails.jsx b/ui/src/artist/MobileArtistDetails.jsx index 3add1e994..03cc4de8f 100644 --- a/ui/src/artist/MobileArtistDetails.jsx +++ b/ui/src/artist/MobileArtistDetails.jsx @@ -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 ( <> diff --git a/ui/src/common/index.js b/ui/src/common/index.js index b93e40219..a7d6a43c4 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -45,3 +45,4 @@ export * from './OverflowTooltip' export * from './useSearchRefocus' export * from './ImageUploadOverlay' export * from './CoverArtAvatar' +export * from './useImageLoadingState' diff --git a/ui/src/artist/useArtistImageState.js b/ui/src/common/useImageLoadingState.js similarity index 76% rename from ui/src/artist/useArtistImageState.js rename to ui/src/common/useImageLoadingState.js index bd7e4ad96..3528b0f3f 100644 --- a/ui/src/artist/useArtistImageState.js +++ b/ui/src/common/useImageLoadingState.js @@ -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 diff --git a/ui/src/consts.js b/ui/src/consts.js index 30731a080..472cd4940 100644 --- a/ui/src/consts.js +++ b/ui/src/consts.js @@ -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 = [ diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx index a2d5e753b..911e6c716 100644 --- a/ui/src/playlist/PlaylistDetails.jsx +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -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}> diff --git a/ui/src/radio/RadioEdit.jsx b/ui/src/radio/RadioEdit.jsx index f00f889f3..6b1d2df79 100644 --- a/ui/src/radio/RadioEdit.jsx +++ b/ui/src/radio/RadioEdit.jsx @@ -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 diff --git a/ui/src/radio/RadioList.jsx b/ui/src/radio/RadioList.jsx index 3d1adacc9..582fcaffc 100644 --- a/ui/src/radio/RadioList.jsx +++ b/ui/src/radio/RadioList.jsx @@ -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} diff --git a/ui/src/radio/helper.jsx b/ui/src/radio/helper.jsx index 57de244b9..4c313d3bc 100644 --- a/ui/src/radio/helper.jsx +++ b/ui/src/radio/helper.jsx @@ -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 { diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 65155e8f4..3579619aa 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -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)) }