@@ -0,0 +1,107 @@+package radio
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "github.com/runawaydevil/hammer/internal/community"
+)
+
+// ErrNoTitle is a stream that does not say what it is playing.
+var ErrNoTitle = errors.New("radio: no title")
+
+const (
+ icyWait = 5 * time.Second
+ maxMetaint = 64 << 10 // audio read before the title, at most
+ icyUserAgent = "Hammer-BBS/1.0"
+ maxTitleCells = 120
+)
+
+// NowPlaying reads the title a Shoutcast or Icecast stream sends between
+// its audio: at most 64 KiB of audio and five seconds, then it hangs up.
+func NowPlaying(ctx context.Context, hc *http.Client, streamURL string) (string, error) {
+ u, err := url.Parse(streamURL)
+ if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
+ return "", fmt.Errorf("radio: not a stream address: %q", streamURL)
+ }
+ if strings.HasSuffix(strings.ToLower(u.Path), ".m3u8") {
+ return "", ErrNoTitle // HLS carries no ICY title
+ }
+ if hc == nil {
+ hc = &http.Client{}
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, icyWait)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Icy-MetaData", "1")
+ req.Header.Set("User-Agent", icyUserAgent)
+ resp, err := hc.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("radio: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("radio: stream answered %s", resp.Status)
+ }
+ if strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "mpegurl") {
+ return "", ErrNoTitle
+ }
+ metaint, err := strconv.Atoi(strings.TrimSpace(resp.Header.Get("icy-metaint")))
+ if err != nil || metaint < 1 || metaint > maxMetaint {
+ return "", ErrNoTitle
+ }
+
+ if _, err := io.CopyN(io.Discard, resp.Body, int64(metaint)); err != nil {
+ return "", fmt.Errorf("radio: %w", err)
+ }
+ var n [1]byte
+ if _, err := io.ReadFull(resp.Body, n[:]); err != nil {
+ return "", fmt.Errorf("radio: %w", err)
+ }
+ meta := make([]byte, int(n[0])*16)
+ if _, err := io.ReadFull(resp.Body, meta); err != nil {
+ return "", fmt.Errorf("radio: %w", err)
+ }
+
+ title := streamTitle(meta)
+ if title == "" {
+ return "", ErrNoTitle
+ }
+
+ return title, nil
+}
+
+// streamTitle pulls StreamTitle='...'; out of an ICY metadata block, as
+// UTF-8 (streams that are not UTF-8 are almost always Latin-1).
+func streamTitle(meta []byte) string {
+ s := string(meta)
+ _, rest, ok := strings.Cut(s, "StreamTitle='")
+ if !ok {
+ return ""
+ }
+ title, _, ok := strings.Cut(rest, "';")
+ if !ok {
+ title = strings.TrimRight(rest, "\x00")
+ }
+ if !utf8.ValidString(title) {
+ r := make([]rune, 0, len(title))
+ for i := 0; i < len(title); i++ {
+ r = append(r, rune(title[i]))
+ }
+ title = string(r)
+ }
+
+ return strings.TrimSpace(community.CleanText(title, maxTitleCells))
+}
@@ -0,0 +1,149 @@+package radio
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "testing"
+ "time"
+)
+
+// icyServer streams metaint bytes of audio, then one metadata block.
+func icyServer(t *testing.T, metaint string, meta []byte, contentType string) *httptest.Server {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Icy-MetaData") != "1" {
+ t.Errorf("Icy-MetaData = %q", r.Header.Get("Icy-MetaData"))
+ }
+ if contentType != "" {
+ w.Header().Set("Content-Type", contentType)
+ }
+ if metaint != "" {
+ w.Header().Set("icy-metaint", metaint)
+ }
+ n, _ := strconv.Atoi(metaint)
+ if n <= 0 || n > 1<<20 {
+ n = 100
+ }
+ w.Write(make([]byte, n))
+ block := make([]byte, (len(meta)+15)/16*16)
+ copy(block, meta)
+ w.Write([]byte{byte(len(block) / 16)})
+ w.Write(block)
+ }))
+ t.Cleanup(srv.Close)
+
+ return srv
+}
+
+func TestNowPlaying(t *testing.T) {
+ srv := icyServer(t, "1000", []byte("StreamTitle='Miles Davis - So What';StreamUrl='';"), "audio/mpeg")
+ got, err := NowPlaying(context.Background(), nil, srv.URL)
+ if err != nil || got != "Miles Davis - So What" {
+ t.Fatalf("NowPlaying = %q, %v", got, err)
+ }
+}
+
+func TestNowPlayingLatin1(t *testing.T) {
+ srv := icyServer(t, "16", []byte("StreamTitle='Jo\xe3o Gilberto';"), "audio/mpeg")
+ got, err := NowPlaying(context.Background(), nil, srv.URL)
+ if err != nil || got != "João Gilberto" {
+ t.Fatalf("NowPlaying = %q, %v", got, err)
+ }
+}
+
+func TestNowPlayingCleaned(t *testing.T) {
+ srv := icyServer(t, "16", []byte("StreamTitle='Song\x1b[2J\x1b]8;;http://evil\x1b\\ Title';"), "audio/mpeg")
+ got, err := NowPlaying(context.Background(), nil, srv.URL)
+ if err != nil || got != "Song Title" {
+ t.Fatalf("NowPlaying = %q, %v", got, err)
+ }
+}
+
+func TestNowPlayingNoMeta(t *testing.T) {
+ srv := icyServer(t, "", nil, "audio/mpeg")
+ if _, err := NowPlaying(context.Background(), nil, srv.URL); !errors.Is(err, ErrNoTitle) {
+ t.Fatalf("err = %v, want ErrNoTitle", err)
+ }
+}
+
+func TestNowPlayingEmptyTitle(t *testing.T) {
+ srv := icyServer(t, "16", []byte("StreamTitle='';"), "audio/mpeg")
+ if _, err := NowPlaying(context.Background(), nil, srv.URL); !errors.Is(err, ErrNoTitle) {
+ t.Fatalf("err = %v, want ErrNoTitle", err)
+ }
+}
+
+func TestNowPlayingHLS(t *testing.T) {
+ srv := icyServer(t, "16", []byte("StreamTitle='x';"), "application/vnd.apple.mpegurl")
+ if _, err := NowPlaying(context.Background(), nil, srv.URL); !errors.Is(err, ErrNoTitle) {
+ t.Fatalf("err = %v, want ErrNoTitle", err)
+ }
+ if _, err := NowPlaying(context.Background(), nil, "https://x.test/live/playlist.m3u8"); !errors.Is(err, ErrNoTitle) {
+ t.Fatalf("m3u8 URL: err = %v, want ErrNoTitle", err)
+ }
+}
+
+// A huge icy-metaint is refused before any audio is read.
+func TestNowPlayingMetaintCap(t *testing.T) {
+ for _, mi := range []string{"10485760", "0", "-5", "abc"} {
+ srv := icyServer(t, mi, []byte("StreamTitle='x';"), "audio/mpeg")
+ if _, err := NowPlaying(context.Background(), nil, srv.URL); !errors.Is(err, ErrNoTitle) {
+ t.Errorf("metaint %s: err = %v, want ErrNoTitle", mi, err)
+ }
+ }
+}
+
+func TestNowPlayingTimeout(t *testing.T) {
+ release := make(chan struct{})
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("icy-metaint", "8192")
+ w.WriteHeader(http.StatusOK)
+ w.(http.Flusher).Flush()
+ <-release
+ }))
+ defer srv.Close()
+ defer close(release)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
+ defer cancel()
+ start := time.Now()
+ if _, err := NowPlaying(ctx, nil, srv.URL); err == nil {
+ t.Fatal("a stalled stream gave a title")
+ }
+ if d := time.Since(start); d > time.Second {
+ t.Fatalf("took %v", d)
+ }
+}
+
+func TestNowPlayingScheme(t *testing.T) {
+ for _, u := range []string{"file:///etc/passwd", "ftp://x/y", "gopher://x"} {
+ if _, err := NowPlaying(context.Background(), nil, u); err == nil {
+ t.Errorf("%s was fetched", u)
+ }
+ }
+}
+
+func TestNowPlayingReadsLittle(t *testing.T) {
+ var sent int
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("icy-metaint", "16")
+ chunk := bytes.Repeat([]byte{0}, 16)
+ w.Write(chunk)
+ w.Write([]byte{1})
+ w.Write([]byte("StreamTitle='a';"))
+ for i := 0; i < 1000; i++ { // an endless stream after the title
+ if _, err := w.Write(make([]byte, 4096)); err != nil {
+ return
+ }
+ sent += 4096
+ }
+ }))
+ defer srv.Close()
+ if got, err := NowPlaying(context.Background(), nil, srv.URL); err != nil || got != "a" {
+ t.Fatalf("%q %v", got, err)
+ }
+}
@@ -0,0 +1,260 @@+// Package radio finds internet radio stations in the open Radio Browser
+// directory (api.radio-browser.info), reads what a station is playing and
+// keeps callers' favourites and the sysop's pick. It never carries audio:
+// listeners play the stream themselves.
+package radio
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "math/rand/v2"
+ "net"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/runawaydevil/hammer/internal/community"
+)
+
+// Station is one station as the directory lists it, cleaned for display.
+type Station struct {
+ UUID string `json:"stationuuid"`
+ Name string `json:"name"`
+ URL string `json:"url_resolved"`
+ Homepage string `json:"homepage"`
+ Tags string `json:"tags"`
+ CountryCode string `json:"countrycode"`
+ Codec string `json:"codec"`
+ Bitrate int `json:"bitrate"`
+ HLS int `json:"hls"`
+ LastCheckOK int `json:"lastcheckok"`
+}
+
+// Query is a directory search; empty fields are left out.
+type Query struct {
+ Name string // contains, any case
+ CountryCode string // two letters, ISO 3166-1
+ Tag string
+ Limit int // 1..50, default 20
+}
+
+// ErrNotFound is a station the directory does not have.
+var ErrNotFound = errors.New("radio: no such station")
+
+const (
+ fallbackServer = "https://de1.api.radio-browser.info"
+ requestWait = 8 * time.Second
+ maxBody = 1 << 20
+ cacheTTL = 10 * time.Minute
+ cacheSize = 128
+ defaultLimit = 20
+ maxLimit = 50
+)
+
+// Client talks to Radio Browser, keeping answers for a while.
+type Client struct {
+ base string
+ ua string
+ hc *http.Client
+
+ once sync.Once
+
+ mu sync.Mutex
+ cache map[string]cached
+ order []string
+}
+
+type cached struct {
+ body []byte
+ at time.Time
+}
+
+// New is a client for base, or for a server found through DNS when base is
+// empty. userAgent names the program, as the directory asks.
+func New(base, userAgent string, hc *http.Client) *Client {
+ if hc == nil {
+ hc = &http.Client{Timeout: requestWait}
+ }
+
+ return &Client{base: strings.TrimRight(base, "/"), ua: userAgent, hc: hc, cache: map[string]cached{}}
+}
+
+// server is the directory server to ask, found once.
+func (c *Client) server() string {
+ c.once.Do(func() {
+ if c.base != "" {
+ return
+ }
+ c.base = fallbackServer
+ _, addrs, err := net.LookupSRV("api", "tcp", "radio-browser.info")
+ if err == nil && len(addrs) > 0 {
+ c.base = "https://" + strings.TrimSuffix(addrs[rand.IntN(len(addrs))].Target, ".")
+ }
+ })
+
+ return c.base
+}
+
+// Search lists stations matching q, most played first.
+func (c *Client) Search(ctx context.Context, q Query) ([]Station, error) {
+ v := url.Values{}
+ if s := strings.TrimSpace(q.Name); s != "" {
+ v.Set("name", s)
+ }
+ if s := strings.TrimSpace(q.CountryCode); s != "" {
+ v.Set("countrycode", strings.ToUpper(s))
+ }
+ if s := strings.TrimSpace(q.Tag); s != "" {
+ v.Set("tag", s)
+ }
+ limit := q.Limit
+ if limit <= 0 {
+ limit = defaultLimit
+ }
+ v.Set("limit", strconv.Itoa(min(limit, maxLimit)))
+ v.Set("hidebroken", "true")
+ v.Set("order", "clickcount")
+ v.Set("reverse", "true")
+
+ return c.stations(ctx, "/json/stations/search?"+v.Encode())
+}
+
+// Top lists the n most played stations.
+func (c *Client) Top(ctx context.Context, n int) ([]Station, error) {
+ n = min(max(n, 1), maxLimit)
+
+ return c.stations(ctx, "/json/stations/topclick/"+strconv.Itoa(n))
+}
+
+var uuidRE = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
+
+// ValidUUID reports whether id is a station id the directory could have.
+func ValidUUID(id string) bool { return uuidRE.MatchString(id) }
+
+// Station is one station by its id.
+func (c *Client) Station(ctx context.Context, id string) (Station, error) {
+ id = strings.ToLower(id)
+ if !ValidUUID(id) {
+ return Station{}, ErrNotFound
+ }
+ list, err := c.stations(ctx, "/json/stations/byuuid/"+id)
+ if err != nil {
+ return Station{}, err
+ }
+ if len(list) == 0 {
+ return Station{}, ErrNotFound
+ }
+
+ return list[0], nil
+}
+
+// Click tells the directory someone tuned in, which keeps its "most played"
+// honest. It is never cached.
+func (c *Client) Click(ctx context.Context, id string) error {
+ id = strings.ToLower(id)
+ if !ValidUUID(id) {
+ return ErrNotFound
+ }
+ _, err := c.get(ctx, "/json/url/"+id, false)
+
+ return err
+}
+
+func (c *Client) stations(ctx context.Context, path string) ([]Station, error) {
+ body, err := c.get(ctx, path, true)
+ if err != nil {
+ return nil, err
+ }
+ var list []Station
+ if err := json.Unmarshal(body, &list); err != nil {
+ return nil, fmt.Errorf("radio: %w", err)
+ }
+ for i := range list {
+ list[i] = clean(list[i])
+ }
+
+ return list, nil
+}
+
+// get fetches path, from the cache when asked and fresh.
+func (c *Client) get(ctx context.Context, path string, useCache bool) ([]byte, error) {
+ key := path
+ if useCache {
+ c.mu.Lock()
+ e, ok := c.cache[key]
+ c.mu.Unlock()
+ if ok && time.Since(e.at) < cacheTTL {
+ return e.body, nil
+ }
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, requestWait)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.server()+path, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("User-Agent", c.ua)
+ resp, err := c.hc.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("radio: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("radio: directory answered %s", resp.Status)
+ }
+ body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
+ if err != nil {
+ return nil, fmt.Errorf("radio: %w", err)
+ }
+
+ if useCache {
+ c.mu.Lock()
+ if _, ok := c.cache[key]; !ok {
+ c.order = append(c.order, key)
+ }
+ c.cache[key] = cached{body, time.Now()}
+ for len(c.order) > cacheSize {
+ delete(c.cache, c.order[0])
+ c.order = c.order[1:]
+ }
+ c.mu.Unlock()
+ }
+
+ return body, nil
+}
+
+// clean makes a station safe to show: no terminal controls in its text,
+// only web addresses for its links.
+func clean(s Station) Station {
+ s.UUID = strings.ToLower(s.UUID)
+ s.Name = community.CleanText(s.Name, 80)
+ s.Tags = community.CleanText(s.Tags, 120)
+ s.Codec = community.CleanText(s.Codec, 12)
+ s.CountryCode = strings.ToUpper(community.CleanText(s.CountryCode, 2))
+ s.URL = webURL(s.URL)
+ s.Homepage = webURL(s.Homepage)
+
+ return s
+}
+
+// webURL keeps an http or https address and drops anything else.
+func webURL(raw string) string {
+ raw = strings.TrimSpace(raw)
+ u, err := url.Parse(raw)
+ if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
+ return ""
+ }
+ if strings.ContainsAny(raw, "\x00\r\n\t\x1b\"'<> ") {
+ return ""
+ }
+
+ return raw
+}
@@ -0,0 +1,175 @@+package radio
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+)
+
+const jazzUUID = "9617a958-0601-11e8-ae97-52543be04c81"
+
+// fakeAPI is a Radio Browser that counts its requests.
+func fakeAPI(t *testing.T, handle func(w http.ResponseWriter, r *http.Request)) (*httptest.Server, *atomic.Int32) {
+ t.Helper()
+ var n atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ n.Add(1)
+ handle(w, r)
+ }))
+ t.Cleanup(srv.Close)
+
+ return srv, &n
+}
+
+const jazzJSON = `[{"stationuuid":"` + jazzUUID + `","name":"Jazz FM","url_resolved":"https://x.test/s","countrycode":"BR","codec":"MP3","bitrate":128,"tags":"jazz,smooth","lastcheckok":1}]`
+
+func TestSearchQueryAndParse(t *testing.T) {
+ api, _ := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ if r.URL.Path != "/json/stations/search" || q.Get("name") != "jazz" || q.Get("countrycode") != "BR" ||
+ q.Get("limit") != "20" || q.Get("hidebroken") != "true" || q.Get("order") != "clickcount" || q.Get("reverse") != "true" {
+ t.Errorf("request %s", r.URL)
+ }
+ if r.Header.Get("User-Agent") != "Hammer-Test/1" {
+ t.Errorf("User-Agent = %q", r.Header.Get("User-Agent"))
+ }
+ io.WriteString(w, jazzJSON)
+ })
+ c := New(api.URL, "Hammer-Test/1", nil)
+ got, err := c.Search(context.Background(), Query{Name: "jazz", CountryCode: "br"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 || got[0].Name != "Jazz FM" || got[0].Bitrate != 128 || got[0].UUID != jazzUUID || got[0].CountryCode != "BR" {
+ t.Fatalf("got %+v", got)
+ }
+}
+
+func TestSearchCached(t *testing.T) {
+ api, n := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, jazzJSON) })
+ c := New(api.URL, "ua", nil)
+ ctx := context.Background()
+ c.Search(ctx, Query{Name: "jazz"})
+ c.Search(ctx, Query{Name: "jazz"})
+ if n.Load() != 1 {
+ t.Fatalf("two equal searches made %d requests, want 1", n.Load())
+ }
+ c.Top(ctx, 20)
+ if n.Load() != 2 {
+ t.Fatalf("a different request was answered from the cache")
+ }
+}
+
+// A failed request is not cached.
+func TestErrorsNotCached(t *testing.T) {
+ fail := true
+ api, n := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
+ if fail {
+ http.Error(w, "busy", http.StatusServiceUnavailable)
+
+ return
+ }
+ io.WriteString(w, jazzJSON)
+ })
+ c := New(api.URL, "ua", nil)
+ if _, err := c.Top(context.Background(), 20); err == nil {
+ t.Fatal("a 503 was not an error")
+ }
+ fail = false
+ if got, err := c.Top(context.Background(), 20); err != nil || len(got) != 1 || n.Load() != 2 {
+ t.Fatalf("after the error: %v %v, %d requests", got, err, n.Load())
+ }
+}
+
+func TestTopPath(t *testing.T) {
+ api, _ := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/json/stations/topclick/20" {
+ t.Errorf("path %s", r.URL.Path)
+ }
+ io.WriteString(w, "[]")
+ })
+ if _, err := New(api.URL, "ua", nil).Top(context.Background(), 20); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestStationNotFound(t *testing.T) {
+ api, _ := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "[]") })
+ _, err := New(api.URL, "ua", nil).Station(context.Background(), jazzUUID)
+ if !errors.Is(err, ErrNotFound) {
+ t.Fatalf("err = %v, want ErrNotFound", err)
+ }
+}
+
+func TestStationByUUID(t *testing.T) {
+ api, _ := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/json/stations/byuuid/"+jazzUUID {
+ t.Errorf("path %s", r.URL.Path)
+ }
+ io.WriteString(w, jazzJSON)
+ })
+ s, err := New(api.URL, "ua", nil).Station(context.Background(), jazzUUID)
+ if err != nil || s.Name != "Jazz FM" {
+ t.Fatalf("%+v %v", s, err)
+ }
+}
+
+func TestStationRejectsBadUUID(t *testing.T) {
+ api, n := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, jazzJSON) })
+ c := New(api.URL, "ua", nil)
+ for _, bad := range []string{"../../x", "", jazzUUID + "x", "9617a958-0601-11e8-ae97-52543be04c8g"} {
+ if _, err := c.Station(context.Background(), bad); !errors.Is(err, ErrNotFound) {
+ t.Errorf("Station(%q) = %v, want ErrNotFound", bad, err)
+ }
+ }
+ if n.Load() != 0 {
+ t.Fatalf("%d requests went out for bad ids", n.Load())
+ }
+}
+
+func TestClickNotCached(t *testing.T) {
+ api, n := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/json/url/"+jazzUUID {
+ t.Errorf("path %s", r.URL.Path)
+ }
+ io.WriteString(w, `{"ok":true}`)
+ })
+ c := New(api.URL, "ua", nil)
+ c.Click(context.Background(), jazzUUID)
+ c.Click(context.Background(), jazzUUID)
+ if n.Load() != 2 {
+ t.Fatalf("%d click requests, want 2", n.Load())
+ }
+}
+
+func TestLimitClamped(t *testing.T) {
+ var limits []string
+ api, _ := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
+ limits = append(limits, r.URL.Query().Get("limit"))
+ io.WriteString(w, "[]")
+ })
+ c := New(api.URL, "ua", nil)
+ c.Search(context.Background(), Query{Name: "a", Limit: 0})
+ c.Search(context.Background(), Query{Name: "b", Limit: 500})
+ if len(limits) != 2 || limits[0] != "20" || limits[1] != "50" {
+ t.Fatalf("limits = %v, want [20 50]", limits)
+ }
+}
+
+// Odd text from the directory is cleaned of terminal controls on the way in.
+func TestStationTextCleaned(t *testing.T) {
+ api, _ := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
+ io.WriteString(w, `[{"stationuuid":"`+jazzUUID+`","name":"Evil\u001b]8;;http://evil\u001b\\FM\u001b[2J","tags":"a\u0007b","url_resolved":"https://x.test/s"}]`)
+ })
+ got, err := New(api.URL, "ua", nil).Top(context.Background(), 1)
+ if err != nil || len(got) != 1 {
+ t.Fatal(err)
+ }
+ if got[0].Name != "EvilFM" || got[0].Tags != "ab" {
+ t.Fatalf("name %q tags %q", got[0].Name, got[0].Tags)
+ }
+}
@@ -0,0 +1,168 @@+package radio
+
+import (
+ "database/sql"
+ "errors"
+ "time"
+
+ "github.com/runawaydevil/hammer/internal/community"
+)
+
+const schema = `
+create table if not exists radio_favorites (
+ identity text not null,
+ uuid text not null,
+ name text not null,
+ url text not null,
+ countrycode text not null default '',
+ at integer not null,
+ primary key (identity, uuid)
+);
+create table if not exists radio_picks (
+ id integer primary key autoincrement,
+ uuid text not null,
+ name text not null,
+ url text not null,
+ countrycode text not null default '',
+ note text not null default '',
+ by_nick text not null,
+ at integer not null
+);`
+
+// MaxFavorites is how many stations one caller may keep.
+const MaxFavorites = 50
+
+// ErrTooMany is a favourite past MaxFavorites.
+var ErrTooMany = errors.New("radio: favorites are full")
+
+// Favorite is a station a caller saved.
+type Favorite struct {
+ UUID, Name, URL, CountryCode string
+ At time.Time
+}
+
+// Favorites keeps callers' saved stations.
+type Favorites struct{ db *sql.DB }
+
+// NewFavorites creates the tables in db.
+func NewFavorites(db *sql.DB) (*Favorites, error) {
+ if _, err := db.Exec(schema); err != nil {
+ return nil, err
+ }
+
+ return &Favorites{db: db}, nil
+}
+
+// Add saves s for identity; saving one already there changes nothing.
+func (f *Favorites) Add(identity string, s Station) error {
+ tx, err := f.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ var have, n int
+ if err := tx.QueryRow(`select count(*) from radio_favorites where identity = ? and uuid = ?`, identity, s.UUID).Scan(&have); err != nil {
+ return err
+ }
+ if have > 0 {
+ return nil
+ }
+ if err := tx.QueryRow(`select count(*) from radio_favorites where identity = ?`, identity).Scan(&n); err != nil {
+ return err
+ }
+ if n >= MaxFavorites {
+ return ErrTooMany
+ }
+ if _, err := tx.Exec(`insert into radio_favorites(identity, uuid, name, url, countrycode, at) values(?, ?, ?, ?, ?, ?)`,
+ identity, s.UUID, s.Name, s.URL, s.CountryCode, time.Now().UnixNano()); err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+
+// Remove forgets one of identity's stations.
+func (f *Favorites) Remove(identity, uuid string) error {
+ _, err := f.db.Exec(`delete from radio_favorites where identity = ? and uuid = ?`, identity, uuid)
+
+ return err
+}
+
+// Has reports whether identity saved uuid.
+func (f *Favorites) Has(identity, uuid string) (bool, error) {
+ var n int
+ err := f.db.QueryRow(`select count(*) from radio_favorites where identity = ? and uuid = ?`, identity, uuid).Scan(&n)
+
+ return n > 0, err
+}
+
+// List is identity's stations, the newest first.
+func (f *Favorites) List(identity string) ([]Favorite, error) {
+ rows, err := f.db.Query(`select uuid, name, url, countrycode, at from radio_favorites
+ where identity = ? order by at desc, rowid desc`, identity)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var out []Favorite
+ for rows.Next() {
+ var (
+ fav Favorite
+ at int64
+ )
+ if err := rows.Scan(&fav.UUID, &fav.Name, &fav.URL, &fav.CountryCode, &at); err != nil {
+ return nil, err
+ }
+ fav.At = time.Unix(0, at)
+ out = append(out, fav)
+ }
+
+ return out, rows.Err()
+}
+
+// Pick is the sysop's station of the week, Hammer FM.
+type Pick struct {
+ UUID, Name, URL, CountryCode, Note, By string
+ At time.Time
+}
+
+// Picks keeps the history of Hammer FM; the newest is on the air.
+type Picks struct{ db *sql.DB }
+
+// NewPicks creates the tables in db.
+func NewPicks(db *sql.DB) (*Picks, error) {
+ if _, err := db.Exec(schema); err != nil {
+ return nil, err
+ }
+
+ return &Picks{db: db}, nil
+}
+
+// maxNote is how long a pick's note may be, in cells.
+const maxNote = 60
+
+// Set puts s on the air as Hammer FM, chosen by the moderator by.
+func (p *Picks) Set(by string, s Station, note string) error {
+ _, err := p.db.Exec(`insert into radio_picks(uuid, name, url, countrycode, note, by_nick, at) values(?, ?, ?, ?, ?, ?, ?)`,
+ s.UUID, s.Name, s.URL, s.CountryCode, community.CleanText(note, maxNote), by, time.Now().Unix())
+
+ return err
+}
+
+// Current is Hammer FM now; ok is false before the first pick.
+func (p *Picks) Current() (pick Pick, ok bool, err error) {
+ var at int64
+ err = p.db.QueryRow(`select uuid, name, url, countrycode, note, by_nick, at from radio_picks order by id desc limit 1`).
+ Scan(&pick.UUID, &pick.Name, &pick.URL, &pick.CountryCode, &pick.Note, &pick.By, &at)
+ if errors.Is(err, sql.ErrNoRows) {
+ return Pick{}, false, nil
+ }
+ if err != nil {
+ return Pick{}, false, err
+ }
+ pick.At = time.Unix(at, 0)
+
+ return pick, true, nil
+}
@@ -0,0 +1,113 @@+package radio
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+ "path/filepath"
+ "testing"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+func testDB(t *testing.T) *sql.DB {
+ t.Helper()
+ db, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "r.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { db.Close() })
+
+ return db
+}
+
+func station(n int) Station {
+ return Station{UUID: fmt.Sprintf("00000000-0000-0000-0000-%012d", n), Name: fmt.Sprintf("Station %d", n), URL: "https://x.test/" + fmt.Sprint(n), CountryCode: "BR"}
+}
+
+func TestFavoritesAddListRemove(t *testing.T) {
+ f, err := NewFavorites(testDB(t))
+ if err != nil {
+ t.Fatal(err)
+ }
+ a, b := station(1), station(2)
+ if err := f.Add("fp:a", a); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.Add("fp:a", b); err != nil {
+ t.Fatal(err)
+ }
+ list, err := f.List("fp:a")
+ if err != nil || len(list) != 2 || list[0].UUID != b.UUID || list[1].UUID != a.UUID {
+ t.Fatalf("list = %+v, %v; want newest first", list, err)
+ }
+ if has, _ := f.Has("fp:a", a.UUID); !has {
+ t.Fatal("Has = false after Add")
+ }
+ if err := f.Remove("fp:a", a.UUID); err != nil {
+ t.Fatal(err)
+ }
+ list, _ = f.List("fp:a")
+ if len(list) != 1 || list[0].UUID != b.UUID {
+ t.Fatalf("after remove: %+v", list)
+ }
+}
+
+func TestFavoritesIdempotent(t *testing.T) {
+ f, _ := NewFavorites(testDB(t))
+ f.Add("fp:a", station(1))
+ f.Add("fp:a", station(1))
+ if list, _ := f.List("fp:a"); len(list) != 1 {
+ t.Fatalf("%d favorites, want 1", len(list))
+ }
+}
+
+func TestFavoritesPerIdentity(t *testing.T) {
+ f, _ := NewFavorites(testDB(t))
+ f.Add("fp:a", station(1))
+ if list, _ := f.List("fp:b"); len(list) != 0 {
+ t.Fatalf("fp:b sees %+v", list)
+ }
+}
+
+func TestFavoritesFull(t *testing.T) {
+ f, _ := NewFavorites(testDB(t))
+ for i := 0; i < MaxFavorites; i++ {
+ if err := f.Add("fp:a", station(i)); err != nil {
+ t.Fatalf("favorite %d: %v", i, err)
+ }
+ }
+ if err := f.Add("fp:a", station(999)); !errors.Is(err, ErrTooMany) {
+ t.Fatalf("one more = %v, want ErrTooMany", err)
+ }
+ // Saving one already there is not "one more".
+ if err := f.Add("fp:a", station(0)); err != nil {
+ t.Fatalf("re-adding an existing favorite: %v", err)
+ }
+}
+
+func TestPicks(t *testing.T) {
+ p, err := NewPicks(testDB(t))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, ok, err := p.Current(); ok || err != nil {
+ t.Fatalf("a new store has a pick: %v %v", ok, err)
+ }
+ if err := p.Set("pablo", station(1), "the best jazz"); err != nil {
+ t.Fatal(err)
+ }
+ if err := p.Set("pablo", station(2), ""); err != nil {
+ t.Fatal(err)
+ }
+ cur, ok, err := p.Current()
+ if err != nil || !ok || cur.UUID != station(2).UUID || cur.By != "pablo" || cur.Name != "Station 2" {
+ t.Fatalf("current = %+v %v %v", cur, ok, err)
+ }
+ if err := p.Set("pablo", station(3), string(make([]byte, 200))); err != nil {
+ t.Fatal(err)
+ }
+ if cur, _, _ := p.Current(); len([]rune(cur.Note)) > 60 {
+ t.Fatalf("note kept %d characters, want at most 60", len([]rune(cur.Note)))
+ }
+}