Compare commits
6 Commits
8a087dea4a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
72ab92d6b8
|
|||
|
4c2f1d6691
|
|||
|
c90e03271e
|
|||
|
91db4c7d84
|
|||
|
035776c504
|
|||
|
1cd87209be
|
2
LICENSE
2
LICENSE
@@ -1,4 +1,4 @@
|
|||||||
Copyright (c) 2016 - 2024, Přemysl Eric 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.
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -1,3 +1,3 @@
|
|||||||
module janouch.name/bbc-on-ice
|
module janouch.name/bbc-on-ice
|
||||||
|
|
||||||
go 1.22.0
|
go 1.20.0
|
||||||
|
|||||||
173
main.go
173
main.go
@@ -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,26 +21,110 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
targetURI = "http://as-hls-%s-live.akamaized.net/pool_904/live/%s/" +
|
targetURI = "http://as-hls-%s-live.akamaized.net/pool_%s/live/%s/" +
|
||||||
"%s/%s.isml/%s-audio%%3d%s.norewind.m3u8"
|
"%s/%s.isml/%s-audio%%3d%s.norewind.m3u8"
|
||||||
metaURI = "https://rms.api.bbc.co.uk/v2/services/%s/segments/latest"
|
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(fmt.Sprintf(metaURI, 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 os.Getenv("DEBUG") != "" {
|
||||||
|
log.Println(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: update more completely for the new OpenAPI
|
// TODO: update more completely for the new OpenAPI
|
||||||
// - `broadcasts/poll/bbc_radio_one` looks almost useful
|
// - `broadcasts/poll/bbc_radio_one` looks almost useful
|
||||||
@@ -63,32 +147,33 @@ func getMeta(name string) (*meta, error) {
|
|||||||
return nil, errors.New("invalid metadata response")
|
return nil, errors.New("invalid metadata response")
|
||||||
}
|
}
|
||||||
if len(v.Data) == 0 || !v.Data[0].Offset.NowPlaying {
|
if len(v.Data) == 0 || !v.Data[0].Offset.NowPlaying {
|
||||||
return nil, errors.New("no song is playing")
|
return nil, errNoSong
|
||||||
}
|
}
|
||||||
|
|
||||||
titles := v.Data[0].Titles
|
titles := v.Data[0].Titles
|
||||||
parts := []string{}
|
parts := []string{titles.Primary}
|
||||||
if titles.Tertiary != nil {
|
|
||||||
parts = append(parts, *titles.Tertiary)
|
|
||||||
}
|
|
||||||
if titles.Secondary != nil {
|
if titles.Secondary != nil {
|
||||||
parts = append(parts, *titles.Secondary)
|
parts = append(parts, *titles.Secondary)
|
||||||
}
|
}
|
||||||
parts = append(parts, titles.Primary)
|
if titles.Tertiary != nil {
|
||||||
|
parts = append(parts, *titles.Tertiary)
|
||||||
|
}
|
||||||
return &meta{timeout: 5000, title: strings.Join(parts, " - ")}, nil
|
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")
|
||||||
}
|
}
|
||||||
@@ -112,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) {
|
||||||
@@ -125,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
|
||||||
@@ -192,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()
|
||||||
}
|
}
|
||||||
@@ -235,10 +351,15 @@ func proxy(w http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// [ww]/[uk], [48000/96000]/[128000/320000], bbc_radio_one/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
|
||||||
|
}
|
||||||
|
|
||||||
mediaPlaylistURL :=
|
mediaPlaylistURL :=
|
||||||
fmt.Sprintf(targetURI, region, region, name, name, name, quality)
|
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.
|
||||||
media, err := resolveM3U8(mediaPlaylistURL)
|
media, err := resolveM3U8(mediaPlaylistURL)
|
||||||
@@ -264,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)
|
||||||
@@ -301,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:
|
||||||
|
|||||||
Reference in New Issue
Block a user