lookbook/internal/embed/detect.go
soup 007e167707
Add support for multiple images from Twitter links
- Fetch all images from Twitter syndication API (photos array)
- Store images with unified 'image' media type (first = thumbnail by ID order)
- Display multi-image tweets in grid layout on detail page
- Add hover cycling through images on home grid (2s interval)
- Show image count indicator on multi-image items
- Extract shared downloadAndStoreImages() helper for create/refresh
- Add migration to convert existing thumbnail/gallery types to image
- Make images clickable to open in new tab on detail page
2026-01-17 13:28:20 -05:00

286 lines
8 KiB
Go

package embed
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
// Provider identifies the video hosting platform.
type Provider string
const (
ProviderYouTube Provider = "youtube"
ProviderVimeo Provider = "vimeo"
ProviderTwitter Provider = "twitter"
)
// VideoInfo contains information about an embedded video.
type VideoInfo struct {
Provider Provider
VideoID string
Title string
Description string
ThumbnailURL string // First/primary thumbnail (for backward compatibility)
ThumbnailURLs []string // All thumbnail URLs (for multi-image tweets)
EmbedHTML string
VideoURL string // Direct video URL (for Twitter videos)
}
var (
youtubeRegex = regexp.MustCompile(`(?:youtube\.com/(?:watch\?v=|embed/|v/|shorts/)|youtu\.be/)([a-zA-Z0-9_-]{11})`)
vimeoRegex = regexp.MustCompile(`(?:vimeo\.com/(?:video/)?|player\.vimeo\.com/video/)(\d+)`)
twitterRegex = regexp.MustCompile(`(?:twitter\.com|x\.com)/([^/]+)/status/(\d+)`)
tcoRegex = regexp.MustCompile(`\s*https://t\.co/\S+`)
)
// Detect checks if a URL is a YouTube, Vimeo, or Twitter/X post and returns its info.
func Detect(ctx context.Context, targetURL string) (*VideoInfo, error) {
// Try YouTube
if matches := youtubeRegex.FindStringSubmatch(targetURL); len(matches) > 1 {
return fetchYouTube(ctx, matches[1])
}
// Try Vimeo
if matches := vimeoRegex.FindStringSubmatch(targetURL); len(matches) > 1 {
return fetchVimeo(ctx, matches[1])
}
// Try Twitter/X
if matches := twitterRegex.FindStringSubmatch(targetURL); len(matches) > 2 {
return fetchTwitter(ctx, matches[2], targetURL)
}
return nil, nil // Not a recognized embed
}
func fetchYouTube(ctx context.Context, videoID string) (*VideoInfo, error) {
// YouTube thumbnails are available without API
thumbnailURL := fmt.Sprintf("https://img.youtube.com/vi/%s/maxresdefault.jpg", videoID)
// Try to get metadata via oEmbed
oembedURL := fmt.Sprintf("https://www.youtube.com/oembed?url=%s&format=json",
url.QueryEscape("https://www.youtube.com/watch?v="+videoID))
var title string
if meta, err := fetchOEmbed(ctx, oembedURL); err == nil {
title = meta.Title
}
embedHTML := fmt.Sprintf(
`<iframe width="560" height="315" src="https://www.youtube.com/embed/%s" frameborder="0" allowfullscreen></iframe>`,
videoID,
)
return &VideoInfo{
Provider: ProviderYouTube,
VideoID: videoID,
Title: title,
ThumbnailURL: thumbnailURL,
EmbedHTML: embedHTML,
}, nil
}
func fetchVimeo(ctx context.Context, videoID string) (*VideoInfo, error) {
oembedURL := fmt.Sprintf("https://vimeo.com/api/oembed.json?url=%s",
url.QueryEscape("https://vimeo.com/"+videoID))
meta, err := fetchOEmbed(ctx, oembedURL)
if err != nil {
return nil, fmt.Errorf("vimeo oembed: %w", err)
}
embedHTML := fmt.Sprintf(
`<iframe src="https://player.vimeo.com/video/%s" width="640" height="360" frameborder="0" allowfullscreen></iframe>`,
videoID,
)
return &VideoInfo{
Provider: ProviderVimeo,
VideoID: videoID,
Title: meta.Title,
Description: meta.Description,
ThumbnailURL: meta.ThumbnailURL,
EmbedHTML: embedHTML,
}, nil
}
// twitterSyndicationResponse represents the Twitter syndication API response
type twitterSyndicationResponse struct {
Text string `json:"text"`
User struct {
Name string `json:"name"`
ScreenName string `json:"screen_name"`
} `json:"user"`
Photos []struct {
URL string `json:"url"`
Width int `json:"width"`
Height int `json:"height"`
} `json:"photos"`
MediaDetails []struct {
MediaURLHTTPS string `json:"media_url_https"`
Type string `json:"type"`
VideoInfo struct {
Variants []struct {
ContentType string `json:"content_type"`
URL string `json:"url"`
Bitrate int `json:"bitrate,omitempty"`
} `json:"variants"`
} `json:"video_info"`
} `json:"mediaDetails"`
Video struct {
Poster string `json:"poster"`
} `json:"video"`
}
func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*VideoInfo, error) {
apiURL := fmt.Sprintf("https://cdn.syndication.twimg.com/tweet-result?id=%s&token=0", tweetID)
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Lookbook/1.0)")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("twitter syndication: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("twitter syndication status: %d", resp.StatusCode)
}
var tweet twitterSyndicationResponse
if err := json.NewDecoder(resp.Body).Decode(&tweet); err != nil {
return nil, fmt.Errorf("twitter syndication decode: %w", err)
}
// Find thumbnail and video URL from media
var thumbnailURL, videoURL string
var thumbnailURLs []string
if len(tweet.Photos) > 0 {
thumbnailURL = tweet.Photos[0].URL
// Collect all photo URLs for multi-image tweets
for _, photo := range tweet.Photos {
thumbnailURLs = append(thumbnailURLs, photo.URL)
}
} else if len(tweet.MediaDetails) > 0 {
media := tweet.MediaDetails[0]
thumbnailURL = media.MediaURLHTTPS
// Extract video URL - find highest bitrate MP4
if media.Type == "video" || media.Type == "animated_gif" {
var bestBitrate int
for _, v := range media.VideoInfo.Variants {
if v.ContentType == "video/mp4" && v.Bitrate >= bestBitrate {
bestBitrate = v.Bitrate
videoURL = v.URL
}
}
}
}
if thumbnailURL == "" && tweet.Video.Poster != "" {
thumbnailURL = tweet.Video.Poster
}
// Build embed HTML using Twitter's embed widget
embedHTML := fmt.Sprintf(
`<blockquote class="twitter-tweet"><a href="%s"></a></blockquote><script async src="https://platform.twitter.com/widgets.js"></script>`,
originalURL,
)
title := fmt.Sprintf("@%s", tweet.User.ScreenName)
if tweet.User.Name != "" {
title = fmt.Sprintf("%s (@%s)", tweet.User.Name, tweet.User.ScreenName)
}
// Clean up tweet text - remove trailing t.co URLs
description := tcoRegex.ReplaceAllString(tweet.Text, "")
description = strings.TrimSpace(description)
return &VideoInfo{
Provider: ProviderTwitter,
VideoID: tweetID,
Title: title,
Description: description,
ThumbnailURL: thumbnailURL,
ThumbnailURLs: thumbnailURLs,
VideoURL: videoURL,
EmbedHTML: embedHTML,
}, nil
}
type oembedResponse struct {
Title string `json:"title"`
Description string `json:"description"`
ThumbnailURL string `json:"thumbnail_url"`
}
func fetchOEmbed(ctx context.Context, oembedURL string) (*oembedResponse, error) {
req, err := http.NewRequestWithContext(ctx, "GET", oembedURL, nil)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("oembed status: %d", resp.StatusCode)
}
var meta oembedResponse
if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil {
return nil, err
}
return &meta, nil
}
// DownloadThumbnail downloads the thumbnail image for a video.
func DownloadThumbnail(ctx context.Context, thumbnailURL string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", thumbnailURL, nil)
if err != nil {
return nil, "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Lookbook/1.0)")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("thumbnail status: %d", resp.StatusCode)
}
contentType := resp.Header.Get("Content-Type")
data := make([]byte, 0, 1<<20) // 1MB initial capacity
buf := make([]byte, 32*1024)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
data = append(data, buf[:n]...)
}
if err != nil {
break
}
}
return data, contentType, nil
}