Compare commits

..

9 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
4 changed files with 187 additions and 61 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 Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted. purpose with or without fee is hereby granted.

View File

@@ -19,14 +19,19 @@ Build dependencies: go
$ cd bbc-on-ice $ cd bbc-on-ice
$ go build $ go build
or, if you know what you're doing:
$ go install janouch.name/bbc-on-ice@master
To run the local server: To run the local server:
$ ./bbc-on-ice :8000 $ ./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/ww/96000/bbc_radio_one
$ mpv http://localhost:8000/uk/sbr_high/bbc_1xtra $ mpv http://localhost:8000/uk/320000/bbc_1xtra
Socket activation Socket activation
----------------- -----------------

3
go.mod Normal file
View File

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

232
main.go
View File

@@ -6,10 +6,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/ioutil"
"log" "log"
"net" "net"
"net/http" "net/http"
"net/url"
"os" "os"
"path" "path"
"regexp" "regexp"
@@ -21,79 +21,159 @@ import (
) )
const ( const (
targetURI = "http://a.files.bbci.co.uk/media/live/manifesto/" + targetURI = "http://as-hls-%s-live.akamaized.net/pool_%s/live/%s/" +
"audio/simulcast/hls/%s/%s/ak/%s.m3u8" "%s/%s.isml/%s-audio%%3d%s.norewind.m3u8"
metaBaseURI = "http://polling.bbc.co.uk/radio/nhppolling/" 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 { type meta struct {
title string // what's playing right now title string // what's playing right now
timeout uint // timeout for the next poll in ms 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. // getMeta retrieves and decodes metadata info from an independent webservice.
func getMeta(name string) (*meta, error) { func getMeta(name string) (*meta, error) {
resp, err := http.Get(metaBaseURI + name) resp, err := get(fmt.Sprintf(metaURI, name))
if resp != nil { if resp != nil {
defer resp.Body.Close() defer resp.Body.Close()
} }
if err != nil { if err != nil {
return nil, err return nil, err
} }
b, err := ioutil.ReadAll(resp.Body) b, err := io.ReadAll(resp.Body)
if len(b) < 2 { if os.Getenv("DEBUG") != "" {
// There needs to be an enclosing () pair log.Println(string(b))
return nil, errors.New("invalid metadata response")
} }
type broadcast struct { // TODO: update more completely for the new OpenAPI
Title string // title of the broadcast // - `broadcasts/poll/bbc_radio_one` looks almost useful
Percentage int // how far we're in // - 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 { var v struct {
Packages struct { Data []struct {
OnAir struct { Titles struct {
Broadcasts []broadcast Primary string `json:"primary"`
BroadcastNowIndex uint Secondary *string `json:"secondary"`
} `json:"on-air"` Tertiary *string `json:"tertiary"`
Richtracks []struct { } `json:"titles"`
Artist string Offset struct {
Title string NowPlaying bool `json:"now_playing"`
IsNowPlaying bool `json:"is_now_playing"` } `json:"offset"`
} } `json:"data"`
}
Timeouts struct {
PollingTimeout uint `json:"polling_timeout"`
}
} }
err = json.Unmarshal(b[1:len(b)-1], &v) err = json.Unmarshal(b, &v)
if err != nil { if err != nil {
return nil, errors.New("invalid metadata response") return nil, errors.New("invalid metadata response")
} }
onAir := v.Packages.OnAir if len(v.Data) == 0 || !v.Data[0].Offset.NowPlaying {
if onAir.BroadcastNowIndex >= uint(len(onAir.Broadcasts)) { return nil, errNoSong
return nil, errors.New("no active broadcast")
} }
title := onAir.Broadcasts[onAir.BroadcastNowIndex].Title
for _, rt := range v.Packages.Richtracks { titles := v.Data[0].Titles
if rt.IsNowPlaying { parts := []string{titles.Primary}
title = rt.Artist + " - " + rt.Title + " / " + title 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 // resolveM3U8 resolves an M3U8 playlist to the first link that seems to
// be playable, possibly recursing. // be playable, possibly recursing.
func resolveM3U8(target string) (out []string, err error) { func resolveM3U8(target string) (out []string, err error) {
resp, err := http.Get(target) resp, err := get(target)
if resp != nil {
defer resp.Body.Close()
}
if err != nil { if err != nil {
return nil, err 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) { if !utf8.Valid(b) {
return nil, errors.New("invalid UTF-8") return nil, errors.New("invalid UTF-8")
} }
@@ -117,6 +197,36 @@ func resolveM3U8(target string) (out []string, err error) {
return out, nil 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 // metaProc periodically polls the sub-URL given by name for titles and sends
// them out the given channel. Never returns prematurely. // them out the given channel. Never returns prematurely.
func metaProc(ctx context.Context, name string, out chan<- string) { func metaProc(ctx context.Context, name string, out chan<- string) {
@@ -130,9 +240,10 @@ func metaProc(ctx context.Context, name string, out chan<- string) {
var interval time.Duration var interval time.Duration
for { for {
meta, err := getMeta(name) meta, err := getMeta(name)
if err != nil { if err == errNoSong {
current = name + " - " + err.Error() interval, current = maxInterval, ""
interval = maxInterval } else if err != nil {
interval, current = maxInterval, err.Error()
} else { } else {
current = meta.title current = meta.title
interval = time.Duration(meta.timeout) * time.Millisecond interval = time.Duration(meta.timeout) * time.Millisecond
@@ -197,7 +308,7 @@ func dataProc(ctx context.Context, playlistURL string, maxChunk int,
go urlProc(ctx, playlistURL, urls) go urlProc(ctx, playlistURL, urls)
for url := range urls { for url := range urls {
resp, err := http.Get(url) resp, err := get(url)
if resp != nil { if resp != nil {
defer resp.Body.Close() defer resp.Body.Close()
} }
@@ -239,15 +350,20 @@ func proxy(w http.ResponseWriter, req *http.Request) {
panic("cannot hijack connection") panic("cannot hijack connection")
} }
// E.g. `nonuk`, `sbr_low` `bbc_radio_one`, or `uk`, `sbr_high`, `bbc_1xtra` // [ww]/[uk], [48000/96000]/[128000/320000], bbc_radio_one/bbc_1xtra/...
region, quality, name := m[1], m[2], m[3] 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. mediaPlaylistURL :=
mainPlaylistURL := fmt.Sprintf(targetURI, region, quality, name) fmt.Sprintf(targetURI, region, pool, region, name, name, name, quality)
// This validates the parameters as a side-effect. // This validates the parameters as a side-effect.
target, err := resolveM3U8(mainPlaylistURL) media, err := resolveM3U8(mediaPlaylistURL)
if err == nil && len(target) == 0 { if err == nil && len(media) == 0 {
err = errors.New("cannot resolve playlist") err = errors.New("cannot resolve playlist")
} }
if err != nil { if err != nil {
@@ -256,7 +372,7 @@ func proxy(w http.ResponseWriter, req *http.Request) {
} }
wantMeta := req.Header.Get("Icy-MetaData") == "1" wantMeta := req.Header.Get("Icy-MetaData") == "1"
resp, err := http.Head(target[0]) resp, err := http.Head(media[0])
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@@ -269,11 +385,10 @@ func proxy(w http.ResponseWriter, req *http.Request) {
} }
defer conn.Close() defer conn.Close()
// TODO: Retrieve some general information from somewhere? serviceTitle, _ := getServiceTitle(name)
// There's nothing interesting in the playlist files.
fmt.Fprintf(bufrw, "ICY 200 OK\r\n") 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. // 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, "content-type:%s\r\n", resp.Header["Content-Type"][0])
fmt.Fprintf(bufrw, "icy-pub:%d\r\n", 0) fmt.Fprintf(bufrw, "icy-pub:%d\r\n", 0)
@@ -286,7 +401,7 @@ func proxy(w http.ResponseWriter, req *http.Request) {
go metaProc(req.Context(), name, metaChan) go metaProc(req.Context(), name, metaChan)
chunkChan := make(chan []byte) 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 // 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. // a maximum count of bytes to return at once and do our own buffering.
@@ -306,6 +421,9 @@ func proxy(w http.ResponseWriter, req *http.Request) {
for { for {
select { select {
case title := <-metaChan: case title := <-metaChan:
if title == "" {
title = serviceTitle
}
queuedMetaUpdate = []byte(fmt.Sprintf("StreamTitle='%s'", queuedMetaUpdate = []byte(fmt.Sprintf("StreamTitle='%s'",
strings.Replace(title, "'", "", -1))) strings.Replace(title, "'", "", -1)))
case chunk, ok := <-chunkChan: case chunk, ok := <-chunkChan: