code.pablomurad.com

bbs-hammer

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

feat(radio): [o] radio menu and Hammer FM
author
Pablo Murad
date
commit
63c1fa16f14f08d414f12e5e91f11310837794de
parent
f0ffb11d3706367b1e00bff27cc3919fce3402de
changes
12 files +956 −13
@@ -19,6 +19,8 @@ EchoPosts = "echo_posts" 	Almanac   = "almanac"    // sittings in sky & almanac
 	Sky       = "sky"        // of those, the sky
 	NightMoon = "night_moon" // the moon looked at between midnight and five
+	Tuned     = "tuned"      // radio stations opened to listen
+	Shares    = "shares"     // stations shared with /np or on the wall
 )
 
 // Badge is something to earn: Counter reaching At.
@@ -44,6 +46,8 @@ {"networker", EchoPosts, 1}, 	{"stargazer", Sky, 10},
 	{"night_owl", NightMoon, 1},
 	{"almanac_reader", Almanac, 25},
+	{"tuned_in", Tuned, 1},
+	{"dj", Shares, 5},
 }
 
 // Award is a badge someone holds.
@@ -87,6 +87,9 @@ Postcards *PostcardsConfig `yaml:"postcards,omitempty"` 
 	// Linecast is [k] sky & almanac (linecast.go).
 	Linecast *LinecastConfig `yaml:"linecast,omitempty"`
+
+	// Radio is [o] on the air (radio.go).
+	Radio *RadioConfig `yaml:"radio,omitempty"`
 	// Echomail shares forum areas with message networks (echomail.go).
 	Echomail *EchomailConfig `yaml:"echomail,omitempty"`
 
@@ -205,6 +208,9 @@ if err := validateDoors(c.Doors); err != nil { 		return err
 	}
 	if err := validateLinecast(c.Linecast); err != nil {
+		return err
+	}
+	if err := c.Radio.validate(); err != nil {
 		return err
 	}
 	if err := c.MRC.validate(); err != nil {
@@ -371,4 +371,44 @@ "BBS_LINECAST_UTF8":         "Sky & almanac needs a UTF-8 terminal; this one speaks CP437", 	"AWARD_STARGAZER":           "Stargazer",
 	"AWARD_NIGHT_OWL":           "Night owl",
 	"AWARD_ALMANAC_READER":      "Almanac reader",
+	"BBS_RADIO":                 "on the air",
+	"BBS_RADIO_SUM":             "radio stations from around the world",
+	"BBS_TITLE_RADIO":           "ON THE AIR",
+	"BBS_TITLE_RADIO_FAVS":      "FAVORITES",
+	"BBS_RADIO_HAMMERFM_NOW":    "Hammer FM: %s (%s)",
+	"BBS_RADIO_TOP":             "most played",
+	"BBS_RADIO_SEARCH":          "search by name",
+	"BBS_RADIO_COUNTRY":         "by country",
+	"BBS_RADIO_TAG":             "by genre",
+	"BBS_RADIO_FAVS":            "your favorites",
+	"BBS_RADIO_LISTENING":       "%d on the air now",
+	"BBS_PROMPT_RADIO":          "radio> ",
+	"BBS_RADIO_BAD_COUNTRY":     "A country is two letters, such as BR or PT",
+	"BBS_RADIO_DOWN":            "radio directory unavailable — try again soon",
+	"BBS_RADIO_NONE":            "No stations found",
+	"BBS_RADIO_NO_FAVS":         "No favorites yet: open a station and press f",
+	"BBS_RADIO_TUNE":            "tune in",
+	"BBS_RADIO_DOING":           "listening to %s",
+	"BBS_RADIO_FROM":            "From",
+	"BBS_RADIO_TAGS":            "Genres",
+	"BBS_RADIO_HOME":            "Website",
+	"BBS_RADIO_ONAIR":           "On air",
+	"BBS_RADIO_NOW":             "Playing",
+	"BBS_RADIO_PRESS_T":         "press t to ask the station",
+	"BBS_RADIO_LISTEN":          "Listen",
+	"BBS_RADIO_PLAYER":          "Player",
+	"BBS_RADIO_HINT_NOW":        "playing now",
+	"BBS_RADIO_HINT_FAV":        "favorite",
+	"BBS_RADIO_HINT_WALL":       "recommend",
+	"BBS_RADIO_TUNING":          "asking the station...",
+	"BBS_RADIO_NOTITLE":         "(the station does not say)",
+	"BBS_RADIO_ANON":            "Saving and sharing stations needs an SSH key",
+	"BBS_RADIO_FAV_ON":          "Station saved to your favorites",
+	"BBS_RADIO_FAV_OFF":         "Station removed from your favorites",
+	"BBS_RADIO_FAVS_FULL":       "You have %d favorites already; remove one first",
+	"BBS_RADIO_RECOMMENDS":      "♪ recommends %s (%s), find it in [o] on the air",
+	"BBS_RADIO_SHARED":          "Recommended on the wall",
+	"BBS_RADIO_PICKED":          "%s is now on Hammer FM",
+	"AWARD_TUNED_IN":            "Tuned in",
+	"AWARD_DJ":                  "DJ",
 }
@@ -73,6 +73,9 @@ } 	if st.linecastOn() {
 		lines = append(lines, st.menuEntry("k", st.t("BBS_LINECAST"), st.t("BBS_LINECAST_SUM")))
 	}
+	if st.radioOn() {
+		lines = append(lines, st.menuEntry("o", st.t("BBS_RADIO"), st.radioSummary()))
+	}
 	if st.postcardsOn() {
 		lines = append(lines, st.menuEntry("p", st.t("BBS_POSTCARDS"), st.t("BBS_POSTCARDS_SUM")))
 	}
