Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ bin
vendor/
/.direnv/

# Mac OS specific files
**/.DS_Store

# Generated files related to _examples/record-and-download
audio.wav

# Architecture specific extensions/prefixes
*.[568vq]
[568vq].out
Expand Down
125 changes: 125 additions & 0 deletions _examples/record-and-download/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package main

import (
"context"
"errors"
"io"
"os"

"golang.org/x/exp/slog"

"github.com/CyCoreSystems/ari/v6"
"github.com/CyCoreSystems/ari/v6/client/native"
"github.com/CyCoreSystems/ari/v6/ext/record"
)

var log = slog.New(slog.NewTextHandler(os.Stderr, nil))

func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

log.Info("Connecting to ARI")

cl, err := native.Connect(&native.Options{
Application: "test",
Logger: log,
Username: "admin",
Password: "admin",
URL: "http://localhost:8088/ari",
WebsocketURL: "ws://localhost:8088/ari/events",
})
if err != nil {
log.Error("Failed to build ARI client", "error", err)
return
}

// setup app
log.Info("Listening for new calls")
sub := cl.Bus().Subscribe(nil, "StasisStart")

for {
select {
case e := <-sub.Events():
v := e.(*ari.StasisStart)

log.Info("Got stasis start", "channel", v.Channel.ID)

go app(ctx, cl.Channel().Get(v.Key(ari.ChannelKey, v.Channel.ID)))
case <-ctx.Done():
return
}
}
}

func app(ctx context.Context, h *ari.ChannelHandle) {
defer h.Hangup() //nolint:errcheck

ctx, cancel := context.WithCancel(ctx)
defer cancel()

log.Info("Running app", "channel", h.ID())

end := h.Subscribe(ari.Events.StasisEnd)
defer end.Cancel()

// End the app when the channel goes away
go func() {
<-end.Events()
cancel()
}()

if err := h.Answer(); err != nil {
log.Error("failed to answer call", "error", err)
return
}

res, err := record.Record(ctx, h,
record.TerminateOn("any"),
record.IfExists("overwrite"),
record.WithLogger(log.With("app", "recorder")),
record.Format("wav"),
record.Name("myrecording"),
).Result()
if err != nil {
log.Error("failed to record", "error", err)
return
}

log.Info("saving recording")

rec, err := res.Download()
if err != nil {
log.Error("failed to download recording", "error", err)
return
}
defer rec.Close() //nolint:errcheck

// Create output file
outFile, err := os.Create("audio.wav")
if err != nil {
log.Error("failed to create output file", "error", err)
return
}
defer outFile.Close() //nolint:errcheck

// Write the data to the output file
buf := make([]byte, 1024)
for {
n, err := rec.Read(buf)
if err != nil {
if !errors.Is(err, io.EOF) {
log.Error("failed to read from recording", "error", err)
}
break
}
if n > 0 {
if _, err := outFile.Write(buf[:n]); err != nil {
log.Error("failed to write to output file", "error", err)
return
}
}
}

log.Info("completed recording")
}
34 changes: 34 additions & 0 deletions client/native/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,40 @@ func (c *Client) makeRequest(method, url string, resp interface{}, req interface
return maybeRequestError(ret)
}

func (c *Client) getRaw(url string) (*http.Response, error) {
url = c.Options.URL + url
return c.makeRequestRaw("GET", url, nil)
}

func (c *Client) makeRequestRaw(method, url string, req interface{}) (*http.Response, error) {
var reqBody io.Reader

if req != nil {
var err error
reqBody, err = structToRequestBody(req)
if err != nil {
return nil, eris.Wrap(err, "failed to marshal request")
}
}

r, err := http.NewRequest(method, url, reqBody)
if err != nil {
return nil, eris.Wrap(err, "failed to create request")
}

r.Header.Set("Content-Type", "application/json")
if c.Options.Username != "" {
r.SetBasicAuth(c.Options.Username, c.Options.Password)
}

ret, err := c.httpClient.Do(r)
if err != nil {
return nil, eris.Wrap(err, "failed to make request")
}

return ret, maybeRequestError(ret)
}

