diff --git a/internal/embed/detect.go b/internal/embed/detect.go index 7f6f2bd..97d4c5e 100644 --- a/internal/embed/detect.go +++ b/internal/embed/detect.go @@ -20,8 +20,8 @@ const ( ProviderTwitter Provider = "twitter" ) -// VideoInfo contains information about an embedded video. -type VideoInfo struct { +// EmbedInfo contains information about an embed (video, image gallery, or text). +type EmbedInfo struct { Provider Provider VideoID string Title string @@ -30,6 +30,7 @@ type VideoInfo struct { ThumbnailURLs []string // All thumbnail URLs (for multi-image tweets) EmbedHTML string VideoURL string // Direct video URL (for Twitter videos) + MediaType string // "video", "images", "text" - for Twitter only } var ( @@ -40,7 +41,7 @@ var ( ) // 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) { +func Detect(ctx context.Context, targetURL string) (*EmbedInfo, error) { // Try YouTube if matches := youtubeRegex.FindStringSubmatch(targetURL); len(matches) > 1 { return fetchYouTube(ctx, matches[1]) @@ -59,7 +60,7 @@ func Detect(ctx context.Context, targetURL string) (*VideoInfo, error) { return nil, nil // Not a recognized embed } -func fetchYouTube(ctx context.Context, videoID string) (*VideoInfo, error) { +func fetchYouTube(ctx context.Context, videoID string) (*EmbedInfo, error) { // YouTube thumbnails are available without API thumbnailURL := fmt.Sprintf("https://img.youtube.com/vi/%s/maxresdefault.jpg", videoID) @@ -77,7 +78,7 @@ func fetchYouTube(ctx context.Context, videoID string) (*VideoInfo, error) { videoID, ) - return &VideoInfo{ + return &EmbedInfo{ Provider: ProviderYouTube, VideoID: videoID, Title: title, @@ -86,7 +87,7 @@ func fetchYouTube(ctx context.Context, videoID string) (*VideoInfo, error) { }, nil } -func fetchVimeo(ctx context.Context, videoID string) (*VideoInfo, error) { +func fetchVimeo(ctx context.Context, videoID string) (*EmbedInfo, error) { oembedURL := fmt.Sprintf("https://vimeo.com/api/oembed.json?url=%s", url.QueryEscape("https://vimeo.com/"+videoID)) @@ -100,7 +101,7 @@ func fetchVimeo(ctx context.Context, videoID string) (*VideoInfo, error) { videoID, ) - return &VideoInfo{ + return &EmbedInfo{ Provider: ProviderVimeo, VideoID: videoID, Title: meta.Title, @@ -110,7 +111,31 @@ func fetchVimeo(ctx context.Context, videoID string) (*VideoInfo, error) { }, nil } -// twitterSyndicationResponse represents the Twitter syndication API response +// fxTwitterResponse represents the FxTwitter API response +type fxTwitterResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Tweet struct { + Text string `json:"text"` + Author struct { + Name string `json:"name"` + ScreenName string `json:"screen_name"` + } `json:"author"` + Media struct { + Photos []struct { + URL string `json:"url"` + Width int `json:"width"` + Height int `json:"height"` + } `json:"photos"` + Videos []struct { + URL string `json:"url"` + } `json:"videos"` + } `json:"media"` + IsNoteTweet bool `json:"is_note_tweet"` + } `json:"tweet"` +} + +// twitterSyndicationResponse represents the Twitter syndication API response (fallback) type twitterSyndicationResponse struct { Text string `json:"text"` User struct { @@ -138,7 +163,91 @@ type twitterSyndicationResponse struct { } `json:"video"` } -func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*VideoInfo, error) { +func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*EmbedInfo, error) { + // Extract username from URL for FxTwitter API + matches := twitterRegex.FindStringSubmatch(originalURL) + if len(matches) < 3 { + return nil, fmt.Errorf("invalid twitter URL format") + } + username := matches[1] + + // Try FxTwitter API first (supports full note tweets) + fxURL := fmt.Sprintf("https://api.fxtwitter.com/%s/status/%s", username, tweetID) + req, err := http.NewRequestWithContext(ctx, "GET", fxURL, nil) + if err == nil { + 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 { + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + var fxResp fxTwitterResponse + if err := json.NewDecoder(resp.Body).Decode(&fxResp); err == nil && fxResp.Code == 200 { + // Successfully got data from FxTwitter + return parseFxTwitterResponse(&fxResp, tweetID, originalURL) + } + } + } + } + + // Fallback to syndication API (may have truncated text for note tweets) + return fetchTwitterSyndication(ctx, tweetID, originalURL) +} + +func parseFxTwitterResponse(fxResp *fxTwitterResponse, tweetID string, originalURL string) (*EmbedInfo, error) { + tweet := &fxResp.Tweet + + // Collect media + var thumbnailURL, videoURL string + var thumbnailURLs []string + var mediaType string + + if len(tweet.Media.Videos) > 0 { + videoURL = tweet.Media.Videos[0].URL + mediaType = "video" + // Videos usually have a poster/thumbnail in photos + if len(tweet.Media.Photos) > 0 { + thumbnailURL = tweet.Media.Photos[0].URL + } + } else if len(tweet.Media.Photos) > 0 { + thumbnailURL = tweet.Media.Photos[0].URL + for _, photo := range tweet.Media.Photos { + thumbnailURLs = append(thumbnailURLs, photo.URL) + } + mediaType = "images" + } else { + mediaType = "text" + } + + // Build embed HTML using Twitter's embed widget + embedHTML := fmt.Sprintf( + `
`, + originalURL, + ) + + title := fmt.Sprintf("@%s", tweet.Author.ScreenName) + if tweet.Author.Name != "" { + title = fmt.Sprintf("%s (@%s)", tweet.Author.Name, tweet.Author.ScreenName) + } + + // Clean up tweet text - remove trailing t.co URLs + description := tcoRegex.ReplaceAllString(tweet.Text, "") + description = strings.TrimSpace(description) + + return &EmbedInfo{ + Provider: ProviderTwitter, + VideoID: tweetID, + Title: title, + Description: description, + ThumbnailURL: thumbnailURL, + ThumbnailURLs: thumbnailURLs, + VideoURL: videoURL, + EmbedHTML: embedHTML, + MediaType: mediaType, + }, nil +} + +func fetchTwitterSyndication(ctx context.Context, tweetID string, originalURL string) (*EmbedInfo, error) { apiURL := fmt.Sprintf("https://cdn.syndication.twimg.com/tweet-result?id=%s&token=0", tweetID) req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) @@ -166,12 +275,15 @@ func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*Vid // Find thumbnail and video URL from media var thumbnailURL, videoURL string var thumbnailURLs []string + var mediaType 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) } + mediaType = "images" } else if len(tweet.MediaDetails) > 0 { media := tweet.MediaDetails[0] thumbnailURL = media.MediaURLHTTPS @@ -185,8 +297,12 @@ func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*Vid videoURL = v.URL } } + mediaType = "video" } + } else { + mediaType = "text" } + if thumbnailURL == "" && tweet.Video.Poster != "" { thumbnailURL = tweet.Video.Poster } @@ -206,7 +322,7 @@ func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*Vid description := tcoRegex.ReplaceAllString(tweet.Text, "") description = strings.TrimSpace(description) - return &VideoInfo{ + return &EmbedInfo{ Provider: ProviderTwitter, VideoID: tweetID, Title: title, @@ -215,6 +331,7 @@ func fetchTwitter(ctx context.Context, tweetID string, originalURL string) (*Vid ThumbnailURLs: thumbnailURLs, VideoURL: videoURL, EmbedHTML: embedHTML, + MediaType: mediaType, }, nil } diff --git a/internal/handlers/api_items.go b/internal/handlers/api_items.go index 4885c76..c47f072 100644 --- a/internal/handlers/api_items.go +++ b/internal/handlers/api_items.go @@ -12,18 +12,17 @@ import ( ) type itemResponse struct { - ID string `json:"id"` - Title *string `json:"title,omitempty"` - Description *string `json:"description,omitempty"` - LinkURL *string `json:"linkUrl,omitempty"` - ItemType string `json:"itemType"` - EmbedHTML *string `json:"embedHtml,omitempty"` - Tags []string `json:"tags"` - CreatedAt string `json:"createdAt"` - MediaID *int64 `json:"mediaId,omitempty"` - ThumbnailID *int64 `json:"thumbnailId,omitempty"` - ThumbnailSourceURL *string `json:"thumbnailSourceUrl,omitempty"` - GalleryIDs []int64 `json:"galleryIds,omitempty"` // Additional images (for multi-image tweets) + ID string `json:"id"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + LinkURL *string `json:"linkUrl,omitempty"` + ItemType string `json:"itemType"` + EmbedHTML *string `json:"embedHtml,omitempty"` + Tags []string `json:"tags"` + CreatedAt string `json:"createdAt"` + MediaID *int64 `json:"mediaId,omitempty"` + ImageIDs []int64 `json:"imageIds,omitempty"` // Fetched images (from URLs/embeds) + ImageURLs []string `json:"imageUrls,omitempty"` // Source URLs for fetched images } type createItemRequest struct { @@ -252,22 +251,17 @@ func buildItemResponse(ctx context.Context, rc *RequestContext, it item.Row) (it } // Get media IDs - // Media is ordered by ID, so first "image" is the thumbnail, rest are gallery mediaList, err := media.QFindByItemID(ctx, rc.DB, it.ID) if err != nil { return itemResponse{}, err } - firstImage := true for _, m := range mediaList { if m.MediaType == "original" { resp.MediaID = &m.ID } else if m.MediaType == "image" { - if firstImage { - resp.ThumbnailID = &m.ID - resp.ThumbnailSourceURL = m.SourceURL - firstImage = false - } else { - resp.GalleryIDs = append(resp.GalleryIDs, m.ID) + resp.ImageIDs = append(resp.ImageIDs, m.ID) + if m.SourceURL != nil { + resp.ImageURLs = append(resp.ImageURLs, *m.SourceURL) } } } diff --git a/internal/handlers/api_upload.go b/internal/handlers/api_upload.go index 960a5a3..3f8aa0d 100644 --- a/internal/handlers/api_upload.go +++ b/internal/handlers/api_upload.go @@ -96,22 +96,24 @@ type urlMetadata struct { VideoID string EmbedHTML string VideoURL string // Direct video URL (for Twitter) + MediaType string // "video", "images", "text" - for Twitter only } func fetchURLMetadata(ctx context.Context, url string) (*urlMetadata, error) { // Check if it's a YouTube/Vimeo/Twitter embed - videoInfo, err := embed.Detect(ctx, url) - if err == nil && videoInfo != nil { + embedInfo, err := embed.Detect(ctx, url) + if err == nil && embedInfo != nil { return &urlMetadata{ - Title: videoInfo.Title, - Description: videoInfo.Description, - ImageURL: videoInfo.ThumbnailURL, - ImageURLs: videoInfo.ThumbnailURLs, + Title: embedInfo.Title, + Description: embedInfo.Description, + ImageURL: embedInfo.ThumbnailURL, + ImageURLs: embedInfo.ThumbnailURLs, IsEmbed: true, - Provider: string(videoInfo.Provider), - VideoID: videoInfo.VideoID, - EmbedHTML: videoInfo.EmbedHTML, - VideoURL: videoInfo.VideoURL, + Provider: string(embedInfo.Provider), + VideoID: embedInfo.VideoID, + EmbedHTML: embedInfo.EmbedHTML, + VideoURL: embedInfo.VideoURL, + MediaType: embedInfo.MediaType, }, nil } @@ -139,6 +141,7 @@ type previewResponse struct { Provider string `json:"provider,omitempty"` VideoID string `json:"videoId,omitempty"` EmbedHTML string `json:"embedHtml,omitempty"` + MediaType string `json:"mediaType,omitempty"` // "video", "images", "text" - for Twitter } // HandlePreviewLink handles POST /api/preview - fetches metadata for a URL @@ -175,6 +178,7 @@ func HandlePreviewLink(rc *RequestContext, w http.ResponseWriter, r *http.Reques Provider: meta.Provider, VideoID: meta.VideoID, EmbedHTML: meta.EmbedHTML, + MediaType: meta.MediaType, }) } @@ -207,35 +211,58 @@ func HandleCreateFromLink(rc *RequestContext, w http.ResponseWriter, r *http.Req var itemType string var embedProvider, embedVideoID, embedHTML *string + var imageURL, videoURL string + var imageURLs []string // For multi-image tweets if req.Provider != nil && *req.Provider != "" { - // It's an embed - itemType = "embed" - embedProvider = req.Provider - embedVideoID = req.VideoID - embedHTML = req.EmbedHTML + // Special handling for Twitter based on media content + if *req.Provider == "twitter" { + // Fetch tweet info to determine content type + videoInfo, err := embed.Detect(ctx, req.URL) + if err == nil && videoInfo != nil { + if videoInfo.VideoURL != "" { + // Tweet has video → keep as embed (proxied) + itemType = "embed" + embedProvider = req.Provider + embedVideoID = req.VideoID + embedHTML = req.EmbedHTML + videoURL = videoInfo.VideoURL + imageURL = videoInfo.ThumbnailURL + } else if len(videoInfo.ThumbnailURLs) > 0 { + // Tweet has image(s) → save as image + itemType = "image" + imageURL = videoInfo.ThumbnailURL + imageURLs = videoInfo.ThumbnailURLs + } else { + // Text-only tweet → save as quote + itemType = "quote" + // title and description already set from request + } + } else { + // If detection fails, fall back to link + itemType = "link" + } + } else { + // YouTube, Vimeo, etc. → keep as embed + itemType = "embed" + embedProvider = req.Provider + embedVideoID = req.VideoID + embedHTML = req.EmbedHTML + // Fetch thumbnail for non-Twitter embeds + if videoInfo, err := embed.Detect(ctx, req.URL); err == nil && videoInfo != nil { + imageURL = videoInfo.ThumbnailURL + videoURL = videoInfo.VideoURL + } + } } else if req.ImageURL != nil && *req.ImageURL != "" { // It's a link with an image itemType = "image" + imageURL = *req.ImageURL } else { // Just a link (will be shown as a card) itemType = "link" } - // For embeds, fetch thumbnail(s) and video URL - var imageURL, videoURL string - var imageURLs []string // For multi-image tweets - if req.ImageURL != nil { - imageURL = *req.ImageURL - } - if itemType == "embed" && embedProvider != nil { - if videoInfo, err := embed.Detect(ctx, req.URL); err == nil && videoInfo != nil { - imageURL = videoInfo.ThumbnailURL - imageURLs = videoInfo.ThumbnailURLs - videoURL = videoInfo.VideoURL - } - } - // Create the item it, err := item.QCreate(ctx, rc.DB, item.CreateParams{ Title: item.Nullable(req.Title), diff --git a/internal/handlers/home.go b/internal/handlers/home.go index 6de90fe..ce5bf26 100644 --- a/internal/handlers/home.go +++ b/internal/handlers/home.go @@ -28,11 +28,9 @@ type homeItem struct { ItemType string EmbedHTML *string Tags []string - ThumbnailID *int64 MediaID *int64 HasVideo bool - GalleryIDs []int64 // Additional images for multi-image embeds - ImageCount int // Total image count (1 + len(GalleryIDs)) + ImageIDs []int64 // Fetched images (from URLs/embeds) } func (h homeContent) Render(sw *ssr.Writer) error { @@ -54,33 +52,30 @@ func (h homeContent) Render(sw *ssr.Writer) error {
{{.Description}}- {{if .Title}}— {{.Title}}{{end}} -
{{.Description}}+ {{if .Title}}— {{.Title}}{{end}} +