Skip to content

Commit

Permalink
Merge pull request #1 from socialdog-inc/media-upload
Browse files Browse the repository at this point in the history
Media upload
  • Loading branch information
jez321 authored Feb 2, 2024
2 parents 912508c + 2b6e0ef commit 60ebaaa
Show file tree
Hide file tree
Showing 3 changed files with 390 additions and 0 deletions.
215 changes: 215 additions & 0 deletions twitter/media.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
package twitter

import (
"encoding/base64"
"fmt"
"net/http"

"github.com/dghubble/sling"
)

// The size of a chunk to upload. There isn't any set size, so we
// choose 1M for convenience.
const chunkSize = 1024 * 1024

// This should really be fetched from the status endpoint, but the
// docs say 15M so we'll go with that.
const maxSize = 15 * 1024 * 1024

// MediaService provides methods for accessing twitter media APIs.
type MediaService struct {
sling *sling.Sling
}

func newMediaService(sling *sling.Sling) *MediaService {
return &MediaService{
sling: sling.Path("media/"),
}
}

type mediaInitResult struct {
MediaID int64 `json:"media_id"`
MediaIDString string `json:"media_id_string"`
Size int `json:"size"`
ExpiresAfterSecs int `json:"expires_after_secs"`
}

type mediaInitParams struct {
Command string `url:"command"`
TotalBytes int `url:"total_bytes"`
MediaType string `url:"media_type"`
}

type mediaAppendParams struct {
Command string `url:"command"`
MediaData string `url:"media_data"`
MediaID int64 `url:"media_id"`
SegmentIndex int `url:"segment_index"`
}

// MediaVideoInfo holds information about media identified as videos.
type MediaVideoInfo struct {
VideoType string `json:"video_type"`
}

// MediaProcessingInfo holds information about pending media uploads.
type MediaProcessingInfo struct {
State string `json:"state"`
CheckAfterSecs int `json:"check_after_secs"`
ProgressPercent int `json:"progress_percent"`
Error *MediaProcessingError `json:"error"`
}

// MediaProcessingError holds information about pending media
// processing failures.
type MediaProcessingError struct {
Code int `json:"code"`
Name string `json:"name"`
Message string `json:"message"`
}

// MediaUploadResult holds information about a successfully completed
// media upload. Note that successful uploads may not be immediately
// usable if twitter is doing background processing on the uploaded
// media.
type MediaUploadResult struct {
MediaID int64 `json:"media_id"`
MediaIDString string `json:"media_id_string"`
Size int `json:"size"`
ExpiresAfterSecs int `json:"expires_after_secs"`
Video *MediaVideoInfo `json:"video"`
ProcessingInfo *MediaProcessingInfo `json:"processing_info"`
}

type mediaFinalizeParams struct {
Command string `url:"command"`
MediaID int64 `url:"media_id"`
}

// Upload sends a piece of media to twitter. You must provide a byte
// slice containing the file contents and the MIME type of the
// file.
//
// This is a potentially asynchronous call, as some file types require
// extra processing by twitter. In those cases the returned
// MediaFinalizeResult will have the ProcessingInfo field set, and you
// can periodically poll Status with the MediaID to get the status of
// the upload.
func (m *MediaService) Upload(media []byte, mediaType string) (*MediaUploadResult, *http.Response, error) {

if len(media) > maxSize {
return nil, nil, fmt.Errorf("file size of %v exceeds twitter maximum %v", len(media), maxSize)
}

mediaID, err := m.UploadInit(len(media), mediaType)
if err != nil {
return nil, nil, err
}

segments := int(len(media) / chunkSize)
for segment := 0; segment <= segments; segment++ {
start := segment * chunkSize
end := (segment + 1) * chunkSize
if end > len(media) {
end = len(media)
}
chunk := media[start:end]

resp, err := m.UploadAppend(mediaID, segment, chunk, mediaType)
if err != nil {
return nil, resp, err
}
}

finalizeRes, resp, err := m.UploadFinalize(mediaID)
if err != nil {
return nil, nil, err
}

return finalizeRes, resp, nil
}

func (m *MediaService) UploadAppend(mediaID int64, segment int, chunk []byte, mediaType string) (*http.Response, error) {
appendParams := &mediaAppendParams{
Command: "APPEND",
MediaID: mediaID,
MediaData: base64.StdEncoding.EncodeToString(chunk),
SegmentIndex: segment,
}

apiError := new(APIError)
resp, err := m.sling.New().Post("upload.json").BodyForm(appendParams).Receive(nil, apiError)

if relevantError(err, *apiError) != nil {
return resp, relevantError(err, *apiError)
}

return resp, nil
}

func (m *MediaService) UploadInit(totalBytes int, mediaType string) (int64, error) {
params := &mediaInitParams{
Command: "INIT",
TotalBytes: totalBytes,
MediaType: mediaType,
}
res := new(mediaInitResult)
apiError := new(APIError)

_, err := m.sling.New().Post("upload.json").BodyForm(params).Receive(res, apiError)

if relevantError(err, *apiError) != nil {
return 0, relevantError(err, *apiError)
}

return res.MediaID, nil
}

func (m *MediaService) UploadFinalize(mediaID int64) (*MediaUploadResult, *http.Response, error) {
finalizeParams := &mediaFinalizeParams{
Command: "FINALIZE",
MediaID: mediaID,
}
finalizeRes := new(MediaUploadResult)
apiError := new(APIError)

resp, err := m.sling.New().Post("upload.json").BodyForm(finalizeParams).Receive(finalizeRes, apiError)

if relevantError(err, *apiError) != nil {
return nil, resp, relevantError(err, *apiError)
}

return finalizeRes, resp, nil
}

