@@ -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())
}
@@ -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"
}