@@ -0,0 +1,433 @@+package bbs
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/url"
+	"regexp"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/runawaydevil/hammer/internal/awards"
+	"github.com/runawaydevil/hammer/internal/community"
+	"github.com/runawaydevil/hammer/internal/radio"
+	"github.com/runawaydevil/hammer/internal/ui"
+)
+
+// [o] on the air is a radio directory: stations from around the world,
+// from the open Radio Browser list. Over SSH or telnet only text travels,
+// so nobody hears a thing inside Hammer: callers get a link to a player
+// page and the command to play the stream themselves. The sysop's pick of
+// the week is Hammer FM.
+
+// RadioConfig is the optional "bbs.radio" block.
+type RadioConfig struct {
+	Enabled bool `yaml:"enabled"`
+	// PublicURL is this board's web address, for links to the player page
+	// (https://hammer.example.com); "" leaves only the stream's own link.
+	PublicURL string `yaml:"publicUrl"`
+	// API is the directory server; "" finds one through DNS.
+	API string `yaml:"api"`
+}
+
+// radioUserAgent names Hammer to the directory, as it asks.
+const radioUserAgent = "Hammer-BBS/1.0 (+https://github.com/runawaydevil/hammer)"
+
+// RadioEnabled reports whether [o] is configured on.
+func (c *Config) RadioEnabled() bool {
+	return c != nil && c.Radio != nil && c.Radio.Enabled
+}
+
+func (c *RadioConfig) validate() error {
+	if c == nil {
+		return nil
+	}
+	for name, v := range map[string]string{"publicUrl": c.PublicURL, "api": c.API} {
+		if v == "" {
+			continue
+		}
+		if u, err := url.Parse(v); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
+			return fmt.Errorf("bbs.radio.%s must be an http(s) address", name)
+		}
+	}
+
+	return nil
+}
+
+// radioOn reports whether the menu offers [o].
+func (st *session) radioOn() bool {
+	return st.srv != nil && st.srv.radio != nil
+}
+
+// radioWait bounds a directory request made while someone waits.
+const radioWait = 8 * time.Second
+
+// radioSummary is the [o] line's extra on the main menu: Hammer FM when
+// there is one.
+func (st *session) radioSummary() string {
+	if pick, ok := st.srv.hammerFM(); ok {
+		return st.tf("BBS_RADIO_HAMMERFM_NOW", pick.Name, pick.CountryCode)
+	}
+
+	return st.t("BBS_RADIO_SUM")
+}
+
+// hammerFM is the station on the air as Hammer FM, if any.
+func (s *Server) hammerFM() (radio.Pick, bool) {
+	if s.radioPicks == nil {
+		return radio.Pick{}, false
+	}
+	pick, ok, err := s.radioPicks.Current()
+
+	return pick, ok && err == nil
+}
+
+var countryCodeRE = regexp.MustCompile(`^[A-Za-z]{2}$`)
+
+// radioMenu is [o]: Hammer FM, the most played, searches and favourites.
+func (st *session) radioMenu() {
+	if st.pres != nil {
+		st.pres.SetWhere(community.WhereRadio)
+		defer st.pres.SetWhere(community.WhereMenu)
+	}
+	for st.alive() {
+		st.clear()
+		rows := []string{""}
+		pick, havePick := st.srv.hammerFM()
+		if havePick {
+			extra := pick.Name
+			if pick.Note != "" {
+				extra += " · " + pick.Note
+			}
+			rows = append(rows, st.menuEntry("0", "Hammer FM", extra), "")
+		}
+		rows = append(rows,
+			st.menuEntry("1", st.t("BBS_RADIO_TOP"), ""),
+			st.menuEntry("2", st.t("BBS_RADIO_SEARCH"), ""),
+			st.menuEntry("3", st.t("BBS_RADIO_COUNTRY"), ""),
+			st.menuEntry("4", st.t("BBS_RADIO_TAG"), ""))
+		if !st.anon {
+			rows = append(rows, st.menuEntry("5", st.t("BBS_RADIO_FAVS"), ""))
+		}
+		if n := st.radioListeners(); n > 0 {
+			rows = append(rows, "", "  "+st.th.Dim+st.tf("BBS_RADIO_LISTENING", n)+st.th.Reset)
+		}
+		rows = append(rows, "")
+		box := st.th.Box(st.th.Heading(st.t("BBS_TITLE_RADIO")), rows, 56)
+		box = append(box, st.th.HintBar([][2]string{{"1-5", st.t("BBS_CHOOSE")}, {"b", st.t("BBS_BACK")}}))
+		st.screenWithArt("radio", box)
+
+		ctx, cancel := context.WithTimeout(context.Background(), radioWait)
+		switch st.choose(st.t("BBS_PROMPT_RADIO")) {
+		case '0':
+			if havePick {
+				s, err := st.srv.radio.Station(ctx, pick.UUID)
+				if err != nil {
+					s = radio.Station{UUID: pick.UUID, Name: pick.Name, URL: pick.URL, CountryCode: pick.CountryCode}
+				}
+				st.radioStation(s)
+			}
+		case '1':
+			list, err := st.srv.radio.Top(ctx, 20)
+			st.radioList(st.t("BBS_RADIO_TOP"), list, err)
+		case '2':
+			if q := strings.TrimSpace(st.readLineMax("search> ", 40)); q != "" {
+				list, err := st.srv.radio.Search(ctx, radio.Query{Name: q})
+				st.radioList(q, list, err)
+			}
+		case '3':
+			cc := strings.TrimSpace(st.readLineMax("country> ", 8))
+			switch {
+			case cc == "":
+			case !countryCodeRE.MatchString(cc):
+				st.note = st.t("BBS_RADIO_BAD_COUNTRY")
+			default:
+				list, err := st.srv.radio.Search(ctx, radio.Query{CountryCode: cc})
+				st.radioList(strings.ToUpper(cc), list, err)
+			}
+		case '4':
+			if tag := strings.TrimSpace(st.readLineMax("genre> ", 30)); tag != "" {
+				list, err := st.srv.radio.Search(ctx, radio.Query{Tag: strings.ToLower(tag)})
+				st.radioList(tag, list, err)
+			}
+		case '5':
+			if !st.anon {
+				st.radioFavorites()
+			}
+		case 'b', 'B', 'q', 'Q', '\r', '\n':
+			cancel()
+
+			return
+		default:
+			st.note = st.t("BBS_FORUM_UNKNOWN")
+		}
+		cancel()
+	}
+}
+
+// radioListeners is how many people are in [o] now.
+func (st *session) radioListeners() int {
+	n := 0
+	for _, o := range st.srv.presence.List() {
+		if o.Where == community.WhereRadio {
+			n++
+		}
+	}
+
+	return n
+}
+
+// radioLine is one station in a list: name, country, codec and bitrate.
+func (st *session) radioLine(s radio.Station, width int) string {
+	info := s.CountryCode
+	if s.Codec != "" {
+		info += " · " + s.Codec
+	}
+	if s.Bitrate > 0 {
+		info += " " + strconv.Itoa(s.Bitrate) + "k"
+	}
+	name := ui.Truncate(s.Name, max(width-ui.CellWidth(info)-3, 10))
+
+	return st.th.Value + name + st.th.Reset + "  " + st.th.Dim + info + st.th.Reset
+}
+
+// radioList shows stations to pick from.
+func (st *session) radioList(title string, list []radio.Station, err error) {
+	if err != nil {
+		st.note = st.t("BBS_RADIO_DOWN")
+
+		return
+	}
+	if len(list) == 0 {
+		st.note = st.t("BBS_RADIO_NONE")
+
+		return
+	}
+	width := min(max(st.width0()-16, 30), 70)
+	for st.alive() {
+		st.clear()
+		rows := []string{""}
+		for i, s := range list {
+			rows = append(rows, fmt.Sprintf(" %s  %s", st.th.Key(strconv.Itoa(i+1)), st.radioLine(s, width)))
+		}
+		rows = append(rows, "")
+		box := st.th.Box(st.th.Heading(strings.ToUpper(ui.Truncate(title, 40))), rows, width+6)
+		box = append(box, st.th.HintBar([][2]string{{fmt.Sprintf("1-%d", len(list)), st.t("BBS_RADIO_TUNE")}, {"Enter", st.t("BBS_BACK")}}))
+		st.screen(box)
+
+		in := strings.TrimSpace(st.readLine("radio> "))
+		if in == "" || in == "b" || in == "q" {
+			return
+		}
+		n, convErr := strconv.Atoi(in)
+		if convErr != nil || n < 1 || n > len(list) {
+			st.note = st.t("BBS_FORUM_UNKNOWN")
+
+			continue
+		}
+		st.radioStation(list[n-1])
+	}
+}
+
+// radioFavorites lists the caller's saved stations; they open even when
+// the directory is down.
+func (st *session) radioFavorites() {
+	favs, err := st.srv.radioFavs.List(st.fp)
+	if err != nil {
+		st.note = err.Error()
+
+		return
+	}
+	if len(favs) == 0 {
+		st.note = st.t("BBS_RADIO_NO_FAVS")
+
+		return
+	}
+	list := make([]radio.Station, len(favs))
+	for i, f := range favs {
+		list[i] = radio.Station{UUID: f.UUID, Name: f.Name, URL: f.URL, CountryCode: f.CountryCode}
+	}
+	st.radioList(st.t("BBS_TITLE_RADIO_FAVS"), list, nil)
+}
+
+// radioLink is a link the terminal can open: an OSC 8 hyperlink over SSH
+// and the web, plain text where an old terminal would print the escape.
+func (st *session) radioLink(text, link string) string {
+	if st.level == ui.NoColor || st.via == ViaTelnet || st.via == ViaRlogin || st.cp437.Load() || text == link {
+		if text == link {
+			return link
+		}
+
+		return text + " " + link
+	}
+
+	return "\x1b]8;;" + link + "\x1b\\" + text + "\x1b]8;;\x1b\\"
+}
+
+// radioStation is one station: what it is, what it is playing, how to
+// listen, and what to do with it.
+func (st *session) radioStation(s radio.Station) {
+	st.tuned = &s
+	if st.pres != nil {
+		st.pres.SetDoing(st.tf("BBS_RADIO_DOING", s.Name))
+	}
+	st.noteBadges(st.srv.count(st.fp, awards.Tuned))
+	go func() {
+		ctx, cancel := context.WithTimeout(context.Background(), radioWait)
+		defer cancel()
+		_ = st.srv.radio.Click(ctx, s.UUID)
+	}()
+
+	admin := !st.anon && st.srv.isAdmin(st.fp)
+	title := ""
+	for st.alive() {
+		st.clear()
+		label := func(key string) string { return st.th.Dim + ui.PadRight(st.t(key), 10) + st.th.Reset }
+		rows := []string{""}
+		info := s.CountryCode
+		if s.Codec != "" {
+			info += " · " + s.Codec
+		}
+		if s.Bitrate > 0 {
+			info += " " + strconv.Itoa(s.Bitrate) + " kbps"
+		}
+		rows = append(rows, " "+label("BBS_RADIO_FROM")+info)
+		if s.Tags != "" {
+			rows = append(rows, " "+label("BBS_RADIO_TAGS")+ui.Truncate(s.Tags, 50))
+		}
+		if s.Homepage != "" {
+			rows = append(rows, " "+label("BBS_RADIO_HOME")+st.radioLink(ui.Truncate(s.Homepage, 50), s.Homepage))
+		}
+		if pick, ok := st.srv.hammerFM(); ok && pick.UUID == s.UUID {
+			rows = append(rows, " "+label("BBS_RADIO_ONAIR")+st.th.Good+"Hammer FM"+st.th.Reset)
+		}
+		rows = append(rows, "")
+		now := st.th.Dim + st.t("BBS_RADIO_PRESS_T") + st.th.Reset
+		if title != "" {
+			now = st.th.Value + title + st.th.Reset
+		}
+		rows = append(rows, " "+label("BBS_RADIO_NOW")+now, "")
+		if s.URL != "" {
+			if pub := st.srv.radioPublicURL(); pub != "" {
+				page := pub + "/:radio/" + s.UUID
+				rows = append(rows, " "+label("BBS_RADIO_LISTEN")+st.radioLink(page, page))
+			} else {
+				rows = append(rows, " "+label("BBS_RADIO_LISTEN")+st.radioLink(s.URL, s.URL))
+			}
+			rows = append(rows, " "+label("BBS_RADIO_PLAYER")+"mpv '"+s.URL+"'")
+		}
+		rows = append(rows, "")
+
+		hints := [][2]string{{"t", st.t("BBS_RADIO_HINT_NOW")}, {"f", st.t("BBS_RADIO_HINT_FAV")}, {"w", st.t("BBS_RADIO_HINT_WALL")}}
+		if admin {
+			hints = append(hints, [2]string{"h", "Hammer FM"})
+		}
+		hints = append(hints, [2]string{"b", st.t("BBS_BACK")})
+		box := st.th.Box(st.th.Heading(ui.Truncate(s.Name, 50)), rows, 64)
+		box = append(box, st.th.HintBar(hints))
+		st.screen(box)
+
+		switch st.choose(st.t("BBS_PROMPT_RADIO")) {
+		case 't', 'T':
+			title = st.nowPlaying(s)
+		case 'f', 'F':
+			st.radioToggleFavorite(s)
+		case 'w', 'W':
+			st.radioRecommend(s)
+		case 'h', 'H':
+			if admin {
+				st.radioPick(s)
+			}
+		case 'b', 'B', 'q', 'Q', '\r', '\n':
+			return
+		}
+	}
+}
+
+// nowPlaying asks the stream for its title, or says it has none.
+func (st *session) nowPlaying(s radio.Station) string {
+	if s.URL == "" {
+		return st.t("BBS_RADIO_NOTITLE")
+	}
+	st.writeString("\r\n" + st.pad() + st.th.Dim + st.t("BBS_RADIO_TUNING") + st.th.Reset)
+	ctx, cancel := context.WithTimeout(context.Background(), radioWait)
+	defer cancel()
+	title, err := radio.NowPlaying(ctx, nil, s.URL)
+	if err != nil {
+		return st.t("BBS_RADIO_NOTITLE")
+	}
+
+	return title
+}
+
+func (st *session) radioToggleFavorite(s radio.Station) {
+	if st.anon {
+		st.note = st.t("BBS_RADIO_ANON")
+
+		return
+	}
+	has, err := st.srv.radioFavs.Has(st.fp, s.UUID)
+	switch {
+	case err != nil:
+		st.note = err.Error()
+	case has:
+		if err := st.srv.radioFavs.Remove(st.fp, s.UUID); err == nil {
+			st.note = st.t("BBS_RADIO_FAV_OFF")
+		}
+	default:
+		err := st.srv.radioFavs.Add(st.fp, s)
+		switch {
+		case errors.Is(err, radio.ErrTooMany):
+			st.note = st.tf("BBS_RADIO_FAVS_FULL", radio.MaxFavorites)
+		case err != nil:
+			st.note = err.Error()
+		default:
+			st.note = st.t("BBS_RADIO_FAV_ON")
+		}
+	}
+}
+
+// radioRecommend puts the station on the wall.
+func (st *session) radioRecommend(s radio.Station) {
+	if st.anon {
+		st.note = st.t("BBS_RADIO_ANON")
+
+		return
+	}
+	text := st.tf("BBS_RADIO_RECOMMENDS", s.Name, s.CountryCode)
+	_, err := st.srv.wall.Post(st.fp, st.profile.Nick, text)
+	var cd *community.CooldownError
+	switch {
+	case err == nil:
+		st.note = st.t("BBS_RADIO_SHARED")
+		st.noteBadges(st.srv.count(st.fp, awards.Shares))
+	case errors.As(err, &cd):
+		st.note = st.tf("BBS_WALL_COOLDOWN", cd.Wait.Round(time.Second))
+	case errors.Is(err, community.ErrBanned):
+		st.note = st.t("BBS_BANNED")
+	default:
+		st.note = err.Error()
+	}
+}
+
+// radioPick makes the station Hammer FM; moderators only.
+func (st *session) radioPick(s radio.Station) {
+	note := strings.TrimSpace(st.readLineMax("note> ", 60))
+	if err := st.srv.radioPicks.Set(st.profile.Nick, s, note); err != nil {
+		st.note = err.Error()
+
+		return
+	}
+	st.note = st.tf("BBS_RADIO_PICKED", s.Name)
+}
+
+// radioPublicURL is the board's web address without a trailing slash.
+func (s *Server) radioPublicURL() string {
+	if s.radioCfg == nil {
+		return ""
+	}
+
+	return strings.TrimRight(s.radioCfg.PublicURL, "/")
+}
@@ -0,0 +1,278 @@+package bbs
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	gossh "golang.org/x/crypto/ssh"
+
+	"github.com/runawaydevil/hammer/internal/awards"
+	"github.com/runawaydevil/hammer/internal/community"
+	"github.com/runawaydevil/hammer/internal/radio"
+)
+
+const (
+	jazzID = "9617a958-0601-11e8-ae97-52543be04c81"
+	evilID = "11111111-2222-3333-4444-555555555555"
+)
+
+// fakeRadio is a Radio Browser with two stations and a stream that is
+// playing "So What". It records the requests it gets.
+type fakeRadio struct {
+	api, stream *httptest.Server
+	mu          sync.Mutex
+	got         []string
+}
+
+func newFakeRadio(t *testing.T) *fakeRadio {
+	t.Helper()
+	f := &fakeRadio{}
+	f.stream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("icy-metaint", "16")
+		w.Write(make([]byte, 16))
+		meta := []byte("StreamTitle='So What';")
+		block := make([]byte, (len(meta)+15)/16*16)
+		copy(block, meta)
+		w.Write([]byte{byte(len(block) / 16)})
+		w.Write(block)
+	}))
+	t.Cleanup(f.stream.Close)
+	stations := fmt.Sprintf(`[{"stationuuid":%q,"name":"Jazz FM","url_resolved":%q,"countrycode":"BR","codec":"MP3","bitrate":128,"tags":"jazz"},`+
+		`{"stationuuid":%q,"name":"Evil\u001b]8;;http://evil\u001b\\FM\u001b[2J","url_resolved":"https://evil.test/s","countrycode":"XX"}]`,
+		jazzID, f.stream.URL+"/live", evilID)
+	f.api = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		f.mu.Lock()
+		f.got = append(f.got, r.URL.String())
+		f.mu.Unlock()
+		switch {
+		case strings.HasPrefix(r.URL.Path, "/json/stations/byuuid/"+jazzID):
+			fmt.Fprintf(w, `[%s]`, strings.TrimPrefix(strings.Split(stations, `},{`)[0], "[")+"}")
+		case strings.HasPrefix(r.URL.Path, "/json/url/"):
+			fmt.Fprint(w, `{"ok":true}`)
+		default:
+			fmt.Fprint(w, stations)
+		}
+	}))
+	t.Cleanup(f.api.Close)
+
+	return f
+}
+
+// saw reports whether a request containing want reached the directory.
+func (f *fakeRadio) saw(want string) bool {
+	for end := time.Now().Add(2 * time.Second); time.Now().Before(end); time.Sleep(20 * time.Millisecond) {
+		f.mu.Lock()
+		for _, g := range f.got {
+			if strings.Contains(g, want) {
+				f.mu.Unlock()
+
+				return true
+			}
+		}
+		f.mu.Unlock()
+	}
+
+	return false
+}
+
+func radioServer(t *testing.T, f *fakeRadio, cfg *Config) *Server {
+	t.Helper()
+	if cfg == nil {
+		cfg = &Config{}
+	}
+	cfg.Radio = &RadioConfig{Enabled: true, API: f.api.URL, PublicURL: "https://hammer.test"}
+	if cfg.Awards == nil {
+		cfg.Awards = &AwardsConfig{Enabled: true}
+	}
+
+	return testServer(t, cfg)
+}
+
+func TestRadioTopAndStation(t *testing.T) {
+	f := newFakeRadio(t)
+	srv := radioServer(t, f, nil)
+	key := newSigner(t)
+	ana := login(t, listen(t, srv), "ana", key)
+	ana.waitFor("on the air")
+
+	ana.send("o")
+	ana.waitFor("ON THE AIR")
+	if !waitWhere(srv, "ana", community.WhereRadio) {
+		t.Errorf("presence does not show ana at the radio")
+	}
+	ana.send("1")
+	ana.waitFor("Jazz FM")
+	ana.send("1\r")
+	ana.waitFor("https://hammer.test/:radio/" + jazzID)
+	ana.waitFor("mpv '" + f.stream.URL + "/live'")
+	if !f.saw("/json/url/" + jazzID) {
+		t.Error("opening a station did not tell the directory")
+	}
+	ana.send("t")
+	ana.waitFor("So What")
+
+	id := identityForFingerprint(gossh.FingerprintSHA256(key.PublicKey()))
+	if n, _ := srv.awards.Counter(id, awards.Tuned); n != 1 {
+		t.Errorf("tuned counter = %d, want 1", n)
+	}
+	for _, o := range srv.presence.List() {
+		if o.Nick == "ana" && !strings.Contains(o.Doing, "Jazz FM") {
+			t.Errorf("doing = %q, want it to name Jazz FM", o.Doing)
+		}
+	}
+}
+
+func TestRadioStationSanitized(t *testing.T) {
+	f := newFakeRadio(t)
+	srv := radioServer(t, f, nil)
+	ana := login(t, listen(t, srv), "ana", newSigner(t))
+	ana.send("o")
+	ana.send("1")
+	ana.waitFor("EvilFM")
+	ana.never("FM\x1b[2J") // the station's own escape, not our screen clears
+	ana.never("http://evil")
+}
+
+func TestRadioSearchAndCountry(t *testing.T) {
+	f := newFakeRadio(t)
+	srv := radioServer(t, f, nil)
+	ana := login(t, listen(t, srv), "ana", newSigner(t))
+	ana.send("o")
+	ana.send("2")
+	ana.waitFor("search>")
+	ana.send("jazz\r")
+	if !f.saw("name=jazz") {
+		t.Fatal("the search did not reach the directory")
+	}
+	ana.waitFor("Jazz FM")
+	ana.send("\r")
+
+	ana.send("3")
+	ana.waitFor("country>")
+	ana.send("b1\r")
+	ana.waitFor("two letters")
+	ana.send("3")
+	ana.send("br\r")
+	if !f.saw("countrycode=BR") {
+		t.Fatal("the country search did not reach the directory")
+	}
+	if f.saw("countrycode=B1") {
+		t.Fatal("a bad country code reached the directory")
+	}
+}
+
+func TestRadioFavorites(t *testing.T) {
+	f := newFakeRadio(t)
+	srv := radioServer(t, f, &Config{AllowAnonymous: true})
+	addr := listen(t, srv)
+	ana := login(t, addr, "ana", newSigner(t))
+	ana.send("o")
+	ana.send("1")
+	ana.send("1\r")
+	ana.waitFor("Listen")
+	ana.send("f")
+	ana.waitFor("saved to your favorites")
+	ana.send("b")
+	ana.send("\r")
+	ana.send("5")
+	ana.waitFor("FAVORITES")
+	ana.waitFor("Jazz FM")
+
+	guest := login(t, addr, "guest", nil)
+	guest.send("o")
+	guest.send("1")
+	guest.send("1\r")
+	guest.waitFor("Listen")
+	guest.send("f")
+	guest.waitFor("needs an SSH key")
+}
+
+func TestRadioAPIDown(t *testing.T) {
+	f := newFakeRadio(t)
+	srv := radioServer(t, f, nil)
+	key := newSigner(t)
+	id := identityForFingerprint(gossh.FingerprintSHA256(key.PublicKey()))
+	if err := srv.radioFavs.Add(id, radio.Station{UUID: jazzID, Name: "Jazz FM", URL: "https://x.test/s", CountryCode: "BR"}); err != nil {
+		t.Fatal(err)
+	}
+	f.api.Close()
+
+	ana := login(t, listen(t, srv), "ana", key)
+	ana.send("o")
+	ana.send("1")
+	ana.waitFor("radio directory unavailable")
+	ana.send("5")
+	ana.waitFor("Jazz FM")
+	ana.send("1\r")
+	ana.waitFor("Listen")
+}
+
+func TestRadioWallRecommend(t *testing.T) {
+	f := newFakeRadio(t)
+	srv := radioServer(t, f, nil)
+	key := newSigner(t)
+	ana := login(t, listen(t, srv), "ana", key)
+	ana.send("o")
+	ana.send("1")
+	ana.send("1\r")
+	ana.waitFor("Listen")
+	ana.send("w")
+	ana.waitFor("on the wall")
+	lines, err := srv.wall.Latest(1)
+	if err != nil || len(lines) != 1 || !strings.Contains(lines[0].Text, "recommends Jazz FM") {
+		t.Fatalf("wall = %+v, %v", lines, err)
+	}
+	id := identityForFingerprint(gossh.FingerprintSHA256(key.PublicKey()))
+	if n, _ := srv.awards.Counter(id, awards.Shares); n != 1 {
+		t.Errorf("shares counter = %d, want 1", n)
+	}
+}
+
+func TestHammerFMModeratorOnly(t *testing.T) {
+	f := newFakeRadio(t)
+	modKey := newSigner(t)
+	srv := radioServer(t, f, &Config{Community: &CommunityConfig{Admins: []string{gossh.FingerprintSHA256(modKey.PublicKey())}}})
+	addr := listen(t, srv)
+
+	bia := login(t, addr, "bia", newSigner(t))
+	bia.send("o")
+	bia.send("1")
+	bia.send("1\r")
+	bia.waitFor("Listen")
+	bia.never("Hammer FM") // no [h] for her, and no pick yet
+	bia.send("h")
+	bia.never("note>")
+
+	mod := login(t, addr, "pablo", modKey)
+	mod.send("o")
+	mod.send("1")
+	mod.send("1\r")
+	mod.waitFor("Listen")
+	mod.send("h")
+	mod.waitFor("note>")
+	mod.send("the best jazz\r")
+	mod.waitFor("now on Hammer FM")
+	pick, ok, err := srv.radioPicks.Current()
+	if err != nil || !ok || pick.UUID != jazzID || pick.Note != "the best jazz" {
+		t.Fatalf("pick = %+v %v %v", pick, ok, err)
+	}
+
+	cia := login(t, addr, "cia", newSigner(t))
+	cia.waitFor("Hammer FM: Jazz FM")
+	mark := cia.mark()
+	cia.send("o")
+	cia.waitSince(mark, "the best jazz")
+	cia.send("0")
+	cia.waitSince(mark, "Listen")
+}
+
+func TestRadioHiddenWhenOff(t *testing.T) {
+	srv := testServer(t, &Config{})
+	ana := login(t, listen(t, srv), "ana", newSigner(t))
+	ana.never("on the air")
+}
@@ -25,6 +25,7 @@ "github.com/runawaydevil/hammer/internal/jobs" 	"github.com/runawaydevil/hammer/internal/localization"
 	"github.com/runawaydevil/hammer/internal/mrc"
 	"github.com/runawaydevil/hammer/internal/postcards"
