diff --git a/internal/data/item/queries.go b/internal/data/item/queries.go
index b3719dc..3c95f72 100644
--- a/internal/data/item/queries.go
+++ b/internal/data/item/queries.go
@@ -9,45 +9,63 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
+// Nullable creates a sql.Null[T] from a pointer.
+func Nullable[T any](v *T) sql.Null[T] {
+ if v == nil {
+ return sql.Null[T]{}
+ }
+ return sql.Null[T]{V: *v, Valid: true}
+}
+
+// Ptr returns a pointer from sql.Null[T], or nil if not valid.
+func Ptr[T any](n sql.Null[T]) *T {
+ if !n.Valid {
+ return nil
+ }
+ return &n.V
+}
+
type Row struct {
ID int64
PubID string
- Title *string
- Description *string
- LinkURL *string
+ Title sql.Null[string]
+ Description sql.Null[string]
+ LinkURL sql.Null[string]
ItemType string // 'image', 'video', 'quote', 'embed'
- EmbedProvider *string
- EmbedVideoID *string
- EmbedHTML *string
+ EmbedProvider sql.Null[string]
+ EmbedVideoID sql.Null[string]
+ EmbedHTML sql.Null[string]
+ EmbedVideoURL sql.Null[string]
CreatedAt time.Time
- DeletedAt *time.Time
+ DeletedAt sql.Null[time.Time]
}
type CreateParams struct {
- Title *string
- Description *string
- LinkURL *string
+ Title sql.Null[string]
+ Description sql.Null[string]
+ LinkURL sql.Null[string]
ItemType string
- EmbedProvider *string
- EmbedVideoID *string
- EmbedHTML *string
+ EmbedProvider sql.Null[string]
+ EmbedVideoID sql.Null[string]
+ EmbedHTML sql.Null[string]
+ EmbedVideoURL sql.Null[string]
}
// QCreate creates a new item.
func QCreate(ctx context.Context, db *sql.DB, p CreateParams) (Row, error) {
query := `
- INSERT INTO item (title, description, link_url, item_type, embed_provider, embed_video_id, embed_html)
- VALUES ($1, $2, $3, $4, $5, $6, $7)
- RETURNING id, pub_id, title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, created_at, deleted_at
+ INSERT INTO item (title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, embed_video_url)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ RETURNING id, pub_id, title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, embed_video_url, created_at, deleted_at
`
var row Row
var pubID pgtype.UUID
err := db.QueryRowContext(ctx, query,
- p.Title, p.Description, p.LinkURL, p.ItemType, p.EmbedProvider, p.EmbedVideoID, p.EmbedHTML,
+ p.Title, p.Description, p.LinkURL, p.ItemType, p.EmbedProvider, p.EmbedVideoID, p.EmbedHTML, p.EmbedVideoURL,
).Scan(
&row.ID, &pubID, &row.Title, &row.Description, &row.LinkURL,
- &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML,
+ &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, &row.EmbedVideoURL,
&row.CreatedAt, &row.DeletedAt,
)
if err == nil {
@@ -59,7 +77,7 @@ func QCreate(ctx context.Context, db *sql.DB, p CreateParams) (Row, error) {
// QFindByPubID finds an item by its public ID.
func QFindByPubID(ctx context.Context, db *sql.DB, pubID string) (*Row, error) {
query := `
- SELECT id, pub_id, title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, created_at, deleted_at
+ SELECT id, pub_id, title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, embed_video_url, created_at, deleted_at
FROM item
WHERE pub_id = $1
`
@@ -68,7 +86,7 @@ func QFindByPubID(ctx context.Context, db *sql.DB, pubID string) (*Row, error) {
var pubUUID pgtype.UUID
err := db.QueryRowContext(ctx, query, pubID).Scan(
&row.ID, &pubUUID, &row.Title, &row.Description, &row.LinkURL,
- &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML,
+ &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, &row.EmbedVideoURL,
&row.CreatedAt, &row.DeletedAt,
)
if err == sql.ErrNoRows {
@@ -84,7 +102,7 @@ func QFindByPubID(ctx context.Context, db *sql.DB, pubID string) (*Row, error) {
// QFindByID finds an item by its internal ID.
func QFindByID(ctx context.Context, db *sql.DB, id int64) (*Row, error) {
query := `
- SELECT id, pub_id, title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, created_at, deleted_at
+ SELECT id, pub_id, title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, embed_video_url, created_at, deleted_at
FROM item
WHERE id = $1
`
@@ -93,7 +111,7 @@ func QFindByID(ctx context.Context, db *sql.DB, id int64) (*Row, error) {
var pubUUID pgtype.UUID
err := db.QueryRowContext(ctx, query, id).Scan(
&row.ID, &pubUUID, &row.Title, &row.Description, &row.LinkURL,
- &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML,
+ &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, &row.EmbedVideoURL,
&row.CreatedAt, &row.DeletedAt,
)
if err == sql.ErrNoRows {
@@ -109,7 +127,7 @@ func QFindByID(ctx context.Context, db *sql.DB, id int64) (*Row, error) {
// QList returns all non-deleted items, newest first.
func QList(ctx context.Context, db *sql.DB) ([]Row, error) {
query := `
- SELECT id, pub_id, title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, created_at, deleted_at
+ SELECT id, pub_id, title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, embed_video_url, created_at, deleted_at
FROM item
WHERE deleted_at IS NULL
ORDER BY created_at DESC
@@ -127,7 +145,7 @@ func QList(ctx context.Context, db *sql.DB) ([]Row, error) {
var pubUUID pgtype.UUID
if err := rows.Scan(
&row.ID, &pubUUID, &row.Title, &row.Description, &row.LinkURL,
- &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML,
+ &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, &row.EmbedVideoURL,
&row.CreatedAt, &row.DeletedAt,
); err != nil {
return nil, err
@@ -141,7 +159,7 @@ func QList(ctx context.Context, db *sql.DB) ([]Row, error) {
// QListByTag returns all non-deleted items with a specific tag, newest first.
func QListByTag(ctx context.Context, db *sql.DB, tagName string) ([]Row, error) {
query := `
- SELECT i.id, i.pub_id, i.title, i.description, i.link_url, i.item_type, i.embed_provider, i.embed_video_id, i.embed_html, i.created_at, i.deleted_at
+ SELECT i.id, i.pub_id, i.title, i.description, i.link_url, i.item_type, i.embed_provider, i.embed_video_id, i.embed_html, i.embed_video_url, i.created_at, i.deleted_at
FROM item i
JOIN item_tag it ON i.id = it.item_id
JOIN tag t ON it.tag_id = t.id
@@ -161,7 +179,7 @@ func QListByTag(ctx context.Context, db *sql.DB, tagName string) ([]Row, error)
var pubUUID pgtype.UUID
if err := rows.Scan(
&row.ID, &pubUUID, &row.Title, &row.Description, &row.LinkURL,
- &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML,
+ &row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, &row.EmbedVideoURL,
&row.CreatedAt, &row.DeletedAt,
); err != nil {
return nil, err
@@ -173,9 +191,9 @@ func QListByTag(ctx context.Context, db *sql.DB, tagName string) ([]Row, error)
}
type UpdateParams struct {
- Title *string
- Description *string
- LinkURL *string
+ Title sql.Null[string]
+ Description sql.Null[string]
+ LinkURL sql.Null[string]
}
// QUpdate updates an item's editable fields.
@@ -196,6 +214,13 @@ func QUpdateType(ctx context.Context, db *sql.DB, id int64, itemType string) err
return err
}
+// QUpdateVideoURL updates an item's embed video URL.
+func QUpdateVideoURL(ctx context.Context, db *sql.DB, id int64, videoURL string) error {
+ query := `UPDATE item SET embed_video_url = $2 WHERE id = $1`
+ _, err := db.ExecContext(ctx, query, id, videoURL)
+ return err
+}
+
// QSoftDelete soft deletes an item.
func QSoftDelete(ctx context.Context, db *sql.DB, id int64) error {
query := `UPDATE item SET deleted_at = NOW() WHERE id = $1`
diff --git a/internal/embed/detect.go b/internal/embed/detect.go
index 107ad89..1faa6cc 100644
--- a/internal/embed/detect.go
+++ b/internal/embed/detect.go
@@ -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
}
diff --git a/internal/handlers/api_items.go b/internal/handlers/api_items.go
index 0b0a453..47e7f8b 100644
--- a/internal/handlers/api_items.go
+++ b/internal/handlers/api_items.go
@@ -118,13 +118,13 @@ func HandleCreateItem(rc *RequestContext, w http.ResponseWriter, r *http.Request
defer cancel()
it, err := item.QCreate(ctx, rc.DB, item.CreateParams{
- Title: req.Title,
- Description: req.Description,
- LinkURL: req.LinkURL,
+ Title: item.Nullable(req.Title),
+ Description: item.Nullable(req.Description),
+ LinkURL: item.Nullable(req.LinkURL),
ItemType: req.ItemType,
- EmbedProvider: req.EmbedProvider,
- EmbedVideoID: req.EmbedVideoID,
- EmbedHTML: req.EmbedHTML,
+ EmbedProvider: item.Nullable(req.EmbedProvider),
+ EmbedVideoID: item.Nullable(req.EmbedVideoID),
+ EmbedHTML: item.Nullable(req.EmbedHTML),
})
if err != nil {
return err
@@ -173,9 +173,9 @@ func HandleUpdateItem(rc *RequestContext, w http.ResponseWriter, r *http.Request
}
if err := item.QUpdate(ctx, rc.DB, it.ID, item.UpdateParams{
- Title: req.Title,
- Description: req.Description,
- LinkURL: req.LinkURL,
+ Title: item.Nullable(req.Title),
+ Description: item.Nullable(req.Description),
+ LinkURL: item.Nullable(req.LinkURL),
}); err != nil {
return err
}
@@ -241,11 +241,11 @@ func buildItemResponse(ctx context.Context, rc *RequestContext, it item.Row) (it
resp := itemResponse{
ID: it.PubID,
- Title: it.Title,
- Description: it.Description,
- LinkURL: it.LinkURL,
+ Title: item.Ptr(it.Title),
+ Description: item.Ptr(it.Description),
+ LinkURL: item.Ptr(it.LinkURL),
ItemType: it.ItemType,
- EmbedHTML: it.EmbedHTML,
+ EmbedHTML: item.Ptr(it.EmbedHTML),
Tags: tagNames,
CreatedAt: it.CreatedAt.Format(time.RFC3339),
}
diff --git a/internal/handlers/api_upload.go b/internal/handlers/api_upload.go
index 7df0a83..c114526 100644
--- a/internal/handlers/api_upload.go
+++ b/internal/handlers/api_upload.go
@@ -2,6 +2,7 @@ package handlers
import (
"context"
+ "database/sql"
"encoding/json"
"fmt"
"io"
@@ -17,6 +18,22 @@ import (
"lookbook/internal/video"
)
+// strOrNil returns nil if s is empty, otherwise a pointer to s.
+func strOrNil(s string) *string {
+ if s == "" {
+ return nil
+ }
+ return &s
+}
+
+// nullStr creates a sql.Null[string] from a string (empty string = invalid).
+func nullStr(s string) sql.Null[string] {
+ if s == "" {
+ return sql.Null[string]{}
+ }
+ return sql.Null[string]{V: s, Valid: true}
+}
+
type urlMetadata struct {
Title string
Description string
@@ -26,9 +43,9 @@ type urlMetadata struct {
Provider string
VideoID string
EmbedHTML string
+ VideoURL string // Direct video URL (for Twitter)
}
-// fetchURLMetadata fetches metadata for a URL, trying embed detection first, then OpenGraph
func fetchURLMetadata(ctx context.Context, url string) (*urlMetadata, error) {
// Check if it's a YouTube/Vimeo/Twitter embed
videoInfo, err := embed.Detect(ctx, url)
@@ -41,6 +58,7 @@ func fetchURLMetadata(ctx context.Context, url string) (*urlMetadata, error) {
Provider: string(videoInfo.Provider),
VideoID: videoInfo.VideoID,
EmbedHTML: videoInfo.EmbedHTML,
+ VideoURL: videoInfo.VideoURL,
}, nil
}
@@ -151,33 +169,34 @@ func HandleCreateFromLink(rc *RequestContext, w http.ResponseWriter, r *http.Req
itemType = "link"
}
+ // For embeds, fetch thumbnail and video URL
+ var imageURL, videoURL string
+ 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
+ videoURL = videoInfo.VideoURL
+ }
+ }
+
// Create the item
it, err := item.QCreate(ctx, rc.DB, item.CreateParams{
- Title: req.Title,
- Description: req.Description,
- LinkURL: &req.URL,
+ Title: item.Nullable(req.Title),
+ Description: item.Nullable(req.Description),
+ LinkURL: item.Nullable(&req.URL),
ItemType: itemType,
- EmbedProvider: embedProvider,
- EmbedVideoID: embedVideoID,
- EmbedHTML: embedHTML,
+ EmbedProvider: item.Nullable(embedProvider),
+ EmbedVideoID: item.Nullable(embedVideoID),
+ EmbedHTML: item.Nullable(embedHTML),
+ EmbedVideoURL: nullStr(videoURL),
})
if err != nil {
return err
}
- // Download and store image if available
- var imageURL string
- if req.ImageURL != nil {
- imageURL = *req.ImageURL
- }
-
- // For embeds, fetch thumbnail
- if itemType == "embed" && embedProvider != nil {
- if videoInfo, err := embed.Detect(ctx, req.URL); err == nil && videoInfo != nil {
- imageURL = videoInfo.ThumbnailURL
- }
- }
-
+ // Download and store thumbnail
if imageURL != "" {
imgData, contentType, err := opengraph.DownloadImage(ctx, imageURL)
if err != nil {
@@ -277,17 +296,9 @@ func HandleUpload(rc *RequestContext, w http.ResponseWriter, r *http.Request) er
}
// Create item
- var titlePtr, descPtr *string
- if title != "" {
- titlePtr = &title
- }
- if description != "" {
- descPtr = &description
- }
-
it, err := item.QCreate(ctx, rc.DB, item.CreateParams{
- Title: titlePtr,
- Description: descPtr,
+ Title: item.Nullable(strOrNil(title)),
+ Description: item.Nullable(strOrNil(description)),
ItemType: itemType,
})
if err != nil {
@@ -359,9 +370,9 @@ func HandleCreateQuote(rc *RequestContext, w http.ResponseWriter, r *http.Reques
defer cancel()
it, err := item.QCreate(ctx, rc.DB, item.CreateParams{
- Title: req.Source,
- Description: &req.Text,
- LinkURL: req.SourceURL,
+ Title: item.Nullable(req.Source),
+ Description: nullStr(req.Text),
+ LinkURL: item.Nullable(req.SourceURL),
ItemType: "quote",
})
if err != nil {
@@ -517,32 +528,28 @@ func HandleRefreshMetadata(rc *RequestContext, w http.ResponseWriter, r *http.Re
return writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
}
- if it.LinkURL == nil || *it.LinkURL == "" {
+ if !it.LinkURL.Valid || it.LinkURL.V == "" {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "item has no link URL"})
}
- meta, err := fetchURLMetadata(ctx, *it.LinkURL)
+ meta, err := fetchURLMetadata(ctx, it.LinkURL.V)
if err != nil {
return writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("failed to fetch: %v", err)})
}
- // Update title and description
- var titlePtr, descPtr *string
- if meta.Title != "" {
- titlePtr = &meta.Title
- }
- if meta.Description != "" {
- descPtr = &meta.Description
- }
+ // Update title, description, and video URL
item.QUpdate(ctx, rc.DB, it.ID, item.UpdateParams{
- Title: titlePtr,
- Description: descPtr,
+ Title: nullStr(meta.Title),
+ Description: nullStr(meta.Description),
LinkURL: it.LinkURL,
})
+ if meta.VideoURL != "" {
+ item.QUpdateVideoURL(ctx, rc.DB, it.ID, meta.VideoURL)
+ }
// Download and replace thumbnail
if meta.ImageURL != "" {
- // Delete existing media for this item
+ // Delete existing thumbnails
media.QDeleteByItemID(ctx, rc.DB, it.ID)
imgData, contentType, err := opengraph.DownloadImage(ctx, meta.ImageURL)
diff --git a/internal/handlers/home.go b/internal/handlers/home.go
index eb8ba14..23c3654 100644
--- a/internal/handlers/home.go
+++ b/internal/handlers/home.go
@@ -3,6 +3,7 @@ package handlers
import (
"context"
"net/http"
+ "strings"
"time"
"git.soup.land/soup/sxgo/ssr"
@@ -29,6 +30,7 @@ type homeItem struct {
Tags []string
ThumbnailID *int64
MediaID *int64
+ HasVideo bool
}
func (h homeContent) Render(sw *ssr.Writer) error {
@@ -57,8 +59,10 @@ func (h homeContent) Render(sw *ssr.Writer) error {
{{else if .ThumbnailID}}
+ {{if or .HasVideo (eq .ItemType "video")}}