Compare commits

..

13 Commits

Author SHA1 Message Date
72ab92d6b8 Respond to lstn.lv being renamed to lsn.lv
All checks were successful
Alpine 3.21 Success
2025-08-02 19:13:45 +02:00
4c2f1d6691 Respond to recent changes
All checks were successful
Alpine 3.20 Success
2025-01-23 21:42:04 +01:00
c90e03271e Accept more Go versions
All checks were successful
Alpine 3.19 Success
2024-04-10 00:21:27 +02:00
91db4c7d84 Try harder to find titles for BBC services 2024-02-11 10:31:43 +01:00
035776c504 Improve metadata readouts
Closes #1
2024-02-11 10:13:22 +01:00
1cd87209be Invert the order of titles 2024-02-11 09:11:51 +01:00
8a087dea4a Make this thing work again 2024-02-11 09:06:16 +01:00
08beb029a7 Convert to Go modules 2024-02-10 16:18:26 +01:00
42e06ebf3d Name change 2020-10-10 14:21:20 +02:00
29b1603886 Update README 2018-11-03 01:57:59 +01:00
131930f45b Increase playlist re-read pause 2018-11-03 01:04:22 +01:00
783fce1175 Fix metadata timeouts 2018-11-03 00:54:50 +01:00
23683424a6 Show station name even on metadata error 2018-11-02 22:56:43 +01:00
4 changed files with 201 additions and 72 deletions

View File

@@ -1,4 +1,4 @@
Copyright (c) 2016 - 2018, Přemysl Janouch <p@janouch.name>
Copyright (c) 2016 - 2025, Přemysl Eric Janouch <p@janouch.name>
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.

View File

@@ -2,7 +2,9 @@ bbc-on-ice
==========
'bbc-on-ice' is a SHOUTcast (ICY protocol) bridge for BBC radio streams.
It adds metadata to the stream so that media players can display it.
It adds metadata to the stream so that media players can display it. There are
some inherent technical limitations to how accurate the information can be
but it's definitely better to have an approximate title than nothing.
Packages
--------
@@ -17,14 +19,19 @@ Build dependencies: go
$ cd bbc-on-ice
$ go build
or, if you know what you're doing:
$ go install janouch.name/bbc-on-ice@master
To run the local server:
$ ./bbc-on-ice :8000
Streams have URLs in the following form:
Streams have URLs in the following form, derived from
https://gist.github.com/bpsib/67089b959e4fa898af69fea59ad74bc3[this list]:
$ mpv http://localhost:8000/nonuk/sbr_low/bbc_radio_one
$ mpv http://localhost:8000/uk/sbr_high/bbc_1xtra
$ mpv http://localhost:8000/ww/96000/bbc_radio_one
$ mpv http://localhost:8000/uk/320000/bbc_1xtra
Socket activation
-----------------

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module janouch.name/bbc-on-ice
go 1.20.0

253
main.go
View File

