Exposes the Vimeo account library for admin workflows and syncs swagger docs. Co-authored-by: Cursor <cursoragent@cursor.com>
536 lines
14 KiB
Go
536 lines
14 KiB
Go
package vimeo
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
BaseURL = "https://api.vimeo.com"
|
||
APIVersion = "application/vnd.vimeo.*+json;version=3.4"
|
||
)
|
||
|
||
type Client struct {
|
||
httpClient *http.Client
|
||
accessToken string
|
||
}
|
||
|
||
func NewClient(accessToken string) *Client {
|
||
return &Client{
|
||
httpClient: &http.Client{
|
||
Timeout: 30 * time.Second,
|
||
},
|
||
accessToken: accessToken,
|
||
}
|
||
}
|
||
|
||
type Video struct {
|
||
URI string `json:"uri"`
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
Duration int `json:"duration"`
|
||
Width int `json:"width"`
|
||
Height int `json:"height"`
|
||
Link string `json:"link"`
|
||
PlayerEmbedURL string `json:"player_embed_url"`
|
||
Pictures *Pictures `json:"pictures"`
|
||
Status string `json:"status"`
|
||
Transcode *Transcode `json:"transcode"`
|
||
Privacy *Privacy `json:"privacy"`
|
||
Embed *Embed `json:"embed"`
|
||
CreatedTime time.Time `json:"created_time"`
|
||
ModifiedTime time.Time `json:"modified_time"`
|
||
}
|
||
|
||
type Pictures struct {
|
||
URI string `json:"uri"`
|
||
Active bool `json:"active"`
|
||
Sizes []Size `json:"sizes"`
|
||
BaseURL string `json:"base_link"`
|
||
}
|
||
|
||
type Size struct {
|
||
Width int `json:"width"`
|
||
Height int `json:"height"`
|
||
Link string `json:"link"`
|
||
}
|
||
|
||
type Transcode struct {
|
||
Status string `json:"status"`
|
||
}
|
||
|
||
type Privacy struct {
|
||
View string `json:"view"`
|
||
Embed string `json:"embed"`
|
||
Download bool `json:"download"`
|
||
}
|
||
|
||
type Embed struct {
|
||
HTML string `json:"html"`
|
||
Badges struct {
|
||
HDR bool `json:"hdr"`
|
||
Live struct{ Streaming bool } `json:"live"`
|
||
StaffPick struct{ Normal bool } `json:"staff_pick"`
|
||
VOD bool `json:"vod"`
|
||
WeekendChallenge bool `json:"weekend_challenge"`
|
||
} `json:"badges"`
|
||
}
|
||
|
||
type UploadRequest struct {
|
||
Name string `json:"name,omitempty"`
|
||
Description string `json:"description,omitempty"`
|
||
Upload UploadParams `json:"upload"`
|
||
Privacy *PrivacyParams `json:"privacy,omitempty"`
|
||
}
|
||
|
||
type UploadParams struct {
|
||
Approach string `json:"approach"`
|
||
Size int64 `json:"size,omitempty"`
|
||
Link string `json:"link,omitempty"`
|
||
RedirectURL string `json:"redirect_url,omitempty"`
|
||
}
|
||
|
||
type PrivacyParams struct {
|
||
View string `json:"view,omitempty"`
|
||
Embed string `json:"embed,omitempty"`
|
||
Download bool `json:"download,omitempty"`
|
||
}
|
||
|
||
type UploadResponse struct {
|
||
URI string `json:"uri"`
|
||
Name string `json:"name"`
|
||
Link string `json:"link"`
|
||
Upload struct {
|
||
Status string `json:"status"`
|
||
UploadLink string `json:"upload_link"`
|
||
Approach string `json:"approach"`
|
||
Size int64 `json:"size"`
|
||
} `json:"upload"`
|
||
Transcode *Transcode `json:"transcode"`
|
||
}
|
||
|
||
type OEmbedResponse struct {
|
||
Type string `json:"type"`
|
||
Version string `json:"version"`
|
||
ProviderName string `json:"provider_name"`
|
||
ProviderURL string `json:"provider_url"`
|
||
Title string `json:"title"`
|
||
AuthorName string `json:"author_name"`
|
||
AuthorURL string `json:"author_url"`
|
||
IsPlus string `json:"is_plus"`
|
||
HTML string `json:"html"`
|
||
Width int `json:"width"`
|
||
Height int `json:"height"`
|
||
Duration int `json:"duration"`
|
||
Description string `json:"description"`
|
||
ThumbnailURL string `json:"thumbnail_url"`
|
||
ThumbnailWidth int `json:"thumbnail_width"`
|
||
ThumbnailHeight int `json:"thumbnail_height"`
|
||
VideoID int64 `json:"video_id"`
|
||
}
|
||
|
||
type UpdateVideoRequest struct {
|
||
Name *string `json:"name,omitempty"`
|
||
Description *string `json:"description,omitempty"`
|
||
Privacy *PrivacyParams `json:"privacy,omitempty"`
|
||
}
|
||
|
||
// ListVideosParams configures GET /me/videos (authenticated user’s library).
|
||
// See https://developer.vimeo.com/api/reference/videos#get_videos
|
||
type ListVideosParams struct {
|
||
Page int // 1-based; omitted when 0
|
||
PerPage int // max 100; omitted when 0
|
||
Query string // optional search filter
|
||
Sort string // e.g. date, alphabetical, plays, likes, comments, duration, relevance
|
||
Direction string // asc or desc
|
||
Filter string // optional: embeddable, playable, playable_in_subscription, etc.
|
||
FilterType string // optional: 8 for staff picks (when using filter)
|
||
}
|
||
|
||
// ListVideosResponse is the JSON envelope Vimeo returns for list endpoints.
|
||
type ListVideosResponse struct {
|
||
Total int `json:"total"`
|
||
Page int `json:"page"`
|
||
PerPage int `json:"per_page"`
|
||
Paging PagingLinks `json:"paging"`
|
||
Data []Video `json:"data"`
|
||
}
|
||
|
||
// PagingLinks contains cursor URLs for the next/previous page from Vimeo.
|
||
type PagingLinks struct {
|
||
Next string `json:"next"`
|
||
Previous string `json:"previous"`
|
||
First string `json:"first"`
|
||
Last string `json:"last"`
|
||
}
|
||
|
||
func (c *Client) doRequest(ctx context.Context, method, path 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, BaseURL+path, reqBody)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||
}
|
||
|
||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||
req.Header.Set("Accept", APIVersion)
|
||
if body != nil {
|
||
req.Header.Set("Content-Type", "application/json")
|
||
}
|
||
|
||
return c.httpClient.Do(req)
|
||
}
|
||
|
||
func (c *Client) GetVideo(ctx context.Context, videoID string) (*Video, error) {
|
||
resp, err := c.doRequest(ctx, http.MethodGet, "/videos/"+videoID, 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 video: status %d, body: %s", resp.StatusCode, string(bodyBytes))
|
||
}
|
||
|
||
var video Video
|
||
if err := json.NewDecoder(resp.Body).Decode(&video); err != nil {
|
||
return nil, fmt.Errorf("failed to decode video response: %w", err)
|
||
}
|
||
|
||
return &video, nil
|
||
}
|
||
|
||
// ListMyVideos calls GET /me/videos for the token’s Vimeo account.
|
||
func (c *Client) ListMyVideos(ctx context.Context, params ListVideosParams) (*ListVideosResponse, error) {
|
||
q := url.Values{}
|
||
if params.Page > 0 {
|
||
q.Set("page", strconv.Itoa(params.Page))
|
||
}
|
||
if params.PerPage > 0 {
|
||
q.Set("per_page", strconv.Itoa(params.PerPage))
|
||
}
|
||
if params.Query != "" {
|
||
q.Set("query", params.Query)
|
||
}
|
||
if params.Sort != "" {
|
||
q.Set("sort", params.Sort)
|
||
}
|
||
if params.Direction != "" {
|
||
q.Set("direction", params.Direction)
|
||
}
|
||
if params.Filter != "" {
|
||
q.Set("filter", params.Filter)
|
||
}
|
||
if params.FilterType != "" {
|
||
q.Set("filter_type", params.FilterType)
|
||
}
|
||
|
||
path := "/me/videos"
|
||
if enc := q.Encode(); enc != "" {
|
||
path += "?" + enc
|
||
}
|
||
|
||
resp, err := c.doRequest(ctx, http.MethodGet, path, 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 list videos: status %d, body: %s", resp.StatusCode, string(bodyBytes))
|
||
}
|
||
|
||
var out ListVideosResponse
|
||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||
return nil, fmt.Errorf("failed to decode list videos response: %w", err)
|
||
}
|
||
|
||
return &out, nil
|
||
}
|
||
|
||
func (c *Client) CreateUpload(ctx context.Context, req *UploadRequest) (*UploadResponse, error) {
|
||
resp, err := c.doRequest(ctx, http.MethodPost, "/me/videos", req)
|
||
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 upload: status %d, body: %s", resp.StatusCode, string(bodyBytes))
|
||
}
|
||
|
||
var uploadResp UploadResponse
|
||
if err := json.NewDecoder(resp.Body).Decode(&uploadResp); err != nil {
|
||
return nil, fmt.Errorf("failed to decode upload response: %w", err)
|
||
}
|
||
|
||
return &uploadResp, nil
|
||
}
|
||
|
||
func (c *Client) CreatePullUpload(ctx context.Context, name, description, sourceURL string, fileSize int64) (*UploadResponse, error) {
|
||
req := &UploadRequest{
|
||
Name: name,
|
||
Description: description,
|
||
Upload: UploadParams{
|
||
Approach: "pull",
|
||
Size: fileSize,
|
||
Link: sourceURL,
|
||
},
|
||
Privacy: &PrivacyParams{
|
||
View: "unlisted",
|
||
Embed: "public",
|
||
},
|
||
}
|
||
return c.CreateUpload(ctx, req)
|
||
}
|
||
|
||
func (c *Client) CreateTusUpload(ctx context.Context, name, description string, fileSize int64) (*UploadResponse, error) {
|
||
req := &UploadRequest{
|
||
Name: name,
|
||
Description: description,
|
||
Upload: UploadParams{
|
||
Approach: "tus",
|
||
Size: fileSize,
|
||
},
|
||
Privacy: &PrivacyParams{
|
||
View: "unlisted",
|
||
Embed: "public",
|
||
},
|
||
}
|
||
return c.CreateUpload(ctx, req)
|
||
}
|
||
|
||
func (c *Client) UploadTusVideoFile(ctx context.Context, uploadLink string, fileData io.Reader, fileSize int64) error {
|
||
uploadClient := &http.Client{
|
||
Timeout: 30 * time.Minute,
|
||
}
|
||
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, uploadLink, fileData)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create TUS upload request: %w", err)
|
||
}
|
||
|
||
req.Header.Set("Tus-Resumable", "1.0.0")
|
||
req.Header.Set("Upload-Offset", "0")
|
||
req.Header.Set("Content-Type", "application/offset+octet-stream")
|
||
req.ContentLength = fileSize
|
||
|
||
resp, err := uploadClient.Do(req)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to upload video file to Vimeo: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||
return fmt.Errorf("TUS upload failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
|
||
}
|
||
|
||
offsetStr := resp.Header.Get("Upload-Offset")
|
||
if offsetStr != "" {
|
||
offset, err := strconv.ParseInt(offsetStr, 10, 64)
|
||
if err == nil && offset < fileSize {
|
||
return fmt.Errorf("incomplete upload: uploaded %d of %d bytes", offset, fileSize)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (c *Client) UpdateVideo(ctx context.Context, videoID string, req *UpdateVideoRequest) (*Video, error) {
|
||
resp, err := c.doRequest(ctx, http.MethodPatch, "/videos/"+videoID, req)
|
||
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 update video: status %d, body: %s", resp.StatusCode, string(bodyBytes))
|
||
}
|
||
|
||
var video Video
|
||
if err := json.NewDecoder(resp.Body).Decode(&video); err != nil {
|
||
return nil, fmt.Errorf("failed to decode video response: %w", err)
|
||
}
|
||
|
||
return &video, nil
|
||
}
|
||
|
||
func (c *Client) DeleteVideo(ctx context.Context, videoID string) error {
|
||
resp, err := c.doRequest(ctx, http.MethodDelete, "/videos/"+videoID, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||
return fmt.Errorf("failed to delete video: status %d, body: %s", resp.StatusCode, string(bodyBytes))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (c *Client) GetTranscodeStatus(ctx context.Context, videoID string) (string, error) {
|
||
video, err := c.GetVideo(ctx, videoID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
if video.Transcode != nil {
|
||
return video.Transcode.Status, nil
|
||
}
|
||
return "unknown", nil
|
||
}
|
||
|
||
func GetOEmbed(ctx context.Context, vimeoURL string, width, height int) (*OEmbedResponse, error) {
|
||
client := &http.Client{Timeout: 10 * time.Second}
|
||
|
||
oembedURL := fmt.Sprintf("https://vimeo.com/api/oembed.json?url=%s", vimeoURL)
|
||
if width > 0 {
|
||
oembedURL += "&width=" + strconv.Itoa(width)
|
||
}
|
||
if height > 0 {
|
||
oembedURL += "&height=" + strconv.Itoa(height)
|
||
}
|
||
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, oembedURL, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to create oembed request: %w", err)
|
||
}
|
||
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to fetch oembed: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||
return nil, fmt.Errorf("oembed failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
|
||
}
|
||
|
||
var oembed OEmbedResponse
|
||
if err := json.NewDecoder(resp.Body).Decode(&oembed); err != nil {
|
||
return nil, fmt.Errorf("failed to decode oembed response: %w", err)
|
||
}
|
||
|
||
return &oembed, nil
|
||
}
|
||
|
||
func GenerateEmbedURL(videoID string, options *EmbedOptions) string {
|
||
url := fmt.Sprintf("https://player.vimeo.com/video/%s", videoID)
|
||
|
||
if options == nil {
|
||
return url
|
||
}
|
||
|
||
params := ""
|
||
if options.Autoplay {
|
||
params += "&autoplay=1"
|
||
}
|
||
if options.Loop {
|
||
params += "&loop=1"
|
||
}
|
||
if options.Muted {
|
||
params += "&muted=1"
|
||
}
|
||
if !options.Title {
|
||
params += "&title=0"
|
||
}
|
||
if !options.Byline {
|
||
params += "&byline=0"
|
||
}
|
||
if !options.Portrait {
|
||
params += "&portrait=0"
|
||
}
|
||
if options.Color != "" {
|
||
params += "&color=" + options.Color
|
||
}
|
||
if options.Background {
|
||
params += "&background=1"
|
||
}
|
||
if options.Responsive {
|
||
params += "&responsive=1"
|
||
}
|
||
|
||
if params != "" {
|
||
url += "?" + params[1:]
|
||
}
|
||
|
||
return url
|
||
}
|
||
|
||
type EmbedOptions struct {
|
||
Autoplay bool
|
||
Loop bool
|
||
Muted bool
|
||
Title bool
|
||
Byline bool
|
||
Portrait bool
|
||
Color string
|
||
Background bool
|
||
Responsive bool
|
||
}
|
||
|
||
func GenerateIframeEmbed(videoID string, width, height int, options *EmbedOptions) string {
|
||
embedURL := GenerateEmbedURL(videoID, options)
|
||
return fmt.Sprintf(
|
||
`<iframe src="%s" width="%d" height="%d" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>`,
|
||
embedURL, width, height,
|
||
)
|
||
}
|
||
|
||
func ExtractVideoID(vimeoURL string) string {
|
||
// Handle URLs like:
|
||
// - https://vimeo.com/123456789
|
||
// - https://player.vimeo.com/video/123456789
|
||
// - /videos/123456789
|
||
|
||
patterns := []string{
|
||
"vimeo.com/",
|
||
"player.vimeo.com/video/",
|
||
"/videos/",
|
||
}
|
||
|
||
for _, pattern := range patterns {
|
||
if idx := len(pattern); len(vimeoURL) > idx {
|
||
for i := 0; i < len(vimeoURL)-len(pattern)+1; i++ {
|
||
if vimeoURL[i:i+len(pattern)] == pattern {
|
||
videoID := ""
|
||
for j := i + len(pattern); j < len(vimeoURL); j++ {
|
||
if vimeoURL[j] >= '0' && vimeoURL[j] <= '9' {
|
||
videoID += string(vimeoURL[j])
|
||
} else {
|
||
break
|
||
}
|
||
}
|
||
if videoID != "" {
|
||
return videoID
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return ""
|
||
}
|