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
 | 
			
		||||
purpose with or without fee is hereby granted.
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										173
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										173
									
								
								main.go
									
									
									
									
									
								
							@@ -6,10 +6,10 @@ import (
 | 
			
		||||
	"errors"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io"
 | 
			
		||||
	"io/ioutil"
 | 
			
		||||
	"log"
 | 
			
		||||
	"net"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"net/url"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path"
 | 
			
		||||
	"regexp"
 | 
			
		||||
@@ -21,26 +21,110 @@ import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
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"
 | 
			
		||||
	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 {
 | 
			
		||||
	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(fmt.Sprintf(metaURI, 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)
 | 
			
		||||
	b, err := io.ReadAll(resp.Body)
 | 
			
		||||
	if os.Getenv("DEBUG") != "" {
 | 
			
		||||
		log.Println(string(b))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// TODO: update more completely for the new OpenAPI
 | 
			
		||||
	//  - `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")
 | 
			
		||||
	}
 | 
			
		||||
	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
 | 
			
		||||
	parts := []string{}
 | 
			
		||||
	if titles.Tertiary != nil {
 | 
			
		||||
		parts = append(parts, *titles.Tertiary)
 | 
			
		||||
	}
 | 
			
		||||
	parts := []string{titles.Primary}
 | 
			
		||||
	if titles.Secondary != nil {
 | 
			
		||||
		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
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 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")
 | 
			
		||||
	}
 | 
			
		||||
@@ -112,6 +197,36 @@ 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) {
 | 
			
		||||
@@ -125,9 +240,10 @@ func metaProc(ctx context.Context, name string, out chan<- string) {
 | 
			
		||||
	var interval time.Duration
 | 
			
		||||
	for {
 | 
			
		||||
		meta, err := getMeta(name)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			current = name + " - " + err.Error()
 | 
			
		||||
			interval = maxInterval
 | 
			
		||||
		if err == errNoSong {
 | 
			
		||||
			interval, current = maxInterval, ""
 | 
			
		||||
		} else if err != nil {
 | 
			
		||||
			interval, current = maxInterval, err.Error()
 | 
			
		||||
		} else {
 | 
			
		||||
			current = meta.title
 | 
			
		||||
			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)
 | 
			
		||||
 | 
			
		||||
	for url := range urls {
 | 
			
		||||
		resp, err := http.Get(url)
 | 
			
		||||
		resp, err := get(url)
 | 
			
		||||
		if resp != nil {
 | 
			
		||||
			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/...
 | 
			
		||||
	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 :=
 | 
			
		||||
		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.
 | 
			
		||||
	media, err := resolveM3U8(mediaPlaylistURL)
 | 
			
		||||
@@ -264,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)
 | 
			
		||||
@@ -301,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:
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user