+	"github.com/runawaydevil/hammer/internal/radio"
 	"github.com/runawaydevil/hammer/internal/ratelimit"
 	"github.com/runawaydevil/hammer/internal/tools"
 	"github.com/runawaydevil/hammer/internal/weather"
@@ -66,8 +67,12 @@ 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
+	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
 	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
@@ -150,6 +155,16 @@ } 	if cfg.Awards != nil && cfg.Awards.Enabled {
 		if s.awards, err = awards.NewStore(store.SQL()); err != nil {
 			return nil, fmt.Errorf("awards: %w", err)
+		}
+	}
+	if cfg.RadioEnabled() {
+		s.radioCfg = cfg.Radio
+		s.radio = radio.New(cfg.Radio.API, radioUserAgent, nil)
+		if s.radioFavs, err = radio.NewFavorites(store.SQL()); err != nil {
+			return nil, fmt.Errorf("radio: %w", err)
+		}
+		if s.radioPicks, err = radio.NewPicks(store.SQL()); err != nil {
+			return nil, fmt.Errorf("radio: %w", err)
 		}
 	}
 
@@ -13,6 +13,7 @@ 	"github.com/gliderlabs/ssh"
 
 	"github.com/runawaydevil/hammer/internal/community"
+	"github.com/runawaydevil/hammer/internal/radio"
 
 	"github.com/runawaydevil/hammer/internal/ui"
 )