// MediaStatusResult holds information about the current status of a
// piece of media.
type MediaStatusResult struct {
MediaID int `json:"media_id"`
MediaIDString string `json:"media_id_string"`
ExpiresAfterSecs int `json:"expires_after_secs"`
ProcessingInfo *MediaProcessingInfo `json:"processing_info"`
Video *MediaVideoInfo `json:"video"`
}

type mediaStatusParams struct {
Command string `url:"command"`
MediaID int64 `url:"media_id"`
}

// Status returns the current status of the media specified by the
// media ID. It's only valid to call Status on a request where the
// Upload call returned something in ProcessingInfo.
// https://developer.twitter.com/en/docs/media/upload-media/api-reference/get-media-upload-status
func (m *MediaService) Status(mediaID int64) (*MediaStatusResult, *http.Response, error) {
params := &mediaStatusParams{
MediaID: mediaID,
Command: "STATUS",
}

status := new(MediaStatusResult)
apiError := new(APIError)
resp, err := m.sling.New().Get("upload.json").QueryStruct(params).Receive(status, apiError)
return status, resp, relevantError(err, *apiError)

}
171 changes: 171 additions & 0 deletions twitter/media_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package twitter

import (
"bytes"
"fmt"
"log"
"net/http"
"testing"

"github.com/stretchr/testify/assert"
)

// Fake a server to handle the upload requests. Failure triggers:
// START:
// 11 byte images fail
// APPEND:
// 17 byte images fail
// FINALIZE
// 23 byte images fail

func uploadResponseFunc(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
}
w.Header().Set("Content-Type", "application/json")

rt := r.FormValue("command")
log.Printf("command is %q", rt)
// No command means it's a bad request
if rt == "" {
log.Printf("no command")
w.WriteHeader(http.StatusBadRequest)
return
}

switch rt {
case "INIT":
tb := r.FormValue("total_bytes")
// 11 byte requests trigger the
if tb == "11" {
w.WriteHeader(http.StatusBadRequest)
return
}
log.Printf("tb is %q", tb)
fmt.Fprintf(w, `{"media_id": %v, "media_id_string": "%v", "size": %v, "expires_after_secs": 86400}`, tb, tb, tb)
case "APPEND":
mid := r.FormValue("media_id")
if mid == "17" {
w.WriteHeader(http.StatusBadRequest)
return
}
case "FINALIZE":
mid := r.FormValue("media_id")
if mid == "23" {
w.WriteHeader(http.StatusBadRequest)
return
}
fmt.Fprintf(w, `{"media_id": %v, "media_id_string": "%v", "size": %v, "expires_after_secs": 86400}`, mid, mid, mid)
default:
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"status":"bad"}`)
}
}

func TestMediaService_Upload(t *testing.T) {
tests := []struct {
name string
data []byte
filetype string
wantErr bool
want *MediaUploadResult
}{
{
name: "small OK",
data: []byte{1, 2, 3},
filetype: "image/jpeg",
want: &MediaUploadResult{
MediaID: 3,
MediaIDString: "3",
Size: 3,
ExpiresAfterSecs: 86400,
},
},
{
name: "multipart OK",
data: bytes.Repeat([]byte{50}, chunkSize*4),
filetype: "video/mp4",
want: &MediaUploadResult{
MediaID: chunkSize * 4,
MediaIDString: fmt.Sprintf("%v", chunkSize*4),
Size: chunkSize * 4,
ExpiresAfterSecs: 86400,
},
},
{
name: "start fails",
data: []byte{11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11},
filetype: "video/mp4",
wantErr: true,
},
{
name: "big fail",
data: bytes.Repeat([]byte{10}, maxSize+20),
filetype: "image/gif",
wantErr: true,
},
{
name: "append fails",
data: bytes.Repeat([]byte{17}, 17),
filetype: "image/jpeg",
wantErr: true,
},
{
name: "finalize fails",
data: bytes.Repeat([]byte{23}, 23),
filetype: "image/gif",
wantErr: true,
},
}

httpClient, mux, server := testServer()
defer server.Close()
mux.HandleFunc("/1.1/media/upload.json", func(w http.ResponseWriter, r *http.Request) {
assertMethod(t, "POST", r)
uploadResponseFunc(w, r)
})

client := NewClient(httpClient)

for _, test := range tests {
resp, _, err := client.Media.Upload(test.data, test.filetype)
if err != nil {
if !test.wantErr {
t.Errorf("Media.Upload(%v): err: %v", test.name, err)
}
continue
}
if err == nil {
assert.Equal(t, test.want, resp)
}

}

}

func TestMediaService_Status(t *testing.T) {

httpClient, mux, server := testServer()
defer server.Close()
mux.HandleFunc("/1.1/media/upload.json", func(w http.ResponseWriter, r *http.Request) {
assertMethod(t, "GET", r)

w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"media_id": 123, "media_id_string": "123", "expires_after_secs": 86400, "processing_info": {"state": "succeeded", "progress_percent": 100}}`)
})

expected := &MediaStatusResult{
MediaID: 123,
MediaIDString: "123",
ExpiresAfterSecs: 86400,
ProcessingInfo: &MediaProcessingInfo{
State: "succeeded",
ProgressPercent: 100,
},
}

client := NewClient(httpClient)
result, _, err := client.Media.Status(123)
assert.Nil(t, err)
assert.Equal(t, expected, result)
}
Loading

0 comments on commit 60ebaaa

Please sign in to comment.