func structToRequestBody(req interface{}) (io.Reader, error) {
buf := new(bytes.Buffer)

Expand Down
20 changes: 19 additions & 1 deletion client/native/storedRecording.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package native

import (
"errors"

"github.com/CyCoreSystems/ari/v6"
)

Expand Down Expand Up @@ -54,6 +53,25 @@ func (sr *StoredRecording) Data(key *ari.Key) (*ari.StoredRecordingData, error)
return data, nil
}

// Download retrieves the data for the stored recording
// IMPORTANT: Don't forget to close the reader when done.
func (sr *StoredRecording) Download(key *ari.Key) (*ari.StoredRecordingBinaryData, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This presents a quandary: it's too easy to forget to close the body's ReadCloser.

I see three ways to mitigate this, each with their own trade-offs:

  • rename Download to ReadCloser, in an attempt to make it clear that it must be Closed
  • take an io.Writer as a second parameter to Download()
  • store the download to a temp file

Personally, I think the safest and cleanest is to pass in an io.Writer; let the caller indicate where the data should go, so it can just be Piped directly there, and we can close the Body within Download.

if key == nil || key.ID == "" {
return nil, errors.New("storedRecording key not supplied")
}

res, err := sr.client.getRaw("/recordings/stored/" + key.ID + "/file")
if err != nil {
return nil, dataGetError(err, "storedRecording", "%v", key.ID)
}

return &ari.StoredRecordingBinaryData{
Key: key,
ReadCloser: res.Body,
ContentType: res.Header.Get("Content-Type"),
}, nil
}

// Copy copies a stored recording and returns the new handle
func (sr *StoredRecording) Copy(key *ari.Key, dest string) (*ari.StoredRecordingHandle, error) {
h, err := sr.StageCopy(key, dest)
Expand Down
14 changes: 14 additions & 0 deletions ext/record/record.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,20 @@ func (r *Result) Save(name string) error {
return nil
}

func (r *Result) Download() (*ari.StoredRecordingBinaryData, error) {
if r.h == nil {
return nil, eris.New("no stored recording handle available")
}

// Download the recording
data, err := r.h.Download()
if err != nil {
return nil, eris.Wrap(err, "failed to download recording")
}

return data, nil
}

// URI returns the AudioURI to play the recording
func (r *Result) URI() string {
return "recording:" + r.h.ID()
Expand Down
41 changes: 41 additions & 0 deletions storedRecording.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
package ari

import (
"errors"
"io"
)

// StoredRecording represents a communication path interacting with an Asterisk
// server for stored recording resources
type StoredRecording interface {
Expand All @@ -12,6 +17,10 @@ type StoredRecording interface {
// data gets the data for the stored recording
Data(key *Key) (*StoredRecordingData, error)

// Download retrieves the data for the stored recording
// IMPORTANT: Don't forget to close the reader when done.
Download(key *Key) (*StoredRecordingBinaryData, error)

// Copy copies the recording to the destination name
//
// NOTE: because ARI offers no forced-copy, Copy should always return the
Expand All @@ -37,6 +46,33 @@ func (d StoredRecordingData) ID() string {
return d.Name // TODO: does the identifier include the Format and Name?
}

// StoredRecordingBinaryData is a binary reader for a stored recording file.
type StoredRecordingBinaryData struct {
// Key is the cluster-unique identifier for this stored recording file
Key *Key

io.ReadCloser

// ContentType is the MIME type of the stored recording file, e.g. "audio/wav"
ContentType string
}

// Close closes the ReadCloser for the stored recording binary data.
func (d *StoredRecordingBinaryData) Close() error {
if d.ReadCloser != nil {
return d.ReadCloser.Close()
}
return nil
}

// Read reads data from the stored recording binary data.
func (d *StoredRecordingBinaryData) Read(p []byte) (n int, err error) {
if d.ReadCloser == nil {
return 0, errors.New("read closer is nil")
}
return d.ReadCloser.Read(p)
}

// A StoredRecordingHandle is a reference to a stored recording that can be operated on
type StoredRecordingHandle struct {
key *Key
Expand Down Expand Up @@ -82,6 +118,11 @@ func (s *StoredRecordingHandle) Data() (*StoredRecordingData, error) {
return s.s.Data(s.key)
}

// Download retrieves binary reader of the stored recording
func (s *StoredRecordingHandle) Download() (*StoredRecordingBinaryData, error) {
return s.s.Download(s.key)
}

// Copy copies the stored recording.
//
// NOTE: because ARI offers no forced-copy, this should always return the
Expand Down