package cloudconvert import ( "bytes" "context" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "time" ) const BaseURL = "https://api.cloudconvert.com/v2" type Client struct { httpClient *http.Client apiKey string } func NewClient(apiKey string) *Client { return &Client{ httpClient: &http.Client{ Timeout: 60 * time.Second, }, apiKey: apiKey, } } type JobRequest struct { Tasks map[string]interface{} `json:"tasks"` } type JobResponse struct { Data Job `json:"data"` } type Job struct { ID string `json:"id"` Status string `json:"status"` Tasks []Task `json:"tasks"` } type Task struct { ID string `json:"id"` Name string `json:"name"` Operation string `json:"operation"` Status string `json:"status"` Message string `json:"message"` Result *TaskResult `json:"result"` } type TaskResult struct { Form *UploadForm `json:"form,omitempty"` Files []ExportFile `json:"files,omitempty"` } type UploadForm struct { URL string `json:"url"` Parameters map[string]interface{} `json:"parameters"` } type ExportFile struct { Filename string `json:"filename"` URL string `json:"url"` Size int64 `json:"size"` } func (c *Client) doRequest(ctx context.Context, method, url string, body interface{}) (*http.Response, error) { var reqBody io.Reader if body != nil { jsonBytes, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("failed to marshal request body: %w", err) } reqBody = bytes.NewReader(jsonBytes) } req, err := http.NewRequestWithContext(ctx, method, url, reqBody) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.apiKey) if body != nil { req.Header.Set("Content-Type", "application/json") } return c.httpClient.Do(req) } func (c *Client) CreateJob(ctx context.Context, jobReq *JobRequest) (*Job, error) { resp, err := c.doRequest(ctx, http.MethodPost, BaseURL+"/jobs", jobReq) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("failed to create job: status %d, body: %s", resp.StatusCode, string(bodyBytes)) } var jobResp JobResponse if err := json.NewDecoder(resp.Body).Decode(&jobResp); err != nil { return nil, fmt.Errorf("failed to decode job response: %w", err) } return &jobResp.Data, nil } func (c *Client) GetJob(ctx context.Context, jobID string) (*Job, error) { resp, err := c.doRequest(ctx, http.MethodGet, BaseURL+"/jobs/"+jobID, nil) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("failed to get job: status %d, body: %s", resp.StatusCode, string(bodyBytes)) } var jobResp JobResponse if err := json.NewDecoder(resp.Body).Decode(&jobResp); err != nil { return nil, fmt.Errorf("failed to decode job response: %w", err) } return &jobResp.Data, nil } func (c *Client) UploadFile(ctx context.Context, form *UploadForm, filename string, fileData io.Reader) error { body := &bytes.Buffer{} writer := multipart.NewWriter(body) for key, val := range form.Parameters { var strVal string switch v := val.(type) { case string: strVal = v case float64: strVal = fmt.Sprintf("%.0f", v) default: strVal = fmt.Sprintf("%v", v) } if err := writer.WriteField(key, strVal); err != nil { return fmt.Errorf("failed to write form field %s: %w", key, err) } } part, err := writer.CreateFormFile("file", filename) if err != nil { return fmt.Errorf("failed to create form file: %w", err) } if _, err := io.Copy(part, fileData); err != nil { return fmt.Errorf("failed to copy file data: %w", err) } if err := writer.Close(); err != nil { return fmt.Errorf("failed to close multipart writer: %w", err) } uploadClient := &http.Client{ Timeout: 30 * time.Minute, CheckRedirect: func(req *http.Request, via []*http.Request) error { return nil }, } req, err := http.NewRequestWithContext(ctx, http.MethodPost, form.URL, body) if err != nil { return fmt.Errorf("failed to create upload request: %w", err) } req.Header.Set("Content-Type", writer.FormDataContentType()) resp, err := uploadClient.Do(req) if err != nil { return fmt.Errorf("failed to upload file: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { bodyBytes, _ := io.ReadAll(resp.Body) return fmt.Errorf("upload failed: status %d, body: %s", resp.StatusCode, string(bodyBytes)) } return nil } func (c *Client) WaitForJob(ctx context.Context, jobID string, pollInterval time.Duration, maxWait time.Duration) (*Job, error) { deadline := time.Now().Add(maxWait) for time.Now().Before(deadline) { select { case <-ctx.Done(): return nil, ctx.Err() default: } job, err := c.GetJob(ctx, jobID) if err != nil { return nil, err } switch job.Status { case "finished": return job, nil case "error": for _, task := range job.Tasks { if task.Status == "error" { return nil, fmt.Errorf("job failed: task '%s' error: %s", task.Name, task.Message) } } return nil, fmt.Errorf("job failed with unknown error") } time.Sleep(pollInterval) } return nil, fmt.Errorf("job timed out after %v", maxWait) } func (c *Client) DownloadFile(ctx context.Context, url string) (io.ReadCloser, int64, error) { downloadClient := &http.Client{ Timeout: 30 * time.Minute, } req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, 0, fmt.Errorf("failed to create download request: %w", err) } resp, err := downloadClient.Do(req) if err != nil { return nil, 0, fmt.Errorf("failed to download file: %w", err) } if resp.StatusCode != http.StatusOK { resp.Body.Close() return nil, 0, fmt.Errorf("download failed: status %d", resp.StatusCode) } return resp.Body, resp.ContentLength, nil } func (c *Client) CreateVideoCompressionJob(ctx context.Context) (*Job, error) { jobReq := &JobRequest{ Tasks: map[string]interface{}{ "import-video": map[string]interface{}{ "operation": "import/upload", }, "convert-video": map[string]interface{}{ "operation": "convert", "input": "import-video", "output_format": "mp4", "video_codec": "x264", "crf": 28, "preset": "medium", "height": 720, "fit": "max", "audio_codec": "aac", "audio_bitrate": 128, }, "export-video": map[string]interface{}{ "operation": "export/url", "input": "convert-video", }, }, } return c.CreateJob(ctx, jobReq) } func (c *Client) CreateImageOptimizationJob(ctx context.Context, width int, quality int) (*Job, error) { jobReq := &JobRequest{ Tasks: map[string]interface{}{ "import-image": map[string]interface{}{ "operation": "import/upload", }, "convert-image": map[string]interface{}{ "operation": "convert", "input": "import-image", "output_format": "webp", "quality": quality, "width": width, "fit": "max", }, "export-image": map[string]interface{}{ "operation": "export/url", "input": "convert-image", }, }, } return c.CreateJob(ctx, jobReq) }