From e076085658a2eea541153c1d54c1513c90087546 Mon Sep 17 00:00:00 2001 From: Yuko <11803292+hiyukoim@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:12:48 +0200 Subject: [PATCH 01/13] feat(radio): add ICY StreamTitle parser Closes #1 Signed-off-by: hiyukoim <11803292+hiyukoim@users.noreply.github.com> --- core/radio/icy/parser.go | 117 ++++++++++++++++++ core/radio/icy/parser_suite_test.go | 17 +++ core/radio/icy/parser_test.go | 177 ++++++++++++++++++++++++++++ 3 files changed, 311 insertions(+) create mode 100644 core/radio/icy/parser.go create mode 100644 core/radio/icy/parser_suite_test.go create mode 100644 core/radio/icy/parser_test.go diff --git a/core/radio/icy/parser.go b/core/radio/icy/parser.go new file mode 100644 index 000000000..1ab7b0898 --- /dev/null +++ b/core/radio/icy/parser.go @@ -0,0 +1,117 @@ +package icy + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "unicode/utf8" + + "golang.org/x/text/encoding/charmap" +) + +var ( + ErrInvalidMetaInt = errors.New("invalid icy metadata interval") + ErrMissingTitleHandler = errors.New("missing icy title handler") + errEndOfStream = errors.New("icy stream ended") +) + +// ReadStreamTitles reads ICY metadata blocks from r and emits changed StreamTitle values. +func ReadStreamTitles(ctx context.Context, r io.Reader, metaInt int, handleTitle func(string)) error { + if metaInt <= 0 { + return ErrInvalidMetaInt + } + if handleTitle == nil { + return ErrMissingTitleHandler + } + + audio := make([]byte, metaInt) + length := make([]byte, 1) + lastTitle := "" + + for { + if err := ctx.Err(); err != nil { + return err + } + + if err := readFullOrEnd(r, audio); err != nil { + if errors.Is(err, errEndOfStream) { + return nil + } + return err + } + + if err := ctx.Err(); err != nil { + return err + } + + if err := readFullOrEnd(r, length); err != nil { + if errors.Is(err, errEndOfStream) { + return nil + } + return err + } + + metadataLen := int(length[0]) * 16 + if metadataLen == 0 { + continue + } + + metadata := make([]byte, metadataLen) + if err := readFullOrEnd(r, metadata); err != nil { + if errors.Is(err, errEndOfStream) { + return nil + } + return err + } + + title := streamTitle(metadata) + if title == "" || title == lastTitle { + continue + } + + lastTitle = title + handleTitle(title) + } +} + +func readFullOrEnd(r io.Reader, buf []byte) error { + if _, err := io.ReadFull(r, buf); err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return errEndOfStream + } + return err + } + return nil +} + +func streamTitle(metadata []byte) string { + text := decodeMetadata(bytes.TrimRight(metadata, "\x00")) + const prefix = "StreamTitle='" + + start := strings.Index(text, prefix) + if start < 0 { + return "" + } + + start += len(prefix) + end := strings.IndexByte(text[start:], '\'') + if end < 0 { + return "" + } + + return strings.TrimSpace(text[start : start+end]) +} + +func decodeMetadata(metadata []byte) string { + if utf8.Valid(metadata) { + return string(metadata) + } + + text, err := charmap.ISO8859_1.NewDecoder().String(string(metadata)) + if err != nil { + return string(bytes.ToValidUTF8(metadata, nil)) + } + return text +} diff --git a/core/radio/icy/parser_suite_test.go b/core/radio/icy/parser_suite_test.go new file mode 100644 index 000000000..94f1e8fdc --- /dev/null +++ b/core/radio/icy/parser_suite_test.go @@ -0,0 +1,17 @@ +package icy + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestICY(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "ICY Suite") +} diff --git a/core/radio/icy/parser_test.go b/core/radio/icy/parser_test.go new file mode 100644 index 000000000..0170bbb96 --- /dev/null +++ b/core/radio/icy/parser_test.go @@ -0,0 +1,177 @@ +package icy + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ReadStreamTitles", func() { + const metaInt = 5 + + readTitles := func(ctx context.Context, stream []byte, interval int) ([]string, error) { + var titles []string + err := ReadStreamTitles(ctx, bytes.NewReader(stream), interval, func(title string) { + titles = append(titles, title) + }) + return titles, err + } + + It("emits changed StreamTitle values", func() { + stream := icyStream(metaInt, + "StreamTitle='Artist One - Track One';", + "StreamTitle='Artist One - Track One';", + "StreamTitle='Artist Two - Track Two';", + ) + + titles, err := readTitles(context.Background(), stream, metaInt) + + Expect(err).ToNot(HaveOccurred()) + Expect(titles).To(Equal([]string{ + "Artist One - Track One", + "Artist Two - Track Two", + })) + }) + + It("ignores empty metadata blocks", func() { + stream := audioInterval(metaInt) + stream = append(stream, 0) + + titles, err := readTitles(context.Background(), stream, metaInt) + + Expect(err).ToNot(HaveOccurred()) + Expect(titles).To(BeEmpty()) + }) + + It("ignores metadata blocks without StreamTitle", func() { + stream := icyStream(metaInt, "StreamUrl='https://example.test';") + + titles, err := readTitles(context.Background(), stream, metaInt) + + Expect(err).ToNot(HaveOccurred()) + Expect(titles).To(BeEmpty()) + }) + + It("ignores empty StreamTitle values", func() { + stream := icyStream(metaInt, "StreamTitle='';") + + titles, err := readTitles(context.Background(), stream, metaInt) + + Expect(err).ToNot(HaveOccurred()) + Expect(titles).To(BeEmpty()) + }) + + It("returns an error for invalid metadata intervals", func() { + titles, err := readTitles(context.Background(), nil, 0) + + Expect(err).To(MatchError(ErrInvalidMetaInt)) + Expect(titles).To(BeEmpty()) + }) + + It("stops when context is cancelled", func() { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + titles, err := readTitles(ctx, audioInterval(metaInt), metaInt) + + Expect(err).To(MatchError(context.Canceled)) + Expect(titles).To(BeEmpty()) + }) + + It("returns cleanly when input ends mid-audio interval", func() { + titles, err := readTitles(context.Background(), []byte("abc"), metaInt) + + Expect(err).ToNot(HaveOccurred()) + Expect(titles).To(BeEmpty()) + }) + + It("returns cleanly when input ends mid-metadata block", func() { + stream := append(audioInterval(metaInt), 2, 'S', 't', 'r') + + titles, err := readTitles(context.Background(), stream, metaInt) + + Expect(err).ToNot(HaveOccurred()) + Expect(titles).To(BeEmpty()) + }) + + It("decodes Latin-1 metadata", func() { + metadata := []byte("StreamTitle='Beyonc") + metadata = append(metadata, 0xe9) + metadata = append(metadata, " - D\xe9j\xe0 Vu';"...) + stream := icyStreamBytes(metaInt, metadata) + + titles, err := readTitles(context.Background(), stream, metaInt) + + Expect(err).ToNot(HaveOccurred()) + Expect(titles).To(Equal([]string{"Beyoncé - Déjà Vu"})) + }) + + It("returns reader errors other than EOF", func() { + errReader := errAfterReader{ + data: []byte("abcde"), + err: errors.New("read failed"), + } + var titles []string + + err := ReadStreamTitles(context.Background(), &errReader, metaInt, func(title string) { + titles = append(titles, title) + }) + + Expect(err).To(MatchError("read failed")) + Expect(titles).To(BeEmpty()) + }) +}) + +func icyStream(metaInt int, metadataBlocks ...string) []byte { + blocks := make([][]byte, 0, len(metadataBlocks)) + for _, block := range metadataBlocks { + blocks = append(blocks, []byte(block)) + } + return icyStreamBytes(metaInt, blocks...) +} + +func icyStreamBytes(metaInt int, metadataBlocks ...[]byte) []byte { + var stream []byte + for _, metadata := range metadataBlocks { + stream = append(stream, audioInterval(metaInt)...) + stream = append(stream, metadataBlock(metadata)...) + } + return stream +} + +func audioInterval(metaInt int) []byte { + return []byte(strings.Repeat("a", metaInt)) +} + +func metadataBlock(metadata []byte) []byte { + if len(metadata) == 0 { + return []byte{0} + } + blockCount := (len(metadata) + 15) / 16 + block := []byte{byte(blockCount)} + block = append(block, metadata...) + padding := blockCount*16 - len(metadata) + block = append(block, bytes.Repeat([]byte{0}, padding)...) + return block +} + +type errAfterReader struct { + data []byte + err error +} + +func (r *errAfterReader) Read(p []byte) (int, error) { + if len(r.data) > 0 { + n := copy(p, r.data) + r.data = r.data[n:] + return n, nil + } + return 0, r.err +} + +var _ io.Reader = (*errAfterReader)(nil) From fd6e779cfd879cda785fe690c54d4c90826782b8 Mon Sep 17 00:00:00 2001 From: Yuko <11803292+hiyukoim@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:16:21 +0200 Subject: [PATCH 02/13] feat(radio): add ICY HTTP metadata reader Closes #2 Signed-off-by: hiyukoim <11803292+hiyukoim@users.noreply.github.com> --- core/radio/icy/reader.go | 55 +++++++++++++++ core/radio/icy/reader_test.go | 124 ++++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 core/radio/icy/reader.go create mode 100644 core/radio/icy/reader_test.go diff --git a/core/radio/icy/reader.go b/core/radio/icy/reader.go new file mode 100644 index 000000000..c2a88622f --- /dev/null +++ b/core/radio/icy/reader.go @@ -0,0 +1,55 @@ +package icy + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" +) + +var ErrMissingMetaInt = errors.New("missing icy metadata interval") + +type ResponseStatusError struct { + StatusCode int + Status string +} + +func (e *ResponseStatusError) Error() string { + return fmt.Sprintf("icy stream returned %s", e.Status) +} + +// ReadHTTPStreamTitles requests ICY metadata from streamURL and emits changed StreamTitle values. +func ReadHTTPStreamTitles(ctx context.Context, client *http.Client, streamURL string, handleTitle func(string)) error { + if client == nil { + client = http.DefaultClient + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil) + if err != nil { + return err + } + req.Header.Set("Icy-MetaData", "1") + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return &ResponseStatusError{StatusCode: resp.StatusCode, Status: resp.Status} + } + + metaIntHeader := resp.Header.Get("icy-metaint") + if metaIntHeader == "" { + return ErrMissingMetaInt + } + + metaInt, err := strconv.Atoi(metaIntHeader) + if err != nil || metaInt <= 0 { + return fmt.Errorf("%w: %q", ErrInvalidMetaInt, metaIntHeader) + } + + return ReadStreamTitles(ctx, resp.Body, metaInt, handleTitle) +} diff --git a/core/radio/icy/reader_test.go b/core/radio/icy/reader_test.go new file mode 100644 index 000000000..bbc3c4c6b --- /dev/null +++ b/core/radio/icy/reader_test.go @@ -0,0 +1,124 @@ +package icy + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ReadHTTPStreamTitles", func() { + const metaInt = 5 + + It("requests ICY metadata and parses stream titles", func() { + var icyHeader string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + icyHeader = r.Header.Get("Icy-MetaData") + w.Header().Set("icy-metaint", "5") + _, _ = w.Write(icyStream(metaInt, "StreamTitle='Artist - Title';")) + })) + defer server.Close() + + var titles []string + err := ReadHTTPStreamTitles(context.Background(), server.Client(), server.URL, func(title string) { + titles = append(titles, title) + }) + + Expect(err).ToNot(HaveOccurred()) + Expect(icyHeader).To(Equal("1")) + Expect(titles).To(Equal([]string{"Artist - Title"})) + }) + + It("follows redirects before reading stream metadata", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/redirect" { + http.Redirect(w, r, "/stream", http.StatusFound) + return + } + + Expect(r.URL.Path).To(Equal("/stream")) + Expect(r.Header.Get("Icy-MetaData")).To(Equal("1")) + w.Header().Set("Icy-MetaInt", "5") + _, _ = w.Write(icyStream(metaInt, "StreamTitle='Redirected - Title';")) + })) + defer server.Close() + + var titles []string + err := ReadHTTPStreamTitles(context.Background(), server.Client(), server.URL+"/redirect", func(title string) { + titles = append(titles, title) + }) + + Expect(err).ToNot(HaveOccurred()) + Expect(titles).To(Equal([]string{"Redirected - Title"})) + }) + + It("returns an error when icy-metaint is missing", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("not an icy stream")) + })) + defer server.Close() + + err := ReadHTTPStreamTitles(context.Background(), server.Client(), server.URL, func(string) {}) + + Expect(errors.Is(err, ErrMissingMetaInt)).To(BeTrue()) + }) + + It("returns an error when icy-metaint is invalid", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("icy-metaint", "zero") + _, _ = w.Write([]byte("not an icy stream")) + })) + defer server.Close() + + err := ReadHTTPStreamTitles(context.Background(), server.Client(), server.URL, func(string) {}) + + Expect(errors.Is(err, ErrInvalidMetaInt)).To(BeTrue()) + }) + + It("returns response status errors for non-2xx responses", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "offline", http.StatusServiceUnavailable) + })) + defer server.Close() + + err := ReadHTTPStreamTitles(context.Background(), server.Client(), server.URL, func(string) {}) + + var statusErr *ResponseStatusError + Expect(errors.As(err, &statusErr)).To(BeTrue()) + Expect(statusErr.StatusCode).To(Equal(http.StatusServiceUnavailable)) + }) + + It("stops when context is cancelled", func() { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := ReadHTTPStreamTitles(ctx, http.DefaultClient, "https://example.test/stream", func(string) {}) + + Expect(errors.Is(err, context.Canceled)).To(BeTrue()) + }) + + It("stops a body read when context is cancelled", func() { + started := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("icy-metaint", "5") + w.(http.Flusher).Flush() + close(started) + <-r.Context().Done() + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + errC := make(chan error, 1) + go func() { + errC <- ReadHTTPStreamTitles(ctx, server.Client(), server.URL, func(string) {}) + }() + + Eventually(started).Should(BeClosed()) + cancel() + + Eventually(errC).Should(Receive(MatchError(ContainSubstring("context canceled")))) + }) +}) From 98b30da5867e10e9079668be08c2e6ad34b6be38 Mon Sep 17 00:00:00 2001 From: Yuko <11803292+hiyukoim@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:20:25 +0200 Subject: [PATCH 03/13] feat(radio): add radio metadata session manager Closes #3 Signed-off-by: hiyukoim <11803292+hiyukoim@users.noreply.github.com> --- core/radio/metadata_manager.go | 224 +++++++++++++++++++++++++++ core/radio/metadata_manager_test.go | 231 ++++++++++++++++++++++++++++ core/radio/radio_suite_test.go | 17 ++ 3 files changed, 472 insertions(+) create mode 100644 core/radio/metadata_manager.go create mode 100644 core/radio/metadata_manager_test.go create mode 100644 core/radio/radio_suite_test.go diff --git a/core/radio/metadata_manager.go b/core/radio/metadata_manager.go new file mode 100644 index 000000000..f4cefe1cd --- /dev/null +++ b/core/radio/metadata_manager.go @@ -0,0 +1,224 @@ +package radio + +import ( + "context" + "errors" + "sync" + "time" +) + +var ( + ErrInvalidSession = errors.New("invalid radio metadata session") + ErrInvalidStation = errors.New("invalid radio metadata station") +) + +type Station struct { + ID string + StreamURL string +} + +type TitleUpdate struct { + RadioID string `json:"radioId"` + Title string `json:"title"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type StreamReader func(ctx context.Context, streamURL string, handleTitle func(string)) error + +type TitlePublisher func(ctx context.Context, update TitleUpdate) + +type MetadataManager struct { + mu sync.Mutex + + reader StreamReader + publish TitlePublisher + now func() time.Time + backoff func(attempt int) time.Duration + + sessions map[string]activeSession + readers map[string]*activeReader +} + +type ManagerOption func(*MetadataManager) + +type activeSession struct { + radioID string + streamURL string +} + +type activeReader struct { + streamURL string + ctx context.Context + cancel context.CancelFunc + radioRefs map[string]int + lastTitle string +} + +func NewMetadataManager(reader StreamReader, publisher TitlePublisher, opts ...ManagerOption) *MetadataManager { + m := &MetadataManager{ + reader: reader, + publish: publisher, + now: time.Now, + backoff: defaultBackoff, + sessions: map[string]activeSession{}, + readers: map[string]*activeReader{}, + } + for _, opt := range opts { + opt(m) + } + return m +} + +func WithNow(now func() time.Time) ManagerOption { + return func(m *MetadataManager) { + if now != nil { + m.now = now + } + } +} + +func WithRetryBackoff(backoff func(attempt int) time.Duration) ManagerOption { + return func(m *MetadataManager) { + if backoff != nil { + m.backoff = backoff + } + } +} + +func (m *MetadataManager) Start(ctx context.Context, sessionID string, station Station) error { + if sessionID == "" { + return ErrInvalidSession + } + if station.ID == "" || station.StreamURL == "" { + return ErrInvalidStation + } + if m.reader == nil { + return errors.New("missing radio metadata stream reader") + } + + m.mu.Lock() + defer m.mu.Unlock() + + if current, ok := m.sessions[sessionID]; ok { + if current.radioID == station.ID && current.streamURL == station.StreamURL { + return nil + } + m.removeSessionLocked(sessionID) + } + + reader := m.readers[station.StreamURL] + if reader == nil { + readerCtx, cancel := context.WithCancel(ctx) + reader = &activeReader{ + streamURL: station.StreamURL, + ctx: readerCtx, + cancel: cancel, + radioRefs: map[string]int{}, + } + m.readers[station.StreamURL] = reader + go m.runReader(reader) + } + + reader.radioRefs[station.ID]++ + m.sessions[sessionID] = activeSession{radioID: station.ID, streamURL: station.StreamURL} + return nil +} + +func (m *MetadataManager) Stop(sessionID string) { + m.mu.Lock() + defer m.mu.Unlock() + m.removeSessionLocked(sessionID) +} + +func (m *MetadataManager) removeSessionLocked(sessionID string) { + session, ok := m.sessions[sessionID] + if !ok { + return + } + delete(m.sessions, sessionID) + + reader := m.readers[session.streamURL] + if reader == nil { + return + } + + if count := reader.radioRefs[session.radioID]; count <= 1 { + delete(reader.radioRefs, session.radioID) + } else { + reader.radioRefs[session.radioID] = count - 1 + } + + if len(reader.radioRefs) == 0 { + delete(m.readers, session.streamURL) + reader.cancel() + } +} + +func (m *MetadataManager) runReader(reader *activeReader) { + attempt := 0 + for { + err := m.reader(reader.ctx, reader.streamURL, func(title string) { + m.publishTitle(reader, title) + }) + if reader.ctx.Err() != nil { + return + } + if err == nil { + attempt = 0 + } else { + attempt++ + } + + delay := m.backoff(attempt) + if delay <= 0 { + continue + } + + timer := time.NewTimer(delay) + select { + case <-reader.ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + } +} + +func (m *MetadataManager) publishTitle(reader *activeReader, title string) { + m.mu.Lock() + if reader.ctx.Err() != nil || title == "" || title == reader.lastTitle { + m.mu.Unlock() + return + } + + reader.lastTitle = title + radioIDs := make([]string, 0, len(reader.radioRefs)) + for radioID := range reader.radioRefs { + radioIDs = append(radioIDs, radioID) + } + updatedAt := m.now() + publish := m.publish + m.mu.Unlock() + + if publish == nil { + return + } + for _, radioID := range radioIDs { + publish(reader.ctx, TitleUpdate{ + RadioID: radioID, + Title: title, + UpdatedAt: updatedAt, + }) + } +} + +func defaultBackoff(attempt int) time.Duration { + if attempt < 1 { + attempt = 1 + } + delay := time.Duration(attempt) * time.Second + if delay > 30*time.Second { + return 30 * time.Second + } + return delay +} diff --git a/core/radio/metadata_manager_test.go b/core/radio/metadata_manager_test.go new file mode 100644 index 000000000..6b1112143 --- /dev/null +++ b/core/radio/metadata_manager_test.go @@ -0,0 +1,231 @@ +package radio + +import ( + "context" + "errors" + "sync" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("MetadataManager", func() { + var ( + reader *fakeStreamReader + publisher *fakePublisher + manager *MetadataManager + ) + + BeforeEach(func() { + reader = newFakeStreamReader() + publisher = newFakePublisher() + manager = NewMetadataManager( + reader.Read, + publisher.Publish, + WithRetryBackoff(func(int) time.Duration { return 0 }), + WithNow(func() time.Time { return time.Unix(123, 0).UTC() }), + ) + }) + + It("starts a reader for an active radio session", func() { + err := manager.Start(context.Background(), "session-1", Station{ + ID: "rd-1", + StreamURL: "https://stream.example.test/radio", + }) + + Expect(err).ToNot(HaveOccurred()) + Eventually(reader.StartCount).Should(Equal(1)) + Expect(reader.URLs()).To(Equal([]string{"https://stream.example.test/radio"})) + }) + + It("shares one reader for sessions using the same stream URL", func() { + Expect(manager.Start(context.Background(), "session-1", Station{ID: "rd-1", StreamURL: "https://stream.example.test/radio"})).To(Succeed()) + Expect(manager.Start(context.Background(), "session-2", Station{ID: "rd-1", StreamURL: "https://stream.example.test/radio"})).To(Succeed()) + + Eventually(reader.StartCount).Should(Equal(1)) + Consistently(reader.StartCount).Should(Equal(1)) + }) + + It("cancels the reader when the last session stops", func() { + Expect(manager.Start(context.Background(), "session-1", Station{ID: "rd-1", StreamURL: "https://stream.example.test/radio"})).To(Succeed()) + Eventually(reader.StartCount).Should(Equal(1)) + + manager.Stop("session-1") + + Eventually(reader.CancelCount).Should(Equal(1)) + }) + + It("keeps the reader alive while another session still references the stream URL", func() { + Expect(manager.Start(context.Background(), "session-1", Station{ID: "rd-1", StreamURL: "https://stream.example.test/radio"})).To(Succeed()) + Expect(manager.Start(context.Background(), "session-2", Station{ID: "rd-1", StreamURL: "https://stream.example.test/radio"})).To(Succeed()) + Eventually(reader.StartCount).Should(Equal(1)) + + manager.Stop("session-1") + + Consistently(reader.CancelCount).Should(Equal(0)) + manager.Stop("session-2") + Eventually(reader.CancelCount).Should(Equal(1)) + }) + + It("stops an existing session before starting that session on another station", func() { + Expect(manager.Start(context.Background(), "session-1", Station{ID: "rd-1", StreamURL: "https://stream.example.test/one"})).To(Succeed()) + Eventually(reader.StartCount).Should(Equal(1)) + + Expect(manager.Start(context.Background(), "session-1", Station{ID: "rd-2", StreamURL: "https://stream.example.test/two"})).To(Succeed()) + + Eventually(reader.CancelCount).Should(Equal(1)) + Eventually(reader.StartCount).Should(Equal(2)) + Expect(reader.URLs()).To(Equal([]string{ + "https://stream.example.test/one", + "https://stream.example.test/two", + })) + }) + + It("publishes title updates for active radio IDs", func() { + Expect(manager.Start(context.Background(), "session-1", Station{ID: "rd-1", StreamURL: "https://stream.example.test/radio"})).To(Succeed()) + Eventually(reader.StartCount).Should(Equal(1)) + + reader.Emit("Live Artist - Live Track") + + Eventually(publisher.Updates).Should(Equal([]TitleUpdate{{ + RadioID: "rd-1", + Title: "Live Artist - Live Track", + UpdatedAt: time.Unix(123, 0).UTC(), + }})) + }) + + It("does not publish duplicate consecutive titles from the same reader", func() { + Expect(manager.Start(context.Background(), "session-1", Station{ID: "rd-1", StreamURL: "https://stream.example.test/radio"})).To(Succeed()) + Eventually(reader.StartCount).Should(Equal(1)) + + reader.Emit("Same Title") + reader.Emit("Same Title") + + Consistently(publisher.Updates).Should(Equal([]TitleUpdate{{ + RadioID: "rd-1", + Title: "Same Title", + UpdatedAt: time.Unix(123, 0).UTC(), + }})) + }) + + It("retries reader failures until stopped", func() { + reader.err = errors.New("stream failed") + + Expect(manager.Start(context.Background(), "session-1", Station{ID: "rd-1", StreamURL: "https://stream.example.test/radio"})).To(Succeed()) + + Eventually(reader.StartCount).Should(BeNumerically(">", 1)) + manager.Stop("session-1") + cancelCount := reader.CancelCount() + Consistently(reader.StartCount).Should(BeNumerically(">=", 2)) + Expect(reader.CancelCount()).To(BeNumerically(">=", cancelCount)) + }) + + It("ignores unknown stops", func() { + manager.Stop("missing-session") + Expect(reader.StartCount()).To(Equal(0)) + }) + + It("rejects invalid start requests", func() { + Expect(manager.Start(context.Background(), "", Station{ID: "rd-1", StreamURL: "https://stream.example.test/radio"})).To(MatchError(ErrInvalidSession)) + Expect(manager.Start(context.Background(), "session-1", Station{ID: "", StreamURL: "https://stream.example.test/radio"})).To(MatchError(ErrInvalidStation)) + Expect(manager.Start(context.Background(), "session-1", Station{ID: "rd-1", StreamURL: ""})).To(MatchError(ErrInvalidStation)) + }) +}) + +type fakeStreamReader struct { + mu sync.Mutex + started chan struct{} + cancelled chan struct{} + handler func(string) + urls []string + starts int + cancels int + err error +} + +func newFakeStreamReader() *fakeStreamReader { + return &fakeStreamReader{ + started: make(chan struct{}), + cancelled: make(chan struct{}), + } +} + +func (r *fakeStreamReader) Read(ctx context.Context, streamURL string, handleTitle func(string)) error { + r.mu.Lock() + r.starts++ + r.urls = append(r.urls, streamURL) + r.handler = handleTitle + if r.starts == 1 { + close(r.started) + } + err := r.err + r.mu.Unlock() + + if err != nil { + return err + } + + <-ctx.Done() + + r.mu.Lock() + r.cancels++ + if r.cancels == 1 { + close(r.cancelled) + } + r.mu.Unlock() + + return ctx.Err() +} + +func (r *fakeStreamReader) Emit(title string) { + Eventually(func() func(string) { + r.mu.Lock() + defer r.mu.Unlock() + return r.handler + }).ShouldNot(BeNil()) + + r.mu.Lock() + handler := r.handler + r.mu.Unlock() + handler(title) +} + +func (r *fakeStreamReader) StartCount() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.starts +} + +func (r *fakeStreamReader) CancelCount() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.cancels +} + +func (r *fakeStreamReader) URLs() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.urls...) +} + +type fakePublisher struct { + mu sync.Mutex + updates []TitleUpdate +} + +func newFakePublisher() *fakePublisher { + return &fakePublisher{} +} + +func (p *fakePublisher) Publish(_ context.Context, update TitleUpdate) { + p.mu.Lock() + defer p.mu.Unlock() + p.updates = append(p.updates, update) +} + +func (p *fakePublisher) Updates() []TitleUpdate { + p.mu.Lock() + defer p.mu.Unlock() + return append([]TitleUpdate(nil), p.updates...) +} diff --git a/core/radio/radio_suite_test.go b/core/radio/radio_suite_test.go new file mode 100644 index 000000000..cf7ce602e --- /dev/null +++ b/core/radio/radio_suite_test.go @@ -0,0 +1,17 @@ +package radio + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestRadio(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Radio Suite") +} From 8f70e0e4f3865705b83b8893400408b2f148a371 Mon Sep 17 00:00:00 2001 From: Yuko <11803292+hiyukoim@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:28:55 +0200 Subject: [PATCH 04/13] feat(radio): add radio metadata lifecycle API Signed-off-by: hiyukoim <11803292+hiyukoim@users.noreply.github.com> --- cmd/wire_gen.go | 6 +- cmd/wire_injectors.go | 2 + core/radio/provider.go | 14 +++ core/wire_providers.go | 2 + server/nativeapi/config_test.go | 2 +- server/nativeapi/library_test.go | 2 +- server/nativeapi/native_api.go | 11 +- server/nativeapi/native_api_song_test.go | 2 +- server/nativeapi/playlists_test.go | 2 +- server/nativeapi/plugin_test.go | 2 +- server/nativeapi/radio_nowplaying_test.go | 140 ++++++++++++++++++++++ server/nativeapi/radios.go | 66 ++++++++++ 12 files changed, 242 insertions(+), 9 deletions(-) create mode 100644 core/radio/provider.go create mode 100644 server/nativeapi/radio_nowplaying_test.go diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 463ac7a44..4588d5810 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -21,6 +21,7 @@ import ( "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/radio" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" @@ -83,7 +84,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager) user := core.NewUser(dataStore, manager) maintenance := core.NewMaintenance(dataStore) - router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, imageUploadService) + metadataManager := radio.NewMetadataManagerService() + router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, imageUploadService, metadataManager) return router } @@ -247,7 +249,7 @@ func getPluginManager() *plugins.Manager { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(nativeapi.RadioMetadataManager), new(*radio.MetadataManager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) func GetPluginManager(ctx context.Context) *plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index 94faa5af3..4f3ff2a29 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" + "github.com/navidrome/navidrome/core/radio" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/db" @@ -53,6 +54,7 @@ var allProviders = wire.NewSet( wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), + wire.Bind(new(nativeapi.RadioMetadataManager), new(*radio.MetadataManager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), diff --git a/core/radio/provider.go b/core/radio/provider.go new file mode 100644 index 000000000..5333bc9ac --- /dev/null +++ b/core/radio/provider.go @@ -0,0 +1,14 @@ +package radio + +import ( + "context" + "net/http" + + "github.com/navidrome/navidrome/core/radio/icy" +) + +func NewMetadataManagerService() *MetadataManager { + return NewMetadataManager(func(ctx context.Context, streamURL string, handleTitle func(string)) error { + return icy.ReadHTTPStreamTitles(ctx, http.DefaultClient, streamURL, handleTitle) + }, nil) +} diff --git a/core/wire_providers.go b/core/wire_providers.go index a2fffa34f..b98c13d91 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -10,6 +10,7 @@ import ( "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/radio" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/stream" ) @@ -36,4 +37,5 @@ var Set = wire.NewSet( playback.GetInstance, metrics.GetInstance, lyrics.NewLyrics, + radio.NewMetadataManagerService, ) diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go index 107b01e01..d1007f457 100644 --- a/server/nativeapi/config_test.go +++ b/server/nativeapi/config_test.go @@ -29,7 +29,7 @@ var _ = Describe("Config API", func() { conf.Server.DevUIShowConfig = true // Enable config endpoint for tests ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/library_test.go b/server/nativeapi/library_test.go index 9b7061845..13b33c238 100644 --- a/server/nativeapi/library_test.go +++ b/server/nativeapi/library_test.go @@ -31,7 +31,7 @@ var _ = Describe("Library API", func() { conf.Server.EnableSharing = false ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 5a7023eb6..8b7c930f9 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -15,6 +15,7 @@ import ( "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/metrics" playlistsvc "github.com/navidrome/navidrome/core/playlists" + coreradio "github.com/navidrome/navidrome/core/radio" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -34,6 +35,11 @@ type PluginManager interface { UnloadDisabledPlugins(ctx context.Context) } +type RadioMetadataManager interface { + Start(ctx context.Context, sessionID string, station coreradio.Station) error + Stop(sessionID string) +} + type Router struct { http.Handler ds model.DataStore @@ -45,10 +51,11 @@ type Router struct { maintenance core.Maintenance pluginManager PluginManager imgUpload core.ImageUploadService + radioMetadata RadioMetadataManager } -func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload core.ImageUploadService) *Router { - r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload} +func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload core.ImageUploadService, radioMetadata RadioMetadataManager) *Router { + r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload, radioMetadata: radioMetadata} r.Handler = r.routes() return r } diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go index b1ed09d65..203fcd4cf 100644 --- a/server/nativeapi/native_api_song_test.go +++ b/server/nativeapi/native_api_song_test.go @@ -95,7 +95,7 @@ var _ = Describe("Song Endpoints", func() { mfRepo.SetData(testSongs) // Create the native API router and wrap it with the JWTVerifier middleware - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go index 9bf502687..bc7c74efc 100644 --- a/server/nativeapi/playlists_test.go +++ b/server/nativeapi/playlists_test.go @@ -99,7 +99,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() { err := userRepo.Put(&testUser) Expect(err).ToNot(HaveOccurred()) - nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) + nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) diff --git a/server/nativeapi/plugin_test.go b/server/nativeapi/plugin_test.go index aa91a7951..1683885e7 100644 --- a/server/nativeapi/plugin_test.go +++ b/server/nativeapi/plugin_test.go @@ -34,7 +34,7 @@ var _ = Describe("Plugin API", func() { ds = &tests.MockDataStore{} mockManager = &tests.MockPluginManager{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/radio_nowplaying_test.go b/server/nativeapi/radio_nowplaying_test.go new file mode 100644 index 000000000..0b7faec10 --- /dev/null +++ b/server/nativeapi/radio_nowplaying_test.go @@ -0,0 +1,140 @@ +package nativeapi + +import ( + "context" + "net/http" + "net/http/httptest" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/auth" + coreradio "github.com/navidrome/navidrome/core/radio" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Radio now-playing metadata endpoints", func() { + var ( + router http.Handler + ds *tests.MockDataStore + radioRepo *tests.MockedRadioRepo + userRepo *tests.MockedUserRepo + manager *fakeRadioMetadataManager + testUser model.User + authRequest func(method, path string) *http.Request + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.SessionTimeout = time.Minute + + radioRepo = &tests.MockedRadioRepo{ + Data: map[string]*model.Radio{ + "rd-1": { + ID: "rd-1", + Name: "Test Radio", + StreamUrl: "https://stream.example.test/radio", + }, + }, + } + userRepo = tests.CreateMockUserRepo() + ds = &tests.MockDataStore{ + MockedRadio: radioRepo, + MockedUser: userRepo, + MockedProperty: &tests.MockedPropertyRepo{}, + } + auth.Init(ds) + + testUser = model.User{ + ID: "user-1", + UserName: "testuser", + Name: "Test User", + IsAdmin: false, + NewPassword: "testpass", + } + Expect(userRepo.Put(&testUser)).To(Succeed()) + + manager = &fakeRadioMetadataManager{} + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, manager) + router = server.JWTVerifier(nativeRouter) + + authRequest = func(method, path string) *http.Request { + req := httptest.NewRequest(method, path, nil) + token, err := auth.CreateToken(&testUser) + Expect(err).ToNot(HaveOccurred()) + req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token) + return req.WithContext(request.WithClientUniqueId(req.Context(), "client-1")) + } + }) + + It("starts metadata reading for a stored radio stream URL", func() { + w := httptest.NewRecorder() + req := authRequest(http.MethodPost, "/radio/rd-1/nowplaying") + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(manager.starts).To(Equal([]metadataStart{{ + sessionID: "user-1:client-1", + station: coreradio.Station{ + ID: "rd-1", + StreamURL: "https://stream.example.test/radio", + }, + }})) + }) + + It("returns not found when starting metadata for a missing radio", func() { + w := httptest.NewRecorder() + req := authRequest(http.MethodPost, "/radio/missing/nowplaying") + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(manager.starts).To(BeEmpty()) + }) + + It("returns unauthorized when request is not authenticated", func() { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/radio/rd-1/nowplaying", nil) + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + Expect(manager.starts).To(BeEmpty()) + }) + + It("stops metadata reading idempotently for the active session", func() { + w := httptest.NewRecorder() + req := authRequest(http.MethodDelete, "/radio/rd-1/nowplaying") + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(manager.stops).To(Equal([]string{"user-1:client-1"})) + }) +}) + +type metadataStart struct { + sessionID string + station coreradio.Station +} + +type fakeRadioMetadataManager struct { + starts []metadataStart + stops []string +} + +func (m *fakeRadioMetadataManager) Start(_ context.Context, sessionID string, station coreradio.Station) error { + m.starts = append(m.starts, metadataStart{sessionID: sessionID, station: station}) + return nil +} + +func (m *fakeRadioMetadataManager) Stop(sessionID string) { + m.stops = append(m.stops, sessionID) +} diff --git a/server/nativeapi/radios.go b/server/nativeapi/radios.go index 701c6c926..0b7bd5d3a 100644 --- a/server/nativeapi/radios.go +++ b/server/nativeapi/radios.go @@ -9,7 +9,9 @@ import ( "github.com/deluan/rest" "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/consts" + coreradio "github.com/navidrome/navidrome/core/radio" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server" ) @@ -27,10 +29,74 @@ func (api *Router) addRadioRoute(r chi.Router) { r.Delete("/", rest.Delete(constructor)) r.Post("/image", api.uploadRadioImage()) r.Delete("/image", api.deleteRadioImage()) + r.Post("/nowplaying", api.startRadioNowPlaying()) + r.Delete("/nowplaying", api.stopRadioNowPlaying()) }) }) } +func (api *Router) startRadioNowPlaying() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if api.radioMetadata == nil { + http.Error(w, "radio metadata is unavailable", http.StatusServiceUnavailable) + return + } + + radioID := chi.URLParam(r, "id") + radio, err := api.ds.Radio(r.Context()).Get(radioID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + err = api.radioMetadata.Start(r.Context(), radioNowPlayingSessionID(r.Context()), coreradio.Station{ + ID: radio.ID, + StreamURL: radio.StreamUrl, + }) + if err != nil { + if errors.Is(err, coreradio.ErrInvalidSession) || errors.Is(err, coreradio.ErrInvalidStation) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) + } +} + +func (api *Router) stopRadioNowPlaying() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if api.radioMetadata == nil { + http.Error(w, "radio metadata is unavailable", http.StatusServiceUnavailable) + return + } + + api.radioMetadata.Stop(radioNowPlayingSessionID(r.Context())) + w.WriteHeader(http.StatusNoContent) + } +} + +func radioNowPlayingSessionID(ctx context.Context) string { + user, hasUser := request.UserFrom(ctx) + clientID, _ := request.ClientUniqueIdFrom(ctx) + if clientID == "" { + clientID = "default" + } + if hasUser && user.ID != "" { + return user.ID + ":" + clientID + } + if username, ok := request.UsernameFrom(ctx); ok && username != "" { + return username + ":" + clientID + } + return clientID +} + func (api *Router) uploadRadioImage() http.HandlerFunc { return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error { radioID := chi.URLParamFromCtx(ctx, "id") From 210917530425ac590611596c0aea3be487cb85c8 Mon Sep 17 00:00:00 2001 From: Yuko <11803292+hiyukoim@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:32:08 +0200 Subject: [PATCH 05/13] feat(radio): publish radio metadata events Signed-off-by: hiyukoim <11803292+hiyukoim@users.noreply.github.com> --- cmd/wire_gen.go | 5 ++-- cmd/wire_injectors.go | 1 + core/radio/provider.go | 4 +-- server/events/events.go | 22 ++++++++++++++ server/events/events_test.go | 57 ++++++++++++++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 4 deletions(-) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 4588d5810..999ccbe49 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -84,7 +84,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager) user := core.NewUser(dataStore, manager) maintenance := core.NewMaintenance(dataStore) - metadataManager := radio.NewMetadataManagerService() + titlePublisher := events.NewRadioMetadataPublisher(broker) + metadataManager := radio.NewMetadataManagerService(titlePublisher) router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, imageUploadService, metadataManager) return router } @@ -249,7 +250,7 @@ func getPluginManager() *plugins.Manager { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(nativeapi.RadioMetadataManager), new(*radio.MetadataManager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, events.NewRadioMetadataPublisher, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(nativeapi.RadioMetadataManager), new(*radio.MetadataManager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) func GetPluginManager(ctx context.Context) *plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index 4f3ff2a29..f639e69da 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -42,6 +42,7 @@ var allProviders = wire.NewSet( lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, + events.NewRadioMetadataPublisher, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, diff --git a/core/radio/provider.go b/core/radio/provider.go index 5333bc9ac..7fed4095a 100644 --- a/core/radio/provider.go +++ b/core/radio/provider.go @@ -7,8 +7,8 @@ import ( "github.com/navidrome/navidrome/core/radio/icy" ) -func NewMetadataManagerService() *MetadataManager { +func NewMetadataManagerService(publisher TitlePublisher) *MetadataManager { return NewMetadataManager(func(ctx context.Context, streamURL string, handleTitle func(string)) error { return icy.ReadHTTPStreamTitles(ctx, http.DefaultClient, streamURL, handleTitle) - }, nil) + }, publisher) } diff --git a/server/events/events.go b/server/events/events.go index ff0a8a40a..2a6edf103 100644 --- a/server/events/events.go +++ b/server/events/events.go @@ -7,6 +7,8 @@ import ( "strings" "time" "unicode" + + coreradio "github.com/navidrome/navidrome/core/radio" ) type eventCtxKey string @@ -68,6 +70,26 @@ type NowPlayingCount struct { Count int `json:"count"` } +type RadioNowPlaying struct { + baseEvent + RadioID string `json:"radioId"` + Title string `json:"title"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func NewRadioMetadataPublisher(broker Broker) coreradio.TitlePublisher { + return func(ctx context.Context, update coreradio.TitleUpdate) { + if broker == nil { + return + } + broker.SendMessage(ctx, &RadioNowPlaying{ + RadioID: update.RadioID, + Title: update.Title, + UpdatedAt: update.UpdatedAt, + }) + } +} + func (rr *RefreshResource) With(resource string, ids ...string) *RefreshResource { if rr.resources == nil { rr.resources = make(map[string][]string) diff --git a/server/events/events_test.go b/server/events/events_test.go index abfab9b6e..0809a57ad 100644 --- a/server/events/events_test.go +++ b/server/events/events_test.go @@ -1,6 +1,11 @@ package events import ( + "context" + "net/http" + "time" + + coreradio "github.com/navidrome/navidrome/core/radio" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -21,6 +26,41 @@ var _ = Describe("Events", func() { }) }) + Describe("RadioNowPlaying", func() { + It("serialises with a stable event name and payload", func() { + updatedAt := time.Date(2026, time.June, 30, 12, 34, 56, 0, time.UTC) + event := &RadioNowPlaying{ + RadioID: "rd-1", + Title: "Artist - Title", + UpdatedAt: updatedAt, + } + + Expect(event.Name(event)).To(Equal("radioNowPlaying")) + Expect(event.Data(event)).To(Equal(`{"radioId":"rd-1","title":"Artist - Title","updatedAt":"2026-06-30T12:34:56Z"}`)) + }) + + It("publishes metadata updates through the broker", func() { + ctx := context.Background() + updatedAt := time.Date(2026, time.June, 30, 12, 34, 56, 0, time.UTC) + broker := &capturingBroker{} + + publish := NewRadioMetadataPublisher(broker) + publish(ctx, coreradio.TitleUpdate{ + RadioID: "rd-1", + Title: "Artist - Title", + UpdatedAt: updatedAt, + }) + + Expect(broker.ctx).To(Equal(ctx)) + Expect(broker.broadcast).To(BeNil()) + Expect(broker.event).To(Equal(&RadioNowPlaying{ + RadioID: "rd-1", + Title: "Artist - Title", + UpdatedAt: updatedAt, + })) + }) + }) + Describe("RefreshResource", func() { var rr *RefreshResource BeforeEach(func() { @@ -44,3 +84,20 @@ var _ = Describe("Events", func() { }) }) }) + +type capturingBroker struct { + ctx context.Context + event Event + broadcast Event +} + +func (b *capturingBroker) ServeHTTP(http.ResponseWriter, *http.Request) {} + +func (b *capturingBroker) SendMessage(ctx context.Context, event Event) { + b.ctx = ctx + b.event = event +} + +func (b *capturingBroker) SendBroadcastMessage(_ context.Context, event Event) { + b.broadcast = event +} From 2f82b7a75207e931d7f5224e06152cf0957e954f Mon Sep 17 00:00:00 2001 From: Yuko <11803292+hiyukoim@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:40:10 +0200 Subject: [PATCH 06/13] feat(ui): show live radio titles in player Signed-off-by: hiyukoim <11803292+hiyukoim@users.noreply.github.com> --- ui/src/actions/serverEvents.js | 1 + ui/src/audioplayer/AudioTitle.jsx | 3 +- ui/src/audioplayer/AudioTitle.test.jsx | 20 +++++ ui/src/audioplayer/Player.jsx | 83 ++++++++++++++++++-- ui/src/eventStream.js | 12 ++- ui/src/eventStream.test.js | 22 +++++- ui/src/reducers/playerReducer.js | 52 +++++++++++++ ui/src/reducers/playerReducer.test.js | 100 +++++++++++++++++++++++++ 8 files changed, 284 insertions(+), 9 deletions(-) diff --git a/ui/src/actions/serverEvents.js b/ui/src/actions/serverEvents.js index 892f2e27e..32751bbbc 100644 --- a/ui/src/actions/serverEvents.js +++ b/ui/src/actions/serverEvents.js @@ -3,6 +3,7 @@ export const EVENT_SERVER_START = 'serverStart' export const EVENT_REFRESH_RESOURCE = 'refreshResource' export const EVENT_NOW_PLAYING_COUNT = 'nowPlayingCount' export const EVENT_NOW_PLAYING_COUNT_SYNC = 'nowPlayingCountSync' +export const EVENT_RADIO_NOW_PLAYING = 'radioNowPlaying' export const EVENT_STREAM_RECONNECTED = 'streamReconnected' export const processEvent = (type, data) => ({ diff --git a/ui/src/audioplayer/AudioTitle.jsx b/ui/src/audioplayer/AudioTitle.jsx index df37edfbb..20eb0061c 100644 --- a/ui/src/audioplayer/AudioTitle.jsx +++ b/ui/src/audioplayer/AudioTitle.jsx @@ -45,7 +45,8 @@ const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => { : {} const subtitle = song.tags?.['subtitle'] - const title = song.title + (subtitle ? ` (${subtitle})` : '') + const displayTitle = audioInfo.radioTitle || song.radioTitle || song.title + const title = displayTitle + (subtitle ? ` (${subtitle})` : '') const linkTo = audioInfo.isRadio ? `/radio/${audioInfo.trackId}/show` diff --git a/ui/src/audioplayer/AudioTitle.test.jsx b/ui/src/audioplayer/AudioTitle.test.jsx index 7b297c07e..37a0918ab 100644 --- a/ui/src/audioplayer/AudioTitle.test.jsx +++ b/ui/src/audioplayer/AudioTitle.test.jsx @@ -55,4 +55,24 @@ describe('', () => { const link = screen.getByRole('link') expect(link.getAttribute('href')).toBe('/album/album-1/show') }) + + it('shows live radio title without changing the station link', () => { + const audioInfo = { + trackId: 'rd-1', + isRadio: true, + radioTitle: 'Artist - Title', + song: { + id: 'rd-1', + title: 'Test Station', + artist: 'Test Station', + album: 'https://stream.example.test/radio', + }, + } + render() + + expect(screen.getByText('Artist - Title')).toBeInTheDocument() + expect(screen.getByRole('link').getAttribute('href')).toBe( + '/radio/rd-1/show', + ) + }) }) diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index c3d795b26..91657d5ed 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -34,6 +34,18 @@ import { keyMap } from '../hotkeys' import keyHandlers from './keyHandlers' import { calculateGain } from '../utils/calculateReplayGain' import { detectBrowserProfile, decisionService } from '../transcode' +import { httpClient } from '../dataProvider' +import { REST_URL } from '../consts' + +const radioNowPlayingURL = (radioId) => + `${REST_URL}/radio/${encodeURIComponent(radioId)}/nowplaying` + +const requestRadioNowPlaying = (radioId, method) => { + if (!radioId) { + return Promise.resolve() + } + return httpClient(radioNowPlayingURL(radioId), { method }).catch(() => {}) +} const Player = () => { const theme = useCurrentTheme() @@ -46,6 +58,7 @@ const Player = () => { const [heartbeatTrackId, setHeartbeatTrackId] = useState(null) const lastPositionMsRef = useRef(0) const currentTrackIdRef = useRef(null) + const currentRadioIdRef = useRef(null) const stoppedRef = useRef(false) const [audioInstance, setAudioInstance] = useState(null) const isDesktop = useMediaQuery('(min-width:810px)') @@ -63,6 +76,30 @@ const Player = () => { currentTrackIdRef.current = currentTrackId + const stopRadioNowPlaying = useCallback( + (radioId = currentRadioIdRef.current) => { + if (!radioId) { + return + } + requestRadioNowPlaying(radioId, 'DELETE') + if (currentRadioIdRef.current === radioId) { + currentRadioIdRef.current = null + } + }, + [], + ) + + const startRadioNowPlaying = useCallback((radioId) => { + if (!radioId || currentRadioIdRef.current === radioId) { + return + } + if (currentRadioIdRef.current) { + requestRadioNowPlaying(currentRadioIdRef.current, 'DELETE') + } + currentRadioIdRef.current = radioId + requestRadioNowPlaying(radioId, 'POST') + }, []) + useInterval( () => { if (heartbeatTrackId && !stoppedRef.current) { @@ -180,6 +217,10 @@ const Player = () => { } const handlePageHide = () => { + if (playerState.current?.isRadio) { + stopRadioNowPlaying(playerState.current.trackId) + return + } if (currentTrackIdRef.current && !playerState.current?.isRadio) { stoppedRef.current = true try { @@ -200,7 +241,7 @@ const Player = () => { window.removeEventListener('beforeunload', handleBeforeUnload) window.removeEventListener('pagehide', handlePageHide) } - }, [playerState, audioInstance]) + }, [playerState, audioInstance, stopRadioNowPlaying]) const defaultOptions = useMemo( () => ({ @@ -285,6 +326,15 @@ const Player = () => { } dispatch(currentPlaying(info)) + if (info.isRadio) { + startRadioNowPlaying(info.trackId) + setHeartbeatTrackId(null) + setCurrentTrackId(null) + return + } + if (currentRadioIdRef.current) { + stopRadioNowPlaying() + } if (info.duration) { const song = info.song document.title = `${song.title} - ${song.artist} - Navidrome` @@ -320,10 +370,20 @@ const Player = () => { } } }, - [context, dispatch, showNotifications, currentTrackId], + [ + context, + dispatch, + showNotifications, + currentTrackId, + startRadioNowPlaying, + stopRadioNowPlaying, + ], ) const onAudioPlayTrackChange = useCallback(() => { + if (currentRadioIdRef.current) { + stopRadioNowPlaying() + } if (currentTrackId) { subsonic.reportPlayback( currentTrackId, @@ -333,11 +393,16 @@ const Player = () => { } setHeartbeatTrackId(null) setCurrentTrackId(null) - }, [currentTrackId]) + }, [currentTrackId, stopRadioNowPlaying]) const onAudioPause = useCallback( (info) => { dispatch(currentPlaying(info)) + if (info.isRadio) { + stopRadioNowPlaying(info.trackId) + setHeartbeatTrackId(null) + return + } if (!info.isRadio && currentTrackId) { const posMs = Math.floor(info.currentTime * 1000) lastPositionMsRef.current = posMs @@ -345,11 +410,14 @@ const Player = () => { } setHeartbeatTrackId(null) }, - [dispatch, currentTrackId], + [dispatch, currentTrackId, stopRadioNowPlaying], ) const onAudioEnded = useCallback( (currentPlayId, audioLists, info) => { + if (info.isRadio) { + stopRadioNowPlaying(info.trackId) + } if (currentTrackId && !info.isRadio) { const posMs = Math.floor((info.duration || 0) * 1000) subsonic.reportPlayback(currentTrackId, posMs, 'stopped') @@ -362,7 +430,7 @@ const Player = () => { // eslint-disable-next-line no-console .catch((e) => console.log('Keepalive error:', e)) }, - [dispatch, dataProvider, currentTrackId], + [dispatch, dataProvider, currentTrackId, stopRadioNowPlaying], ) const onCoverClick = useCallback((mode, audioLists, audioInfo) => { @@ -395,6 +463,9 @@ const Player = () => { const onBeforeDestroy = useCallback(() => { return new Promise((resolve, reject) => { + if (currentRadioIdRef.current) { + stopRadioNowPlaying() + } if (currentTrackId && !playerStateRef.current?.current?.isRadio) { subsonic.reportPlayback( currentTrackId, @@ -407,7 +478,7 @@ const Player = () => { dispatch(clearQueue()) reject() }) - }, [dispatch, currentTrackId]) + }, [dispatch, currentTrackId, stopRadioNowPlaying]) if (!visible) { document.title = 'Navidrome' diff --git a/ui/src/eventStream.js b/ui/src/eventStream.js index c91dae875..21a3c1f02 100644 --- a/ui/src/eventStream.js +++ b/ui/src/eventStream.js @@ -1,6 +1,11 @@ import { baseUrl } from './utils' import throttle from 'lodash.throttle' -import { processEvent, serverDown, streamReconnected } from './actions' +import { + EVENT_RADIO_NOW_PLAYING, + processEvent, + serverDown, + streamReconnected, +} from './actions' import { REST_URL } from './consts' import config from './config' @@ -20,6 +25,7 @@ const setupHandlers = (stream, dispatchFn) => { stream.addEventListener('serverStart', eventHandler(dispatchFn)) stream.addEventListener('scanStatus', throttledEventHandler(dispatchFn)) stream.addEventListener('refreshResource', eventHandler(dispatchFn)) + stream.addEventListener(EVENT_RADIO_NOW_PLAYING, eventHandler(dispatchFn)) if (config.enableNowPlaying) { stream.addEventListener('nowPlayingCount', eventHandler(dispatchFn)) } @@ -76,6 +82,10 @@ const startEventStreamLegacy = async (dispatchFn) => { throttledEventHandler(dispatchFn), ) newStream.addEventListener('refreshResource', eventHandler(dispatchFn)) + newStream.addEventListener( + EVENT_RADIO_NOW_PLAYING, + eventHandler(dispatchFn), + ) if (config.enableNowPlaying) { newStream.addEventListener('nowPlayingCount', eventHandler(dispatchFn)) } diff --git a/ui/src/eventStream.test.js b/ui/src/eventStream.test.js index 27f53c872..7df399a20 100644 --- a/ui/src/eventStream.test.js +++ b/ui/src/eventStream.test.js @@ -1,6 +1,6 @@ import { describe, it, beforeEach, vi, expect } from 'vitest' import { startEventStream } from './eventStream' -import { serverDown } from './actions' +import { processEvent, serverDown } from './actions' import config from './config' class MockEventSource { @@ -48,4 +48,24 @@ describe('startEventStream', () => { vi.advanceTimersByTime(5000) expect(global.EventSource).toHaveBeenCalledTimes(2) }) + + it('dispatches radio now-playing events', async () => { + await startEventStream(dispatch) + instance.listeners.radioNowPlaying({ + type: 'radioNowPlaying', + data: JSON.stringify({ + radioId: 'rd-1', + title: 'Artist - Title', + updatedAt: '2026-06-30T12:34:56Z', + }), + }) + + expect(dispatch).toHaveBeenCalledWith( + processEvent('radioNowPlaying', { + radioId: 'rd-1', + title: 'Artist - Title', + updatedAt: '2026-06-30T12:34:56Z', + }), + ) + }) }) diff --git a/ui/src/reducers/playerReducer.js b/ui/src/reducers/playerReducer.js index d6ab7484b..ad8662bc6 100644 --- a/ui/src/reducers/playerReducer.js +++ b/ui/src/reducers/playerReducer.js @@ -12,6 +12,7 @@ import { PLAYER_SYNC_QUEUE, PLAYER_SET_MODE, PLAYER_REFRESH_QUEUE, + EVENT_RADIO_NOW_PLAYING, } from '../actions' import config from '../config' @@ -208,6 +209,55 @@ const reduceMode = (state, { data: { mode } }) => { } } +const activeQueueItem = (state) => { + if (state.current?.isRadio) { + return state.current + } + const index = state.playIndex ?? state.savedPlayIndex + return state.queue[index] +} + +const applyRadioTitle = (item, title) => { + if (!item) { + return item + } + const song = { ...(item.song || {}) } + if (title) { + return { + ...item, + radioTitle: title, + song: { ...song, radioTitle: title }, + } + } + delete song.radioTitle + const next = { ...item, song } + delete next.radioTitle + return next +} + +const reduceRadioNowPlaying = (state, { data }) => { + const radioId = data?.radioId + const active = activeQueueItem(state) + if (!radioId || !active?.isRadio || active.trackId !== radioId) { + return state + } + + const title = (data.title || '').trim() + const matchesActive = (item) => + active.uuid ? item.uuid === active.uuid : item.trackId === radioId + + return { + ...state, + queue: state.queue.map((item) => + matchesActive(item) ? applyRadioTitle(item, title) : item, + ), + current: + state.current?.isRadio && state.current.trackId === radioId + ? applyRadioTitle(state.current, title) + : state.current, + } +} + export const playerReducer = (previousState = initialState, payload) => { const { type } = payload switch (type) { @@ -229,6 +279,8 @@ export const playerReducer = (previousState = initialState, payload) => { return reduceCurrent(previousState, payload) case PLAYER_SET_MODE: return reduceMode(previousState, payload) + case EVENT_RADIO_NOW_PLAYING: + return reduceRadioNowPlaying(previousState, payload) case PLAYER_REFRESH_QUEUE: { const resolvedUrls = payload.data || {} return { diff --git a/ui/src/reducers/playerReducer.test.js b/ui/src/reducers/playerReducer.test.js index 110ce8c53..12a2c1a86 100644 --- a/ui/src/reducers/playerReducer.test.js +++ b/ui/src/reducers/playerReducer.test.js @@ -4,6 +4,7 @@ import { PLAYER_SYNC_QUEUE, PLAYER_CURRENT, PLAYER_REFRESH_QUEUE, + EVENT_RADIO_NOW_PLAYING, } from '../actions' describe('playerReducer', () => { @@ -224,4 +225,103 @@ describe('playerReducer', () => { expect(result.playIndex).toBe(0) }) }) + + describe('radioNowPlaying event', () => { + const radioItem = { + trackId: 'rd-1', + uuid: 'radio-uuid', + name: 'Test Station', + musicSrc: 'https://stream.example.test/radio', + isRadio: true, + song: { + id: 'rd-1', + title: 'Test Station', + artist: 'Test Station', + album: 'https://stream.example.test/radio', + streamUrl: 'https://stream.example.test/radio', + }, + } + + it('updates the active radio with live title metadata', () => { + const state = { + queue: [radioItem], + current: radioItem, + savedPlayIndex: 0, + clear: false, + volume: 1, + } + const result = playerReducer(state, { + type: EVENT_RADIO_NOW_PLAYING, + data: { + radioId: 'rd-1', + title: 'Artist - Title', + updatedAt: '2026-06-30T12:34:56Z', + }, + }) + + expect(result.current.radioTitle).toBe('Artist - Title') + expect(result.current.song.radioTitle).toBe('Artist - Title') + expect(result.current.name).toBe('Test Station') + expect(result.current.song.title).toBe('Test Station') + expect(result.current.musicSrc).toBe('https://stream.example.test/radio') + expect(result.queue[0].radioTitle).toBe('Artist - Title') + }) + + it('ignores events for non-active radios', () => { + const state = { + queue: [radioItem], + current: radioItem, + savedPlayIndex: 0, + clear: false, + volume: 1, + } + const result = playerReducer(state, { + type: EVENT_RADIO_NOW_PLAYING, + data: { radioId: 'rd-2', title: 'Other Station - Title' }, + }) + + expect(result).toBe(state) + }) + + it('clears live title metadata when the title is empty', () => { + const liveRadio = { + ...radioItem, + radioTitle: 'Old Title', + song: { ...radioItem.song, radioTitle: 'Old Title' }, + } + const state = { + queue: [liveRadio], + current: liveRadio, + savedPlayIndex: 0, + clear: false, + volume: 1, + } + const result = playerReducer(state, { + type: EVENT_RADIO_NOW_PLAYING, + data: { radioId: 'rd-1', title: '' }, + }) + + expect(result.current.radioTitle).toBeUndefined() + expect(result.current.song.radioTitle).toBeUndefined() + expect(result.current.song.title).toBe('Test Station') + expect(result.current.musicSrc).toBe('https://stream.example.test/radio') + }) + + it('updates the queued active radio before current is set', () => { + const state = { + queue: [radioItem], + current: {}, + savedPlayIndex: 0, + clear: false, + volume: 1, + } + const result = playerReducer(state, { + type: EVENT_RADIO_NOW_PLAYING, + data: { radioId: 'rd-1', title: 'Artist - Title' }, + }) + + expect(result.current).toEqual({}) + expect(result.queue[0].radioTitle).toBe('Artist - Title') + }) + }) }) From b0675e076135b0db66c83c9d11cc0eddcfae5434 Mon Sep 17 00:00:00 2001 From: Yuko <11803292+hiyukoim@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:50:35 +0200 Subject: [PATCH 07/13] fix(radio): keep radio metadata events alive Signed-off-by: hiyukoim <11803292+hiyukoim@users.noreply.github.com> --- core/radio/metadata_manager.go | 2 +- core/radio/metadata_manager_test.go | 33 ++++++++++++++++++++++++++++- server/events/events.go | 7 +++++- server/events/events_test.go | 21 ++++++++++++------ server/events/sse.go | 9 ++++++++ server/events/sse_test.go | 12 +++++++++++ 6 files changed, 75 insertions(+), 9 deletions(-) diff --git a/core/radio/metadata_manager.go b/core/radio/metadata_manager.go index f4cefe1cd..e28056a07 100644 --- a/core/radio/metadata_manager.go +++ b/core/radio/metadata_manager.go @@ -108,7 +108,7 @@ func (m *MetadataManager) Start(ctx context.Context, sessionID string, station S reader := m.readers[station.StreamURL] if reader == nil { - readerCtx, cancel := context.WithCancel(ctx) + readerCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) reader = &activeReader{ streamURL: station.StreamURL, ctx: readerCtx, diff --git a/core/radio/metadata_manager_test.go b/core/radio/metadata_manager_test.go index 6b1112143..a3852a5a5 100644 --- a/core/radio/metadata_manager_test.go +++ b/core/radio/metadata_manager_test.go @@ -11,6 +11,8 @@ import ( ) var _ = Describe("MetadataManager", func() { + type contextKey string + var ( reader *fakeStreamReader publisher *fakePublisher @@ -39,6 +41,27 @@ var _ = Describe("MetadataManager", func() { Expect(reader.URLs()).To(Equal([]string{"https://stream.example.test/radio"})) }) + It("keeps a reader alive after the start request context is cancelled", func() { + ctx, cancel := context.WithCancel(context.WithValue(context.Background(), contextKey("user"), "janedoe")) + Expect(manager.Start(ctx, "session-1", Station{ID: "rd-1", StreamURL: "https://stream.example.test/radio"})).To(Succeed()) + Eventually(reader.StartCount).Should(Equal(1)) + + cancel() + + Consistently(reader.CancelCount).Should(Equal(0)) + reader.Emit("Live Artist - Live Track") + Eventually(func() any { + contexts := publisher.Contexts() + if len(contexts) == 0 { + return nil + } + return contexts[0].Value(contextKey("user")) + }).Should(Equal("janedoe")) + + manager.Stop("session-1") + Eventually(reader.CancelCount).Should(Equal(1)) + }) + It("shares one reader for sessions using the same stream URL", func() { Expect(manager.Start(context.Background(), "session-1", Station{ID: "rd-1", StreamURL: "https://stream.example.test/radio"})).To(Succeed()) Expect(manager.Start(context.Background(), "session-2", Station{ID: "rd-1", StreamURL: "https://stream.example.test/radio"})).To(Succeed()) @@ -212,16 +235,18 @@ func (r *fakeStreamReader) URLs() []string { type fakePublisher struct { mu sync.Mutex updates []TitleUpdate + ctxs []context.Context } func newFakePublisher() *fakePublisher { return &fakePublisher{} } -func (p *fakePublisher) Publish(_ context.Context, update TitleUpdate) { +func (p *fakePublisher) Publish(ctx context.Context, update TitleUpdate) { p.mu.Lock() defer p.mu.Unlock() p.updates = append(p.updates, update) + p.ctxs = append(p.ctxs, ctx) } func (p *fakePublisher) Updates() []TitleUpdate { @@ -229,3 +254,9 @@ func (p *fakePublisher) Updates() []TitleUpdate { defer p.mu.Unlock() return append([]TitleUpdate(nil), p.updates...) } + +func (p *fakePublisher) Contexts() []context.Context { + p.mu.Lock() + defer p.mu.Unlock() + return append([]context.Context(nil), p.ctxs...) +} diff --git a/server/events/events.go b/server/events/events.go index 2a6edf103..d0962c59d 100644 --- a/server/events/events.go +++ b/server/events/events.go @@ -14,12 +14,17 @@ import ( type eventCtxKey string const broadcastToAllKey eventCtxKey = "broadcastToAll" +const sendToSameUserKey eventCtxKey = "sendToSameUser" // broadcastToAll is a context key that can be used to broadcast an event to all clients func broadcastToAll(ctx context.Context) context.Context { return context.WithValue(ctx, broadcastToAllKey, true) } +func sendToSameUser(ctx context.Context) context.Context { + return context.WithValue(ctx, sendToSameUserKey, true) +} + type Event interface { Name(Event) string Data(Event) string @@ -82,7 +87,7 @@ func NewRadioMetadataPublisher(broker Broker) coreradio.TitlePublisher { if broker == nil { return } - broker.SendMessage(ctx, &RadioNowPlaying{ + broker.SendMessage(sendToSameUser(ctx), &RadioNowPlaying{ RadioID: update.RadioID, Title: update.Title, UpdatedAt: update.UpdatedAt, diff --git a/server/events/events_test.go b/server/events/events_test.go index 0809a57ad..986b597f3 100644 --- a/server/events/events_test.go +++ b/server/events/events_test.go @@ -6,6 +6,7 @@ import ( "time" coreradio "github.com/navidrome/navidrome/core/radio" + "github.com/navidrome/navidrome/model/request" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -40,24 +41,32 @@ var _ = Describe("Events", func() { }) It("publishes metadata updates through the broker", func() { - ctx := context.Background() + ctx := request.WithClientUniqueId(request.WithUsername(context.Background(), "janedoe"), "browser-1") updatedAt := time.Date(2026, time.June, 30, 12, 34, 56, 0, time.UTC) - broker := &capturingBroker{} + capture := &capturingBroker{} - publish := NewRadioMetadataPublisher(broker) + publish := NewRadioMetadataPublisher(capture) publish(ctx, coreradio.TitleUpdate{ RadioID: "rd-1", Title: "Artist - Title", UpdatedAt: updatedAt, }) - Expect(broker.ctx).To(Equal(ctx)) - Expect(broker.broadcast).To(BeNil()) - Expect(broker.event).To(Equal(&RadioNowPlaying{ + username, hasUsername := request.UsernameFrom(capture.ctx) + clientID, hasClientID := request.ClientUniqueIdFrom(capture.ctx) + Expect(hasUsername).To(BeTrue()) + Expect(username).To(Equal("janedoe")) + Expect(hasClientID).To(BeTrue()) + Expect(clientID).To(Equal("browser-1")) + Expect(capture.broadcast).To(BeNil()) + Expect(capture.event).To(Equal(&RadioNowPlaying{ RadioID: "rd-1", Title: "Artist - Title", UpdatedAt: updatedAt, })) + routingBroker := broker{} + Expect(routingBroker.shouldSend(message{senderCtx: capture.ctx}, client{username: "janedoe", clientUniqueId: "browser-1"})).To(BeTrue()) + Expect(routingBroker.shouldSend(message{senderCtx: capture.ctx}, client{username: "johndoe", clientUniqueId: "browser-2"})).To(BeFalse()) }) }) diff --git a/server/events/sse.go b/server/events/sse.go index 565d8c016..49b9638b2 100644 --- a/server/events/sse.go +++ b/server/events/sse.go @@ -197,6 +197,15 @@ func (b *broker) shouldSend(msg message, c client) bool { if broadcastToAll, ok := msg.senderCtx.Value(broadcastToAllKey).(bool); ok && broadcastToAll { return true } + if sendToSameUser, ok := msg.senderCtx.Value(sendToSameUserKey).(bool); ok && sendToSameUser { + if username, ok := request.UsernameFrom(msg.senderCtx); ok { + return username == c.username + } + if user, ok := request.UserFrom(msg.senderCtx); ok { + return user.UserName == c.username + } + return true + } clientUniqueId, originatedFromClient := request.ClientUniqueIdFrom(msg.senderCtx) if !originatedFromClient { return true diff --git a/server/events/sse_test.go b/server/events/sse_test.go index e6a44ca15..9656e4d3d 100644 --- a/server/events/sse_test.go +++ b/server/events/sse_test.go @@ -38,6 +38,18 @@ var _ = Describe("Broker", func() { m := message{senderCtx: ctx} Expect(b.shouldSend(m, c)).To(BeFalse()) }) + It("sends same-user message for same username and same clientUniqueId", func() { + ctx = request.WithClientUniqueId(ctx, "1111") + ctx = request.WithUsername(ctx, "janedoe") + m := message{senderCtx: sendToSameUser(ctx)} + Expect(b.shouldSend(m, c)).To(BeTrue()) + }) + It("does not send same-user message for a different username", func() { + ctx = request.WithClientUniqueId(ctx, "1111") + ctx = request.WithUsername(ctx, "johndoe") + m := message{senderCtx: sendToSameUser(ctx)} + Expect(b.shouldSend(m, c)).To(BeFalse()) + }) It("does not send message for different username", func() { ctx = request.WithClientUniqueId(ctx, "3333") ctx = request.WithUsername(ctx, "johndoe") From 733fab0fcb3f9c30522bf9534926a385edecd30d Mon Sep 17 00:00:00 2001 From: hiyukoim <11803292+hiyukoim@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:52:44 +0200 Subject: [PATCH 08/13] fix(ui): read live radio title from redux in player title The react-jinke-music-player library renders the title from its own internal snapshot of the queue item, copied when playback starts, so radioNowPlaying updates applied to the redux queue never became visible. AudioTitle now subscribes to state.player.current.radioTitle directly, guarded by isRadio and a trackId match, falling back to the previous behaviour when no live title exists. Fixes the visible half of #6; verified against real ICY stations. Co-Authored-By: Claude Fable 5 Signed-off-by: hiyukoim <11803292+hiyukoim@users.noreply.github.com> --- ui/src/audioplayer/AudioTitle.jsx | 14 ++++- ui/src/audioplayer/AudioTitle.test.jsx | 78 +++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/ui/src/audioplayer/AudioTitle.jsx b/ui/src/audioplayer/AudioTitle.jsx index 20eb0061c..575f5c534 100644 --- a/ui/src/audioplayer/AudioTitle.jsx +++ b/ui/src/audioplayer/AudioTitle.jsx @@ -1,6 +1,7 @@ import React from 'react' import { useMediaQuery } from '@material-ui/core' import { Link } from 'react-router-dom' +import { useSelector } from 'react-redux' import clsx from 'clsx' import { QualityInfo } from '../common' import { decisionService } from '../transcode' @@ -23,6 +24,16 @@ const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => { [song], ) + // The react-jinke-music-player library keeps its own internal snapshot of + // the queue item (audioInfo), copied when playback starts. Redux updates + // to state.player.current never reach that snapshot, so we read the live + // radio title from redux directly rather than relying on audioInfo alone. + const liveRadioTitle = useSelector((state) => + audioInfo.isRadio && state.player?.current?.trackId === audioInfo.trackId + ? state.player.current.radioTitle + : undefined, + ) + if (!song) { return '' } @@ -45,7 +56,8 @@ const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => { : {} const subtitle = song.tags?.['subtitle'] - const displayTitle = audioInfo.radioTitle || song.radioTitle || song.title + const displayTitle = + liveRadioTitle || audioInfo.radioTitle || song.radioTitle || song.title const title = displayTitle + (subtitle ? ` (${subtitle})` : '') const linkTo = audioInfo.isRadio diff --git a/ui/src/audioplayer/AudioTitle.test.jsx b/ui/src/audioplayer/AudioTitle.test.jsx index 37a0918ab..cca732768 100644 --- a/ui/src/audioplayer/AudioTitle.test.jsx +++ b/ui/src/audioplayer/AudioTitle.test.jsx @@ -1,6 +1,8 @@ import React from 'react' import { render, screen } from '@testing-library/react' import { describe, it, expect, beforeEach, vi } from 'vitest' +import { Provider } from 'react-redux' +import { createStore } from 'redux' import AudioTitle from './AudioTitle' vi.mock('@material-ui/core', async () => { @@ -24,6 +26,11 @@ vi.mock('react-dnd', () => ({ useDrag: vi.fn(() => [null, () => {}]), })) +const renderWithStore = (ui, playerState = {}) => { + const store = createStore(() => ({ player: playerState })) + return render({ui}) +} + describe('', () => { const baseSong = { id: 'song-1', @@ -41,7 +48,9 @@ describe('', () => { it('links to playlist when playlistId is provided', () => { const audioInfo = { trackId: 'track-1', song: baseSong } - render() + renderWithStore( + , + ) const link = screen.getByRole('link') expect(link.getAttribute('href')).toBe('/playlist/playlist-1/show') }) @@ -51,7 +60,9 @@ describe('', () => { trackId: 'track-1', song: { ...baseSong, playlistId: undefined }, } - render() + renderWithStore( + , + ) const link = screen.getByRole('link') expect(link.getAttribute('href')).toBe('/album/album-1/show') }) @@ -68,11 +79,72 @@ describe('', () => { album: 'https://stream.example.test/radio', }, } - render() + renderWithStore( + , + ) expect(screen.getByText('Artist - Title')).toBeInTheDocument() expect(screen.getByRole('link').getAttribute('href')).toBe( '/radio/rd-1/show', ) }) + + it('prefers the live redux title over the stale player-library snapshot', () => { + const audioInfo = { + trackId: 'radio-1', + isRadio: true, + // Note: no radioTitle here — this simulates react-jinke-music-player's + // stale internal snapshot, which never receives redux updates. + song: { + id: 'radio-1', + title: 'Station Name', + artist: 'Station Name', + album: 'https://stream.example.test/radio', + }, + } + renderWithStore( + , + { + current: { + isRadio: true, + trackId: 'radio-1', + radioTitle: 'Live Song - Artist', + }, + }, + ) + + expect(screen.getByText('Live Song - Artist')).toBeInTheDocument() + expect(screen.getByRole('link').getAttribute('href')).toBe( + '/radio/radio-1/show', + ) + }) + + it('falls back to the station name when redux current is a different radio', () => { + const audioInfo = { + trackId: 'radio-1', + isRadio: true, + song: { + id: 'radio-1', + title: 'Station Name', + // Artist deliberately differs from title so getByText stays unambiguous + artist: 'Station Artist', + album: 'https://stream.example.test/radio', + }, + } + renderWithStore( + , + { + current: { + isRadio: true, + trackId: 'radio-2', + radioTitle: 'Some Other Live Title', + }, + }, + ) + + expect(screen.getByText('Station Name')).toBeInTheDocument() + expect( + screen.queryByText('Some Other Live Title'), + ).not.toBeInTheDocument() + }) }) From cadce143838f3b43df2a23fb8be21a9433898c88 Mon Sep 17 00:00:00 2001 From: hiyukoim <11803292+hiyukoim@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:33:35 +0200 Subject: [PATCH 09/13] fix(radio): harden now-playing sessions and titles Expire idle now-playing sessions after a TTL (refreshed by the player every few minutes) so closed clients cannot leak ICY readers; bound ICY connection setup with transport timeouts; stop titles leaking between stations on switch; identify the ICY reader with the real Navidrome version. Signed-off-by: hiyukoim <11803292+hiyukoim@users.noreply.github.com> --- core/radio/icy/reader.go | 3 + core/radio/metadata_manager.go | 108 +++++++++++++++++++++---- core/radio/metadata_manager_test.go | 36 +++++++++ core/radio/provider.go | 17 +++- ui/src/audioplayer/AudioTitle.jsx | 33 ++++++-- ui/src/audioplayer/AudioTitle.test.jsx | 33 +++++++- ui/src/audioplayer/Player.jsx | 53 ++++++++---- ui/src/dataProvider/httpClient.js | 49 ++++++++--- ui/src/dataProvider/index.js | 8 +- ui/src/reducers/playerReducer.js | 20 ++++- ui/src/reducers/playerReducer.test.js | 58 +++++++++++++ 11 files changed, 362 insertions(+), 56 deletions(-) diff --git a/core/radio/icy/reader.go b/core/radio/icy/reader.go index c2a88622f..e640dd116 100644 --- a/core/radio/icy/reader.go +++ b/core/radio/icy/reader.go @@ -6,6 +6,8 @@ import ( "fmt" "net/http" "strconv" + + "github.com/navidrome/navidrome/consts" ) var ErrMissingMetaInt = errors.New("missing icy metadata interval") @@ -30,6 +32,7 @@ func ReadHTTPStreamTitles(ctx context.Context, client *http.Client, streamURL st return err } req.Header.Set("Icy-MetaData", "1") + req.Header.Set("User-Agent", consts.HTTPUserAgent) resp, err := client.Do(req) if err != nil { diff --git a/core/radio/metadata_manager.go b/core/radio/metadata_manager.go index e28056a07..e4e6842e9 100644 --- a/core/radio/metadata_manager.go +++ b/core/radio/metadata_manager.go @@ -5,6 +5,8 @@ import ( "errors" "sync" "time" + + "github.com/navidrome/navidrome/log" ) var ( @@ -12,6 +14,11 @@ var ( ErrInvalidStation = errors.New("invalid radio metadata station") ) +// defaultSessionTTL bounds how long a session survives without a refresh, so +// clients that vanish without calling Stop (e.g. a closed browser tab) do not +// keep an ICY reader connected to the station forever. +const defaultSessionTTL = 10 * time.Minute + type Station struct { ID string StreamURL string @@ -30,10 +37,11 @@ type TitlePublisher func(ctx context.Context, update TitleUpdate) type MetadataManager struct { mu sync.Mutex - reader StreamReader - publish TitlePublisher - now func() time.Time - backoff func(attempt int) time.Duration + reader StreamReader + publish TitlePublisher + now func() time.Time + backoff func(attempt int) time.Duration + sessionTTL time.Duration sessions map[string]activeSession readers map[string]*activeReader @@ -42,8 +50,11 @@ type MetadataManager struct { type ManagerOption func(*MetadataManager) type activeSession struct { - radioID string - streamURL string + radioID string + streamURL string + notifyCtx context.Context + expiresAt time.Time + expireTimer *time.Timer } type activeReader struct { @@ -56,12 +67,13 @@ type activeReader struct { func NewMetadataManager(reader StreamReader, publisher TitlePublisher, opts ...ManagerOption) *MetadataManager { m := &MetadataManager{ - reader: reader, - publish: publisher, - now: time.Now, - backoff: defaultBackoff, - sessions: map[string]activeSession{}, - readers: map[string]*activeReader{}, + reader: reader, + publish: publisher, + now: time.Now, + backoff: defaultBackoff, + sessionTTL: defaultSessionTTL, + sessions: map[string]activeSession{}, + readers: map[string]*activeReader{}, } for _, opt := range opts { opt(m) @@ -85,6 +97,14 @@ func WithRetryBackoff(backoff func(attempt int) time.Duration) ManagerOption { } } +// WithSessionTTL sets how long a session lives without a refreshing Start +// call. A zero or negative TTL disables expiry. +func WithSessionTTL(ttl time.Duration) ManagerOption { + return func(m *MetadataManager) { + m.sessionTTL = ttl + } +} + func (m *MetadataManager) Start(ctx context.Context, sessionID string, station Station) error { if sessionID == "" { return ErrInvalidSession @@ -101,6 +121,7 @@ func (m *MetadataManager) Start(ctx context.Context, sessionID string, station S if current, ok := m.sessions[sessionID]; ok { if current.radioID == station.ID && current.streamURL == station.StreamURL { + m.touchSessionLocked(sessionID, current) return nil } m.removeSessionLocked(sessionID) @@ -120,10 +141,44 @@ func (m *MetadataManager) Start(ctx context.Context, sessionID string, station S } reader.radioRefs[station.ID]++ - m.sessions[sessionID] = activeSession{radioID: station.ID, streamURL: station.StreamURL} + session := activeSession{ + radioID: station.ID, + streamURL: station.StreamURL, + notifyCtx: context.WithoutCancel(ctx), + } + if m.sessionTTL > 0 { + session.expiresAt = m.now().Add(m.sessionTTL) + session.expireTimer = time.AfterFunc(m.sessionTTL, func() { m.expireSession(sessionID) }) + } + m.sessions[sessionID] = session return nil } +// touchSessionLocked extends the session deadline. The pending expiry timer +// reschedules itself when it fires before the new deadline. +func (m *MetadataManager) touchSessionLocked(sessionID string, session activeSession) { + if m.sessionTTL <= 0 || session.expireTimer == nil { + return + } + session.expiresAt = m.now().Add(m.sessionTTL) + m.sessions[sessionID] = session +} + +func (m *MetadataManager) expireSession(sessionID string) { + m.mu.Lock() + defer m.mu.Unlock() + session, ok := m.sessions[sessionID] + if !ok || session.expireTimer == nil { + return + } + if remaining := session.expiresAt.Sub(m.now()); remaining > 0 { + session.expireTimer.Reset(remaining) + return + } + log.Debug("Radio metadata session expired without refresh", "sessionID", sessionID, "radioID", session.radioID) + m.removeSessionLocked(sessionID) +} + func (m *MetadataManager) Stop(sessionID string) { m.mu.Lock() defer m.mu.Unlock() @@ -136,6 +191,9 @@ func (m *MetadataManager) removeSessionLocked(sessionID string) { return } delete(m.sessions, sessionID) + if session.expireTimer != nil { + session.expireTimer.Stop() + } reader := m.readers[session.streamURL] if reader == nil { @@ -167,6 +225,7 @@ func (m *MetadataManager) runReader(reader *activeReader) { attempt = 0 } else { attempt++ + log.Trace("Radio metadata reader retrying", "streamURL", reader.streamURL, "attempt", attempt, err) } delay := m.backoff(attempt) @@ -198,14 +257,31 @@ func (m *MetadataManager) publishTitle(reader *activeReader, title string) { } updatedAt := m.now() publish := m.publish + // Copy matching session contexts while the lock is held. + type sessionNotify struct { + radioID string + notifyCtx context.Context + } + notifies := make([]sessionNotify, 0, len(m.sessions)) + for _, session := range m.sessions { + if session.streamURL != reader.streamURL { + continue + } + for _, radioID := range radioIDs { + if session.radioID == radioID { + notifies = append(notifies, sessionNotify{radioID: radioID, notifyCtx: session.notifyCtx}) + break + } + } + } m.mu.Unlock() if publish == nil { return } - for _, radioID := range radioIDs { - publish(reader.ctx, TitleUpdate{ - RadioID: radioID, + for _, target := range notifies { + publish(target.notifyCtx, TitleUpdate{ + RadioID: target.radioID, Title: title, UpdatedAt: updatedAt, }) diff --git a/core/radio/metadata_manager_test.go b/core/radio/metadata_manager_test.go index a3852a5a5..da217853d 100644 --- a/core/radio/metadata_manager_test.go +++ b/core/radio/metadata_manager_test.go @@ -154,6 +154,42 @@ var _ = Describe("MetadataManager", func() { Expect(manager.Start(context.Background(), "session-1", Station{ID: "", StreamURL: "https://stream.example.test/radio"})).To(MatchError(ErrInvalidStation)) Expect(manager.Start(context.Background(), "session-1", Station{ID: "rd-1", StreamURL: ""})).To(MatchError(ErrInvalidStation)) }) + + It("expires sessions that are never refreshed", func() { + expiring := NewMetadataManager( + reader.Read, + publisher.Publish, + WithRetryBackoff(func(int) time.Duration { return 0 }), + WithSessionTTL(20*time.Millisecond), + ) + + Expect(expiring.Start(context.Background(), "session-1", Station{ + ID: "rd-1", + StreamURL: "https://stream.example.test/radio", + })).To(Succeed()) + + Eventually(reader.StartCount).Should(Equal(1)) + Eventually(reader.CancelCount).Should(Equal(1)) + }) + + It("keeps refreshed sessions alive past the TTL", func() { + expiring := NewMetadataManager( + reader.Read, + publisher.Publish, + WithRetryBackoff(func(int) time.Duration { return 0 }), + WithSessionTTL(50*time.Millisecond), + ) + station := Station{ID: "rd-1", StreamURL: "https://stream.example.test/radio"} + + Expect(expiring.Start(context.Background(), "session-1", station)).To(Succeed()) + Eventually(reader.StartCount).Should(Equal(1)) + + // Refresh the session on every poll, well inside the TTL window. + Consistently(func() int { + Expect(expiring.Start(context.Background(), "session-1", station)).To(Succeed()) + return reader.CancelCount() + }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(0)) + }) }) type fakeStreamReader struct { diff --git a/core/radio/provider.go b/core/radio/provider.go index 7fed4095a..087361e93 100644 --- a/core/radio/provider.go +++ b/core/radio/provider.go @@ -2,13 +2,28 @@ package radio import ( "context" + "net" "net/http" + "time" "github.com/navidrome/navidrome/core/radio/icy" ) +// icyHTTPClient has no overall timeout (ICY streams are long-lived), but bounds +// connection setup and header wait so a stalled station cannot hang the reader. +var icyHTTPClient = &http.Client{ + Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 30 * time.Second, + }, +} + func NewMetadataManagerService(publisher TitlePublisher) *MetadataManager { return NewMetadataManager(func(ctx context.Context, streamURL string, handleTitle func(string)) error { - return icy.ReadHTTPStreamTitles(ctx, http.DefaultClient, streamURL, handleTitle) + return icy.ReadHTTPStreamTitles(ctx, icyHTTPClient, streamURL, handleTitle) }, publisher) } diff --git a/ui/src/audioplayer/AudioTitle.jsx b/ui/src/audioplayer/AudioTitle.jsx index 575f5c534..90f2583a9 100644 --- a/ui/src/audioplayer/AudioTitle.jsx +++ b/ui/src/audioplayer/AudioTitle.jsx @@ -28,11 +28,21 @@ const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => { // the queue item (audioInfo), copied when playback starts. Redux updates // to state.player.current never reach that snapshot, so we read the live // radio title from redux directly rather than relying on audioInfo alone. - const liveRadioTitle = useSelector((state) => - audioInfo.isRadio && state.player?.current?.trackId === audioInfo.trackId - ? state.player.current.radioTitle - : undefined, - ) + const liveRadioTitle = useSelector((state) => { + if (!audioInfo.isRadio) { + return undefined + } + if ( + state.player?.current?.trackId === audioInfo.trackId && + state.player.current.radioTitle + ) { + return state.player.current.radioTitle + } + const queued = state.player.queue?.find( + (item) => item.isRadio && item.trackId === audioInfo.trackId, + ) + return queued?.radioTitle + }) if (!song) { return '' @@ -55,9 +65,18 @@ const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => { } : {} + const stationTitle = song.title const subtitle = song.tags?.['subtitle'] - const displayTitle = - liveRadioTitle || audioInfo.radioTitle || song.radioTitle || song.title + // Keep the configured station name until ICY delivers a StreamTitle. Stations that + // already embed the current song in the stream metadata show up via radioTitle; + // others stay on stationTitle until the backend reader picks up ICY. + const icyTitle = ( + liveRadioTitle || + audioInfo.radioTitle || + song.radioTitle || + '' + ).trim() + const displayTitle = icyTitle || stationTitle const title = displayTitle + (subtitle ? ` (${subtitle})` : '') const linkTo = audioInfo.isRadio diff --git a/ui/src/audioplayer/AudioTitle.test.jsx b/ui/src/audioplayer/AudioTitle.test.jsx index cca732768..f2a4b574f 100644 --- a/ui/src/audioplayer/AudioTitle.test.jsx +++ b/ui/src/audioplayer/AudioTitle.test.jsx @@ -143,8 +143,35 @@ describe('', () => { ) expect(screen.getByText('Station Name')).toBeInTheDocument() - expect( - screen.queryByText('Some Other Live Title'), - ).not.toBeInTheDocument() + expect(screen.queryByText('Some Other Live Title')).not.toBeInTheDocument() + }) + + it('shows live title from the queue when current is not set yet', () => { + const audioInfo = { + trackId: 'radio-1', + isRadio: true, + song: { + id: 'radio-1', + title: 'Station Name', + artist: 'Station Artist', + album: 'https://stream.example.test/radio', + }, + } + renderWithStore( + , + { + current: {}, + queue: [ + { + trackId: 'radio-1', + isRadio: true, + radioTitle: 'Queued Live Title', + song: audioInfo.song, + }, + ], + }, + ) + + expect(screen.getByText('Queued Live Title')).toBeInTheDocument() }) }) diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index 91657d5ed..91d7524a1 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -34,7 +34,7 @@ import { keyMap } from '../hotkeys' import keyHandlers from './keyHandlers' import { calculateGain } from '../utils/calculateReplayGain' import { detectBrowserProfile, decisionService } from '../transcode' -import { httpClient } from '../dataProvider' +import { httpVoid } from '../dataProvider' import { REST_URL } from '../consts' const radioNowPlayingURL = (radioId) => @@ -44,9 +44,13 @@ const requestRadioNowPlaying = (radioId, method) => { if (!radioId) { return Promise.resolve() } - return httpClient(radioNowPlayingURL(radioId), { method }).catch(() => {}) + return httpVoid(radioNowPlayingURL(radioId), { method }).catch(() => {}) } +// Server-side now-playing sessions expire after an idle TTL; re-POST while a +// radio session is active so long listening sessions keep their live titles. +const radioSessionRefreshMs = 4 * 60 * 1000 + const Player = () => { const theme = useCurrentTheme() const translate = useTranslate() @@ -59,6 +63,7 @@ const Player = () => { const lastPositionMsRef = useRef(0) const currentTrackIdRef = useRef(null) const currentRadioIdRef = useRef(null) + const [activeRadioId, setActiveRadioId] = useState(null) const stoppedRef = useRef(false) const [audioInstance, setAudioInstance] = useState(null) const isDesktop = useMediaQuery('(min-width:810px)') @@ -84,6 +89,7 @@ const Player = () => { requestRadioNowPlaying(radioId, 'DELETE') if (currentRadioIdRef.current === radioId) { currentRadioIdRef.current = null + setActiveRadioId(null) } }, [], @@ -97,9 +103,19 @@ const Player = () => { requestRadioNowPlaying(currentRadioIdRef.current, 'DELETE') } currentRadioIdRef.current = radioId + setActiveRadioId(radioId) requestRadioNowPlaying(radioId, 'POST') }, []) + useInterval( + () => { + if (currentRadioIdRef.current) { + requestRadioNowPlaying(currentRadioIdRef.current, 'POST') + } + }, + activeRadioId ? radioSessionRefreshMs : null, + ) + useInterval( () => { if (heartbeatTrackId && !stoppedRef.current) { @@ -380,20 +396,25 @@ const Player = () => { ], ) - const onAudioPlayTrackChange = useCallback(() => { - if (currentRadioIdRef.current) { - stopRadioNowPlaying() - } - if (currentTrackId) { - subsonic.reportPlayback( - currentTrackId, - lastPositionMsRef.current, - 'stopped', - ) - } - setHeartbeatTrackId(null) - setCurrentTrackId(null) - }, [currentTrackId, stopRadioNowPlaying]) + const onAudioPlayTrackChange = useCallback( + (playId, audioLists, audioInfo) => { + // Switching to another radio is handled in onAudioPlay; avoid stopping the + // session here or the ICY reader is torn down before playback resumes. + if (!audioInfo?.isRadio && currentRadioIdRef.current) { + stopRadioNowPlaying() + } + if (currentTrackId) { + subsonic.reportPlayback( + currentTrackId, + lastPositionMsRef.current, + 'stopped', + ) + } + setHeartbeatTrackId(null) + setCurrentTrackId(null) + }, + [currentTrackId, stopRadioNowPlaying], + ) const onAudioPause = useCallback( (info) => { diff --git a/ui/src/dataProvider/httpClient.js b/ui/src/dataProvider/httpClient.js index 3820f658d..aa203c3a7 100644 --- a/ui/src/dataProvider/httpClient.js +++ b/ui/src/dataProvider/httpClient.js @@ -5,25 +5,54 @@ import config from '../config' import { jwtDecode } from 'jwt-decode' import { removeHomeCache } from '../utils/removeHomeCache' -const customAuthorizationHeader = 'X-ND-Authorization' +export const customAuthorizationHeader = 'X-ND-Authorization' export const clientUniqueIdHeader = 'X-ND-Client-Unique-Id' export const clientUniqueId = uuidv4() +const applyAuthHeaders = (headers) => { + headers.set(clientUniqueIdHeader, clientUniqueId) + const token = localStorage.getItem('token') + if (token) { + headers.set(customAuthorizationHeader, `Bearer ${token}`) + } + return headers +} + +// Endpoints that return 204 No Content (e.g. radio now-playing) must not use fetchJson. +export const httpVoid = (url, options = {}) => { + url = baseUrl(url) + const headers = applyAuthHeaders( + options.headers instanceof Headers + ? options.headers + : new Headers({ Accept: 'application/json', ...options.headers }), + ) + return fetch(url, { ...options, headers }).then((response) => { + const refreshedToken = response.headers.get(customAuthorizationHeader) + if (refreshedToken) { + const decoded = jwtDecode(refreshedToken) + localStorage.setItem('token', refreshedToken) + localStorage.setItem('userId', decoded.uid) + config.firstTime = false + removeHomeCache() + } + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`) + } + return response + }) +} + const httpClient = (url, options = {}) => { url = baseUrl(url) if (!options.headers) { options.headers = new Headers({ Accept: 'application/json' }) } - options.headers.set(clientUniqueIdHeader, clientUniqueId) - const token = localStorage.getItem('token') - if (token) { - options.headers.set(customAuthorizationHeader, `Bearer ${token}`) - } + applyAuthHeaders(options.headers) return fetchUtils.fetchJson(url, options).then((response) => { - const token = response.headers.get(customAuthorizationHeader) - if (token) { - const decoded = jwtDecode(token) - localStorage.setItem('token', token) + const refreshedToken = response.headers.get(customAuthorizationHeader) + if (refreshedToken) { + const decoded = jwtDecode(refreshedToken) + localStorage.setItem('token', refreshedToken) localStorage.setItem('userId', decoded.uid) // Avoid going to create admin dialog after logout/login without a refresh config.firstTime = false diff --git a/ui/src/dataProvider/index.js b/ui/src/dataProvider/index.js index 059023378..208484239 100644 --- a/ui/src/dataProvider/index.js +++ b/ui/src/dataProvider/index.js @@ -1,6 +1,10 @@ -import httpClient, { clientUniqueId, clientUniqueIdHeader } from './httpClient' +import httpClient, { + clientUniqueId, + clientUniqueIdHeader, + httpVoid, +} from './httpClient' import wrapperDataProvider from './wrapperDataProvider' -export { httpClient, clientUniqueId, clientUniqueIdHeader } +export { httpClient, httpVoid, clientUniqueId, clientUniqueIdHeader } export default wrapperDataProvider diff --git a/ui/src/reducers/playerReducer.js b/ui/src/reducers/playerReducer.js index ad8662bc6..2fd142ccb 100644 --- a/ui/src/reducers/playerReducer.js +++ b/ui/src/reducers/playerReducer.js @@ -183,10 +183,28 @@ const reduceSyncQueue = (state, { data: { audioInfo, audioLists } }) => { } const reduceCurrent = (state, { data }) => { - const current = data.ended ? {} : data + let current = data.ended ? {} : data const savedPlayIndex = state.queue.findIndex( (item) => item.uuid === current.uuid, ) + // Preserve live radio titles when the music player snapshot omits them. + if (current.isRadio && current.trackId) { + const queued = state.queue.find( + (item) => + (current.uuid && item.uuid === current.uuid) || + item.trackId === current.trackId, + ) + // Only fall back to the previous current item when it is the same track, + // otherwise switching stations would inherit the old station's title. + const previousTitle = + state.current?.trackId === current.trackId + ? state.current?.radioTitle + : undefined + const liveTitle = queued?.radioTitle ?? previousTitle + if (liveTitle && !current.radioTitle) { + current = applyRadioTitle(current, liveTitle) + } + } // When a track selection is pending (playIndex is set), keep it alive // until the music player confirms it actually switched to the requested // track. Without this, a premature onAudioPlay callback for the diff --git a/ui/src/reducers/playerReducer.test.js b/ui/src/reducers/playerReducer.test.js index 12a2c1a86..1cda4be9e 100644 --- a/ui/src/reducers/playerReducer.test.js +++ b/ui/src/reducers/playerReducer.test.js @@ -323,5 +323,63 @@ describe('playerReducer', () => { expect(result.current).toEqual({}) expect(result.queue[0].radioTitle).toBe('Artist - Title') }) + + it('preserves live radio title when the player snapshot omits it', () => { + const liveRadio = { + ...radioItem, + radioTitle: 'Artist - Title', + song: { ...radioItem.song, radioTitle: 'Artist - Title' }, + } + const state = { + queue: [liveRadio], + current: {}, + savedPlayIndex: 0, + clear: false, + volume: 1, + } + const result = playerReducer(state, { + type: PLAYER_CURRENT, + data: { ...radioItem }, + }) + + expect(result.current.radioTitle).toBe('Artist - Title') + expect(result.current.song.radioTitle).toBe('Artist - Title') + }) + + it('does not inherit the previous station title when switching stations', () => { + const previousStation = { + ...radioItem, + radioTitle: 'Old Station Song', + song: { ...radioItem.song, radioTitle: 'Old Station Song' }, + } + const nextStation = { + trackId: 'rd-2', + uuid: 'radio-uuid-2', + name: 'Other Station', + musicSrc: 'https://stream.example.test/other', + isRadio: true, + song: { + id: 'rd-2', + title: 'Other Station', + artist: 'Other Station', + album: 'https://stream.example.test/other', + streamUrl: 'https://stream.example.test/other', + }, + } + const state = { + queue: [previousStation, nextStation], + current: previousStation, + savedPlayIndex: 0, + clear: false, + volume: 1, + } + const result = playerReducer(state, { + type: PLAYER_CURRENT, + data: { ...nextStation }, + }) + + expect(result.current.radioTitle).toBeUndefined() + expect(result.current.song.title).toBe('Other Station') + }) }) }) From 51997d2d1f7bae4a99953b0c39a9bc8cb7a4db6c Mon Sep 17 00:00:00 2001 From: hiyukoim <11803292+hiyukoim@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:36:54 +0200 Subject: [PATCH 10/13] test(radio): disable sharing in now-playing endpoint tests Upstream enables sharing by default; mirror the other nativeapi suites so the router constructs without a share repository. Signed-off-by: hiyukoim <11803292+hiyukoim@users.noreply.github.com> --- server/nativeapi/radio_nowplaying_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/server/nativeapi/radio_nowplaying_test.go b/server/nativeapi/radio_nowplaying_test.go index 0b7faec10..e3b2afd97 100644 --- a/server/nativeapi/radio_nowplaying_test.go +++ b/server/nativeapi/radio_nowplaying_test.go @@ -33,6 +33,7 @@ var _ = Describe("Radio now-playing metadata endpoints", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) conf.Server.SessionTimeout = time.Minute + conf.Server.EnableSharing = false radioRepo = &tests.MockedRadioRepo{ Data: map[string]*model.Radio{ From 989d15074a529d1bba690c3d356d53eb8a11232d Mon Sep 17 00:00:00 2001 From: hiyukoim <11803292+hiyukoim@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:24:42 +0200 Subject: [PATCH 11/13] fix(radio): expire idle ICY connections Pooled keep-alive connections from failed ICY attempts lingered until the station closed them (observed live against zeno.fm); bound the idle pool. Signed-off-by: hiyukoim <11803292+hiyukoim@users.noreply.github.com> --- core/radio/provider.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/radio/provider.go b/core/radio/provider.go index 087361e93..b440545c2 100644 --- a/core/radio/provider.go +++ b/core/radio/provider.go @@ -19,6 +19,8 @@ var icyHTTPClient = &http.Client{ }).DialContext, TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 30 * time.Second, + IdleConnTimeout: 60 * time.Second, + MaxIdleConnsPerHost: 1, }, } From fd3c3b2be2a431b4fd6c4b12e32622b384424e05 Mon Sep 17 00:00:00 2001 From: hiyukoim <11803292+hiyukoim@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:26:13 +0200 Subject: [PATCH 12/13] feat(radio): back off long on permanent ICY errors Dead stream URLs (4xx) and stations without icy-metaint retried every 30s for the whole session; classify them permanent and retry every 10 minutes instead. Signed-off-by: hiyukoim <11803292+hiyukoim@users.noreply.github.com> --- core/radio/metadata_manager.go | 26 ++++++++++++++++++++++++++ core/radio/metadata_manager_test.go | 19 +++++++++++++++++++ core/radio/provider.go | 19 ++++++++++++++++++- 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/core/radio/metadata_manager.go b/core/radio/metadata_manager.go index e4e6842e9..4b4ed9613 100644 --- a/core/radio/metadata_manager.go +++ b/core/radio/metadata_manager.go @@ -19,6 +19,29 @@ var ( // keep an ICY reader connected to the station forever. const defaultSessionTTL = 10 * time.Minute +// permanentRetryDelay is used instead of the regular backoff when the reader +// reports a permanent error (dead URL, station without ICY metadata), so we +// do not hammer a stream that will never yield titles. +const permanentRetryDelay = 10 * time.Minute + +type permanentError struct{ err error } + +func (e *permanentError) Error() string { return e.err.Error() } +func (e *permanentError) Unwrap() error { return e.err } + +// MarkPermanent wraps err so the retry loop switches to permanentRetryDelay. +func MarkPermanent(err error) error { + if err == nil { + return nil + } + return &permanentError{err: err} +} + +func isPermanent(err error) bool { + var p *permanentError + return errors.As(err, &p) +} + type Station struct { ID string StreamURL string @@ -229,6 +252,9 @@ func (m *MetadataManager) runReader(reader *activeReader) { } delay := m.backoff(attempt) + if isPermanent(err) && delay < permanentRetryDelay { + delay = permanentRetryDelay + } if delay <= 0 { continue } diff --git a/core/radio/metadata_manager_test.go b/core/radio/metadata_manager_test.go index da217853d..b03bf1f44 100644 --- a/core/radio/metadata_manager_test.go +++ b/core/radio/metadata_manager_test.go @@ -155,6 +155,25 @@ var _ = Describe("MetadataManager", func() { Expect(manager.Start(context.Background(), "session-1", Station{ID: "rd-1", StreamURL: ""})).To(MatchError(ErrInvalidStation)) }) + It("backs off for a long time on permanent reader errors", func() { + reader.err = MarkPermanent(errors.New("missing icy metadata interval")) + slow := NewMetadataManager( + reader.Read, + publisher.Publish, + WithRetryBackoff(func(int) time.Duration { return time.Millisecond }), + ) + + Expect(slow.Start(context.Background(), "session-1", Station{ + ID: "rd-1", + StreamURL: "https://stream.example.test/radio", + })).To(Succeed()) + + Eventually(reader.StartCount).Should(Equal(1)) + // Regular backoff is 1ms; the permanent error must stretch it, so no + // second attempt happens within the observation window. + Consistently(reader.StartCount, 300*time.Millisecond, 20*time.Millisecond).Should(Equal(1)) + }) + It("expires sessions that are never refreshed", func() { expiring := NewMetadataManager( reader.Read, diff --git a/core/radio/provider.go b/core/radio/provider.go index b440545c2..f67e535fe 100644 --- a/core/radio/provider.go +++ b/core/radio/provider.go @@ -2,6 +2,7 @@ package radio import ( "context" + "errors" "net" "net/http" "time" @@ -26,6 +27,22 @@ var icyHTTPClient = &http.Client{ func NewMetadataManagerService(publisher TitlePublisher) *MetadataManager { return NewMetadataManager(func(ctx context.Context, streamURL string, handleTitle func(string)) error { - return icy.ReadHTTPStreamTitles(ctx, icyHTTPClient, streamURL, handleTitle) + return classifyICYError(icy.ReadHTTPStreamTitles(ctx, icyHTTPClient, streamURL, handleTitle)) }, publisher) } + +// classifyICYError marks errors that a retry will not fix — a stream without +// ICY metadata or a client-error HTTP status — so the reader backs off for +// much longer instead of hammering the station. +func classifyICYError(err error) error { + if errors.Is(err, icy.ErrMissingMetaInt) { + return MarkPermanent(err) + } + var statusErr *icy.ResponseStatusError + if errors.As(err, &statusErr) && + statusErr.StatusCode >= 400 && statusErr.StatusCode < 500 && + statusErr.StatusCode != http.StatusRequestTimeout && statusErr.StatusCode != http.StatusTooManyRequests { + return MarkPermanent(err) + } + return err +} From e25071f87e3d744bb19fda2ec253f9bc13c51b0f Mon Sep 17 00:00:00 2001 From: hiyukoim <11803292+hiyukoim@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:25:18 +0200 Subject: [PATCH 13/13] fix(radio): address ICY parser review feedback Signed-off-by: hiyukoim <11803292+hiyukoim@users.noreply.github.com> --- core/radio/icy/parser.go | 22 +++++++++++++++------- core/radio/icy/parser_test.go | 16 ++++++++++++++++ core/radio/provider.go | 2 +- core/radio/provider_test.go | 20 ++++++++++++++++++++ 4 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 core/radio/provider_test.go diff --git a/core/radio/icy/parser.go b/core/radio/icy/parser.go index 1ab7b0898..39371dfca 100644 --- a/core/radio/icy/parser.go +++ b/core/radio/icy/parser.go @@ -17,17 +17,22 @@ var ( errEndOfStream = errors.New("icy stream ended") ) +const ( + maxMetaInt = 1024 * 1024 + maxMetadataLen = 255 * 16 +) + // ReadStreamTitles reads ICY metadata blocks from r and emits changed StreamTitle values. func ReadStreamTitles(ctx context.Context, r io.Reader, metaInt int, handleTitle func(string)) error { - if metaInt <= 0 { + if metaInt <= 0 || metaInt > maxMetaInt { return ErrInvalidMetaInt } if handleTitle == nil { return ErrMissingTitleHandler } - audio := make([]byte, metaInt) length := make([]byte, 1) + metadataBuf := make([]byte, maxMetadataLen) lastTitle := "" for { @@ -35,8 +40,8 @@ func ReadStreamTitles(ctx context.Context, r io.Reader, metaInt int, handleTitle return err } - if err := readFullOrEnd(r, audio); err != nil { - if errors.Is(err, errEndOfStream) { + if _, err := io.CopyN(io.Discard, r, int64(metaInt)); err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { return nil } return err @@ -58,7 +63,7 @@ func ReadStreamTitles(ctx context.Context, r io.Reader, metaInt int, handleTitle continue } - metadata := make([]byte, metadataLen) + metadata := metadataBuf[:metadataLen] if err := readFullOrEnd(r, metadata); err != nil { if errors.Is(err, errEndOfStream) { return nil @@ -96,9 +101,12 @@ func streamTitle(metadata []byte) string { } start += len(prefix) - end := strings.IndexByte(text[start:], '\'') + end := strings.Index(text[start:], "';") if end < 0 { - return "" + end = strings.IndexByte(text[start:], '\'') + if end < 0 { + return "" + } } return strings.TrimSpace(text[start : start+end]) diff --git a/core/radio/icy/parser_test.go b/core/radio/icy/parser_test.go index 0170bbb96..1877c2baa 100644 --- a/core/radio/icy/parser_test.go +++ b/core/radio/icy/parser_test.go @@ -73,6 +73,22 @@ var _ = Describe("ReadStreamTitles", func() { Expect(titles).To(BeEmpty()) }) + It("returns an error for huge metadata intervals", func() { + titles, err := readTitles(context.Background(), nil, maxMetaInt+1) + + Expect(err).To(MatchError(ErrInvalidMetaInt)) + Expect(titles).To(BeEmpty()) + }) + + It("keeps single quotes inside StreamTitle values", func() { + stream := icyStream(metaInt, "StreamTitle='L'Oiseau - Track';") + + titles, err := readTitles(context.Background(), stream, metaInt) + + Expect(err).ToNot(HaveOccurred()) + Expect(titles).To(Equal([]string{"L'Oiseau - Track"})) + }) + It("stops when context is cancelled", func() { ctx, cancel := context.WithCancel(context.Background()) cancel() diff --git a/core/radio/provider.go b/core/radio/provider.go index f67e535fe..8a2558fab 100644 --- a/core/radio/provider.go +++ b/core/radio/provider.go @@ -35,7 +35,7 @@ func NewMetadataManagerService(publisher TitlePublisher) *MetadataManager { // ICY metadata or a client-error HTTP status — so the reader backs off for // much longer instead of hammering the station. func classifyICYError(err error) error { - if errors.Is(err, icy.ErrMissingMetaInt) { + if errors.Is(err, icy.ErrMissingMetaInt) || errors.Is(err, icy.ErrInvalidMetaInt) { return MarkPermanent(err) } var statusErr *icy.ResponseStatusError diff --git a/core/radio/provider_test.go b/core/radio/provider_test.go new file mode 100644 index 000000000..f73ef770b --- /dev/null +++ b/core/radio/provider_test.go @@ -0,0 +1,20 @@ +package radio + +import ( + "fmt" + + "github.com/navidrome/navidrome/core/radio/icy" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Metadata provider", func() { + Describe("classifyICYError", func() { + It("marks invalid metadata intervals as permanent", func() { + err := classifyICYError(fmt.Errorf("%w: %q", icy.ErrInvalidMetaInt, "999999999")) + + Expect(isPermanent(err)).To(BeTrue()) + Expect(err).To(MatchError(ContainSubstring(icy.ErrInvalidMetaInt.Error()))) + }) + }) +})