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
This commit is contained in:
soup 2026-01-17 13:28:20 -05:00
parent e917e67930
commit 007e167707
Signed by: soup
SSH key fingerprint: SHA256:GYxje8eQkJ6HZKzVWDdyOUF1TyDiprruGhE0Ym8qYDY
9 changed files with 625 additions and 403 deletions

View file

@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
@ -34,10 +35,61 @@ func nullStr(s string) sql.Null[string] {
return sql.Null[string]{V: s, Valid: true}
}
// downloadAndStoreImages downloads images from URLs and stores them for an item.
// All images are stored with media_type "image". The first image (by ID order) serves as the thumbnail.
// If imageURLs is empty but imageURL is set, stores that single image.
func downloadAndStoreImages(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
itemID int64,
imageURL string,
imageURLs []string,
) {
if len(imageURLs) > 0 {
// Multi-image (e.g., Twitter with multiple photos)
for i, imgURL := range imageURLs {
imgData, contentType, err := opengraph.DownloadImage(ctx, imgURL)
if err != nil {
logger.Warn("failed to download image", "url", imgURL, "index", i, "error", err)
continue
}
_, err = media.QCreate(ctx, db, media.CreateParams{
ItemID: itemID,
MediaType: "image",
ContentType: contentType,
Data: imgData,
SourceURL: &imgURL,
})
if err != nil {
logger.Warn("failed to store image", "index", i, "error", err)
}
}
} else if imageURL != "" {
// Single image
imgData, contentType, err := opengraph.DownloadImage(ctx, imageURL)
if err != nil {
logger.Warn("failed to download image", "url", imageURL, "error", err)
return
}
_, err = media.QCreate(ctx, db, media.CreateParams{
ItemID: itemID,
MediaType: "image",
ContentType: contentType,
Data: imgData,
SourceURL: &imageURL,
})
if err != nil {
logger.Warn("failed to store image", "error", err)
}
}
}
type urlMetadata struct {
Title string
Description string
ImageURL string
ImageURLs []string // All image URLs (for multi-image tweets)
SiteName string
IsEmbed bool
Provider string
@ -54,6 +106,7 @@ func fetchURLMetadata(ctx context.Context, url string) (*urlMetadata, error) {
Title: videoInfo.Title,
Description: videoInfo.Description,
ImageURL: videoInfo.ThumbnailURL,
ImageURLs: videoInfo.ThumbnailURLs,
IsEmbed: true,
Provider: string(videoInfo.Provider),
VideoID: videoInfo.VideoID,
@ -169,14 +222,16 @@ func HandleCreateFromLink(rc *RequestContext, w http.ResponseWriter, r *http.Req
itemType = "link"
}
// For embeds, fetch thumbnail and video URL
// 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
}
}
@ -196,24 +251,8 @@ func HandleCreateFromLink(rc *RequestContext, w http.ResponseWriter, r *http.Req
return err
}
// Download and store thumbnail
if imageURL != "" {
imgData, contentType, err := opengraph.DownloadImage(ctx, imageURL)
if err != nil {
rc.Logger.Warn("failed to download image", "url", imageURL, "error", err)
} else {
_, err = media.QCreate(ctx, rc.DB, media.CreateParams{
ItemID: it.ID,
MediaType: "thumbnail",
ContentType: contentType,
Data: imgData,
SourceURL: &imageURL,
})
if err != nil {
rc.Logger.Warn("failed to store image", "error", err)
}
}
}
// Download and store images
downloadAndStoreImages(ctx, rc.DB, rc.Logger, it.ID, imageURL, imageURLs)
// Set tags
if len(req.Tags) > 0 {
@ -547,26 +586,11 @@ func HandleRefreshMetadata(rc *RequestContext, w http.ResponseWriter, r *http.Re
item.QUpdateVideoURL(ctx, rc.DB, it.ID, meta.VideoURL)
}
// Download and replace thumbnail
if meta.ImageURL != "" {
// Delete existing thumbnails
// Download and replace images
if len(meta.ImageURLs) > 0 || meta.ImageURL != "" {
// Delete existing media (thumbnails and gallery)
media.QDeleteByItemID(ctx, rc.DB, it.ID)
imgData, contentType, err := opengraph.DownloadImage(ctx, meta.ImageURL)
if err != nil {
rc.Logger.Warn("failed to download image during refresh", "url", meta.ImageURL, "error", err)
} else {
_, err = media.QCreate(ctx, rc.DB, media.CreateParams{
ItemID: it.ID,
MediaType: "thumbnail",
ContentType: contentType,
Data: imgData,
SourceURL: &meta.ImageURL,
})
if err != nil {
rc.Logger.Warn("failed to store refreshed image", "error", err)
}
}
downloadAndStoreImages(ctx, rc.DB, rc.Logger, it.ID, meta.ImageURL, meta.ImageURLs)
}
// Refetch and return updated item