@@ -53,6 +54,7 @@ th      ui.Theme        // colours, boxes and art for this terminal 	level   ui.ColorLevel   // what the terminal can show
 	in      chan inChunk    // input, read by startReader
 	resized chan struct{}   // a window resize, for a door that wants to know
+	tuned   *radio.Station  // the station last opened in [o], for /np
 	since   time.Time       // when the session started
 	pres    *community.Conn // this connection in "who is online"
 	chatSub *community.Sub  // set while the chat screen is open
@@ -192,6 +194,12 @@ } 		case 'k', 'K':
 			if st.linecastOn() {
 				st.linecastMenu()
+			} else {
+				st.note = st.tf("BBS_UNKNOWN_MAIN", st.mainNumericRange())
+			}
+		case 'o', 'O':
+			if st.radioOn() {
+				st.radioMenu()
 			} else {
 				st.note = st.tf("BBS_UNKNOWN_MAIN", st.mainNumericRange())
 			}
@@ -564,3 +564,45 @@ if p.Hangup(9) { 		t.Fatal("hung up a node that does not exist")
 	}
 }
+
+// What someone is doing shows beside where they are, cleaned and short;
+// with several connections the busiest place wins.
+func TestPresenceDoingAndPriority(t *testing.T) {
+	p := NewPresence()
+	a := p.Join("fp:a", "ana", false)
+	a.SetWhere(WhereRadio)
+	a.SetDoing("listening to Jazz FM\x1b[2J and a very long station name that goes on")
+	list := p.List()
+	if len(list) != 1 || list[0].Where != WhereRadio {
+		t.Fatalf("list = %+v", list)
+	}
+	if d := list[0].Doing; strings.Contains(d, "\x1b") || !strings.HasPrefix(d, "listening to Jazz FM") || len([]rune(d)) > 40 {
+		t.Fatalf("doing = %q", d)
+	}
+	if n := p.Nodes(); len(n) != 1 || n[0].Doing != list[0].Doing {
+		t.Fatalf("nodes = %+v", n)
+	}
+
+	// A second connection in the almanac outranks the radio, a game
+	// outranks both, and the chat outranks everything.
+	b := p.Join("fp:a", "ana", false)
+	b.SetWhere(WhereAlmanac)
+	if got := p.List()[0].Where; got != WhereAlmanac {
+		t.Fatalf("radio + almanac = %q", got)
+	}
+	b.SetWhere(WhereGame)
+	if got := p.List()[0].Where; got != WhereGame {
+		t.Fatalf("radio + game = %q", got)
+	}
+	a.SetWhere(WhereChat)
+	if got := p.List()[0].Where; got != WhereChat {
+		t.Fatalf("chat + game = %q", got)
+	}
+
+	// Back at the menu, the doing line goes.
+	a.SetWhere(WhereMenu)
+	b.Leave()
+	if d := p.List()[0].Doing; d != "" {
+		t.Fatalf("doing at the menu = %q", d)
+	}
+}
@@ -13,13 +13,35 @@ WhereMenu    = "menu" 	WhereChat    = "chat"
 	WhereGame    = "game"
 	WhereAlmanac = "almanac" // [k] sky & almanac
