Yimaru-BackEnd/internal/pkgs/vimeo/client.go
Yared Yemane 7f8ef3373c Add paginated Vimeo video list API (GET /me/videos).
Exposes the Vimeo account library for admin workflows and syncs swagger docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 22:23:50 -07:00

536 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 users 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 tokens 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 ""
}