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