Initial commit
A half-working prototype.
This commit is contained in:
commit
42a430345f
14
LICENSE
Normal file
14
LICENSE
Normal file
@ -0,0 +1,14 @@
|
||||
Copyright (c) 2016, Přemysl Janouch <p.janouch@gmail.com>
|
||||
All rights reserved.
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
|
||||
SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
|
||||
OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
|
||||
CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
49
README.adoc
Normal file
49
README.adoc
Normal file
@ -0,0 +1,49 @@
|
||||
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.
|
||||
|
||||
Packages
|
||||
--------
|
||||
Regular releases are sporadic. git master should be stable enough. You can get
|
||||
a package with the latest development version from Archlinux's AUR, or from
|
||||
openSUSE Build Service for the rest of mainstream distributions. Consult the
|
||||
list of repositories and their respective links at:
|
||||
|
||||
https://build.opensuse.org/project/repositories/home:pjanouch:git
|
||||
|
||||
Building and Running
|
||||
--------------------
|
||||
Build dependencies: CMake, go
|
||||
|
||||
$ git clone --recursive https://github.com/pjanouch/bbc-on-ice.git
|
||||
$ mkdir bbc-on-ice/build
|
||||
$ cd bbc-on-ice/build
|
||||
$ cmake .. -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Debug
|
||||
$ make
|
||||
|
||||
To install the application, you can do either the usual:
|
||||
|
||||
# make install
|
||||
|
||||
Or you can try telling CMake to make a package for you. For Debian it is:
|
||||
|
||||
$ cpack -G DEB
|
||||
# dpkg -i bbc-on-ice-*.deb
|
||||
|
||||
Contributing and Support
|
||||
------------------------
|
||||
Use this project's GitHub to report any bugs, request features, or submit pull
|
||||
requests. If you want to discuss this project, or maybe just hang out with
|
||||
the developer, feel free to join me at irc://irc.janouch.name, channel #dev.
|
||||
|
||||
License
|
||||
-------
|
||||
'bbc-on-ice' is written by Přemysl Janouch <p.janouch@gmail.com>.
|
||||
|
||||
You may use the software under the terms of the ISC license, the text of which
|
||||
is included within the package, or, at your option, you may relicense the work
|
||||
under the MIT or the Modified BSD License, as listed at the following site:
|
||||
|
||||
http://www.gnu.org/licenses/license-list.html
|
253
bbc-on-ice.go
Normal file
253
bbc-on-ice.go
Normal file
@ -0,0 +1,253 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type meta struct {
|
||||
title string // What's playing right now
|
||||
timeout uint // Timeout for the next poll in ms
|
||||
}
|
||||
|
||||
// Retrieve and decode metadata information from an independent webservice
|
||||
func getMeta(name string) (*meta, error) {
|
||||
const metaBaseURI = "http://polling.bbc.co.uk/radio/nhppolling/"
|
||||
resp, err := http.Get(metaBaseURI + name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := ioutil.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if len(b) < 2 {
|
||||
// There needs to be an enclosing () pair
|
||||
return nil, errors.New("invalid metadata response")
|
||||
}
|
||||
|
||||
// TODO: also retrieve richtracks/is_now_playing, see example file
|
||||
type broadcast struct {
|
||||
Title string `json:"title"` // Title of the broadcast
|
||||
Percentage int `json:"percentage"` // How far we're in
|
||||
}
|
||||
var v struct {
|
||||
Packages struct {
|
||||
OnAir struct {
|
||||
Broadcasts []broadcast `json:"broadcasts"`
|
||||
BroadcastNowIndex uint `json:"broadcastNowIndex"`
|
||||
} `json:"on-air"`
|
||||
} `json:"packages"`
|
||||
Timeouts struct {
|
||||
PollingTimeout uint `json:"polling_timeout"`
|
||||
} `json:"timeouts"`
|
||||
}
|
||||
err = json.Unmarshal(b[1:len(b)-1], &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")
|
||||
}
|
||||
return &meta{
|
||||
timeout: v.Timeouts.PollingTimeout,
|
||||
title: onAir.Broadcasts[onAir.BroadcastNowIndex].Title,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Resolve an M3U8 playlist to the first link that seems to be playable
|
||||
func resolveM3U8(target string) (out []string, err error) {
|
||||
resp, err := http.Get(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := ioutil.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if !utf8.Valid(b) {
|
||||
return nil, errors.New("invalid UTF-8")
|
||||
}
|
||||
lines := strings.Split(string(b), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(line, "/") {
|
||||
// Seems to be a relative link, let's make it absolute
|
||||
dir, _ := path.Split(target)
|
||||
line = dir + line
|
||||
}
|
||||
if strings.HasSuffix(line, "m3u8") {
|
||||
// The playlist seems to recurse, and so do we
|
||||
return resolveM3U8(line)
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var pathRE = regexp.MustCompile("^/(.*?)/(.*?)/(.*?)$")
|
||||
|
||||
func proxy(w http.ResponseWriter, req *http.Request) {
|
||||
const targetURI = "http://a.files.bbci.co.uk/media/live/manifesto/" +
|
||||
"audio/simulcast/hls/%s/%s/ak/%s.m3u8"
|
||||
const metaint = 1 << 16
|
||||
m := pathRE.FindStringSubmatch(req.URL.Path)
|
||||
if m == nil {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
// We're not using TLS where HTTP/2 could have caused this
|
||||
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]
|
||||
// This validates the params as a side-effect
|
||||
target, err := resolveM3U8(fmt.Sprintf(targetURI, region, quality, name))
|
||||
if err == nil && len(target) == 0 {
|
||||
err = errors.New("cannot resolve playlist")
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
wantMeta := false
|
||||
if icyMeta, ok := req.Header["Icy-MetaData"]; ok {
|
||||
wantMeta = len(icyMeta) == 1 && icyMeta[0] == "1"
|
||||
}
|
||||
resp, err := http.Get(target[0])
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
conn, bufrw, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// TODO: retrieve some general information from somewhere?
|
||||
// There's nothing interesting in the playlist files.
|
||||
fmt.Fprintf(bufrw, "ICY 200 OK\r\n")
|
||||
fmt.Fprintf(bufrw, "icy-name:%s\r\n", name)
|
||||
// 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)
|
||||
if wantMeta {
|
||||
fmt.Fprintf(bufrw, "icy-metaint: %d\r\n", metaint)
|
||||
}
|
||||
fmt.Fprintf(bufrw, "\r\n")
|
||||
|
||||
// TODO: move to a normal function
|
||||
metaChan := make(chan string)
|
||||
go func() {
|
||||
var current, last string
|
||||
var interval time.Duration
|
||||
for {
|
||||
meta, err := getMeta(name)
|
||||
if err != nil {
|
||||
current = "Error: " + err.Error()
|
||||
interval = 30 * time.Second
|
||||
} else {
|
||||
current = meta.title
|
||||
interval = time.Duration(meta.timeout)
|
||||
}
|
||||
if current != last {
|
||||
metaChan <- current
|
||||
last = current
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(time.Duration(interval) * time.Millisecond):
|
||||
case <-req.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// TODO: move to a normal function
|
||||
// FIXME: this will load a few seconds (one URL) and die
|
||||
// - we can either try to implement this and hope for the best
|
||||
// https://tools.ietf.org/html/draft-pantos-http-live-streaming-20
|
||||
// then like https://github.com/kz26/gohls/blob/master/main.go
|
||||
// - or we can become more of a proxy, which complicates ICY
|
||||
chunkChan := make(chan []byte)
|
||||
go func() {
|
||||
defer resp.Body.Close()
|
||||
defer close(chunkChan)
|
||||
for {
|
||||
chunk := make([]byte, metaint)
|
||||
n, err := io.ReadFull(resp.Body, chunk)
|
||||
chunkChan <- chunk[:n]
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
default:
|
||||
case <-req.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
var queuedMeta []byte
|
||||
makeMetaChunk := func() []byte {
|
||||
meta := queuedMeta
|
||||
queuedMeta = nil
|
||||
for len(meta)%16 != 0 {
|
||||
meta = append(meta, 0)
|
||||
}
|
||||
if len(meta) > 16*255 {
|
||||
meta = meta[:16*255]
|
||||
}
|
||||
chunk := []byte{byte(len(meta) / 16)}
|
||||
return append(chunk, meta...)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case title := <-metaChan:
|
||||
queuedMeta = []byte(fmt.Sprintf("StreamTitle='%s'", title))
|
||||
case chunk, connected := <-chunkChan:
|
||||
if !connected {
|
||||
return
|
||||
}
|
||||
if wantMeta {
|
||||
chunk = append(chunk, makeMetaChunk()...)
|
||||
}
|
||||
if _, err := bufrw.Write(chunk); err != nil {
|
||||
return
|
||||
}
|
||||
if err := bufrw.Flush(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
// TODO: also try to support systemd socket activation
|
||||
address := ":8000"
|
||||
if len(os.Args) == 2 {
|
||||
address = os.Args[1]
|
||||
}
|
||||
|
||||
http.HandleFunc("/", proxy)
|
||||
log.Fatal(http.ListenAndServe(address, nil))
|
||||
}
|
Loading…
Reference in New Issue
Block a user