Initial commit: Lookbook personal collection app
Pinterest-like app for saving images, videos, quotes, and embeds. Features: - Go backend with PostgreSQL, SSR templates - Console-based admin auth (login/logout via browser console) - Item types: images, videos (ffmpeg transcoding), quotes, embeds - Media stored as BLOBs in PostgreSQL - OpenGraph metadata extraction for links - Embed detection for YouTube, Vimeo, Twitter/X - Masonry grid layout, item detail pages - Tag system with filtering - Refresh metadata endpoint with change warnings - Replace media endpoint for updating item images/videos
This commit is contained in:
commit
cdcc5b5293
45 changed files with 4634 additions and 0 deletions
251
internal/embed/detect.go
Normal file
251
internal/embed/detect.go
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
package embed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"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
|
||||
EmbedHTML string
|
||||
}
|
||||
|
||||
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+)`)
|
||||
)
|
||||
|
||||
// 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"`
|
||||
} `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 - prefer photos, then video poster
|
||||
var thumbnailURL 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 != "" {
|
||||
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)
|
||||
}
|
||||
|
||||
return &VideoInfo{
|
||||
Provider: ProviderTwitter,
|
||||
VideoID: tweetID,
|
||||
Title: title,
|
||||
Description: tweet.Text,
|
||||
ThumbnailURL: thumbnailURL,
|
||||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue