mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
feat(radio): add ICY HTTP metadata reader
Closes #2 Signed-off-by: hiyukoim <11803292+hiyukoim@users.noreply.github.com>
This commit is contained in:
parent
e076085658
commit
fd6e779cfd
55
core/radio/icy/reader.go
Normal file
55
core/radio/icy/reader.go
Normal file
@ -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)
|
||||
}
|
||||
124
core/radio/icy/reader_test.go
Normal file
124
core/radio/icy/reader_test.go
Normal file
@ -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"))))
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user