code.pablomurad.com

bbs-hammer

SSH BBS with forums, chat, door games, retro terminals and a weather service, written in Go

feat(radio): web station browser, https fallback
author
Pablo Murad
date
commit
51c41a75ed4599f806466922718457d18d5e0ead
parent
78a5a8d505b3b611def6ddc1dd6bc0583e1c22b6
changes
13 files +999 −89
@@ -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"
 }