+	WhereRadio   = "radio"   // [o] on the air
 )
 
+// whereRank orders places for a person with several connections: the one
+// that says most about them shows.
+func whereRank(where string) int {
+	switch where {
+	case WhereChat:
+		return 4
+	case WhereGame:
+		return 3
+	case WhereAlmanac:
+		return 2
+	case WhereRadio:
+		return 1
+	}
+
+	return 0
+}
+
+// maxDoing is how long a "doing" line may be, in cells.
+const maxDoing = 40
+
 // Online is one person in the "who is online" list. Several connections from
 // the same key show up once.
 type Online struct {
 	Nick      string    `json:"nick"`
 	Where     string    `json:"where"`
+	Doing     string    `json:"doing,omitempty"` // e.g. "listening to Jazz FM"
 	Since     time.Time `json:"since"`
 	Conns     int       `json:"connections"`
 	Anonymous bool      `json:"anonymous,omitempty"`
@@ -62,6 +84,7 @@ node      int // a small number, reused once free, for the sysop 	identity  string
 	nick      string
 	where     string
+	doing     string
 	since     time.Time
 	anonymous bool
 	via       string    // how the caller came in: ssh, telnet, web
@@ -77,6 +100,7 @@ Nick      string        `json:"nick"` 	Via       string        `json:"via"`
 	IP        string        `json:"ip"`
 	Where     string        `json:"where"`
+	Doing     string        `json:"doing,omitempty"`
 	Since     time.Time     `json:"since"`
 	Idle      time.Duration `json:"idleNs"`
 	Anonymous bool          `json:"anonymous,omitempty"`
@@ -170,14 +194,27 @@ } 	}
 }
 
