Add video picker and Discord video attachments.
CI / test (pull_request) Successful in 6m29s

The picker accepts one MP4 or WebM, the thread lightbox zooms photos, and Discord gets the video as a file on a follow-up message so photo embeds still render.
This commit is contained in:
2026-08-31 02:00:56 -07:00
parent 84dea8ea5d
commit f96df3222d
22 changed files with 1018 additions and 144 deletions
+62 -27
View File
@@ -22,6 +22,7 @@ import (
_ "golang.org/x/image/webp"
"plumber/internal/blob"
"plumber/internal/events"
"plumber/internal/store"
)
@@ -109,18 +110,18 @@ func (s *Server) postImagesFromForm(
r *http.Request,
postID string,
existing []store.PostImage,
) ([]store.PostImage, []string, error) {
) ([]store.PostImage, []string, []events.Media, error) {
if r.MultipartForm == nil {
return append([]store.PostImage(nil), existing...), nil, nil
return append([]store.PostImage(nil), existing...), nil, nil, nil
}
retained, err := retainedPostImages(r.MultipartForm, existing)
if err != nil {
return nil, nil, err
return nil, nil, nil, err
}
files := r.MultipartForm.File["images"]
descriptions := r.MultipartForm.Value["image_description"]
if len(descriptions) > len(files) {
return nil, nil, invalidPostImage("Image descriptions do not match selected images.", nil)
return nil, nil, nil, invalidPostImage("Image descriptions do not match selected images.", nil)
}
kinds := make([]string, len(files))
newImages, newVideos := 0, 0
@@ -134,13 +135,13 @@ func (s *Server) postImagesFromForm(
}
retainedImages, retainedVideos := countPostMedia(retained)
if retainedImages+newImages > store.MaxPostImages {
return nil, nil, invalidPostImage("You can attach up to 4 images.", nil)
return nil, nil, nil, invalidPostImage("You can attach up to 4 images.", nil)
}
if retainedVideos+newVideos > store.MaxPostVideos {
return nil, nil, invalidPostImage("You can attach one video.", nil)
return nil, nil, nil, invalidPostImage("You can attach one video.", nil)
}
if len(files) > 0 && !s.cfg.Blob.Enabled() {
return nil, nil, &postImageRequestError{
return nil, nil, nil, &postImageRequestError{
status: http.StatusServiceUnavailable,
message: "Image uploads are not configured on this server.",
}
@@ -148,24 +149,28 @@ func (s *Server) postImagesFromForm(
images := append([]store.PostImage(nil), retained...)
newKeys := make([]string, 0, len(files))
held := make([]events.Media, 0, len(files))
for i, header := range files {
description := ""
if i < len(descriptions) {
description = strings.TrimSpace(descriptions[i])
}
if len([]rune(description)) > store.MaxImageDescriptionRunes {
closeHeldMedia(held)
s.deletePostImageObjects(newKeys)
return nil, nil, invalidPostImage("Image descriptions must be 500 characters or fewer.", nil)
return nil, nil, nil, invalidPostImage("Image descriptions must be 500 characters or fewer.", nil)
}
item, objectKey, err := s.uploadPostMedia(ctx, postID, header, kinds[i], description)
item, objectKey, media, err := s.uploadPostMedia(ctx, postID, header, kinds[i], description)
if err != nil {
closeHeldMedia(held)
s.deletePostImageObjects(newKeys)
return nil, nil, err
return nil, nil, nil, err
}
newKeys = append(newKeys, objectKey)
images = append(images, item)
held = append(held, media)
}
return images, newKeys, nil
return images, newKeys, held, nil
}
func countPostMedia(items []store.PostImage) (images, videos int) {
@@ -179,18 +184,26 @@ func countPostMedia(items []store.PostImage) (images, videos int) {
return images, videos
}
func closeHeldMedia(media []events.Media) {
for i := range media {
if media[i].Body != nil {
_ = media[i].Body.Close()
media[i].Body = nil
}
}
}
func (s *Server) uploadPostMedia(
ctx context.Context,
postID string,
header *multipart.FileHeader,
kind, description string,
) (store.PostImage, string, error) {
) (store.PostImage, string, events.Media, error) {
if kind == store.MediaKindVideo {
prepared, err := preparePostVideo(header)
if err != nil {
return store.PostImage{}, "", err
return store.PostImage{}, "", events.Media{}, err
}
defer prepared.body.Close()
mediaID := uuid.NewString()
objectKey := path.Join("post-videos", postID, mediaID+prepared.extension)
publicURL, err := s.cfg.Blob.Upload(ctx, blob.FileUpload{
@@ -200,12 +213,28 @@ func (s *Server) uploadPostMedia(
Size: prepared.size,
})
if err != nil {
return store.PostImage{}, "", &postImageRequestError{
_ = prepared.body.Close()
return store.PostImage{}, "", events.Media{}, &postImageRequestError{
status: http.StatusServiceUnavailable,
message: "Could not upload video. Try again later.",
cause: err,
}
}
media := events.Media{
Name: mediaID + prepared.extension,
ContentType: prepared.contentType,
Kind: store.MediaKindVideo,
Size: prepared.size,
}
if seeker, ok := prepared.body.(io.Seeker); ok {
if _, err := seeker.Seek(0, io.SeekStart); err == nil {
media.Body = prepared.body
} else {
_ = prepared.body.Close()
}
} else {
_ = prepared.body.Close()
}
return store.PostImage{
ID: mediaID,
PostID: postID,
@@ -213,11 +242,11 @@ func (s *Server) uploadPostMedia(
PublicURL: publicURL,
Description: description,
Kind: store.MediaKindVideo,
}, objectKey, nil
}, objectKey, media, nil
}
prepared, err := preparePostImage(header)
if err != nil {
return store.PostImage{}, "", err
return store.PostImage{}, "", events.Media{}, err
}
imageID := uuid.NewString()
objectKey := path.Join("post-images", postID, imageID+prepared.extension)
@@ -228,22 +257,28 @@ func (s *Server) uploadPostMedia(
Size: int64(len(prepared.body)),
})
if err != nil {
return store.PostImage{}, "", &postImageRequestError{
return store.PostImage{}, "", events.Media{}, &postImageRequestError{
status: http.StatusServiceUnavailable,
message: "Could not upload image. Try again later.",
cause: err,
}
}
return store.PostImage{
ID: imageID,
PostID: postID,
ObjectKey: objectKey,
PublicURL: publicURL,
Description: description,
Kind: store.MediaKindImage,
Width: prepared.width,
Height: prepared.height,
}, objectKey, nil
ID: imageID,
PostID: postID,
ObjectKey: objectKey,
PublicURL: publicURL,
Description: description,
Kind: store.MediaKindImage,
Width: prepared.width,
Height: prepared.height,
}, objectKey, events.Media{
Name: imageID + prepared.extension,
ContentType: prepared.contentType,
Kind: store.MediaKindImage,
Size: int64(len(prepared.body)),
Bytes: prepared.body,
}, nil
}
func retainedPostImages(form *multipart.Form, existing []store.PostImage) ([]store.PostImage, error) {