Cleanup http writer/flusher/ICY, use channel-based approach
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
4f354bd813
commit
5396dfc036
|
@ -10,7 +10,7 @@ Radio streamer ([kawa](https://github.com/Luminarys/kawa) drop-in compatible).
|
||||||
* Normalized channels / sample rates for mounts.
|
* Normalized channels / sample rates for mounts.
|
||||||
* Implements ICY metadata (artist, title, url).
|
* Implements ICY metadata (artist, title, url).
|
||||||
* Uses sample/timed packet buffers, instead of kawa byte buffers, which caused wild differences between endpoints. Mounts usually align within 0.2s of each other, depending on client.
|
* Uses sample/timed packet buffers, instead of kawa byte buffers, which caused wild differences between endpoints. Mounts usually align within 0.2s of each other, depending on client.
|
||||||
* Use `queue.buffer_size` to specify number of seconds to buffer.
|
* Use `queue.buffer_size` to specify number of seconds to buffer (by default 0, automatic per client).
|
||||||
* Implements `queue.nr` and `/random` (to be deprecated/changed)
|
* Implements `queue.nr` and `/random` (to be deprecated/changed)
|
||||||
|
|
||||||
# Future improvements
|
# Future improvements
|
||||||
|
|
|
@ -18,6 +18,7 @@ host="127.0.0.1"
|
||||||
# }
|
# }
|
||||||
#
|
#
|
||||||
# The path is the path to an audio file on the filesystem you want MeteorLight to play.
|
# The path is the path to an audio file on the filesystem you want MeteorLight to play.
|
||||||
|
# Additionally, the "title", "artist" and "art" properties can be included to be used as metadata.
|
||||||
random_song_api="http://localhost:8012/api/random"
|
random_song_api="http://localhost:8012/api/random"
|
||||||
#
|
#
|
||||||
# An HTTP POST is issued to this URL when MeteorLight starts playing a track. The body
|
# An HTTP POST is issued to this URL when MeteorLight starts playing a track. The body
|
||||||
|
|
271
queue.go
271
queue.go
|
@ -1,6 +1,7 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"git.gammaspectra.live/S.O.N.G/Kirika/audio"
|
"git.gammaspectra.live/S.O.N.G/Kirika/audio"
|
||||||
"git.gammaspectra.live/S.O.N.G/Kirika/audio/format/flac"
|
"git.gammaspectra.live/S.O.N.G/Kirika/audio/format/flac"
|
||||||
|
@ -8,7 +9,6 @@ import (
|
||||||
"git.gammaspectra.live/S.O.N.G/Kirika/audio/format/opus"
|
"git.gammaspectra.live/S.O.N.G/Kirika/audio/format/opus"
|
||||||
"git.gammaspectra.live/S.O.N.G/Kirika/audio/format/tta"
|
"git.gammaspectra.live/S.O.N.G/Kirika/audio/format/tta"
|
||||||
"git.gammaspectra.live/S.O.N.G/Kirika/audio/packetizer"
|
"git.gammaspectra.live/S.O.N.G/Kirika/audio/packetizer"
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
@ -16,6 +16,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -60,15 +61,16 @@ func (p *QueueMetadataPacket) GetData() []byte {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Queue struct {
|
type Queue struct {
|
||||||
NowPlaying chan *QueueTrackEntry
|
NowPlaying chan *QueueTrackEntry
|
||||||
QueueEmpty chan *QueueTrackEntry
|
QueueEmpty chan *QueueTrackEntry
|
||||||
Duration time.Duration
|
Duration time.Duration
|
||||||
audioQueue *audio.Queue
|
durationError int64
|
||||||
mounts []*StreamMount
|
audioQueue *audio.Queue
|
||||||
queue []*QueueTrackEntry
|
mounts []*StreamMount
|
||||||
mutex sync.RWMutex
|
queue []*QueueTrackEntry
|
||||||
config *Config
|
mutex sync.RWMutex
|
||||||
wg sync.WaitGroup
|
config *Config
|
||||||
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewQueue(config *Config) *Queue {
|
func NewQueue(config *Config) *Queue {
|
||||||
|
@ -152,11 +154,7 @@ func (q *Queue) AddTrack(entry *QueueTrackEntry, tail bool) error {
|
||||||
|
|
||||||
removeCallback := func(queue *audio.Queue, entry *audio.QueueEntry) {
|
removeCallback := func(queue *audio.Queue, entry *audio.QueueEntry) {
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
atomic.AddInt64((*int64)(&q.Duration), int64((time.Second*time.Duration(entry.ReadSamples))/time.Duration(entry.Source.SampleRate)))
|
||||||
q.mutex.Lock()
|
|
||||||
//TODO: carry error
|
|
||||||
q.Duration += (time.Second * time.Duration(entry.ReadSamples)) / time.Duration(entry.Source.SampleRate)
|
|
||||||
q.mutex.Unlock()
|
|
||||||
|
|
||||||
q.Remove(entry.Identifier)
|
q.Remove(entry.Identifier)
|
||||||
q.HandleQueue()
|
q.HandleQueue()
|
||||||
|
@ -267,105 +265,6 @@ func (q *Queue) Remove(identifier audio.QueueIdentifier) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
type httpAudioWriter struct {
|
|
||||||
timeout time.Duration
|
|
||||||
writer http.ResponseWriter
|
|
||||||
metadataToSend struct {
|
|
||||||
Title string
|
|
||||||
URL string
|
|
||||||
}
|
|
||||||
icyInterval int
|
|
||||||
icyCounter int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *httpAudioWriter) writeIcy() error {
|
|
||||||
packetContent := make([]byte, 1, 4096)
|
|
||||||
if len(h.metadataToSend.Title) > 0 {
|
|
||||||
//TODO: quote quotes
|
|
||||||
packetContent = append(packetContent, []byte(fmt.Sprintf("StreamTitle='%s';", strings.ReplaceAll(h.metadataToSend.Title, "'", "")))...)
|
|
||||||
h.metadataToSend.Title = ""
|
|
||||||
}
|
|
||||||
if len(h.metadataToSend.URL) > 0 {
|
|
||||||
//TODO: quote quotes
|
|
||||||
packetContent = append(packetContent, []byte(fmt.Sprintf("StreamURL='%s';", strings.ReplaceAll(h.metadataToSend.URL, "'", "")))...)
|
|
||||||
h.metadataToSend.URL = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
contentLength := len(packetContent) - 1
|
|
||||||
if contentLength > 16*255 {
|
|
||||||
//cannot send long titles
|
|
||||||
_, err := h.writer.Write(make([]byte, 1))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentLength % 16) == 0 { //already padded
|
|
||||||
packetContent[0] = byte(contentLength / 16)
|
|
||||||
} else {
|
|
||||||
packetContent[0] = byte(contentLength/16) + 1
|
|
||||||
packetContent = append(packetContent, make([]byte, 16-(contentLength%16))...)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := h.writer.Write(packetContent)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *httpAudioWriter) Write(p []byte) (n int, err error) {
|
|
||||||
if h.writer != nil {
|
|
||||||
if h.icyInterval > 0 {
|
|
||||||
var i int
|
|
||||||
for len(p) > 0 {
|
|
||||||
l := h.icyInterval - h.icyCounter
|
|
||||||
if l <= len(p) {
|
|
||||||
i, err = h.writer.Write(p[:l])
|
|
||||||
n += i
|
|
||||||
if err != nil {
|
|
||||||
h.writer = nil
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = h.writeIcy(); err != nil {
|
|
||||||
h.writer = nil
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
h.icyCounter = 0
|
|
||||||
p = p[l:]
|
|
||||||
} else {
|
|
||||||
i, err = h.writer.Write(p)
|
|
||||||
n += i
|
|
||||||
if err != nil {
|
|
||||||
h.writer = nil
|
|
||||||
break
|
|
||||||
}
|
|
||||||
h.icyCounter += i
|
|
||||||
p = p[:0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
n, err = h.writer.Write(p)
|
|
||||||
if err != nil {
|
|
||||||
h.writer = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return 0, io.EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *httpAudioWriter) Close() (err error) {
|
|
||||||
h.writer = nil
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *httpAudioWriter) Flush() {
|
|
||||||
if h.writer != nil {
|
|
||||||
//TODO: not deadline aware?
|
|
||||||
/*if flusher, ok := h.writer.(http.Flusher); ok {
|
|
||||||
flusher.Flush()
|
|
||||||
}*/
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queue) GetListeners() (listeners []*ListenerInformation) {
|
func (q *Queue) GetListeners() (listeners []*ListenerInformation) {
|
||||||
q.mutex.RLock()
|
q.mutex.RLock()
|
||||||
defer q.mutex.RUnlock()
|
defer q.mutex.RUnlock()
|
||||||
|
@ -389,39 +288,122 @@ func (q *Queue) HandleRadioRequest(writer http.ResponseWriter, request *http.Req
|
||||||
writer.Header().Set("Cache-Control", "no-store, max-age=0, no-transform")
|
writer.Header().Set("Cache-Control", "no-store, max-age=0, no-transform")
|
||||||
writer.Header().Set("X-Content-Type-Options", "nosniff")
|
writer.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
|
||||||
byteWriter := &httpAudioWriter{writer: writer, timeout: time.Second * 2}
|
var packetWriteCallback func(packet packetizer.Packet) error
|
||||||
|
|
||||||
if numberValue, err := strconv.Atoi(request.Header.Get("icy-metadata")); err == nil && numberValue >= 1 {
|
|
||||||
byteWriter.icyInterval = 8192
|
|
||||||
writer.Header().Set("Icy-MetaInt", fmt.Sprintf("%d", byteWriter.icyInterval))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
//buffer a bit, drop channels when buffer grows to not lock others. They will get disconnected elsewhere
|
||||||
|
const byteSliceChannelBuffer = 128
|
||||||
|
writeChannel := make(chan []byte, byteSliceChannelBuffer)
|
||||||
|
var requestDone error
|
||||||
var wgClient sync.WaitGroup
|
var wgClient sync.WaitGroup
|
||||||
|
|
||||||
writeCallback := func(packet packetizer.Packet) error {
|
if numberValue, err := strconv.Atoi(request.Header.Get("icy-metadata")); err == nil && numberValue >= 1 {
|
||||||
if metadataPacket, ok := packet.(*QueueMetadataPacket); ok {
|
metadataToSend := make(map[string]string)
|
||||||
if len(metadataPacket.TrackEntry.Metadata.Artist) > 0 {
|
const icyInterval = 8192
|
||||||
byteWriter.metadataToSend.Title = fmt.Sprintf("%s - %s", metadataPacket.TrackEntry.Metadata.Artist, metadataPacket.TrackEntry.Metadata.Title)
|
icyCounter := 0
|
||||||
} else {
|
writer.Header().Set("Icy-MetaInt", fmt.Sprintf("%d", icyInterval))
|
||||||
byteWriter.metadataToSend.Title = metadataPacket.TrackEntry.Metadata.Title
|
|
||||||
}
|
writeIcy := func() []byte {
|
||||||
byteWriter.metadataToSend.URL = metadataPacket.TrackEntry.Metadata.Art
|
packetContent := make([]byte, 1, 4096)
|
||||||
return nil
|
for k, v := range metadataToSend {
|
||||||
}
|
packetContent = append(packetContent, []byte(fmt.Sprintf("%s='%s';", k, v))...)
|
||||||
//TODO: icy
|
delete(metadataToSend, k)
|
||||||
/*
|
|
||||||
select {
|
//shouldn't send multiple properties in same packet if we want working single quotes, wait until next ICY frame
|
||||||
case <-request.Context().Done():
|
break
|
||||||
// Client gave up
|
|
||||||
default:
|
|
||||||
}
|
}
|
||||||
|
|
||||||
*/
|
contentLength := len(packetContent) - 1
|
||||||
_, err := byteWriter.Write(packet.GetData())
|
if contentLength > 16*255 {
|
||||||
byteWriter.Flush()
|
//cannot send long titles
|
||||||
return err
|
return make([]byte, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentLength % 16) == 0 { //already padded
|
||||||
|
packetContent[0] = byte(contentLength / 16)
|
||||||
|
} else {
|
||||||
|
packetContent[0] = byte(contentLength/16) + 1
|
||||||
|
packetContent = append(packetContent, make([]byte, 16-(contentLength%16))...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return packetContent
|
||||||
|
}
|
||||||
|
|
||||||
|
packetWriteCallback = func(packet packetizer.Packet) error {
|
||||||
|
if requestDone != nil {
|
||||||
|
return requestDone
|
||||||
|
}
|
||||||
|
if metadataPacket, ok := packet.(*QueueMetadataPacket); ok {
|
||||||
|
if len(metadataPacket.TrackEntry.Metadata.Artist) > 0 {
|
||||||
|
metadataToSend["StreamTitle"] = fmt.Sprintf("%s - %s", metadataPacket.TrackEntry.Metadata.Artist, metadataPacket.TrackEntry.Metadata.Title)
|
||||||
|
} else {
|
||||||
|
metadataToSend["StreamTitle"] = metadataPacket.TrackEntry.Metadata.Title
|
||||||
|
}
|
||||||
|
if len(metadataPacket.TrackEntry.Metadata.Art) > 0 {
|
||||||
|
metadataToSend["StreamURL"] = metadataPacket.TrackEntry.Metadata.Art
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p := packet.GetData()
|
||||||
|
var data []byte
|
||||||
|
for len(p) > 0 {
|
||||||
|
l := icyInterval - icyCounter
|
||||||
|
if l <= len(p) {
|
||||||
|
data = append(data, p[:l]...)
|
||||||
|
data = append(data, writeIcy()...)
|
||||||
|
icyCounter = 0
|
||||||
|
p = p[l:]
|
||||||
|
} else {
|
||||||
|
data = append(data, p...)
|
||||||
|
icyCounter += len(p)
|
||||||
|
p = p[:0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(writeChannel) >= byteSliceChannelBuffer-1 {
|
||||||
|
requestDone = errors.New("client ran out of buffer")
|
||||||
|
return requestDone
|
||||||
|
}
|
||||||
|
writeChannel <- data
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
packetWriteCallback = func(packet packetizer.Packet) error {
|
||||||
|
if requestDone != nil {
|
||||||
|
return requestDone
|
||||||
|
}
|
||||||
|
if _, ok := packet.(*QueueMetadataPacket); ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(writeChannel) >= byteSliceChannelBuffer-1 {
|
||||||
|
requestDone = errors.New("client ran out of buffer")
|
||||||
|
return requestDone
|
||||||
|
}
|
||||||
|
writeChannel <- packet.GetData()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wgClient.Add(1)
|
wgClient.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wgClient.Done()
|
||||||
|
var flusher http.Flusher
|
||||||
|
if httpFlusher, ok := writer.(http.Flusher); ok {
|
||||||
|
flusher = httpFlusher
|
||||||
|
}
|
||||||
|
|
||||||
|
for byteSlice := range writeChannel {
|
||||||
|
if _, requestDone = writer.Write(byteSlice); requestDone != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
//try flush
|
||||||
|
if flusher != nil {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}()
|
||||||
|
|
||||||
var headers []HeaderEntry
|
var headers []HeaderEntry
|
||||||
for k, v := range request.Header {
|
for k, v := range request.Header {
|
||||||
|
@ -469,6 +451,7 @@ func (q *Queue) HandleRadioRequest(writer http.ResponseWriter, request *http.Req
|
||||||
sampleBufferLimit = int64(getKnownBufferSize().Seconds() * float64(mount.SampleRate))
|
sampleBufferLimit = int64(getKnownBufferSize().Seconds() * float64(mount.SampleRate))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wgClient.Add(1)
|
||||||
mount.AddListener(&StreamListener{
|
mount.AddListener(&StreamListener{
|
||||||
Information: ListenerInformation{
|
Information: ListenerInformation{
|
||||||
Mount: mount.Mount,
|
Mount: mount.Mount,
|
||||||
|
@ -480,7 +463,7 @@ func (q *Queue) HandleRadioRequest(writer http.ResponseWriter, request *http.Req
|
||||||
sampleBufferMin := packets[len(packets)-1].GetStartSampleNumber() - sampleBufferLimit
|
sampleBufferMin := packets[len(packets)-1].GetStartSampleNumber() - sampleBufferLimit
|
||||||
for _, p := range packets {
|
for _, p := range packets {
|
||||||
if p.KeepMode() != packetizer.Discard || p.GetEndSampleNumber() >= sampleBufferMin {
|
if p.KeepMode() != packetizer.Discard || p.GetEndSampleNumber() >= sampleBufferMin {
|
||||||
if err := writeCallback(p); err != nil {
|
if err := packetWriteCallback(p); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -488,10 +471,10 @@ func (q *Queue) HandleRadioRequest(writer http.ResponseWriter, request *http.Req
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
Write: writeCallback,
|
Write: packetWriteCallback,
|
||||||
Close: func() {
|
Close: func() {
|
||||||
byteWriter.Close()
|
defer wgClient.Done()
|
||||||
wgClient.Done()
|
close(writeChannel)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
wgClient.Wait()
|
wgClient.Wait()
|
||||||
|
|
Loading…
Reference in a new issue