It's about time to make this work
Luckily BBC is /still/ using HLS for their streams, and even the external metadata format hasn't changed.
This commit is contained in:
parent
ae26e5a8ea
commit
dddbc5556e
2
LICENSE
2
LICENSE
|
@ -1,4 +1,4 @@
|
||||||
Copyright (c) 2016 - 2017, Přemysl Janouch <p@janouch.name>
|
Copyright (c) 2016 - 2018, Přemysl 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.
|
||||||
|
|
28
README.adoc
28
README.adoc
|
@ -11,22 +11,28 @@ a package with the latest development version from Archlinux's AUR.
|
||||||
|
|
||||||
Building and Running
|
Building and Running
|
||||||
--------------------
|
--------------------
|
||||||
Build dependencies: CMake, go
|
Build dependencies: go
|
||||||
|
|
||||||
$ git clone --recursive https://git.janouch.name/p/bbc-on-ice.git
|
$ git clone https://git.janouch.name/p/bbc-on-ice.git
|
||||||
$ mkdir bbc-on-ice/build
|
$ cd bbc-on-ice
|
||||||
$ cd bbc-on-ice/build
|
$ go build
|
||||||
$ cmake .. -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Debug
|
|
||||||
$ make
|
|
||||||
|
|
||||||
To install the application, you can do either the usual:
|
To run the local server:
|
||||||
|
|
||||||
# make install
|
$ ./bbc-on-ice :8000
|
||||||
|
|
||||||
Or you can try telling CMake to make a package for you. For Debian it is:
|
Streams have URLs in the following form:
|
||||||
|
|
||||||
$ cpack -G DEB
|
$ mpv http://localhost:8000/nonuk/sbr_low/bbc_radio_one
|
||||||
# dpkg -i bbc-on-ice-*.deb
|
$ mpv http://localhost:8000/uk/sbr_high/bbc_1xtra
|
||||||
|
|
||||||
|
Socket activation
|
||||||
|
-----------------
|
||||||
|
The provided bbc-on-ice.service and bbc-on-ice.socket should do, just change
|
||||||
|
the `ExecStart` path as needed and place the files appropriately. Then:
|
||||||
|
|
||||||
|
$ systemctl enable bbc-on-ice.socket
|
||||||
|
$ systemctl start bbc-on-ice.socket
|
||||||
|
|
||||||
Contributing and Support
|
Contributing and Support
|
||||||
------------------------
|
------------------------
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
[Unit]
|
||||||
|
Description=bbc-on-ice
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/bbc-on-ice
|
|
@ -0,0 +1,5 @@
|
||||||
|
[Socket]
|
||||||
|
ListenStream=[::1]:8000
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=sockets.target
|
230
main.go
230
main.go
|
@ -20,14 +20,19 @@ import (
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
targetURI = "http://a.files.bbci.co.uk/media/live/manifesto/" +
|
||||||
|
"audio/simulcast/hls/%s/%s/ak/%s.m3u8"
|
||||||
|
metaBaseURI = "http://polling.bbc.co.uk/radio/nhppolling/"
|
||||||
|
)
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retrieve and decode metadata information 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) {
|
||||||
const metaBaseURI = "http://polling.bbc.co.uk/radio/nhppolling/"
|
|
||||||
resp, err := http.Get(metaBaseURI + name)
|
resp, err := http.Get(metaBaseURI + name)
|
||||||
if resp != nil {
|
if resp != nil {
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
@ -41,10 +46,9 @@ func getMeta(name string) (*meta, error) {
|
||||||
return nil, errors.New("invalid metadata response")
|
return nil, errors.New("invalid metadata response")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: also retrieve richtracks/is_now_playing, see example file
|
|
||||||
type broadcast struct {
|
type broadcast struct {
|
||||||
Title string // Title of the broadcast
|
Title string // title of the broadcast
|
||||||
Percentage int // How far we're in
|
Percentage int // how far we're in
|
||||||
}
|
}
|
||||||
var v struct {
|
var v struct {
|
||||||
Packages struct {
|
Packages struct {
|
||||||
|
@ -52,6 +56,11 @@ func getMeta(name string) (*meta, error) {
|
||||||
Broadcasts []broadcast
|
Broadcasts []broadcast
|
||||||
BroadcastNowIndex uint
|
BroadcastNowIndex uint
|
||||||
} `json:"on-air"`
|
} `json:"on-air"`
|
||||||
|
Richtracks []struct {
|
||||||
|
Artist string
|
||||||
|
Title string
|
||||||
|
IsNowPlaying bool `json:"is_now_playing"`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Timeouts struct {
|
Timeouts struct {
|
||||||
PollingTimeout uint `json:"polling_timeout"`
|
PollingTimeout uint `json:"polling_timeout"`
|
||||||
|
@ -65,13 +74,17 @@ func getMeta(name string) (*meta, error) {
|
||||||
if onAir.BroadcastNowIndex >= uint(len(onAir.Broadcasts)) {
|
if onAir.BroadcastNowIndex >= uint(len(onAir.Broadcasts)) {
|
||||||
return nil, errors.New("no active broadcast")
|
return nil, errors.New("no active broadcast")
|
||||||
}
|
}
|
||||||
return &meta{
|
title := onAir.Broadcasts[onAir.BroadcastNowIndex].Title
|
||||||
timeout: v.Timeouts.PollingTimeout,
|
for _, rt := range v.Packages.Richtracks {
|
||||||
title: onAir.Broadcasts[onAir.BroadcastNowIndex].Title,
|
if rt.IsNowPlaying {
|
||||||
}, nil
|
title = rt.Artist + " - " + rt.Title + " / " + title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &meta{timeout: v.Timeouts.PollingTimeout, title: title}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve an M3U8 playlist to the first link that seems to be playable
|
// resolveM3U8 resolves an M3U8 playlist to the first link that seems to
|
||||||
|
// 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 := http.Get(target)
|
||||||
if resp != nil {
|
if resp != nil {
|
||||||
|
@ -90,12 +103,13 @@ func resolveM3U8(target string) (out []string, err error) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !strings.Contains(line, "/") {
|
if !strings.Contains(line, "/") {
|
||||||
// Seems to be a relative link, let's make it absolute
|
// Seems to be a relative link, let's make it absolute.
|
||||||
dir, _ := path.Split(target)
|
dir, _ := path.Split(target)
|
||||||
line = dir + line
|
line = dir + line
|
||||||
}
|
}
|
||||||
if strings.HasSuffix(line, "m3u8") {
|
if strings.HasSuffix(line, "m3u8") {
|
||||||
// The playlist seems to recurse, and so do we
|
// The playlist seems to recurse, and so will we.
|
||||||
|
// XXX: This should be bounded, not just by the stack.
|
||||||
return resolveM3U8(line)
|
return resolveM3U8(line)
|
||||||
}
|
}
|
||||||
out = append(out, line)
|
out = append(out, line)
|
||||||
|
@ -103,6 +117,8 @@ func resolveM3U8(target string) (out []string, err error) {
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func metaProc(ctx context.Context, name string, out chan<- string) {
|
||||||
defer close(out)
|
defer close(out)
|
||||||
|
|
||||||
|
@ -116,10 +132,14 @@ func metaProc(ctx context.Context, name string, out chan<- string) {
|
||||||
} else {
|
} else {
|
||||||
current = meta.title
|
current = meta.title
|
||||||
interval = time.Duration(meta.timeout)
|
interval = time.Duration(meta.timeout)
|
||||||
|
|
||||||
|
// It seems to normally use 25 seconds which is a lot,
|
||||||
|
// especially considering all the possible additional buffering.
|
||||||
|
if interval > 5000 {
|
||||||
|
interval = 5000
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if current != last {
|
if current != last {
|
||||||
// TODO: see https://blog.golang.org/pipelines
|
|
||||||
// find out if we can do this better
|
|
||||||
select {
|
select {
|
||||||
case out <- current:
|
case out <- current:
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
@ -136,12 +156,77 @@ func metaProc(ctx context.Context, name string, out chan<- string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// urlProc periodically checks the playlist for yet unseen URLs and sends them
|
||||||
|
// over the channel. Assumes that URLs are incremental for simplicity, although
|
||||||
|
// there doesn't seem to be any such gaurantee by the HLS protocol.
|
||||||
|
func urlProc(ctx context.Context, playlistURL string, out chan<- string) {
|
||||||
|
defer close(out)
|
||||||
|
|
||||||
|
highest := ""
|
||||||
|
for {
|
||||||
|
target, err := resolveM3U8(playlistURL)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, url := range target {
|
||||||
|
if url <= highest {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case out <- url:
|
||||||
|
highest = url
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// I expect this to be mainly driven by the buffered channel but
|
||||||
|
// a small (less than target duration) additional pause will not hurt.
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://tools.ietf.org/html/rfc8216
|
||||||
|
// http://www.gpac-licensing.com/2014/12/08/apple-hls-technical-depth/
|
||||||
|
func dataProc(ctx context.Context, playlistURL string, maxChunk int,
|
||||||
|
out chan<- []byte) {
|
||||||
|
defer close(out)
|
||||||
|
|
||||||
|
// The channel is buffered so that the urlProc can fetch in advance.
|
||||||
|
urls := make(chan string, 3)
|
||||||
|
go urlProc(ctx, playlistURL, urls)
|
||||||
|
|
||||||
|
for url := range urls {
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if resp != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
chunk := make([]byte, maxChunk)
|
||||||
|
n, err := resp.Body.Read(chunk)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case out <- chunk[:n]:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var pathRE = regexp.MustCompile(`^/(.*?)/(.*?)/(.*?)$`)
|
var pathRE = regexp.MustCompile(`^/(.*?)/(.*?)/(.*?)$`)
|
||||||
|
|
||||||
func proxy(w http.ResponseWriter, req *http.Request) {
|
func proxy(w http.ResponseWriter, req *http.Request) {
|
||||||
const targetURI = "http://a.files.bbci.co.uk/media/live/manifesto/" +
|
const metaint = 1 << 15
|
||||||
"audio/simulcast/hls/%s/%s/ak/%s.m3u8"
|
|
||||||
const metaint = 1 << 16
|
|
||||||
m := pathRE.FindStringSubmatch(req.URL.Path)
|
m := pathRE.FindStringSubmatch(req.URL.Path)
|
||||||
if m == nil {
|
if m == nil {
|
||||||
http.NotFound(w, req)
|
http.NotFound(w, req)
|
||||||
|
@ -149,14 +234,18 @@ func proxy(w http.ResponseWriter, req *http.Request) {
|
||||||
}
|
}
|
||||||
hijacker, ok := w.(http.Hijacker)
|
hijacker, ok := w.(http.Hijacker)
|
||||||
if !ok {
|
if !ok {
|
||||||
// We're not using TLS where HTTP/2 could have caused this
|
// We're not using TLS where HTTP/2 could have caused this.
|
||||||
panic("cannot hijack connection")
|
panic("cannot hijack connection")
|
||||||
}
|
}
|
||||||
|
|
||||||
// E.g. `nonuk`, `sbr_low` `bbc_radio_one`, or `uk`, `sbr_high`, `bbc_1xtra`
|
// E.g. `nonuk`, `sbr_low` `bbc_radio_one`, or `uk`, `sbr_high`, `bbc_1xtra`
|
||||||
region, quality, name := m[1], m[2], m[3]
|
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))
|
// TODO: We probably shouldn't poll the top level playlist.
|
||||||
|
mainPlaylistURL := fmt.Sprintf(targetURI, region, quality, name)
|
||||||
|
|
||||||
|
// This validates the parameters as a side-effect.
|
||||||
|
target, err := resolveM3U8(mainPlaylistURL)
|
||||||
if err == nil && len(target) == 0 {
|
if err == nil && len(target) == 0 {
|
||||||
err = errors.New("cannot resolve playlist")
|
err = errors.New("cannot resolve playlist")
|
||||||
}
|
}
|
||||||
|
@ -165,11 +254,8 @@ func proxy(w http.ResponseWriter, req *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
wantMeta := false
|
wantMeta := req.Header.Get("Icy-MetaData") == "1"
|
||||||
if icyMeta, ok := req.Header["Icy-MetaData"]; ok {
|
resp, err := http.Head(target[0])
|
||||||
wantMeta = len(icyMeta) == 1 && icyMeta[0] == "1"
|
|
||||||
}
|
|
||||||
resp, err := http.Get(target[0])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
@ -182,11 +268,12 @@ func proxy(w http.ResponseWriter, req *http.Request) {
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
// TODO: retrieve some general information from somewhere?
|
// TODO: Retrieve some general information from somewhere?
|
||||||
// There's nothing interesting in the playlist files.
|
// 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", name)
|
||||||
// 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)
|
||||||
if wantMeta {
|
if wantMeta {
|
||||||
|
@ -197,49 +284,43 @@ func proxy(w http.ResponseWriter, req *http.Request) {
|
||||||
metaChan := make(chan string)
|
metaChan := make(chan string)
|
||||||
go metaProc(req.Context(), name, metaChan)
|
go metaProc(req.Context(), name, metaChan)
|
||||||
|
|
||||||
// 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)
|
chunkChan := make(chan []byte)
|
||||||
go func() {
|
go dataProc(req.Context(), mainPlaylistURL, metaint, chunkChan)
|
||||||
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 {
|
// dataProc may return less data near the end of a subfile, so we give it
|
||||||
default:
|
// a maximum count of bytes to return at once and do our own buffering.
|
||||||
case <-req.Context().Done():
|
var queuedMetaUpdate, queuedChunk []byte
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var queuedMeta []byte
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case title := <-metaChan:
|
case title := <-metaChan:
|
||||||
queuedMeta = []byte(fmt.Sprintf("StreamTitle='%s'", title))
|
queuedMetaUpdate = []byte(fmt.Sprintf("StreamTitle='%s'", title))
|
||||||
case chunk, ok := <-chunkChan:
|
case chunk, ok := <-chunkChan:
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
space := metaint - len(queuedChunk)
|
||||||
|
if space > len(chunk) {
|
||||||
|
space = len(chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
queuedChunk = append(queuedChunk, chunk[:space]...)
|
||||||
|
if len(queuedChunk) < metaint {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if _, err := bufrw.Write(queuedChunk); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
queuedChunk = chunk[space:]
|
||||||
if wantMeta {
|
if wantMeta {
|
||||||
var meta [1 + 16*255]byte
|
var meta [1 + 16*255]byte
|
||||||
meta[0] = byte((copy(meta[1:], queuedMeta) + 15) / 16)
|
meta[0] = byte((copy(meta[1:], queuedMetaUpdate) + 15) / 16)
|
||||||
chunk = append(chunk, meta[:1+int(meta[0])*16]...)
|
queuedMetaUpdate = nil
|
||||||
queuedMeta = nil
|
|
||||||
}
|
if _, err := bufrw.Write(meta[:1+int(meta[0])*16]); err != nil {
|
||||||
if _, err := bufrw.Write(chunk); err != nil {
|
return
|
||||||
return
|
}
|
||||||
}
|
}
|
||||||
if err := bufrw.Flush(); err != nil {
|
if err := bufrw.Flush(); err != nil {
|
||||||
return
|
return
|
||||||
|
@ -257,6 +338,7 @@ func socketActivationListener() net.Listener {
|
||||||
|
|
||||||
nfds, err := strconv.Atoi(os.Getenv("LISTEN_FDS"))
|
nfds, err := strconv.Atoi(os.Getenv("LISTEN_FDS"))
|
||||||
if err != nil || nfds == 0 {
|
if err != nil || nfds == 0 {
|
||||||
|
log.Println("LISTEN_FDS unworkable")
|
||||||
return nil
|
return nil
|
||||||
} else if nfds > 1 {
|
} else if nfds > 1 {
|
||||||
log.Fatalln("not supporting more than one listening socket")
|
log.Fatalln("not supporting more than one listening socket")
|
||||||
|
@ -271,7 +353,7 @@ func socketActivationListener() net.Listener {
|
||||||
return ln
|
return ln
|
||||||
}
|
}
|
||||||
|
|
||||||
// Had to copy this from Server.ListenAndServe()
|
// Had to copy this from Server.ListenAndServe.
|
||||||
type tcpKeepAliveListener struct{ *net.TCPListener }
|
type tcpKeepAliveListener struct{ *net.TCPListener }
|
||||||
|
|
||||||
func (ln tcpKeepAliveListener) Accept() (c net.Conn, err error) {
|
func (ln tcpKeepAliveListener) Accept() (c net.Conn, err error) {
|
||||||
|
@ -285,22 +367,22 @@ func (ln tcpKeepAliveListener) Accept() (c net.Conn, err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
listenAddr := ":8000"
|
|
||||||
if len(os.Args) == 2 {
|
|
||||||
listenAddr = os.Args[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
var listener net.Listener
|
var listener net.Listener
|
||||||
if ln := socketActivationListener(); listener != nil {
|
if ln := socketActivationListener(); ln != nil {
|
||||||
// Keepalives can be set in the systemd unit, see systemd.socket(5)
|
// Keepalives can be set in the systemd unit, see systemd.socket(5).
|
||||||
listener = ln
|
listener = ln
|
||||||
} else if ln, err := net.Listen("tcp", listenAddr); err != nil {
|
|
||||||
log.Fatalln(err)
|
|
||||||
} else {
|
} else {
|
||||||
listener = tcpKeepAliveListener{ln.(*net.TCPListener)}
|
if len(os.Args) < 2 {
|
||||||
|
log.Fatalf("usage: %s LISTEN-ADDR\n", os.Args[0])
|
||||||
|
}
|
||||||
|
if ln, err := net.Listen("tcp", os.Args[1]); err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
} else {
|
||||||
|
listener = tcpKeepAliveListener{ln.(*net.TCPListener)}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
http.HandleFunc("/", proxy)
|
http.HandleFunc("/", proxy)
|
||||||
// We don't need to clean up properly since we store no data
|
// We don't need to clean up properly since we store no data.
|
||||||
log.Fatalln(http.Serve(listener, nil))
|
log.Fatalln(http.Serve(listener, nil))
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue