@@ -420,8 +420,16 @@ through Hammer, since SSH and telnet carry only text: listeners play the stream themselves. A moderator puts a station on the air as **Hammer FM** with `[h]` or
`radio.pick`; it shows on the main menu, the bulletin, `/:radio` and the Commodore.
In the chat, `/np` tells the room the station you last opened and what it is playing.
- `/:radio` and `/:radio/<id>` play https streams in the browser. The tools are
- `radio.top`, `radio.search`, `radio.np`, `radio.fav` and `radio.pick`.
+ The tools are `radio.top`, `radio.search`, `radio.np`, `radio.fav` and `radio.pick`.
+
+ On the web, `/:radio` is the same directory to browse: the most played, trending and
+ top voted, every station with filters by name, country, genre and language, 30 to a
+ page, lists of all countries, genres and languages, and "surprise me" for a station at
+ random. A station's page plays it right there when the stream is https. A station
+ listed with an http address is tried once over https, since browsers refuse plain http
+ audio on an https page; when there is no https stream, the page offers a one-line
+ `.m3u` playlist that opens in VLC or mpv, never the endless stream itself. The pages
+ run no scripts and allow 60 views a minute per address.
```yaml
radio: {enabled: true, publicUrl: https://hammer.example.com}
@@ -418,4 +418,5 @@ "BBS_CHAT_NP": "%s is tuned to %s", "BBS_CHAT_HELP_NP": "/np share the station you are tuned to",
"BBS_RADIO_NP_NONE": "Open a station in [o] first, then /np",
"BBS_BULLETIN_RADIO": "Radio",
+ "BBS_RADIO_WEB": "on the web: %s",
}
@@ -115,6 +115,9 @@ } if n := st.radioListeners(); n > 0 {
rows = append(rows, "", " "+st.th.Dim+st.tf("BBS_RADIO_LISTENING", n)+st.th.Reset)
}
+ if pub := st.srv.radioPublicURL(); pub != "" {
+ rows = append(rows, "", " "+st.th.Dim+st.tf("BBS_RADIO_WEB", pub+radioPagePath)+st.th.Reset)
+ }
rows = append(rows, "")
box := st.th.Box(st.th.Heading(st.t("BBS_TITLE_RADIO")), rows, 56)
keys := "1-5"
@@ -107,6 +107,7 @@ ana.waitFor("on the air")
ana.send("o")
ana.waitFor("ON THE AIR")
+ ana.waitFor("on the web: https://hammer.test/:radio")
if !waitWhere(srv, "ana", community.WhereRadio) {
t.Errorf("presence does not show ana at the radio")
}
@@ -3,73 +3,181 @@ import (
"context"
"html/template"
+ "io"
"net/http"
+ "net/url"
+ "sort"
"strconv"
"strings"
+ "unicode/utf8"
+ "github.com/runawaydevil/hammer/internal/clientip"
"github.com/runawaydevil/hammer/internal/community"
"github.com/runawaydevil/hammer/internal/radio"
)
-// /:radio is Hammer FM and the most played stations, and /:radio/<id> one
-// station, both with a player when the stream is https (browsers refuse
-// plain http media on an https page). The audio goes from the station to
-// the listener's browser; Hammer only serves the page.
+// The radio on the web, under /:radio: a home page, every station with
+// filters and pages, the countries, genres and languages, a station drawn
+// at random, and one page per station with a player when the browser can
+// play the stream. Browsers refuse plain http audio on an https page, so an
+// http station is tried over https; failing that, the page gives a playlist
+// for a real player instead of the endless stream. No audio passes through
+// Hammer, and no script runs on the pages.
const (
radioPagePath = "/:radio"
radioPrefix = "/:radio/"
+ radioBrowse = radioPrefix + "browse"
radioCSP = "default-src 'none'; media-src https:; style-src 'unsafe-inline'"
+
+ radioPerPage = 30
+ radioMaxPage = 50
+ radioShortList = 10
+ radioShortcuts = 12
+ radioMinGenre = 20 // stations a genre needs to be listed
+ radioGenreCount = 150 // genres asked for
+ radioMoreLike = 8
+ radioWebPerMin = 60 // page views a minute per address
+ radioWebBurst = 20
)
// RadioRoutes are the radio's web pages.
func (s *Server) RadioRoutes() map[string]http.Handler {
return map[string]http.Handler{
- radioPagePath: http.HandlerFunc(s.serveRadioIndex),
- radioPrefix: http.HandlerFunc(s.serveRadioStation),
+ radioPagePath: http.HandlerFunc(s.serveRadio),
+ radioPrefix: http.HandlerFunc(s.serveRadio),
}
}
// radioPageStation is one station as the pages show it.
type radioPageStation struct {
- Name, Country, Info, Tags, Home, Stream, Link string
- Playable bool // https: the page can play it
+ Name, Country, Info, Home, Stream, Link, Playlist, Language string
+ Tags []string
+ Playable bool // the page can play it
}
func pageStation(st radio.Station) radioPageStation {
info := st.Codec
if st.Bitrate > 0 {
info = strings.TrimSpace(info + " " + strconv.Itoa(st.Bitrate) + " kbps")
+ }
+ var tags []string
+ for _, t := range strings.Split(st.Tags, ",") {
+ if t = strings.TrimSpace(t); t != "" && len(tags) < 6 {
+ tags = append(tags, t)
+ }
}
return radioPageStation{
- Name: st.Name, Country: st.CountryCode, Info: info, Tags: st.Tags, Home: st.Homepage,
- Stream: st.URL, Link: radioPrefix + st.UUID, Playable: strings.HasPrefix(st.URL, "https://"),
+ Name: st.Name, Country: st.CountryCode, Info: info, Home: st.Homepage, Language: st.Language,
+ Stream: st.URL, Link: radioPrefix + st.UUID, Playlist: radioPrefix + st.UUID + ".m3u", Tags: tags,
+ Playable: strings.HasPrefix(st.URL, "https://") && !strings.HasSuffix(strings.ToLower(st.URL), ".m3u8"),
+ }
+}
+
+func pageStations(list []radio.Station) []radioPageStation {
+ out := make([]radioPageStation, 0, len(list))
+ for _, st := range list {
+ out = append(out, pageStation(st))
+ }
+
+ return out
+}
+
+// radioLinkTo is a /:radio/browse address with these filters.
+func radioLinkTo(v url.Values) string {
+ if len(v) == 0 {
+ return radioBrowse
}
+
+ // %20 rather than +: plainer in the address bar and in the HTML.
+ return radioBrowse + "?" + strings.ReplaceAll(v.Encode(), "+", "%20")
+}
+
+// radioChip is a link with a label: a filter to add or remove, an order.
+type radioChip struct {
+ Label, Link string
+ On bool
+}
+
+// radioSection is a short list on the home page.
+type radioSection struct {
+ Title, More string
+ Stations []radioPageStation
}
type radioPage struct {
+ Kind string // home, browse, facets, station
Title string
- Station *radioPageStation
- OnAir bool // the station is Hammer FM
- Pick *radioPageStation
- PickNote string
- PickBy string
- Top []radioPageStation
Listeners int
+ Q string // the search box
+
+ // home
+ Pick *radioPageStation
+ PickNote, PickBy string
+ Sections []radioSection
+ Countries, Genres []radioChip
+
+ // browse
+ Filters, Orders []radioChip
+ Stations []radioPageStation
+ Page int
+ Prev, Next, Down string
+
+ // facets
+ Facets []radioChip
+
+ // station
+ Station *radioPageStation
+ OnAir bool
+ More []radioPageStation
}
-func (s *Server) radioPageHeaders(w http.ResponseWriter, r *http.Request) bool {
+// serveRadio sends each /:radio address to its page.
+func (s *Server) serveRadio(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return false
+ return
}
+ if ok, wait := s.radioWebLimit.Allow(clientip.FromRequest(r)); !ok {
+ w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
+ http.Error(w, "too many requests, slow down", http.StatusTooManyRequests)
+
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), radioWait)
+ defer cancel()
+
+ rest := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, radioPagePath), "/")
+ switch rest {
+ case "":
+ s.radioHome(ctx, w)
+ case "browse":
+ s.radioBrowsePage(ctx, w, r.URL.Query())
+ case "countries", "genres", "languages":
+ s.radioFacetPage(ctx, w, rest)
+ case "random":
+ st, err := s.radio.Random(ctx)
+ if err != nil {
+ http.Redirect(w, r, radioPagePath, http.StatusFound)
+
+ return
+ }
+ http.Redirect(w, r, radioPrefix+st.UUID, http.StatusFound)
+ default:
+ id, playlist := strings.CutSuffix(rest, ".m3u")
+ s.radioStationPage(ctx, w, r, id, playlist)
+ }
+}
+
+// radioRender writes a page.
+func (s *Server) radioRender(w http.ResponseWriter, page radioPage) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Security-Policy", radioCSP)
w.Header().Set("Cache-Control", "max-age=300")
-
- return true
+ page.Listeners = s.radioListenersNow()
+ _ = radioTemplate.Execute(w, page)
}
// radioListenersNow is how many people are in [o] now; nobody is named.
@@ -84,39 +192,167 @@ return n
}
-func (s *Server) serveRadioIndex(w http.ResponseWriter, r *http.Request) {
- if !s.radioPageHeaders(w, r) {
- return
- }
- page := radioPage{Title: "On the air", Listeners: s.radioListenersNow()}
+func (s *Server) radioHome(ctx context.Context, w http.ResponseWriter) {
+ page := radioPage{Kind: "home", Title: "On the air"}
if pick, ok := s.hammerFM(); ok {
- p := pageStation(radio.Station{UUID: pick.UUID, Name: pick.Name, URL: pick.URL, CountryCode: pick.CountryCode})
+ p := pageStation(s.playableStation(ctx, radio.Station{UUID: pick.UUID, Name: pick.Name, URL: pick.URL, CountryCode: pick.CountryCode}))
page.Pick, page.PickNote, page.PickBy = &p, pick.Note, pick.By
}
- ctx, cancel := context.WithTimeout(r.Context(), radioWait)
- defer cancel()
- if top, err := s.radio.Top(ctx, 20); err == nil {
- for _, st := range top {
- page.Top = append(page.Top, pageStation(st))
+ for _, sec := range []struct{ title, order string }{
+ {"MOST PLAYED", "popular"}, {"TRENDING", "trending"}, {"TOP VOTED", "votes"},
+ } {
+ list, err := s.radio.Search(ctx, radio.Query{Order: sec.order, Limit: radioShortList})
+ if err != nil || len(list) == 0 {
+ continue
+ }
+ page.Sections = append(page.Sections, radioSection{sec.title, radioLinkTo(url.Values{"order": {sec.order}}), pageStations(list)})
+ }
+ if countries, err := s.radio.Countries(ctx); err == nil {
+ sort.Slice(countries, func(i, j int) bool { return countries[i].Stations > countries[j].Stations })
+ for _, c := range countries[:min(len(countries), radioShortcuts)] {
+ page.Countries = append(page.Countries, radioChip{Label: c.Name, Link: radioLinkTo(url.Values{"country": {c.Code}})})
}
}
- _ = radioTemplate.Execute(w, page)
+ if genres, err := s.radio.Tags(ctx, radioShortcuts); err == nil {
+ for _, g := range genres {
+ page.Genres = append(page.Genres, radioChip{Label: g.Name, Link: radioLinkTo(url.Values{"tag": {g.Name}})})
+ }
+ }
+ s.radioRender(w, page)
}
-func (s *Server) serveRadioStation(w http.ResponseWriter, r *http.Request) {
- id := strings.TrimPrefix(r.URL.Path, radioPrefix)
- if id == "" {
- s.serveRadioIndex(w, r)
+// cutRunes keeps at most n characters of s.
+func cutRunes(s string, n int) string {
+ s = strings.TrimSpace(s)
+ if utf8.RuneCountInString(s) <= n {
+ return s
+ }
+
+ return string([]rune(s)[:n])
+}
+
+func (s *Server) radioBrowsePage(ctx context.Context, w http.ResponseWriter, in url.Values) {
+ // Only what is well formed goes to the directory; the rest is dropped.
+ f := url.Values{}
+ if q := cutRunes(in.Get("q"), 40); q != "" {
+ f.Set("q", q)
+ }
+ if c := strings.TrimSpace(in.Get("country")); countryCodeRE.MatchString(c) {
+ f.Set("country", strings.ToUpper(c))
+ }
+ if t := cutRunes(in.Get("tag"), 30); t != "" {
+ f.Set("tag", strings.ToLower(t))
+ }
+ if l := cutRunes(in.Get("lang"), 30); l != "" {
+ f.Set("lang", strings.ToLower(l))
+ }
+ order := in.Get("order")
+ if !radio.ValidOrder(order) {
+ order = "popular"
+ }
+ pageNo, err := strconv.Atoi(in.Get("page"))
+ if err != nil || pageNo < 1 || pageNo > radioMaxPage {
+ pageNo = 1
+ }
+
+ list, listErr := s.radio.Search(ctx, radio.Query{
+ Name: f.Get("q"), CountryCode: f.Get("country"), Tag: f.Get("tag"), Language: f.Get("lang"),
+ Order: order, Offset: (pageNo - 1) * radioPerPage, Limit: radioPerPage + 1,
+ })
+ page := radioPage{Kind: "browse", Title: "Stations", Q: f.Get("q"), Page: pageNo}
+ if listErr != nil {
+ page.Down = "The radio directory is unavailable right now; try again soon."
+ }
+ more := len(list) > radioPerPage
+ page.Stations = pageStations(list[:min(len(list), radioPerPage)])
- return
+ with := func(key, value string) url.Values {
+ v := url.Values{}
+ for k := range f {
+ v.Set(k, f.Get(k))
+ }
+ if order != "popular" {
+ v.Set("order", order)
+ }
+ if value == "" {
+ v.Del(key)
+ } else {
+ v.Set(key, value)
+ }
+
+ return v
+ }
+ labels := map[string]string{"q": "“%s”", "country": "%s", "tag": "%s", "lang": "%s"}
+ for _, k := range []string{"q", "country", "tag", "lang"} {
+ if v := f.Get(k); v != "" {
+ page.Filters = append(page.Filters, radioChip{Label: strings.Replace(labels[k], "%s", v, 1) + " ×", Link: radioLinkTo(with(k, ""))})
+ }
+ }
+ for _, o := range []string{"popular", "trending", "votes", "name"} {
+ v := with("order", o)
+ if o == "popular" {
+ v.Del("order")
+ }
+ page.Orders = append(page.Orders, radioChip{Label: o, Link: radioLinkTo(v), On: o == order})
+ }
+ if pageNo > 1 {
+ v := with("page", strconv.Itoa(pageNo-1))
+ if pageNo == 2 {
+ v.Del("page")
+ }
+ page.Prev = radioLinkTo(v)
+ }
+ if more && pageNo < radioMaxPage {
+ page.Next = radioLinkTo(with("page", strconv.Itoa(pageNo+1)))
+ }
+ s.radioRender(w, page)
+}
+
+func (s *Server) radioFacetPage(ctx context.Context, w http.ResponseWriter, kind string) {
+ page := radioPage{Kind: "facets"}
+ var (
+ list []radio.Facet
+ err error
+ key string
+ )
+ switch kind {
+ case "countries":
+ page.Title, key = "Countries", "country"
+ if list, err = s.radio.Countries(ctx); err == nil {
+ sort.Slice(list, func(i, j int) bool { return strings.ToLower(list[i].Name) < strings.ToLower(list[j].Name) })
+ }
+ case "genres":
+ page.Title, key = "Genres", "tag"
+ list, err = s.radio.Tags(ctx, radioGenreCount)
+ case "languages":
+ page.Title, key = "Languages", "lang"
+ list, err = s.radio.Languages(ctx, 100)
+ }
+ if err != nil {
+ page.Down = "The radio directory is unavailable right now; try again soon."
+ }
+ for _, f := range list {
+ if kind == "genres" && f.Stations < radioMinGenre {
+ continue
+ }
+ value := f.Name
+ if kind == "countries" {
+ if f.Code == "" {
+ continue
+ }
+ value = f.Code
+ }
+ page.Facets = append(page.Facets, radioChip{Label: f.Name + " (" + strconv.Itoa(f.Stations) + ")", Link: radioLinkTo(url.Values{key: {value}})})
}
+ s.radioRender(w, page)
+}
+
+func (s *Server) radioStationPage(ctx context.Context, w http.ResponseWriter, r *http.Request, id string, playlist bool) {
if !radio.ValidUUID(id) {
http.NotFound(w, r)
return
}
- ctx, cancel := context.WithTimeout(r.Context(), radioWait)
- defer cancel()
st, err := s.radio.Station(ctx, id)
if err != nil {
// The directory may be down; Hammer FM still plays.
@@ -128,61 +364,142 @@ return }
st = radio.Station{UUID: pick.UUID, Name: pick.Name, URL: pick.URL, CountryCode: pick.CountryCode}
}
- if !s.radioPageHeaders(w, r) {
+ if playlist {
+ s.serveRadioPlaylist(w, r, st)
+
return
}
- p := pageStation(st)
- page := radioPage{Title: st.Name, Station: &p, Listeners: s.radioListenersNow()}
+
+ p := pageStation(s.playableStation(ctx, st))
+ page := radioPage{Kind: "station", Title: st.Name, Station: &p}
if pick, ok := s.hammerFM(); ok && pick.UUID == st.UUID {
page.OnAir = true
}
- _ = radioTemplate.Execute(w, page)
+ if len(p.Tags) > 0 {
+ if like, err := s.radio.Search(ctx, radio.Query{Tag: p.Tags[0], Limit: radioMoreLike + 1}); err == nil {
+ for _, o := range like {
+ if o.UUID != st.UUID && len(page.More) < radioMoreLike {
+ page.More = append(page.More, pageStation(o))
+ }
+ }
+ }
+ }
+ s.radioRender(w, page)
}
-var radioTemplate = template.Must(template.New("radio").Parse(`<!doctype html>
+// playableStation swaps an http stream for its https twin when there is
+// one, so the page can play it.
+func (s *Server) playableStation(ctx context.Context, st radio.Station) radio.Station {
+ if sec := s.radio.Secure(ctx, st.URL); sec != "" {
+ st.URL = sec
+ }
+
+ return st
+}
+
+// serveRadioPlaylist is a one-station .m3u, which opens in VLC, mpv and
+// most players when the page cannot play the stream itself.
+func (s *Server) serveRadioPlaylist(w http.ResponseWriter, r *http.Request, st radio.Station) {
+ if st.URL == "" {
+ http.NotFound(w, r)
+
+ return
+ }
+ w.Header().Set("Content-Type", "audio/x-mpegurl")
+ w.Header().Set("Content-Disposition", `attachment; filename="hammer-radio.m3u"`)
+ w.Header().Set("Cache-Control", "max-age=300")
+ name := strings.NewReplacer("\r", " ", "\n", " ").Replace(st.Name)
+ _, _ = io.WriteString(w, "#EXTM3U\n#EXTINF:-1,"+name+"\n"+st.URL+"\n")
+}
+
+// radioFilter is a browse link for one filter, for the templates.
+func radioFilter(key, value string) string { return radioLinkTo(url.Values{key: {value}}) }
+
+var radioTemplate = template.Must(template.New("radio").Funcs(template.FuncMap{"filter": radioFilter}).Parse(`<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · Hammer BBS</title>
<style>
:root{color-scheme:dark}
body{margin:0;background:#000;color:#aaa;font:16px/1.5 "Courier New",ui-monospace,monospace;padding:16px}
-main{max-width:720px;margin:0 auto}
+main{max-width:860px;margin:0 auto}
h1,h2{color:#ff5555;font-weight:normal;letter-spacing:.1em;margin:.6em 0 .3em}
h2{color:#55ffff;font-size:1em}
a{color:#55ffff}
-.dim{color:#555}
+.dim{color:#666}
.box{border:3px double #00aaaa;padding:12px 16px;margin:12px 0}
+nav{border-top:1px solid #00aaaa;border-bottom:1px solid #00aaaa;padding:6px 0;margin:8px 0}
+nav a{margin-right:1.2em;white-space:nowrap}
+form{margin:10px 0;display:flex;gap:8px;flex-wrap:wrap}
+input{background:#000;color:#fff;border:1px solid #00aaaa;font:inherit;padding:4px 8px;flex:1;min-width:12em}
+button{background:#00aaaa;color:#000;border:0;font:inherit;padding:4px 14px;cursor:pointer}
audio{width:100%;margin:8px 0;filter:invert(1) hue-rotate(180deg)}
ol{padding-left:2.5em}
-li{margin:2px 0}
+li{margin:3px 0}
+.chips a{display:inline-block;margin:3px 10px 3px 0;white-space:nowrap}
.on{color:#55ff55}
+.sel{color:#ffff55;text-decoration:none}
+.cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:0 24px}
+code{color:#fff;word-break:break-all}
+.pages{margin:12px 0}
+.pages a{margin-right:1.5em}
</style></head><body><main>
<h1>♪ ON THE AIR</h1>
-<p class="dim">Hammer BBS · {{.Listeners}} tuned in from the BBS now · <a href="/:radio">all stations</a></p>
-{{with .Station}}
-<div class="box">
-<h2>{{.Name}}</h2>
-{{if $.OnAir}}<p class="on">Hammer FM, this week's pick</p>{{end}}
-<p>{{.Country}}{{if .Info}} · {{.Info}}{{end}}</p>
-{{if .Tags}}<p class="dim">{{.Tags}}</p>{{end}}
+<p class="dim">Hammer BBS radio · {{.Listeners}} tuned in from the BBS now</p>
+<nav><a href="/:radio">home</a><a href="/:radio/browse">all stations</a><a href="/:radio/countries">countries</a><a href="/:radio/genres">genres</a><a href="/:radio/languages">languages</a><a href="/:radio/random">surprise me</a></nav>
+<form action="/:radio/browse" method="get"><input name="q" value="{{.Q}}" maxlength="40" placeholder="search stations by name"><button>search</button></form>
+
+{{if eq .Kind "home"}}
+{{with .Pick}}<div class="box">
+<h2>HAMMER FM · {{.Name}}</h2>
+<p class="on">Hammer FM, this week's pick</p>
+{{if $.PickNote}}<p>“{{$.PickNote}}” <span class="dim">— {{$.PickBy}}</span></p>{{end}}
{{template "player" .}}
-{{if .Home}}<p><a href="{{.Home}}" rel="noopener noreferrer">{{.Home}}</a></p>{{end}}
+<p><a href="{{.Link}}">station page</a></p>
+</div>{{end}}
+<div class="cols">
+{{range .Sections}}<div><h2>{{.Title}}</h2>
+<ol>{{range .Stations}}<li><a href="{{.Link}}">{{.Name}}</a> <span class="dim">{{.Country}}</span></li>{{end}}</ol>
+<p><a href="{{.More}}">see all →</a></p></div>{{end}}
</div>
+{{if .Countries}}<h2>COUNTRIES</h2><p class="chips">{{range .Countries}}<a href="{{.Link}}">{{.Label}}</a>{{end}}<a href="/:radio/countries">all countries →</a></p>{{end}}
+{{if .Genres}}<h2>GENRES</h2><p class="chips">{{range .Genres}}<a href="{{.Link}}">{{.Label}}</a>{{end}}<a href="/:radio/genres">all genres →</a></p>{{end}}
{{end}}
-{{with .Pick}}
+
+{{if eq .Kind "browse"}}
+<h2>STATIONS{{if gt .Page 1}} · page {{.Page}}{{end}}</h2>
+{{if .Filters}}<p class="chips">{{range .Filters}}<a href="{{.Link}}">{{.Label}}</a>{{end}}</p>{{end}}
+<p class="chips">order: {{range .Orders}}{{if .On}}<a class="sel" href="{{.Link}}">{{.Label}}</a>{{else}}<a href="{{.Link}}">{{.Label}}</a>{{end}}{{end}}</p>
+{{if .Down}}<p>{{.Down}}</p>{{else if not .Stations}}<p>No stations found.</p>{{end}}
+<ol start="{{.Page}}">{{range .Stations}}<li>{{template "row" .}}</li>{{end}}</ol>
+<p class="pages">{{if .Prev}}<a href="{{.Prev}}">← previous</a>{{end}}{{if .Next}}<a href="{{.Next}}">next →</a>{{end}}</p>
+{{end}}
+
+{{if eq .Kind "facets"}}
+<h2>{{.Title}}</h2>
+{{if .Down}}<p>{{.Down}}</p>{{end}}
+<p class="chips">{{range .Facets}}<a href="{{.Link}}">{{.Label}}</a>{{end}}</p>
+{{end}}
+
+{{if eq .Kind "station"}}{{with .Station}}
<div class="box">
-<h2>HAMMER FM · {{.Name}}</h2>
-<p class="on">Hammer FM, this week's pick</p>
-{{if $.PickNote}}<p>“{{$.PickNote}}” <span class="dim">— {{$.PickBy}}</span></p>{{end}}
+<h2>{{.Name}}</h2>
+{{if $.OnAir}}<p class="on">Hammer FM, this week's pick</p>{{end}}
+<p>{{if .Country}}<a href="{{filter "country" .Country}}">{{.Country}}</a>{{end}}{{if .Language}} · <a href="{{filter "lang" .Language}}">{{.Language}}</a>{{end}}{{if .Info}} · {{.Info}}{{end}}</p>
+{{if .Tags}}<p class="chips">{{range .Tags}}<a href="{{filter "tag" .}}">{{.}}</a>{{end}}</p>{{end}}
{{template "player" .}}
+{{if .Home}}<p><a href="{{.Home}}" rel="noopener noreferrer">{{.Home}}</a></p>{{end}}
</div>
{{end}}
-{{if .Top}}
-<h2>MOST PLAYED</h2>
-<ol>{{range .Top}}<li><a href="{{.Link}}">{{.Name}}</a> <span class="dim">{{.Country}}{{if .Info}} · {{.Info}}{{end}}</span></li>{{end}}</ol>
+{{if .More}}<h2>MORE LIKE THIS</h2><ol>{{range .More}}<li>{{template "row" .}}</li>{{end}}</ol>{{end}}
{{end}}
+
<p class="dim">Stations from the Radio Browser directory. On the BBS: [o] on the air.</p>
</main></body></html>
+{{define "row"}}<a href="{{.Link}}">{{.Name}}</a> <span class="dim">{{if .Country}}<a class="dim" href="{{filter "country" .Country}}">{{.Country}}</a>{{end}}{{if .Info}} · {{.Info}}{{end}}</span>{{range .Tags}} <a class="dim" href="{{filter "tag" .}}">{{.}}</a>{{end}}{{end}}
{{define "player"}}{{if .Playable}}<audio controls preload="none" src="{{.Stream}}"></audio>
-{{else if .Stream}}<p>This station streams over plain HTTP, which browsers block on this page. <a href="{{.Stream}}" rel="noopener noreferrer">Open the stream</a> in your player.</p>
+{{else if .Stream}}<p>This station only streams over plain HTTP, which browsers will not play here.
+<a href="{{.Playlist}}">Open it in your player</a>: the file opens in VLC, mpv, iTunes and most players.
+Or paste this address into one:</p>
+<p><code>{{.Stream}}</code></p>
{{end}}{{end}}`))
@@ -1,10 +1,13 @@ package bbs
import (
+ "fmt"
"io"
"net/http"
"net/http/httptest"
+ "strconv"
"strings"
+ "sync"
"testing"
"github.com/runawaydevil/hammer/internal/radio"
@@ -53,13 +56,63 @@ t.Fatal("the station's name reached the page unescaped") }
}
-// Browsers block plain http media on an https page: link out instead.
+// Browsers block plain http media on an https page. With no https stream
+// to be found, the page hands over a playlist for a real player and shows
+// the address; it never links the endless stream itself, which a browser
+// would try to download for ever.
func TestRadioPageHTTPStream(t *testing.T) {
f := newFakeRadio(t)
get := radioWeb(t, radioServer(t, f, nil))
_, body := get(http.MethodGet, "/:radio/"+jazzID)
- if strings.Contains(body, "<audio") || !strings.Contains(body, "plain HTTP") || !strings.Contains(body, f.stream.URL+"/live") {
- t.Fatalf("http stream page:\n%s", body)
+ if strings.Contains(body, "<audio") || !strings.Contains(body, `href="/:radio/`+jazzID+`.m3u"`) {
+ t.Fatalf("http stream page has no playlist link:\n%s", body)
+ }
+ if strings.Contains(body, `href="`+f.stream.URL) {
+ t.Fatalf("the page links the stream itself:\n%s", body)
+ }
+ if !strings.Contains(body, f.stream.URL+"/live") {
+ t.Fatalf("the page does not show the address to paste:\n%s", body)
+ }
+}
+
+// A station listed as http that also serves https plays on the page.
+func TestRadioPageUpgradesToHTTPS(t *testing.T) {
+ stream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "audio/mpeg")
+ w.Write(make([]byte, 64))
+ }))
+ t.Cleanup(stream.Close)
+ listed := strings.Replace(stream.URL, "https://", "http://", 1) + "/live"
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ io.WriteString(w, `[{"stationuuid":"`+jazzID+`","name":"Jazz FM","url_resolved":"`+listed+`","countrycode":"BR"}]`)
+ }))
+ t.Cleanup(api.Close)
+
+ f := newFakeRadio(t)
+ srv := radioServer(t, f, nil)
+ srv.radio = radio.New(api.URL, "ua", stream.Client())
+ _, body := radioWeb(t, srv)(http.MethodGet, "/:radio/"+jazzID)
+ if !strings.Contains(body, `<audio`) || !strings.Contains(body, `src="`+stream.URL+`/live"`) {
+ t.Fatalf("no player on the https address:\n%s", body)
+ }
+}
+
+// The playlist opens the station in VLC, mpv and friends.
+func TestRadioM3U(t *testing.T) {
+ f := newFakeRadio(t)
+ get := radioWeb(t, radioServer(t, f, nil))
+ resp, body := get(http.MethodGet, "/:radio/"+jazzID+".m3u")
+ if resp.StatusCode != http.StatusOK || resp.Header.Get("Content-Type") != "audio/x-mpegurl" {
+ t.Fatalf("status %d, type %q", resp.StatusCode, resp.Header.Get("Content-Type"))
+ }
+ if !strings.HasPrefix(body, "#EXTM3U\n") || !strings.Contains(body, "#EXTINF:-1,Jazz FM\n") || !strings.Contains(body, f.stream.URL+"/live\n") {
+ t.Fatalf("playlist:\n%s", body)
+ }
+ if cd := resp.Header.Get("Content-Disposition"); !strings.Contains(cd, `filename="hammer-radio.m3u"`) {
+ t.Fatalf("Content-Disposition = %q", cd)
+ }
+ if resp, _ := get(http.MethodGet, "/:radio/nope.m3u"); resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("bad id: status %d", resp.StatusCode)
}
}
@@ -111,3 +164,182 @@ if resp.StatusCode != http.StatusOK || !strings.Contains(body, "Night Jazz") { t.Fatalf("status %d:\n%s", resp.StatusCode, body)
}
}
+
+// browseAPI is a Radio Browser with total stations, countries, genres and
+// languages; it records each request's path and query.
+type browseAPI struct {
+ srv *httptest.Server
+ mu sync.Mutex
+ got []string
+}
+
+func (b *browseAPI) saw(want string) bool {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ for _, g := range b.got {
+ if strings.Contains(g, want) {
+ return true
+ }
+ }
+
+ return false
+}
+
+func newBrowseAPI(t *testing.T, total int) *browseAPI {
+ t.Helper()
+ b := &browseAPI{}
+ station := func(i int) string {
+ return fmt.Sprintf(`{"stationuuid":"00000000-0000-0000-0000-%012d","name":"Station %d","url_resolved":"https://s.test/%d","countrycode":"BR","codec":"MP3","bitrate":128,"tags":"jazz,bossa nova","language":"portuguese"}`, i, i, i)
+ }
+ b.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b.mu.Lock()
+ b.got = append(b.got, r.URL.String())
+ b.mu.Unlock()
+ q := r.URL.Query()
+ switch {
+ case r.URL.Path == "/json/countries":
+ io.WriteString(w, `[{"name":"Brazil","iso_3166_1":"BR","stationcount":1234},{"name":"Argentina","iso_3166_1":"AR","stationcount":800}]`)
+ case r.URL.Path == "/json/tags":
+ io.WriteString(w, `[{"name":"jazz","stationcount":900},{"name":"rock","stationcount":800},{"name":"tiny","stationcount":3}]`)
+ case r.URL.Path == "/json/languages":
+ io.WriteString(w, `[{"name":"portuguese","stationcount":700}]`)
+ case strings.HasPrefix(r.URL.Path, "/json/stations/byuuid/"):
+ io.WriteString(w, "["+station(1)+"]")
+ case strings.HasPrefix(r.URL.Path, "/json/url/"):
+ io.WriteString(w, `{"ok":true}`)
+ default: // search and topclick
+ limit, _ := strconv.Atoi(q.Get("limit"))
+ if limit == 0 {
+ limit = 10
+ }
+ offset, _ := strconv.Atoi(q.Get("offset"))
+ var parts []string
+ for i := offset; i < min(offset+limit, total); i++ {
+ parts = append(parts, station(i+1))
+ }
+ io.WriteString(w, "["+strings.Join(parts, ",")+"]")
+ }
+ }))
+ t.Cleanup(b.srv.Close)
+
+ return b
+}
+
+// browseServer is a radio server whose directory is api.
+func browseServer(t *testing.T, api *browseAPI) *Server {
+ t.Helper()
+ srv := radioServer(t, newFakeRadio(t), nil)
+ srv.radio = radio.New(api.srv.URL, "ua", nil)
+
+ return srv
+}
+
+func TestRadioHome(t *testing.T) {
+ api := newBrowseAPI(t, 100)
+ _, body := radioWeb(t, browseServer(t, api))(http.MethodGet, "/:radio")
+ for _, want := range []string{"MOST PLAYED", "TRENDING", "TOP VOTED", "Station 1", `href="/:radio/browse?order=trending"`,
+ `href="/:radio/browse?country=BR"`, `href="/:radio/browse?tag=jazz"`, `href="/:radio/random"`, `name="q"`} {
+ if !strings.Contains(body, want) {
+ t.Errorf("home lacks %q", want)
+ }
+ }
+ if !api.saw("order=clicktrend") || !api.saw("order=votes") {
+ t.Error("home did not ask for trending and top voted")
+ }
+}
+
+func TestRadioBrowseFilters(t *testing.T) {
+ api := newBrowseAPI(t, 100)
+ _, body := radioWeb(t, browseServer(t, api))(http.MethodGet, "/:radio/browse?country=br&tag=jazz&page=2")
+ if !api.saw("countrycode=BR") || !api.saw("tag=jazz") || !api.saw("offset=30") || !api.saw("limit=31") {
+ t.Fatalf("browse asked %v", api.got)
+ }
+ for _, want := range []string{"Station 31", "BR ×", "jazz ×", `href="/:radio/browse?tag=jazz"`, `href="/:radio/browse?country=BR"`, "page 2"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("browse page lacks %q", want)
+ }
+ }
+}
+
+func TestRadioBrowseNextPage(t *testing.T) {
+ get := radioWeb(t, browseServer(t, newBrowseAPI(t, 31)))
+ if _, body := get(http.MethodGet, "/:radio/browse?tag=jazz"); !strings.Contains(body, "page=2") || strings.Contains(body, "Station 31<") {
+ t.Fatalf("31 stations: want 30 shown and a next page:\n%s", body)
+ }
+ get = radioWeb(t, browseServer(t, newBrowseAPI(t, 30)))
+ if _, body := get(http.MethodGet, "/:radio/browse?tag=jazz"); strings.Contains(body, "page=2") {
+ t.Fatal("30 stations: there is no next page")
+ }
+}
+
+func TestRadioBrowseBadInput(t *testing.T) {
+ api := newBrowseAPI(t, 100)
+ get := radioWeb(t, browseServer(t, api))
+ resp, _ := get(http.MethodGet, "/:radio/browse?country=b1&page=999&order=evil&q="+strings.Repeat("x", 300))
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status %d", resp.StatusCode)
+ }
+ if api.saw("countrycode=B1") || api.saw("offset=29940") || api.saw("order=evil") || api.saw(strings.Repeat("x", 41)) {
+ t.Fatalf("bad input reached the directory: %v", api.got)
+ }
+}
+
+func TestRadioFacetPages(t *testing.T) {
+ get := radioWeb(t, browseServer(t, newBrowseAPI(t, 10)))
+ for path, want := range map[string]string{
+ "/:radio/countries": `href="/:radio/browse?country=BR"`,
+ "/:radio/genres": `href="/:radio/browse?tag=jazz"`,
+ "/:radio/languages": `href="/:radio/browse?lang=portuguese"`,
+ } {
+ resp, body := get(http.MethodGet, path)
+ if resp.StatusCode != http.StatusOK || !strings.Contains(body, want) {
+ t.Errorf("%s: status %d, lacks %q", path, resp.StatusCode, want)
+ }
+ }
+ if _, body := get(http.MethodGet, "/:radio/genres"); strings.Contains(body, "tiny") {
+ t.Error("a genre with 3 stations was listed")
+ }
+}
+
+func TestRadioRandom(t *testing.T) {
+ srv := browseServer(t, newBrowseAPI(t, 10))
+ mux := http.NewServeMux()
+ for pattern, h := range srv.RadioRoutes() {
+ mux.Handle(pattern, h)
+ }
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/:radio/random", nil))
+ if rec.Code != http.StatusFound || !strings.HasPrefix(rec.Header().Get("Location"), "/:radio/00000000-") {
+ t.Fatalf("random: %d %q", rec.Code, rec.Header().Get("Location"))
+ }
+}
+
+func TestRadioStationMoreLikeThis(t *testing.T) {
+ api := newBrowseAPI(t, 20)
+ _, body := radioWeb(t, browseServer(t, api))(http.MethodGet, "/:radio/00000000-0000-0000-0000-000000000001")
+ if !api.saw("tag=jazz") || !strings.Contains(body, "MORE LIKE THIS") || !strings.Contains(body, "Station 2") {
+ t.Fatalf("no more like this:\n%s", body)
+ }
+ if !strings.Contains(body, `href="/:radio/browse?lang=portuguese"`) || !strings.Contains(body, `href="/:radio/browse?tag=bossa%20nova"`) {
+ t.Fatal("the station's language and genres are not links")
+ }
+}
+
+func TestRadioWebRateLimit(t *testing.T) {
+ srv := browseServer(t, newBrowseAPI(t, 10))
+ mux := http.NewServeMux()
+ for pattern, h := range srv.RadioRoutes() {
+ mux.Handle(pattern, h)
+ }
+ code := 0
+ for i := 0; i < 21; i++ {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/:radio/countries", nil)
+ req.RemoteAddr = "203.0.113.7:1234"
+ mux.ServeHTTP(rec, req)
+ code = rec.Code
+ }
+ if code != http.StatusTooManyRequests {
+ t.Fatalf("21st request: %d, want 429", code)
+ }
+}
@@ -67,22 +67,24 @@ chat *community.Hub cstore *community.Store // bans, chat log, last callers
forums *forum.Store
- doors []*doorEntry // the [d] menu; empty when none can run
- linecast *linecastSetup // [k] sky & almanac; nil when off
- radio *radio.Client // [o] on the air; nil when off
- radioCfg *RadioConfig
- radioFavs *radio.Favorites
- radioPicks *radio.Picks
- npLimit npLimiter
- dosbox string // the DOSBox program, "" when DOS doors are off
- doorServers *DoorServersConfig // credentials for remote door servers
- mrc *mrc.Client // the inter-BBS chat; nil when off
- fido *fidoNode // the FidoNet node; nil when off
- qwk *qwkNode // the QWK node; nil when off
- live liveStreams // open /:live event streams
- awards *awards.Store // nil when bbs.awards is off
- postcards *postcards.Store // nil when bbs.postcards is off
- pngCache pngCache // rendered postcards
+ doors []*doorEntry // the [d] menu; empty when none can run
+ linecast *linecastSetup // [k] sky & almanac; nil when off
+ radio *radio.Client // [o] on the air; nil when off
+ radioCfg *RadioConfig
+ radioFavs *radio.Favorites
+ radioPicks *radio.Picks
+ npLimit npLimiter
+ // radioWebLimit caps /:radio page views per address.
+ radioWebLimit *ratelimit.Limiter
+ dosbox string // the DOSBox program, "" when DOS doors are off
+ doorServers *DoorServersConfig // credentials for remote door servers
+ mrc *mrc.Client // the inter-BBS chat; nil when off
+ fido *fidoNode // the FidoNet node; nil when off
+ qwk *qwkNode // the QWK node; nil when off
+ live liveStreams // open /:live event streams
+ awards *awards.Store // nil when bbs.awards is off
+ postcards *postcards.Store // nil when bbs.postcards is off
+ pngCache pngCache // rendered postcards
games *gamesSettings // nil when games are off or the door is missing
gameSlots *door.Slots // nodes, one per person playing
@@ -161,6 +163,7 @@ } if cfg.RadioEnabled() {
s.radioCfg = cfg.Radio
s.radio = radio.New(cfg.Radio.API, radioUserAgent, nil)
+ s.radioWebLimit = ratelimit.New(radioWebPerMin, radioWebBurst)
if s.radioFavs, err = radio.NewFavorites(store.SQL()); err != nil {
return nil, fmt.Errorf("radio: %w", err)
}
@@ -33,6 +33,8 @@ Tags string `json:"tags"` CountryCode string `json:"countrycode"`
Codec string `json:"codec"`
Bitrate int `json:"bitrate"`
+ Language string `json:"language"`
+ Votes int `json:"votes"`
HLS int `json:"hls"`
LastCheckOK int `json:"lastcheckok"`
}
@@ -42,7 +44,23 @@ type Query struct { Name string // contains, any case
CountryCode string // two letters, ISO 3166-1
Tag string
- Limit int // 1..50, default 20
+ Language string // as the directory names it, e.g. "portuguese"
+ Order string // popular (default), trending, votes, name
+ Offset int // skip this many, for the next page
+ Limit int // 1..50, default 20
+}
+
+// Orders a search can take, and how the directory calls them.
+var orders = map[string]string{"popular": "clickcount", "trending": "clicktrend", "votes": "votes", "name": "name"}
+
+// ValidOrder reports whether o is an order Search knows.
+func ValidOrder(o string) bool { _, ok := orders[o]; return ok }
+
+// Facet is one country, genre or language and how many stations it has.
+type Facet struct {
+ Name string `json:"name"`
+ Code string `json:"iso_3166_1"` // countries only
+ Stations int `json:"stationcount"`
}
// ErrNotFound is a station the directory does not have.
@@ -69,6 +87,9 @@ mu sync.Mutex
cache map[string]cached
order []string
+
+ secMu sync.Mutex
+ secure map[string]secureEntry
}
type cached struct {
@@ -114,18 +135,83 @@ } if s := strings.TrimSpace(q.Tag); s != "" {
v.Set("tag", s)
}
+ if s := strings.TrimSpace(q.Language); s != "" {
+ v.Set("language", s)
+ }
limit := q.Limit
if limit <= 0 {
limit = defaultLimit
}
v.Set("limit", strconv.Itoa(min(limit, maxLimit)))
+ if q.Offset > 0 {
+ v.Set("offset", strconv.Itoa(q.Offset))
+ }
v.Set("hidebroken", "true")
- v.Set("order", "clickcount")
- v.Set("reverse", "true")
+ order, ok := orders[q.Order]
+ if !ok {
+ order = orders["popular"]
+ }
+ v.Set("order", order)
+ if order != "name" {
+ v.Set("reverse", "true")
+ }
return c.stations(ctx, "/json/stations/search?"+v.Encode())
}
+// Random is one working station picked at random; never cached.
+func (c *Client) Random(ctx context.Context) (Station, error) {
+ body, err := c.get(ctx, "/json/stations/search?order=random&limit=1&hidebroken=true", false)
+ if err != nil {
+ return Station{}, err
+ }
+ var list []Station
+ if err := json.Unmarshal(body, &list); err != nil {
+ return Station{}, fmt.Errorf("radio: %w", err)
+ }
+ if len(list) == 0 {
+ return Station{}, ErrNotFound
+ }
+
+ return clean(list[0]), nil
+}
+
+// Countries are all the countries with stations.
+func (c *Client) Countries(ctx context.Context) ([]Facet, error) {
+ return c.facets(ctx, "/json/countries")
+}
+
+// Tags are the n genres with the most stations.
+func (c *Client) Tags(ctx context.Context, n int) ([]Facet, error) {
+ return c.facets(ctx, "/json/tags?order=stationcount&reverse=true&hidebroken=true&limit="+strconv.Itoa(n))
+}
+
+// Languages are the n languages with the most stations.
+func (c *Client) Languages(ctx context.Context, n int) ([]Facet, error) {
+ return c.facets(ctx, "/json/languages?order=stationcount&reverse=true&hidebroken=true&limit="+strconv.Itoa(n))
+}
+
+func (c *Client) facets(ctx context.Context, path string) ([]Facet, error) {
+ body, err := c.get(ctx, path, true)
+ if err != nil {
+ return nil, err
+ }
+ var list []Facet
+ if err := json.Unmarshal(body, &list); err != nil {
+ return nil, fmt.Errorf("radio: %w", err)
+ }
+ out := list[:0]
+ for _, f := range list {
+ f.Name = community.CleanText(f.Name, 60)
+ f.Code = strings.ToUpper(community.CleanText(f.Code, 2))
+ if f.Name != "" {
+ out = append(out, f)
+ }
+ }
+
+ return out, nil
+}
+
// Top lists the n most played stations.
func (c *Client) Top(ctx context.Context, n int) ([]Station, error) {
n = min(max(n, 1), maxLimit)
@@ -238,6 +324,7 @@ 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.Language = community.CleanText(s.Language, 40)
s.CountryCode = strings.ToUpper(community.CleanText(s.CountryCode, 2))
s.URL = webURL(s.URL)
s.Homepage = webURL(s.Homepage)
@@ -6,6 +6,7 @@ "errors" "io"
"net/http"
"net/http/httptest"
+ "strings"
"sync/atomic"
"testing"
)
@@ -173,3 +174,69 @@ if got[0].Name != "EvilFM" || got[0].Tags != "ab" { t.Fatalf("name %q tags %q", got[0].Name, got[0].Tags)
}
}
+
+func TestSearchOrderLanguageOffset(t *testing.T) {
+ var got []string
+ api, _ := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
+ got = append(got, r.URL.RawQuery)
+ io.WriteString(w, "[]")
+ })
+ c := New(api.URL, "ua", nil)
+ c.Search(context.Background(), Query{Tag: "jazz", Language: "portuguese", Order: "trending", Offset: 30})
+ c.Search(context.Background(), Query{Tag: "jazz", Order: "name"})
+ if len(got) != 2 {
+ t.Fatalf("%d requests", len(got))
+ }
+ for _, want := range []string{"order=clicktrend", "reverse=true", "language=portuguese", "offset=30"} {
+ if !strings.Contains(got[0], want) {
+ t.Errorf("trending query %q lacks %s", got[0], want)
+ }
+ }
+ if !strings.Contains(got[1], "order=name") || strings.Contains(got[1], "reverse=true") || strings.Contains(got[1], "offset=") {
+ t.Errorf("by-name query %q: want order=name, ascending, no offset", got[1])
+ }
+}
+
+func TestCountriesTagsLanguages(t *testing.T) {
+ api, _ := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/json/countries":
+ io.WriteString(w, `[{"name":"Brazil","iso_3166_1":"BR","stationcount":1234},{"name":"Evil\u001b[2J","iso_3166_1":"XX","stationcount":1}]`)
+ case "/json/tags":
+ if q := r.URL.Query(); q.Get("order") != "stationcount" || q.Get("reverse") != "true" || q.Get("limit") != "150" {
+ t.Errorf("tags query %s", r.URL.RawQuery)
+ }
+ io.WriteString(w, `[{"name":"jazz","stationcount":900}]`)
+ case "/json/languages":
+ io.WriteString(w, `[{"name":"portuguese","stationcount":700}]`)
+ }
+ })
+ c := New(api.URL, "ua", nil)
+ countries, err := c.Countries(context.Background())
+ if err != nil || len(countries) != 2 || countries[0] != (Facet{Name: "Brazil", Code: "BR", Stations: 1234}) || countries[1].Name != "Evil" {
+ t.Fatalf("countries = %+v %v", countries, err)
+ }
+ tags, err := c.Tags(context.Background(), 150)
+ if err != nil || len(tags) != 1 || tags[0].Name != "jazz" || tags[0].Stations != 900 {
+ t.Fatalf("tags = %+v %v", tags, err)
+ }
+ langs, err := c.Languages(context.Background(), 50)
+ if err != nil || len(langs) != 1 || langs[0].Name != "portuguese" {
+ t.Fatalf("languages = %+v %v", langs, err)
+ }
+}
+
+func TestRandomNotCached(t *testing.T) {
+ api, n := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Query().Get("order") != "random" || r.URL.Query().Get("limit") != "1" {
+ t.Errorf("random query %s", r.URL.RawQuery)
+ }
+ io.WriteString(w, jazzJSON)
+ })
+ c := New(api.URL, "ua", nil)
+ s, err := c.Random(context.Background())
+ c.Random(context.Background())
+ if err != nil || s.Name != "Jazz FM" || n.Load() != 2 {
+ t.Fatalf("random = %+v %v after %d requests, want 2", s, err, n.Load())
+ }
+}
@@ -0,0 +1,106 @@+package radio
+
+import (
+ "context"
+ "errors"
+ "mime"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// Browsers will not play http audio on an https page, and many stations
+// listed with an http address serve the same stream over https. Secure
+// finds that address, once a station, so the player page can play it.
+
+const (
+ secureWait = 5 * time.Second
+ secureTTL = 6 * time.Hour
+ secureSize = 512
+)
+
+type secureEntry struct {
+ url string // "" when the station has no https stream
+ at time.Time
+}
+
+// Secure is an https address that plays streamURL, or "" when there is
+// none. An https address comes back as it is; an http one is tried over
+// https, following redirects only to https, and must answer with audio.
+// Answers are remembered for hours.
+func (c *Client) Secure(ctx context.Context, streamURL string) string {
+ if strings.HasPrefix(streamURL, "https://") {
+ return streamURL
+ }
+ if !strings.HasPrefix(streamURL, "http://") {
+ return ""
+ }
+
+ c.secMu.Lock()
+ if e, ok := c.secure[streamURL]; ok && time.Since(e.at) < secureTTL {
+ c.secMu.Unlock()
+
+ return e.url
+ }
+ c.secMu.Unlock()
+
+ found := c.probeSecure(ctx, "https://"+strings.TrimPrefix(streamURL, "http://"))
+ if ctx.Err() != nil && found == "" {
+ return "" // the caller gave up; try again another time
+ }
+
+ c.secMu.Lock()
+ if c.secure == nil || len(c.secure) >= secureSize {
+ c.secure = map[string]secureEntry{}
+ }
+ c.secure[streamURL] = secureEntry{found, time.Now()}
+ c.secMu.Unlock()
+
+ return found
+}
+
+// probeSecure asks target for its headers and reports where audio came
+// from, or "".
+func (c *Client) probeSecure(ctx context.Context, target string) string {
+ ctx, cancel := context.WithTimeout(ctx, secureWait)
+ defer cancel()
+ probe := &http.Client{
+ Transport: c.hc.Transport,
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if req.URL.Scheme != "https" || len(via) >= 5 {
+ return errors.New("radio: redirect away from https")
+ }
+
+ return nil
+ },
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
+ if err != nil {
+ return ""
+ }
+ req.Header.Set("User-Agent", icyUserAgent)
+ resp, err := probe.Do(req)
+ if err != nil {
+ return ""
+ }
+ resp.Body.Close() // only the headers matter; the stream never ends
+ if resp.StatusCode != http.StatusOK || !playableType(resp.Header.Get("Content-Type")) {
+ return ""
+ }
+
+ return resp.Request.URL.String()
+}
+
+// playableType reports whether a browser's audio element plays this:
+// audio, but not a playlist.
+func playableType(contentType string) bool {
+ mt, _, err := mime.ParseMediaType(contentType)
+ if err != nil {
+ return false
+ }
+ if strings.Contains(mt, "mpegurl") || strings.Contains(mt, "scpls") {
+ return false
+ }
+
+ return strings.HasPrefix(mt, "audio/") || mt == "application/ogg"
+}
@@ -0,0 +1,83 @@+package radio
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+)
+
+// tlsStream is an https server answering with contentType, counting hits.
+func tlsStream(t *testing.T, contentType string) (*httptest.Server, *atomic.Int32) {
+ t.Helper()
+ var n atomic.Int32
+ srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ n.Add(1)
+ w.Header().Set("Content-Type", contentType)
+ w.Write(make([]byte, 64))
+ }))
+ t.Cleanup(srv.Close)
+
+ return srv, &n
+}
+
+// httpOf is srv's address with http instead of https: what the directory
+// lists for a station that also serves https.
+func httpOf(srv *httptest.Server) string { return strings.Replace(srv.URL, "https://", "http://", 1) }
+
+func TestSecureUpgrades(t *testing.T) {
+ srv, _ := tlsStream(t, "audio/mpeg")
+ c := New("http://unused.test", "ua", srv.Client())
+ if got := c.Secure(context.Background(), httpOf(srv)+"/live"); got != srv.URL+"/live" {
+ t.Fatalf("Secure = %q, want %q", got, srv.URL+"/live")
+ }
+}
+
+func TestSecureKeepsHTTPS(t *testing.T) {
+ c := New("http://unused.test", "ua", nil)
+ if got := c.Secure(context.Background(), "https://x.test/s"); got != "https://x.test/s" {
+ t.Fatalf("Secure = %q", got)
+ }
+}
+
+// A web page where the stream should be is not a stream.
+func TestSecureNotAudio(t *testing.T) {
+ srv, _ := tlsStream(t, "text/html")
+ c := New("http://unused.test", "ua", srv.Client())
+ if got := c.Secure(context.Background(), httpOf(srv)+"/live"); got != "" {
+ t.Fatalf("Secure = %q, want none", got)
+ }
+}
+
+// A station with no https at all.
+func TestSecureNone(t *testing.T) {
+ plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "audio/mpeg")
+ }))
+ defer plain.Close()
+ c := New("http://unused.test", "ua", nil)
+ if got := c.Secure(context.Background(), plain.URL+"/live"); got != "" {
+ t.Fatalf("Secure = %q, want none", got)
+ }
+}
+
+// The answer, yes or no, is remembered: one probe a station.
+func TestSecureCached(t *testing.T) {
+ srv, n := tlsStream(t, "audio/aacp")
+ c := New("http://unused.test", "ua", srv.Client())
+ c.Secure(context.Background(), httpOf(srv)+"/live")
+ if got := c.Secure(context.Background(), httpOf(srv)+"/live"); got == "" || n.Load() != 1 {
+ t.Fatalf("second Secure = %q after %d probes, want the cached answer after 1", got, n.Load())
+ }
+}
+
+// HLS plays in no desktop browser's <audio>, so it is never "playable".
+func TestSecureNotHLS(t *testing.T) {
+ srv, _ := tlsStream(t, "application/vnd.apple.mpegurl")
+ c := New("http://unused.test", "ua", srv.Client())
+ if got := c.Secure(context.Background(), httpOf(srv)+"/live.m3u8"); got != "" {
+ t.Fatalf("Secure = %q, want none for HLS", got)
+ }
+}
@@ -412,5 +412,6 @@ "BBS_LINECAST_DOING": "watching the %s", "BBS_CHAT_NP": "%s is tuned to %s",
"BBS_CHAT_HELP_NP": "/np share the station you are tuned to",
"BBS_RADIO_NP_NONE": "Open a station in [o] first, then /np",
- "BBS_BULLETIN_RADIO": "Radio"
+ "BBS_BULLETIN_RADIO": "Radio",
+ "BBS_RADIO_WEB": "on the web: %s"
}
@@ -412,5 +412,6 @@ "BBS_LINECAST_DOING": "olhando: %s", "BBS_CHAT_NP": "%s está ouvindo %s",
"BBS_CHAT_HELP_NP": "/np conta qual rádio você está ouvindo",
"BBS_RADIO_NP_NONE": "Abra uma rádio no [o] primeiro, depois /np",
- "BBS_BULLETIN_RADIO": "Rádio"
+ "BBS_BULLETIN_RADIO": "Rádio",
+ "BBS_RADIO_WEB": "na web: %s"
}