-// SetWhere records where the connection is (WhereMenu, WhereChat, WhereGame).
+// SetWhere records where the connection is (WhereMenu, WhereChat, ...).
+// Moving somewhere else forgets what it was doing.
 func (c *Conn) SetWhere(where string) {
 	c.p.mu.Lock()
 	defer c.p.mu.Unlock()
 
+	if c.where != where {
+		c.doing = ""
+	}
 	c.where = where
 }
 
+// SetDoing records what the connection is doing where it is, such as the
+// station it is listening to; "" clears it.
+func (c *Conn) SetDoing(text string) {
+	c.p.mu.Lock()
+	defer c.p.mu.Unlock()
+
+	c.doing = CleanText(text, maxDoing)
+}
+
 // SetNick updates the nickname shown for the connection.
 func (c *Conn) SetNick(nick string) {
 	c.p.mu.Lock()
@@ -196,7 +233,7 @@ byID := map[string]*Online{} 	for _, c := range p.conns {
 		o, ok := byID[c.identity]
 		if !ok {
-			o = &Online{Nick: c.nick, Where: c.where, Since: c.since, Anonymous: c.anonymous}
+			o = &Online{Nick: c.nick, Where: c.where, Doing: c.doing, Since: c.since, Anonymous: c.anonymous}
 			byID[c.identity] = o
 		}
 		o.Conns++
@@ -204,12 +241,9 @@ if c.since.Before(o.Since) { 			o.Since = c.since
 		}
 		// Several connections from one person: the chat shows first, then a
-		// game, then the menu.
-		switch {
-		case c.where == WhereChat:
-			o.Where = WhereChat
-		case c.where == WhereGame && o.Where != WhereChat:
-			o.Where = WhereGame
+		// game, the almanac, the radio and the menu.
+		if whereRank(c.where) > whereRank(o.Where) {
+			o.Where, o.Doing = c.where, c.doing
 		}
 	}
 
@@ -274,7 +308,7 @@ 	now := p.now()
 	out := make([]Node, 0, len(p.conns))
 	for _, c := range p.conns {
-		out = append(out, Node{Node: c.node, Nick: c.nick, Via: c.via, IP: c.ip, Where: c.where,
+		out = append(out, Node{Node: c.node, Nick: c.nick, Via: c.via, IP: c.ip, Where: c.where, Doing: c.doing,
 			Since: c.since, Idle: now.Sub(c.active), Anonymous: c.anonymous})
 	}
 	sort.Slice(out, func(i, j int) bool { return out[i].Node < out[j].Node })
@@ -365,5 +365,45 @@ "BBS_LINECAST_ANON": "Sky & almanac needs an SSH key — connect with one to look",   "BBS_LINECAST_UTF8": "Sky & almanac needs a UTF-8 terminal; this one speaks CP437",
   "AWARD_STARGAZER": "Stargazer",
   "AWARD_NIGHT_OWL": "Night owl",
-  "AWARD_ALMANAC_READER": "Almanac reader"
+  "AWARD_ALMANAC_READER": "Almanac reader",
+  "BBS_RADIO": "on the air",
+  "BBS_RADIO_SUM": "radio stations from around the world",
+  "BBS_TITLE_RADIO": "ON THE AIR",
+  "BBS_TITLE_RADIO_FAVS": "FAVORITES",
+  "BBS_RADIO_HAMMERFM_NOW": "Hammer FM: %s (%s)",
+  "BBS_RADIO_TOP": "most played",
+  "BBS_RADIO_SEARCH": "search by name",
+  "BBS_RADIO_COUNTRY": "by country",
+  "BBS_RADIO_TAG": "by genre",
+  "BBS_RADIO_FAVS": "your favorites",
+  "BBS_RADIO_LISTENING": "%d on the air now",
+  "BBS_PROMPT_RADIO": "radio> ",
+  "BBS_RADIO_BAD_COUNTRY": "A country is two letters, such as BR or PT",
+  "BBS_RADIO_DOWN": "radio directory unavailable — try again soon",
+  "BBS_RADIO_NONE": "No stations found",
+  "BBS_RADIO_NO_FAVS": "No favorites yet: open a station and press f",
+  "BBS_RADIO_TUNE": "tune in",
+  "BBS_RADIO_DOING": "listening to %s",
+  "BBS_RADIO_FROM": "From",
+  "BBS_RADIO_TAGS": "Genres",
+  "BBS_RADIO_HOME": "Website",
+  "BBS_RADIO_ONAIR": "On air",
+  "BBS_RADIO_NOW": "Playing",
+  "BBS_RADIO_PRESS_T": "press t to ask the station",
+  "BBS_RADIO_LISTEN": "Listen",
+  "BBS_RADIO_PLAYER": "Player",
+  "BBS_RADIO_HINT_NOW": "playing now",
+  "BBS_RADIO_HINT_FAV": "favorite",
+  "BBS_RADIO_HINT_WALL": "recommend",
+  "BBS_RADIO_TUNING": "asking the station...",
+  "BBS_RADIO_NOTITLE": "(the station does not say)",
+  "BBS_RADIO_ANON": "Saving and sharing stations needs an SSH key",
+  "BBS_RADIO_FAV_ON": "Station saved to your favorites",
+  "BBS_RADIO_FAV_OFF": "Station removed from your favorites",
+  "BBS_RADIO_FAVS_FULL": "You have %d favorites already; remove one first",
+  "BBS_RADIO_RECOMMENDS": "♪ recommends %s (%s), find it in [o] on the air",
+  "BBS_RADIO_SHARED": "Recommended on the wall",
+  "BBS_RADIO_PICKED": "%s is now on Hammer FM",
+  "AWARD_TUNED_IN": "Tuned in",
+  "AWARD_DJ": "DJ"
 }
@@ -365,5 +365,45 @@ "BBS_LINECAST_ANON": "Céu e almanaque precisa de uma chave SSH — entre com uma para ver",   "BBS_LINECAST_UTF8": "Céu e almanaque precisa de um terminal UTF-8; este usa CP437",
   "AWARD_STARGAZER": "Observador de estrelas",
   "AWARD_NIGHT_OWL": "Coruja",
-  "AWARD_ALMANAC_READER": "Leitor de almanaque"
+  "AWARD_ALMANAC_READER": "Leitor de almanaque",
+  "BBS_RADIO": "no ar",
+  "BBS_RADIO_SUM": "rádios do mundo inteiro",
+  "BBS_TITLE_RADIO": "NO AR",
+  "BBS_TITLE_RADIO_FAVS": "FAVORITAS",
+  "BBS_RADIO_HAMMERFM_NOW": "Hammer FM: %s (%s)",
+  "BBS_RADIO_TOP": "mais tocadas",
+  "BBS_RADIO_SEARCH": "buscar pelo nome",
+  "BBS_RADIO_COUNTRY": "por país",
+  "BBS_RADIO_TAG": "por gênero",
+  "BBS_RADIO_FAVS": "suas favoritas",
+  "BBS_RADIO_LISTENING": "%d no ar agora",
+  "BBS_PROMPT_RADIO": "rádio> ",
+  "BBS_RADIO_BAD_COUNTRY": "Um país tem duas letras, como BR ou PT",
+  "BBS_RADIO_DOWN": "diretório de rádios fora do ar — tente de novo daqui a pouco",
+  "BBS_RADIO_NONE": "Nenhuma rádio encontrada",
+  "BBS_RADIO_NO_FAVS": "Nenhuma favorita ainda: abra uma rádio e aperte f",
+  "BBS_RADIO_TUNE": "sintonizar",
+  "BBS_RADIO_DOING": "ouvindo %s",
+  "BBS_RADIO_FROM": "De",
+  "BBS_RADIO_TAGS": "Gêneros",
+  "BBS_RADIO_HOME": "Site",
+  "BBS_RADIO_ONAIR": "No ar",
+  "BBS_RADIO_NOW": "Tocando",
+  "BBS_RADIO_PRESS_T": "aperte t para perguntar à rádio",
+  "BBS_RADIO_LISTEN": "Ouvir",
+  "BBS_RADIO_PLAYER": "Player",
+  "BBS_RADIO_HINT_NOW": "tocando agora",
+  "BBS_RADIO_HINT_FAV": "favorita",
+  "BBS_RADIO_HINT_WALL": "recomendar",
+  "BBS_RADIO_TUNING": "perguntando à rádio...",
+  "BBS_RADIO_NOTITLE": "(a rádio não informa)",
+  "BBS_RADIO_ANON": "Salvar e compartilhar rádios precisa de uma chave SSH",
+  "BBS_RADIO_FAV_ON": "Rádio salva nas suas favoritas",
+  "BBS_RADIO_FAV_OFF": "Rádio tirada das suas favoritas",
+  "BBS_RADIO_FAVS_FULL": "Você já tem %d favoritas; tire uma antes",
+  "BBS_RADIO_RECOMMENDS": "♪ recomenda %s (%s), está no [o] no ar",
+  "BBS_RADIO_SHARED": "Recomendada no mural",
+  "BBS_RADIO_PICKED": "%s agora está na Hammer FM",
+  "AWARD_TUNED_IN": "Sintonizado",
+  "AWARD_DJ": "DJ"
 }