@@ -6,10 +6,10 @@ import (
"errors"
"fmt"
"io"
"io/ioutil"
"log"
"net"
"net/http"
"net/url"
"os"
"path"
"regexp"
@@ -21,79 +21,159 @@ import (
)
const (
targetURI = "http://a.files.bbci.co.uk/media/live/manifesto/" +
"audio/simulcast/hls/%s/%s/ak/%s.m3u8"
metaBaseURI = "http://polling.bbc.co.uk/radio/nhppolling/"
targetURI = "http://as-hls-%s-live.akamaized.net/pool_%s/live/%s/" +
"%s/%s.isml/%s-audio%%3d%s.norewind.m3u8"
networksURI1 = "https://rms.api.bbc.co.uk/radio/networks.json"
networksURI2 = "https://rms.api.bbc.co.uk/v2/networks/%s"
metaURI = "https://rms.api.bbc.co.uk/v2/services/%s/segments/latest"
)
var client = &http.Client{Transport: &http.Transport{}}
func get(url string) (resp *http.Response, err error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
// lsn.lv returned 403 for the default net.http User-Agent.
req.Header.Set("User-Agent", "bbc-on-ice/1")
return client.Do(req)
}
func getServiceTitle1(name string) (string, error) {
resp, err := get(networksURI1)
if resp != nil {
defer resp.Body.Close()
}
if err != nil {
return name, err
}
b, err := io.ReadAll(resp.Body)
var v struct {
Results []struct {
Services []struct {
ID string `json:"id"`
Title string `json:"title"`
} `json:"services"`
} `json:"results"`
}
err = json.Unmarshal(b, &v)
if err != nil {
return name, errors.New("invalid metadata response")
}
for _, network := range v.Results {
for _, service := range network.Services {
if service.ID == name {
return service.Title, nil
}
}
}
return name, errors.New("unknown service")
}
// getServiceTitle returns a human-friendly identifier for a BBC service ID.
func getServiceTitle(name string) (string, error) {
// This endpoint is incomplete,
// but it contains the kind of service titles we want.
title, err := getServiceTitle1(name)
if err == nil {
return title, nil
}
// Network IDs tend to coincide with service IDs.
resp, err := get(fmt.Sprintf(networksURI2, name))
if resp != nil {
defer resp.Body.Close()
}
if err != nil {
return name, err
}
b, err := io.ReadAll(resp.Body)
var v struct {
LongTitle string `json:"long_title"`
}
err = json.Unmarshal(b, &v)
if err != nil {
return name, errors.New("invalid metadata response")
}
if v.LongTitle == "" {
return name, errors.New("unknown service")
}
return v.LongTitle, nil
}
type meta struct {
title string // what's playing right now
timeout uint // timeout for the next poll in ms
}
var errNoSong = errors.New("no song is playing")
// getMeta retrieves and decodes metadata info from an independent webservice.
func getMeta(name string) (*meta, error) {
resp, err := http.Get(metaBaseURI + name)
resp, err := get(fmt.Sprintf(metaURI, name))
if resp != nil {
defer resp.Body.Close()
}
if err != nil {
return nil, err
}
b, err := ioutil.ReadAll(resp.Body)
if len(b) < 2 {
// There needs to be an enclosing () pair
return nil, errors.New("invalid metadata response")
b, err := io.ReadAll(resp.Body)
if os.Getenv("DEBUG") != "" {
log.Println(string(b))
}
type broadcast struct {
Title string // title of the broadcast
Percentage int // how far we're in
}
// TODO: update more completely for the new OpenAPI
// - `broadcasts/poll/bbc_radio_one` looks almost useful
// - https://rms.api.bbc.co.uk/v2/experience/inline/play/${name}
// seems to be what we want, even provides timer/polling values
var v struct {
Packages struct {
OnAir struct {
Broadcasts []broadcast
BroadcastNowIndex uint
} `json:"on-air"`
Richtracks []struct {
Artist string
Title string
IsNowPlaying bool `json:"is_now_playing"`
}
}
Timeouts struct {
PollingTimeout uint `json:"polling_timeout"`
}
Data []struct {
Titles struct {
Primary string `json:"primary"`
Secondary *string `json:"secondary"`
Tertiary *string `json:"tertiary"`
} `json:"titles"`
Offset struct {
NowPlaying bool `json:"now_playing"`
} `json:"offset"`
} `json:"data"`
}
err = json.Unmarshal(b[1:len(b)-1], &v)
err = json.Unmarshal(b, &v)
if err != nil {
return nil, errors.New("invalid metadata response")
}
onAir := v.Packages.OnAir
if onAir.BroadcastNowIndex >= uint(len(onAir.Broadcasts)) {
return nil, errors.New("no active broadcast")
if len(v.Data) == 0 || !v.Data[0].Offset.NowPlaying {
return nil, errNoSong
}
title := onAir.Broadcasts[onAir.BroadcastNowIndex].Title
for _, rt := range v.Packages.Richtracks {
if rt.IsNowPlaying {
title = rt.Artist + " - " + rt.Title + " / " + title
}
titles := v.Data[0].Titles
parts := []string{titles.Primary}
if titles.Secondary != nil {
parts = append(parts, *titles.Secondary)
}
return &meta{timeout: v.Timeouts.PollingTimeout, title: title}, nil
if titles.Tertiary != nil {
parts = append(parts, *titles.Tertiary)
}
return &meta{timeout: 5000, title: strings.Join(parts, " - ")}, nil
}
// resolveM3U8 resolves an M3U8 playlist to the first link that seems to
// be playable, possibly recursing.
func resolveM3U8(target string) (out []string, err error) {
resp, err := http.Get(target)
if resp != nil {
defer resp.Body.Close()
}
resp, err := get(target)
if err != nil {
return nil, err
}
b, err := ioutil.ReadAll(resp.Body)
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s: %s", target, resp.Status)
}
if !utf8.Valid(b) {
return nil, errors.New("invalid UTF-8")
}
@@ -117,26 +197,58 @@ func resolveM3U8(target string) (out []string, err error) {
return out, nil
}
const resolveURI = "https://lsn.lv/bbcradio.m3u8?station=%s"
var poolRE = regexp.MustCompile(`/pool_([^/]+)/`)
// resolvePool figures out the randomized part of stream URIs.
func resolvePool(name string) (pool string, err error) {
target := fmt.Sprintf(resolveURI, url.QueryEscape(name))
resp, err := get(target)
if err != nil {
return "", err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("%s: %s", target, resp.Status)
}
for _, line := range strings.Split(string(b), "\n") {
if strings.HasPrefix(line, "#") {
continue
}
if m := poolRE.FindStringSubmatch(line); m == nil {
return "", fmt.Errorf("%s: unexpected URI", target)
} else {
return m[1], nil
}
}
return "", fmt.Errorf("%s: no URI", target)
}
// metaProc periodically polls the sub-URL given by name for titles and sends
// them out the given channel. Never returns prematurely.
func metaProc(ctx context.Context, name string, out chan<- string) {
defer close(out)
// "polling_timeout" seems to normally be 25 seconds, which is a lot,
// especially considering all the possible additional buffering.
const maxInterval = 5 * time.Second
var current, last string
var interval time.Duration
for {
meta, err := getMeta(name)
if err != nil {
current = "Error: " + err.Error()
interval = 30 * time.Second
if err == errNoSong {
interval, current = maxInterval, ""
} else if err != nil {
interval, current = maxInterval, err.Error()
} else {
current = meta.title
interval = time.Duration(meta.timeout)
// It seems to normally use 25 seconds which is a lot,
// especially considering all the possible additional buffering.
if interval > 5000 {
interval = 5000
interval = time.Duration(meta.timeout) * time.Millisecond
if interval > maxInterval {
interval = maxInterval
}
}
if current != last {
@@ -149,7 +261,7 @@ func metaProc(ctx context.Context, name string, out chan<- string) {
}
select {
case <-time.After(time.Duration(interval) * time.Millisecond):
case <-time.After(interval):
case <-ctx.Done():
return
}
@@ -179,9 +291,9 @@ func urlProc(ctx context.Context, playlistURL string, out chan<- string) {
return
}
}
// I expect this to be mainly driven by the buffered channel but
// a small (less than target duration) additional pause will not hurt.
time.Sleep(1 * time.Second)
// Media players will happily buffer the whole playlist at once,
// a small (less than target duration) additional pause is appropriate.
time.Sleep(3 * time.Second)
}
}
@@ -196,7 +308,7 @@ func dataProc(ctx context.Context, playlistURL string, maxChunk int,
go urlProc(ctx, playlistURL, urls)
for url := range urls {
resp, err := http.Get(url)
resp, err := get(url)
if resp != nil {
defer resp.Body.Close()
}
@@ -238,15 +350,20 @@ func proxy(w http.ResponseWriter, req *http.Request) {
panic("cannot hijack connection")
}
// E.g. `nonuk`, `sbr_low` `bbc_radio_one`, or `uk`, `sbr_high`, `bbc_1xtra`
region, quality, name := m[1], m[2], m[3]
// [ww]/[uk], [48000/96000]/[128000/320000], bbc_radio_one/bbc_1xtra/...
region, quality, name, pool := m[1], m[2], m[3], "904"
if p, err := resolvePool(name); err != nil {
log.Printf("failed to resolve pool: %s\n", err)
} else {
pool = p
}
// TODO: We probably shouldn't poll the top level playlist.
mainPlaylistURL := fmt.Sprintf(targetURI, region, quality, name)
mediaPlaylistURL :=
fmt.Sprintf(targetURI, region, pool, region, name, name, name, quality)
// This validates the parameters as a side-effect.
target, err := resolveM3U8(mainPlaylistURL)
if err == nil && len(target) == 0 {
media, err := resolveM3U8(mediaPlaylistURL)
if err == nil && len(media) == 0 {
err = errors.New("cannot resolve playlist")
}
if err != nil {
@@ -255,7 +372,7 @@ func proxy(w http.ResponseWriter, req *http.Request) {
}
wantMeta := req.Header.Get("Icy-MetaData") == "1"
resp, err := http.Head(target[0])
resp, err := http.Head(media[0])
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -268,11 +385,10 @@ func proxy(w http.ResponseWriter, req *http.Request) {
}
defer conn.Close()
// TODO: Retrieve some general information from somewhere?
// There's nothing interesting in the playlist files.
serviceTitle, _ := getServiceTitle(name)
fmt.Fprintf(bufrw, "ICY 200 OK\r\n")
fmt.Fprintf(bufrw, "icy-name:%s\r\n", name)
fmt.Fprintf(bufrw, "icy-name:%s\r\n", serviceTitle)
// BBC marks this as a video type, maybe just force audio/mpeg.
fmt.Fprintf(bufrw, "content-type:%s\r\n", resp.Header["Content-Type"][0])
fmt.Fprintf(bufrw, "icy-pub:%d\r\n", 0)
@@ -285,7 +401,7 @@ func proxy(w http.ResponseWriter, req *http.Request) {
go metaProc(req.Context(), name, metaChan)
chunkChan := make(chan []byte)
go dataProc(req.Context(), mainPlaylistURL, metaint, chunkChan)
go dataProc(req.Context(), mediaPlaylistURL, metaint, chunkChan)
// dataProc may return less data near the end of a subfile, so we give it
// a maximum count of bytes to return at once and do our own buffering.
@@ -305,6 +421,9 @@ func proxy(w http.ResponseWriter, req *http.Request) {
for {
select {
case title := <-metaChan:
if title == "" {
title = serviceTitle
}
queuedMetaUpdate = []byte(fmt.Sprintf("StreamTitle='%s'",
strings.Replace(title, "'", "", -1)))
case chunk, ok := <-chunkChan: