Add Twitter video support and use sql.Null[T] for nullable columns

- Store Twitter video URLs in embed_video_url column instead of downloading
- Play videos directly from Twitter's CDN in <video> element
- Fix Twitter API parsing (content_type/url fields)
- Strip t.co URLs from tweet text descriptions
- Use sql.Null[T] generic type for nullable DB columns
- Add Nullable[T] and Ptr[T] helper functions
- Add play indicator overlay for video items in grid
- Add migration for embed_video_url column
This commit is contained in:
soup 2026-01-17 01:23:32 -05:00
parent cdcc5b5293
commit 2887d9c430
Signed by: soup
SSH key fingerprint: SHA256:GYxje8eQkJ6HZKzVWDdyOUF1TyDiprruGhE0Ym8qYDY
8 changed files with 226 additions and 120 deletions

View file

@ -7,6 +7,7 @@ import (
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
@ -27,12 +28,14 @@ type VideoInfo struct {
Description string
ThumbnailURL string
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.
@ -121,6 +124,13 @@ type twitterSyndicationResponse struct {
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"`
@ -152,13 +162,26 @@ func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*Vid
return nil, fmt.Errorf("twitter syndication decode: %w", err)
}
// Find thumbnail - prefer photos, then video poster
var thumbnailURL string
// Find thumbnail and video URL from media
var thumbnailURL, videoURL string
if len(tweet.Photos) > 0 {
thumbnailURL = tweet.Photos[0].URL
} else if len(tweet.MediaDetails) > 0 {
thumbnailURL = tweet.MediaDetails[0].MediaURLHTTPS
} else if tweet.Video.Poster != "" {
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
}
@ -173,12 +196,17 @@ func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*Vid
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: tweet.Text,
Description: description,
ThumbnailURL: thumbnailURL,
VideoURL: videoURL,
EmbedHTML: embedHTML,
}, nil
}