diff --git a/.gitignore b/.gitignore index 8a90e75c..bdb84176 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/_examples/record-and-download/main.go b/_examples/record-and-download/main.go new file mode 100644 index 00000000..04bf2ffb --- /dev/null +++ b/_examples/record-and-download/main.go @@ -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") +} diff --git a/client/native/request.go b/client/native/request.go index 8f5e3e4a..06c4f18a 100644 --- a/client/native/request.go +++ b/client/native/request.go @@ -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) diff --git a/client/native/storedRecording.go b/client/native/storedRecording.go index 32064eda..c0531497 100644 --- a/client/native/storedRecording.go +++ b/client/native/storedRecording.go @@ -2,7 +2,6 @@ package native import ( "errors" - "github.com/CyCoreSystems/ari/v6" ) @@ -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) { + 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) diff --git a/ext/record/record.go b/ext/record/record.go index 067e7a5c..1f38351a 100644 --- a/ext/record/record.go +++ b/ext/record/record.go @@ -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() diff --git a/storedRecording.go b/storedRecording.go index b50cf2f3..fd5619be 100644 --- a/storedRecording.go +++ b/storedRecording.go @@ -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 { @@ -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 @@ -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 @@ -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