diff options
Diffstat (limited to 'internal/media')
-rw-r--r-- | internal/media/image.go | 37 | ||||
-rw-r--r-- | internal/media/manager.go | 628 | ||||
-rw-r--r-- | internal/media/manager_test.go | 411 | ||||
-rw-r--r-- | internal/media/processingemoji.go | 207 | ||||
-rw-r--r-- | internal/media/processingmedia.go | 287 | ||||
-rw-r--r-- | internal/media/refetch.go | 6 | ||||
-rw-r--r-- | internal/media/types.go | 76 | ||||
-rw-r--r-- | internal/media/util.go | 1 |
8 files changed, 903 insertions, 750 deletions
diff --git a/internal/media/image.go b/internal/media/image.go index 29527c085..8a34e5062 100644 --- a/internal/media/image.go +++ b/internal/media/image.go @@ -43,12 +43,9 @@ var ( BufferPool: &pngEncoderBufferPool{}, } - // jpegBufferPool is a memory pool of byte buffers for JPEG encoding. - jpegBufferPool = sync.Pool{ - New: func() any { - return bufio.NewWriter(nil) - }, - } + // jpegBufferPool is a memory pool + // of byte buffers for JPEG encoding. + jpegBufferPool sync.Pool ) // gtsImage is a thin wrapper around the standard library image @@ -80,25 +77,29 @@ func decodeImage(r io.Reader, opts ...imaging.DecodeOption) (*gtsImage, error) { } // Width returns the image width in pixels. -func (m *gtsImage) Width() uint32 { - return uint32(m.image.Bounds().Size().X) +func (m *gtsImage) Width() int { + return m.image.Bounds().Size().X } // Height returns the image height in pixels. -func (m *gtsImage) Height() uint32 { - return uint32(m.image.Bounds().Size().Y) +func (m *gtsImage) Height() int { + return m.image.Bounds().Size().Y } // Size returns the total number of image pixels. -func (m *gtsImage) Size() uint64 { - return uint64(m.image.Bounds().Size().X) * - uint64(m.image.Bounds().Size().Y) +func (m *gtsImage) Size() int { + return m.image.Bounds().Size().X * + m.image.Bounds().Size().Y } // AspectRatio returns the image ratio of width:height. func (m *gtsImage) AspectRatio() float32 { - return float32(m.image.Bounds().Size().X) / - float32(m.image.Bounds().Size().Y) + + // note: we cast bounds to float64 to prevent truncation + // and only at the end aspect ratio do we cast to float32 + // (as the sizes are likely to be much larger than ratio). + return float32(float64(m.image.Bounds().Size().X) / + float64(m.image.Bounds().Size().Y)) } // Thumbnail returns a small sized copy of gtsImage{}, limited to 512x512 if not small enough. @@ -160,7 +161,11 @@ func (m *gtsImage) ToPNG() io.Reader { // getJPEGBuffer fetches a reset JPEG encoding buffer from global JPEG buffer pool. func getJPEGBuffer(w io.Writer) *bufio.Writer { - buf, _ := jpegBufferPool.Get().(*bufio.Writer) + v := jpegBufferPool.Get() + if v == nil { + v = bufio.NewWriter(nil) + } + buf := v.(*bufio.Writer) buf.Reset(w) return buf } diff --git a/internal/media/manager.go b/internal/media/manager.go index be428aa3b..90a2923b5 100644 --- a/internal/media/manager.go +++ b/internal/media/manager.go @@ -56,383 +56,413 @@ func NewManager(state *state.State) *Manager { return &Manager{state: state} } -// PreProcessMedia begins the process of decoding -// and storing the given data as an attachment. -// It will return a pointer to a ProcessingMedia -// struct upon which further actions can be performed, -// such as getting the finished media, thumbnail, -// attachment, etc. -// -// - data: a function that the media manager can call -// to return a reader containing the media data. -// - accountID: the account that the media belongs to. -// - ai: optional and can be nil. Any additional information -// about the attachment provided will be put in the database. -// -// Note: unlike ProcessMedia, this will NOT -// queue the media to be asynchronously processed. -func (m *Manager) PreProcessMedia( - data DataFunc, +// CreateMedia creates a new media attachment entry +// in the database for given owning account ID and +// extra information, and prepares a new processing +// media entry to dereference it using the given +// data function, decode the media and finish filling +// out remaining media fields (e.g. type, path, etc). +func (m *Manager) CreateMedia( + ctx context.Context, accountID string, - ai *AdditionalMediaInfo, -) *ProcessingMedia { - // Populate initial fields on the new media, - // leaving out fields with values we don't know - // yet. These will be overwritten as we go. + data DataFunc, + info AdditionalMediaInfo, +) ( + *ProcessingMedia, + error, +) { now := time.Now() - attachment := >smodel.MediaAttachment{ - ID: id.NewULID(), - CreatedAt: now, - UpdatedAt: now, - Type: gtsmodel.FileTypeUnknown, - FileMeta: gtsmodel.FileMeta{}, - AccountID: accountID, - Processing: gtsmodel.ProcessingStatusReceived, - File: gtsmodel.File{ - UpdatedAt: now, - ContentType: "application/octet-stream", - }, - Thumbnail: gtsmodel.Thumbnail{UpdatedAt: now}, - Avatar: util.Ptr(false), - Header: util.Ptr(false), - Cached: util.Ptr(false), - } - attachment.URL = uris.URIForAttachment( + // Generate new ID. + id := id.NewULID() + + // Placeholder URL for attachment. + url := uris.URIForAttachment( accountID, string(TypeAttachment), string(SizeOriginal), - attachment.ID, + id, "unknown", ) - attachment.File.Path = uris.StoragePathForAttachment( + // Placeholder storage path for attachment. + path := uris.StoragePathForAttachment( accountID, string(TypeAttachment), string(SizeOriginal), - attachment.ID, + id, "unknown", ) - // Check if we were provided additional info - // to add to the attachment, and overwrite - // some of the attachment fields if so. - if ai != nil { - if ai.CreatedAt != nil { - attachment.CreatedAt = *ai.CreatedAt - } - - if ai.StatusID != nil { - attachment.StatusID = *ai.StatusID - } - - if ai.RemoteURL != nil { - attachment.RemoteURL = *ai.RemoteURL - } - - if ai.Description != nil { - attachment.Description = *ai.Description - } + // Calculate attachment thumbnail file path + thumbPath := uris.StoragePathForAttachment( + accountID, + string(TypeAttachment), + string(SizeSmall), + id, - if ai.ScheduledStatusID != nil { - attachment.ScheduledStatusID = *ai.ScheduledStatusID - } + // Always encode attachment + // thumbnails as jpg. + "jpg", + ) - if ai.Blurhash != nil { - attachment.Blurhash = *ai.Blurhash - } + // Calculate attachment thumbnail URL. + thumbURL := uris.URIForAttachment( + accountID, + string(TypeAttachment), + string(SizeSmall), + id, - if ai.Avatar != nil { - attachment.Avatar = ai.Avatar - } + // Always encode attachment + // thumbnails as jpg. + "jpg", + ) - if ai.Header != nil { - attachment.Header = ai.Header - } + // Populate initial fields on the new media, + // leaving out fields with values we don't know + // yet. These will be overwritten as we go. + attachment := >smodel.MediaAttachment{ + ID: id, + CreatedAt: now, + UpdatedAt: now, + URL: url, + Type: gtsmodel.FileTypeUnknown, + AccountID: accountID, + Processing: gtsmodel.ProcessingStatusReceived, + File: gtsmodel.File{ + ContentType: "application/octet-stream", + Path: path, + }, + Thumbnail: gtsmodel.Thumbnail{ + ContentType: mimeImageJpeg, // thumbs always jpg. + Path: thumbPath, + URL: thumbURL, + }, + Avatar: util.Ptr(false), + Header: util.Ptr(false), + Cached: util.Ptr(false), + } - if ai.FocusX != nil { - attachment.FileMeta.Focus.X = *ai.FocusX - } + // Check if we were provided additional info + // to add to the attachment, and overwrite + // some of the attachment fields if so. + if info.CreatedAt != nil { + attachment.CreatedAt = *info.CreatedAt + } + if info.StatusID != nil { + attachment.StatusID = *info.StatusID + } + if info.RemoteURL != nil { + attachment.RemoteURL = *info.RemoteURL + } + if info.Description != nil { + attachment.Description = *info.Description + } + if info.ScheduledStatusID != nil { + attachment.ScheduledStatusID = *info.ScheduledStatusID + } + if info.Blurhash != nil { + attachment.Blurhash = *info.Blurhash + } + if info.Avatar != nil { + attachment.Avatar = info.Avatar + } + if info.Header != nil { + attachment.Header = info.Header + } + if info.FocusX != nil { + attachment.FileMeta.Focus.X = *info.FocusX + } + if info.FocusY != nil { + attachment.FileMeta.Focus.Y = *info.FocusY + } - if ai.FocusY != nil { - attachment.FileMeta.Focus.Y = *ai.FocusY - } + // Store attachment in database in initial form. + err := m.state.DB.PutAttachment(ctx, attachment) + if err != nil { + return nil, err } - processingMedia := &ProcessingMedia{ - media: attachment, + // Pass prepared media as ready to be cached. + return m.RecacheMedia(attachment, data), nil +} + +// RecacheMedia wraps a media model (assumed already +// inserted in the database!) with given data function +// to perform a blocking dereference / decode operation +// from the data stream returned. +func (m *Manager) RecacheMedia( + media *gtsmodel.MediaAttachment, + data DataFunc, +) *ProcessingMedia { + return &ProcessingMedia{ + media: media, dataFn: data, mgr: m, } - - return processingMedia } -// PreProcessMediaRecache refetches, reprocesses, -// and recaches an existing attachment that has -// been uncached via cleaner pruning. -// -// Note: unlike ProcessMedia, this will NOT queue -// the media to be asychronously processed. -func (m *Manager) PreProcessMediaRecache( +// CreateEmoji creates a new emoji entry in the +// database for given shortcode, domain and extra +// information, and prepares a new processing emoji +// entry to dereference it using the given data +// function, decode the media and finish filling +// out remaining fields (e.g. type, path, etc). +func (m *Manager) CreateEmoji( ctx context.Context, + shortcode string, + domain string, data DataFunc, - attachmentID string, -) (*ProcessingMedia, error) { - // Get the existing attachment from database. - attachment, err := m.state.DB.GetAttachmentByID(ctx, attachmentID) + info AdditionalEmojiInfo, +) ( + *ProcessingEmoji, + error, +) { + now := time.Now() + + // Generate new ID. + id := id.NewULID() + + // Fetch the local instance account for emoji path generation. + instanceAcc, err := m.state.DB.GetInstanceAccount(ctx, "") if err != nil { - return nil, err + return nil, gtserror.Newf("error fetching instance account: %w", err) + } + + if domain == "" && info.URI == nil { + // Generate URI for local emoji. + uri := uris.URIForEmoji(id) + info.URI = &uri } - processingMedia := &ProcessingMedia{ - media: attachment, - dataFn: data, - recache: true, // Indicate it's a recache. - mgr: m, + // Generate static URL for attachment. + staticURL := uris.URIForAttachment( + instanceAcc.ID, + string(TypeEmoji), + string(SizeStatic), + id, + + // All static emojis + // are encoded as png. + mimePng, + ) + + // Generate static image path for attachment. + staticPath := uris.StoragePathForAttachment( + instanceAcc.ID, + string(TypeEmoji), + string(SizeStatic), + id, + + // All static emojis + // are encoded as png. + mimePng, + ) + + // Populate initial fields on the new emoji, + // leaving out fields with values we don't know + // yet. These will be overwritten as we go. + emoji := >smodel.Emoji{ + ID: id, + Shortcode: shortcode, + Domain: domain, + ImageStaticURL: staticURL, + ImageStaticPath: staticPath, + ImageStaticContentType: mimeImagePng, + Disabled: util.Ptr(false), + VisibleInPicker: util.Ptr(true), + CreatedAt: now, + UpdatedAt: now, } - return processingMedia, nil + // Finally, create new emoji. + return m.createEmoji(ctx, + m.state.DB.PutEmoji, + data, + emoji, + info, + ) } -// PreProcessEmoji begins the process of decoding and storing -// the given data as an emoji. It will return a pointer to a -// ProcessingEmoji struct upon which further actions can be -// performed, such as getting the finished media, thumbnail, -// attachment, etc. -// -// - data: function that the media manager can call -// to return a reader containing the emoji data. -// - shortcode: the emoji shortcode without the ':'s around it. -// - emojiID: database ID that should be used to store the emoji. -// - uri: ActivityPub URI/ID of the emoji. -// - ai: optional and can be nil. Any additional information -// about the emoji provided will be put in the database. -// - refresh: refetch/refresh the emoji. -// -// Note: unlike ProcessEmoji, this will NOT queue -// the emoji to be asynchronously processed. -func (m *Manager) PreProcessEmoji( +// RefreshEmoji will prepare a recache operation +// for the given emoji, updating it with extra +// information, and in particular using new storage +// paths for the dereferenced media files to skirt +// around browser caching of the old files. +func (m *Manager) RefreshEmoji( ctx context.Context, + emoji *gtsmodel.Emoji, data DataFunc, - shortcode string, - emojiID string, - uri string, - ai *AdditionalEmojiInfo, - refresh bool, -) (*ProcessingEmoji, error) { - var ( - newPathID string - emoji *gtsmodel.Emoji - now = time.Now() - ) - + info AdditionalEmojiInfo, +) ( + *ProcessingEmoji, + error, +) { // Fetch the local instance account for emoji path generation. instanceAcc, err := m.state.DB.GetInstanceAccount(ctx, "") if err != nil { return nil, gtserror.Newf("error fetching instance account: %w", err) } - if refresh { - // Existing emoji! + // Create references to old emoji image + // paths before they get updated with new + // path ID. These are required for later + // deleting the old image files on refresh. + shortcodeDomain := util.ShortcodeDomain(emoji) + oldStaticPath := emoji.ImageStaticPath + oldPath := emoji.ImagePath + + // Since this is a refresh we will end up storing new images at new + // paths, so we should wrap closer to delete old paths at completion. + wrapped := func(ctx context.Context) (io.ReadCloser, int64, error) { - emoji, err = m.state.DB.GetEmojiByID(ctx, emojiID) + // Call original data func. + rc, sz, err := data(ctx) if err != nil { - err = gtserror.Newf("error fetching emoji to refresh from the db: %w", err) - return nil, err + return nil, 0, err } - // Since this is a refresh, we will end up with - // new images stored for this emoji, so we should - // use an io.Closer callback to perform clean up - // of the original images from storage. - originalData := data - originalImagePath := emoji.ImagePath - originalImageStaticPath := emoji.ImageStaticPath - - data = func(ctx context.Context) (io.ReadCloser, int64, error) { - // Call original data func. - rc, sz, err := originalData(ctx) - if err != nil { - return nil, 0, err - } - - // Wrap closer to cleanup old data. - c := iotools.CloserCallback(rc, func() { - if err := m.state.Storage.Delete(ctx, originalImagePath); err != nil && !storage.IsNotFound(err) { - log.Errorf(ctx, "error removing old emoji %s@%s from storage: %v", emoji.Shortcode, emoji.Domain, err) - } + // Wrap closer to cleanup old data. + c := iotools.CloserFunc(func() error { - if err := m.state.Storage.Delete(ctx, originalImageStaticPath); err != nil && !storage.IsNotFound(err) { - log.Errorf(ctx, "error removing old static emoji %s@%s from storage: %v", emoji.Shortcode, emoji.Domain, err) - } - }) + // First try close original. + if rc.Close(); err != nil { + return err + } - // Return newly wrapped readcloser and size. - return iotools.ReadCloser(rc, c), sz, nil - } + // Remove any *old* emoji image file path now stream is closed. + if err := m.state.Storage.Delete(ctx, oldPath); err != nil && + !storage.IsNotFound(err) { + log.Errorf(ctx, "error deleting old emoji %s from storage: %v", shortcodeDomain, err) + } - // Reuse existing shortcode and URI - - // these don't change when we refresh. - emoji.Shortcode = shortcode - emoji.URI = uri + // Remove any *old* emoji static image file path now stream is closed. + if err := m.state.Storage.Delete(ctx, oldStaticPath); err != nil && + !storage.IsNotFound(err) { + log.Errorf(ctx, "error deleting old static emoji %s from storage: %v", shortcodeDomain, err) + } - // Use a new ID to create a new path - // for the new images, to get around - // needing to do cache invalidation. - newPathID, err = id.NewRandomULID() - if err != nil { - return nil, gtserror.Newf("error generating alternateID for emoji refresh: %s", err) - } + return nil + }) - emoji.ImageStaticURL = uris.URIForAttachment( - instanceAcc.ID, - string(TypeEmoji), - string(SizeStatic), - newPathID, - // All static emojis - // are encoded as png. - mimePng, - ) - - emoji.ImageStaticPath = uris.StoragePathForAttachment( - instanceAcc.ID, - string(TypeEmoji), - string(SizeStatic), - newPathID, - // All static emojis - // are encoded as png. - mimePng, - ) - } else { - // New emoji! - - imageStaticURL := uris.URIForAttachment( - instanceAcc.ID, - string(TypeEmoji), - string(SizeStatic), - emojiID, - // All static emojis - // are encoded as png. - mimePng, - ) - - imageStaticPath := uris.StoragePathForAttachment( - instanceAcc.ID, - string(TypeEmoji), - string(SizeStatic), - emojiID, - // All static emojis - // are encoded as png. - mimePng, - ) - - // Populate initial fields on the new emoji, - // leaving out fields with values we don't know - // yet. These will be overwritten as we go. - emoji = >smodel.Emoji{ - ID: emojiID, - CreatedAt: now, - UpdatedAt: now, - Shortcode: shortcode, - ImageStaticURL: imageStaticURL, - ImageStaticPath: imageStaticPath, - ImageStaticContentType: mimeImagePng, - ImageUpdatedAt: now, - Disabled: util.Ptr(false), - URI: uri, - VisibleInPicker: util.Ptr(true), - } + // Return newly wrapped readcloser and size. + return iotools.ReadCloser(rc, c), sz, nil } - // Check if we have additional info to add to the emoji, - // and overwrite some of the emoji fields if so. - if ai != nil { - if ai.CreatedAt != nil { - emoji.CreatedAt = *ai.CreatedAt - } - - if ai.Domain != nil { - emoji.Domain = *ai.Domain - } + // Use a new ID to create a new path + // for the new images, to get around + // needing to do cache invalidation. + newPathID, err := id.NewRandomULID() + if err != nil { + return nil, gtserror.Newf("error generating newPathID for emoji refresh: %s", err) + } - if ai.ImageRemoteURL != nil { - emoji.ImageRemoteURL = *ai.ImageRemoteURL - } + // Generate new static URL for emoji. + emoji.ImageStaticURL = uris.URIForAttachment( + instanceAcc.ID, + string(TypeEmoji), + string(SizeStatic), + newPathID, - if ai.ImageStaticRemoteURL != nil { - emoji.ImageStaticRemoteURL = *ai.ImageStaticRemoteURL - } + // All static emojis + // are encoded as png. + mimePng, + ) - if ai.Disabled != nil { - emoji.Disabled = ai.Disabled - } + // Generate new static image storage path for emoji. + emoji.ImageStaticPath = uris.StoragePathForAttachment( + instanceAcc.ID, + string(TypeEmoji), + string(SizeStatic), + newPathID, - if ai.VisibleInPicker != nil { - emoji.VisibleInPicker = ai.VisibleInPicker - } + // All static emojis + // are encoded as png. + mimePng, + ) - if ai.CategoryID != nil { - emoji.CategoryID = *ai.CategoryID - } + // Finally, create new emoji in database. + processingEmoji, err := m.createEmoji(ctx, + func(ctx context.Context, emoji *gtsmodel.Emoji) error { + return m.state.DB.UpdateEmoji(ctx, emoji) + }, + wrapped, + emoji, + info, + ) + if err != nil { + return nil, err } - processingEmoji := &ProcessingEmoji{ - emoji: emoji, - existing: refresh, - newPathID: newPathID, - dataFn: data, - mgr: m, - } + // Set the refreshed path ID used. + processingEmoji.newPathID = newPathID return processingEmoji, nil } -// PreProcessEmojiRecache refetches, reprocesses, and recaches -// an existing emoji that has been uncached via cleaner pruning. -// -// Note: unlike ProcessEmoji, this will NOT queue the emoji to -// be asychronously processed. -func (m *Manager) PreProcessEmojiRecache( +func (m *Manager) createEmoji( ctx context.Context, + putDB func(context.Context, *gtsmodel.Emoji) error, data DataFunc, - emojiID string, -) (*ProcessingEmoji, error) { - // Get the existing emoji from the database. - emoji, err := m.state.DB.GetEmojiByID(ctx, emojiID) - if err != nil { + emoji *gtsmodel.Emoji, + info AdditionalEmojiInfo, +) ( + *ProcessingEmoji, + error, +) { + // Check if we have additional info to add to the emoji, + // and overwrite some of the emoji fields if so. + if info.URI != nil { + emoji.URI = *info.URI + } + if info.CreatedAt != nil { + emoji.CreatedAt = *info.CreatedAt + } + if info.Domain != nil { + emoji.Domain = *info.Domain + } + if info.ImageRemoteURL != nil { + emoji.ImageRemoteURL = *info.ImageRemoteURL + } + if info.ImageStaticRemoteURL != nil { + emoji.ImageStaticRemoteURL = *info.ImageStaticRemoteURL + } + if info.Disabled != nil { + emoji.Disabled = info.Disabled + } + if info.VisibleInPicker != nil { + emoji.VisibleInPicker = info.VisibleInPicker + } + if info.CategoryID != nil { + emoji.CategoryID = *info.CategoryID + } + + // Store emoji in database in initial form. + if err := putDB(ctx, emoji); err != nil { return nil, err } + // Return wrapped emoji for later processing. processingEmoji := &ProcessingEmoji{ - emoji: emoji, - dataFn: data, - existing: true, // Indicate recache. - mgr: m, + emoji: emoji, + dataFn: data, + mgr: m, } return processingEmoji, nil } -// ProcessEmoji will call PreProcessEmoji, followed -// by queuing the emoji in the emoji worker queue. -func (m *Manager) ProcessEmoji( - ctx context.Context, +// RecacheEmoji wraps an emoji model (assumed already +// inserted in the database!) with given data function +// to perform a blocking dereference / decode operation +// from the data stream returned. +func (m *Manager) RecacheEmoji( + emoji *gtsmodel.Emoji, data DataFunc, - shortcode string, - id string, - uri string, - ai *AdditionalEmojiInfo, - refresh bool, -) (*ProcessingEmoji, error) { - // Create a new processing emoji object for this emoji request. - emoji, err := m.PreProcessEmoji(ctx, data, shortcode, id, uri, ai, refresh) - if err != nil { - return nil, err +) *ProcessingEmoji { + return &ProcessingEmoji{ + emoji: emoji, + dataFn: data, + mgr: m, } - - // Attempt to add emoji item to the worker queue. - m.state.Workers.Media.Queue.Push(emoji.Process) - - return emoji, nil } diff --git a/internal/media/manager_test.go b/internal/media/manager_test.go index d184e4605..53c08eed8 100644 --- a/internal/media/manager_test.go +++ b/internal/media/manager_test.go @@ -40,7 +40,7 @@ type ManagerTestSuite struct { MediaStandardTestSuite } -func (suite *ManagerTestSuite) TestEmojiProcessBlocking() { +func (suite *ManagerTestSuite) TestEmojiProcess() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -52,27 +52,26 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlocking() { return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil } - emojiID := "01GDQ9G782X42BAMFASKP64343" - emojiURI := "http://localhost:8080/emoji/01GDQ9G782X42BAMFASKP64343" - - processingEmoji, err := suite.manager.ProcessEmoji(ctx, data, "rainbow_test", emojiID, emojiURI, nil, false) + processing, err := suite.manager.CreateEmoji(ctx, + "rainbow_test", + "", + data, + media.AdditionalEmojiInfo{}, + ) suite.NoError(err) // do a blocking call to fetch the emoji - emoji, err := processingEmoji.LoadEmoji(ctx) + emoji, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(emoji) - // make sure it's got the stuff set on it that we expect - suite.Equal(emojiID, emoji.ID) - // file meta should be correctly derived from the image suite.Equal("image/png", emoji.ImageContentType) suite.Equal("image/png", emoji.ImageStaticContentType) suite.Equal(36702, emoji.ImageFileSize) // now make sure the emoji is in the database - dbEmoji, err := suite.db.GetEmojiByID(ctx, emojiID) + dbEmoji, err := suite.db.GetEmojiByID(ctx, emoji.ID) suite.NoError(err) suite.NotNil(dbEmoji) @@ -101,14 +100,15 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlocking() { suite.Equal(processedStaticBytesExpected, processedStaticBytes) } -func (suite *ManagerTestSuite) TestEmojiProcessBlockingRefresh() { +func (suite *ManagerTestSuite) TestEmojiProcessRefresh() { ctx := context.Background() // we're going to 'refresh' the remote 'yell' emoji by changing the image url to the pixellated gts logo originalEmoji := suite.testEmojis["yell"] - emojiToUpdate := >smodel.Emoji{} - *emojiToUpdate = *originalEmoji + emojiToUpdate, err := suite.db.GetEmojiByID(ctx, originalEmoji.ID) + suite.NoError(err) + newImageRemoteURL := "http://fossbros-anonymous.io/some/image/path.png" oldEmojiImagePath := emojiToUpdate.ImagePath @@ -122,23 +122,24 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlockingRefresh() { return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil } - emojiID := emojiToUpdate.ID - emojiURI := emojiToUpdate.URI - - processingEmoji, err := suite.manager.ProcessEmoji(ctx, data, "yell", emojiID, emojiURI, &media.AdditionalEmojiInfo{ - CreatedAt: &emojiToUpdate.CreatedAt, - Domain: &emojiToUpdate.Domain, - ImageRemoteURL: &newImageRemoteURL, - }, true) + processing, err := suite.manager.RefreshEmoji(ctx, + emojiToUpdate, + data, + media.AdditionalEmojiInfo{ + CreatedAt: &emojiToUpdate.CreatedAt, + Domain: &emojiToUpdate.Domain, + ImageRemoteURL: &newImageRemoteURL, + }, + ) suite.NoError(err) // do a blocking call to fetch the emoji - emoji, err := processingEmoji.LoadEmoji(ctx) + emoji, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(emoji) // make sure it's got the stuff set on it that we expect - suite.Equal(emojiID, emoji.ID) + suite.Equal(originalEmoji.ID, emoji.ID) // file meta should be correctly derived from the image suite.Equal("image/png", emoji.ImageContentType) @@ -146,7 +147,7 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlockingRefresh() { suite.Equal(10296, emoji.ImageFileSize) // now make sure the emoji is in the database - dbEmoji, err := suite.db.GetEmojiByID(ctx, emojiID) + dbEmoji, err := suite.db.GetEmojiByID(ctx, emoji.ID) suite.NoError(err) suite.NotNil(dbEmoji) @@ -185,7 +186,6 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlockingRefresh() { suite.NotEqual(originalEmoji.ImageStaticPath, dbEmoji.ImageStaticPath) suite.NotEqual(originalEmoji.ImageStaticPath, dbEmoji.ImageStaticPath) suite.NotEqual(originalEmoji.UpdatedAt, dbEmoji.UpdatedAt) - suite.NotEqual(originalEmoji.ImageUpdatedAt, dbEmoji.ImageUpdatedAt) // the old image files should no longer be in storage _, err = suite.storage.Get(ctx, oldEmojiImagePath) @@ -194,7 +194,7 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlockingRefresh() { suite.True(storage.IsNotFound(err)) } -func (suite *ManagerTestSuite) TestEmojiProcessBlockingTooLarge() { +func (suite *ManagerTestSuite) TestEmojiProcessTooLarge() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -206,19 +206,20 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlockingTooLarge() { return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil } - emojiID := "01GDQ9G782X42BAMFASKP64343" - emojiURI := "http://localhost:8080/emoji/01GDQ9G782X42BAMFASKP64343" - - processingEmoji, err := suite.manager.ProcessEmoji(ctx, data, "big_panda", emojiID, emojiURI, nil, false) + processing, err := suite.manager.CreateEmoji(ctx, + "big_panda", + "", + data, + media.AdditionalEmojiInfo{}, + ) suite.NoError(err) // do a blocking call to fetch the emoji - emoji, err := processingEmoji.LoadEmoji(ctx) + _, err = processing.Load(ctx) suite.EqualError(err, "store: given emoji size 630kiB greater than max allowed 50.0kiB") - suite.Nil(emoji) } -func (suite *ManagerTestSuite) TestEmojiProcessBlockingTooLargeNoSizeGiven() { +func (suite *ManagerTestSuite) TestEmojiProcessTooLargeNoSizeGiven() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -230,19 +231,20 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlockingTooLargeNoSizeGiven() { return io.NopCloser(bytes.NewBuffer(b)), -1, nil } - emojiID := "01GDQ9G782X42BAMFASKP64343" - emojiURI := "http://localhost:8080/emoji/01GDQ9G782X42BAMFASKP64343" - - processingEmoji, err := suite.manager.ProcessEmoji(ctx, data, "big_panda", emojiID, emojiURI, nil, false) + processing, err := suite.manager.CreateEmoji(ctx, + "big_panda", + "", + data, + media.AdditionalEmojiInfo{}, + ) suite.NoError(err) // do a blocking call to fetch the emoji - emoji, err := processingEmoji.LoadEmoji(ctx) - suite.EqualError(err, "store: calculated emoji size 630kiB greater than max allowed 50.0kiB") - suite.Nil(emoji) + _, err = processing.Load(ctx) + suite.EqualError(err, "store: written emoji size 630kiB greater than max allowed 50.0kiB") } -func (suite *ManagerTestSuite) TestEmojiProcessBlockingNoFileSizeGiven() { +func (suite *ManagerTestSuite) TestEmojiProcessNoFileSizeGiven() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -254,28 +256,27 @@ func (suite *ManagerTestSuite) TestEmojiProcessBlockingNoFileSizeGiven() { return io.NopCloser(bytes.NewBuffer(b)), -1, nil } - emojiID := "01GDQ9G782X42BAMFASKP64343" - emojiURI := "http://localhost:8080/emoji/01GDQ9G782X42BAMFASKP64343" - // process the media with no additional info provided - processingEmoji, err := suite.manager.ProcessEmoji(ctx, data, "rainbow_test", emojiID, emojiURI, nil, false) + processing, err := suite.manager.CreateEmoji(ctx, + "rainbow_test", + "", + data, + media.AdditionalEmojiInfo{}, + ) suite.NoError(err) // do a blocking call to fetch the emoji - emoji, err := processingEmoji.LoadEmoji(ctx) + emoji, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(emoji) - // make sure it's got the stuff set on it that we expect - suite.Equal(emojiID, emoji.ID) - // file meta should be correctly derived from the image suite.Equal("image/png", emoji.ImageContentType) suite.Equal("image/png", emoji.ImageStaticContentType) suite.Equal(36702, emoji.ImageFileSize) // now make sure the emoji is in the database - dbEmoji, err := suite.db.GetEmojiByID(ctx, emojiID) + dbEmoji, err := suite.db.GetEmojiByID(ctx, emoji.ID) suite.NoError(err) suite.NotNil(dbEmoji) @@ -316,27 +317,27 @@ func (suite *ManagerTestSuite) TestEmojiWebpProcess() { return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil } - emojiID := "01GDQ9G782X42BAMFASKP64343" - emojiURI := "http://localhost:8080/emoji/01GDQ9G782X42BAMFASKP64343" - - processingEmoji, err := suite.manager.ProcessEmoji(ctx, data, "nb-flag", emojiID, emojiURI, nil, false) + // process the media with no additional info provided + processing, err := suite.manager.CreateEmoji(ctx, + "nb-flag", + "", + data, + media.AdditionalEmojiInfo{}, + ) suite.NoError(err) // do a blocking call to fetch the emoji - emoji, err := processingEmoji.LoadEmoji(ctx) + emoji, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(emoji) - // make sure it's got the stuff set on it that we expect - suite.Equal(emojiID, emoji.ID) - // file meta should be correctly derived from the image suite.Equal("image/webp", emoji.ImageContentType) suite.Equal("image/png", emoji.ImageStaticContentType) suite.Equal(294, emoji.ImageFileSize) // now make sure the emoji is in the database - dbEmoji, err := suite.db.GetEmojiByID(ctx, emojiID) + dbEmoji, err := suite.db.GetEmojiByID(ctx, emoji.ID) suite.NoError(err) suite.NotNil(dbEmoji) @@ -365,7 +366,7 @@ func (suite *ManagerTestSuite) TestEmojiWebpProcess() { suite.Equal(processedStaticBytesExpected, processedStaticBytes) } -func (suite *ManagerTestSuite) TestSimpleJpegProcessBlocking() { +func (suite *ManagerTestSuite) TestSimpleJpegProcess() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -380,18 +381,22 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlocking() { accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) - // fetch the attachment id from the processing media - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // do a blocking call to fetch the attachment - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(attachment) // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) // file meta should be correctly derived from the image @@ -407,7 +412,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlocking() { suite.Equal("LiBzRk#6V[WF_NvzV@WY_3rqV@a$", attachment.Blurhash) // now make sure the attachment is in the database - dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) suite.NoError(err) suite.NotNil(dbAttachment) @@ -456,13 +461,16 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessPartial() { accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) - - // fetch the attachment id from the processing media - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // do a blocking call to fetch the attachment - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) // Since we're cutting off the byte stream // halfway through, we should get an error here. @@ -471,17 +479,16 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessPartial() { // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) // file meta should be correctly derived from the image suite.Zero(attachment.FileMeta) suite.Equal("image/jpeg", attachment.File.ContentType) - suite.Equal("image/jpeg", attachment.Thumbnail.ContentType) suite.Empty(attachment.Blurhash) // now make sure the attachment is in the database - dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) suite.NoError(err) suite.NotNil(dbAttachment) @@ -518,19 +525,22 @@ func (suite *ManagerTestSuite) TestPDFProcess() { accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) - - // fetch the attachment id from the processing media - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // do a blocking call to fetch the attachment - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(attachment) // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) // file meta should be correctly derived from the image @@ -540,7 +550,7 @@ func (suite *ManagerTestSuite) TestPDFProcess() { suite.Empty(attachment.Blurhash) // now make sure the attachment is in the database - dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) suite.NoError(err) suite.NotNil(dbAttachment) @@ -561,7 +571,7 @@ func (suite *ManagerTestSuite) TestPDFProcess() { suite.False(stored) } -func (suite *ManagerTestSuite) TestSlothVineProcessBlocking() { +func (suite *ManagerTestSuite) TestSlothVineProcess() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -576,18 +586,22 @@ func (suite *ManagerTestSuite) TestSlothVineProcessBlocking() { accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) - // fetch the attachment id from the processing media - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // do a blocking call to fetch the attachment - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(attachment) // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) // file meta should be correctly derived from the video @@ -607,7 +621,7 @@ func (suite *ManagerTestSuite) TestSlothVineProcessBlocking() { suite.Equal("L00000fQfQfQfQfQfQfQfQfQfQfQ", attachment.Blurhash) // now make sure the attachment is in the database - dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) suite.NoError(err) suite.NotNil(dbAttachment) @@ -636,7 +650,7 @@ func (suite *ManagerTestSuite) TestSlothVineProcessBlocking() { suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes) } -func (suite *ManagerTestSuite) TestLongerMp4ProcessBlocking() { +func (suite *ManagerTestSuite) TestLongerMp4Process() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -651,18 +665,22 @@ func (suite *ManagerTestSuite) TestLongerMp4ProcessBlocking() { accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) - // fetch the attachment id from the processing media - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // do a blocking call to fetch the attachment - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(attachment) // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) // file meta should be correctly derived from the video @@ -682,7 +700,7 @@ func (suite *ManagerTestSuite) TestLongerMp4ProcessBlocking() { suite.Equal("L00000fQfQfQfQfQfQfQfQfQfQfQ", attachment.Blurhash) // now make sure the attachment is in the database - dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) suite.NoError(err) suite.NotNil(dbAttachment) @@ -711,7 +729,7 @@ func (suite *ManagerTestSuite) TestLongerMp4ProcessBlocking() { suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes) } -func (suite *ManagerTestSuite) TestBirdnestMp4ProcessBlocking() { +func (suite *ManagerTestSuite) TestBirdnestMp4Process() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -726,18 +744,22 @@ func (suite *ManagerTestSuite) TestBirdnestMp4ProcessBlocking() { accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) - // fetch the attachment id from the processing media - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // do a blocking call to fetch the attachment - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(attachment) // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) // file meta should be correctly derived from the video @@ -757,7 +779,7 @@ func (suite *ManagerTestSuite) TestBirdnestMp4ProcessBlocking() { suite.Equal("L00000fQfQfQfQfQfQfQfQfQfQfQ", attachment.Blurhash) // now make sure the attachment is in the database - dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) suite.NoError(err) suite.NotNil(dbAttachment) @@ -786,7 +808,7 @@ func (suite *ManagerTestSuite) TestBirdnestMp4ProcessBlocking() { suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes) } -func (suite *ManagerTestSuite) TestNotAnMp4ProcessBlocking() { +func (suite *ManagerTestSuite) TestNotAnMp4Process() { // try to load an 'mp4' that's actually an mkv in disguise ctx := context.Background() @@ -803,10 +825,16 @@ func (suite *ManagerTestSuite) TestNotAnMp4ProcessBlocking() { accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // pre processing should go fine but... - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // we should get an error while loading - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) suite.EqualError(err, "finish: error decoding video: error determining video metadata: [width height framerate]") // partial attachment should be @@ -815,7 +843,7 @@ func (suite *ManagerTestSuite) TestNotAnMp4ProcessBlocking() { suite.Equal(gtsmodel.FileTypeUnknown, attachment.Type) } -func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingNoContentLengthGiven() { +func (suite *ManagerTestSuite) TestSimpleJpegProcessNoContentLengthGiven() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -831,18 +859,22 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingNoContentLengthGiven accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) - // fetch the attachment id from the processing media - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // do a blocking call to fetch the attachment - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(attachment) // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) // file meta should be correctly derived from the image @@ -858,7 +890,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingNoContentLengthGiven suite.Equal("LiBzRk#6V[WF_NvzV@WY_3rqV@a$", attachment.Blurhash) // now make sure the attachment is in the database - dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) suite.NoError(err) suite.NotNil(dbAttachment) @@ -887,7 +919,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingNoContentLengthGiven suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes) } -func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingReadCloser() { +func (suite *ManagerTestSuite) TestSimpleJpegProcessReadCloser() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -903,18 +935,22 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingReadCloser() { accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) - // fetch the attachment id from the processing media - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // do a blocking call to fetch the attachment - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(attachment) // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) // file meta should be correctly derived from the image @@ -930,7 +966,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingReadCloser() { suite.Equal("LiBzRk#6V[WF_NvzV@WY_3rqV@a$", attachment.Blurhash) // now make sure the attachment is in the database - dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) suite.NoError(err) suite.NotNil(dbAttachment) @@ -959,7 +995,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingReadCloser() { suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes) } -func (suite *ManagerTestSuite) TestPngNoAlphaChannelProcessBlocking() { +func (suite *ManagerTestSuite) TestPngNoAlphaChannelProcess() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -974,18 +1010,22 @@ func (suite *ManagerTestSuite) TestPngNoAlphaChannelProcessBlocking() { accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) - // fetch the attachment id from the processing media - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // do a blocking call to fetch the attachment - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(attachment) // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) // file meta should be correctly derived from the image @@ -1001,7 +1041,7 @@ func (suite *ManagerTestSuite) TestPngNoAlphaChannelProcessBlocking() { suite.Equal("LFQT7e.A%O%4?co$M}M{_1W9~TxV", attachment.Blurhash) // now make sure the attachment is in the database - dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) suite.NoError(err) suite.NotNil(dbAttachment) @@ -1030,7 +1070,7 @@ func (suite *ManagerTestSuite) TestPngNoAlphaChannelProcessBlocking() { suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes) } -func (suite *ManagerTestSuite) TestPngAlphaChannelProcessBlocking() { +func (suite *ManagerTestSuite) TestPngAlphaChannelProcess() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -1045,18 +1085,22 @@ func (suite *ManagerTestSuite) TestPngAlphaChannelProcessBlocking() { accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) - // fetch the attachment id from the processing media - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // do a blocking call to fetch the attachment - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(attachment) // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) // file meta should be correctly derived from the image @@ -1072,7 +1116,7 @@ func (suite *ManagerTestSuite) TestPngAlphaChannelProcessBlocking() { suite.Equal("LFQT7e.A%O%4?co$M}M{_1W9~TxV", attachment.Blurhash) // now make sure the attachment is in the database - dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) suite.NoError(err) suite.NotNil(dbAttachment) @@ -1101,7 +1145,7 @@ func (suite *ManagerTestSuite) TestPngAlphaChannelProcessBlocking() { suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes) } -func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingWithCallback() { +func (suite *ManagerTestSuite) TestSimpleJpegProcessWithCallback() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -1116,18 +1160,22 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingWithCallback() { accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) - // fetch the attachment id from the processing media - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // do a blocking call to fetch the attachment - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(attachment) // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) // file meta should be correctly derived from the image @@ -1143,7 +1191,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingWithCallback() { suite.Equal("LiBzRk#6V[WF_NvzV@WY_3rqV@a$", attachment.Blurhash) // now make sure the attachment is in the database - dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) suite.NoError(err) suite.NotNil(dbAttachment) @@ -1172,7 +1220,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingWithCallback() { suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes) } -func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingWithDiskStorage() { +func (suite *ManagerTestSuite) TestSimpleJpegProcessWithDiskStorage() { ctx := context.Background() data := func(_ context.Context) (io.ReadCloser, int64, error) { @@ -1209,18 +1257,22 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingWithDiskStorage() { suite.manager = diskManager // process the media with no additional info provided - processingMedia := diskManager.PreProcessMedia(data, accountID, nil) - // fetch the attachment id from the processing media - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) // do a blocking call to fetch the attachment - attachment, err := processingMedia.LoadAttachment(ctx) + attachment, err := processing.Load(ctx) suite.NoError(err) suite.NotNil(attachment) // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) // file meta should be correctly derived from the image @@ -1236,7 +1288,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingWithDiskStorage() { suite.Equal("LiBzRk#6V[WF_NvzV@WY_3rqV@a$", attachment.Blurhash) // now make sure the attachment is in the database - dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) suite.NoError(err) suite.NotNil(dbAttachment) @@ -1307,22 +1359,27 @@ func (suite *ManagerTestSuite) TestSmallSizedMediaTypeDetection_issue2263() { accountID := "01FS1X72SK9ZPW0J1QQ68BD264" // process the media with no additional info provided - processingMedia := suite.manager.PreProcessMedia(data, accountID, nil) - if _, err := processingMedia.LoadAttachment(ctx); err != nil { - suite.FailNow(err.Error()) - } - - attachmentID := processingMedia.AttachmentID() + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) + + // Load the attachment (but ignore return). + _, err = processing.Load(ctx) + suite.NoError(err) // fetch the attachment id from the processing media - attachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + attachment, err := suite.db.GetAttachmentByID(ctx, processing.ID()) if err != nil { suite.FailNow(err.Error()) } // make sure it's got the stuff set on it that we expect // the attachment ID and accountID we expect - suite.Equal(attachmentID, attachment.ID) + suite.Equal(processing.ID(), attachment.ID) suite.Equal(accountID, attachment.AccountID) actual := attachment.File.ContentType @@ -1350,13 +1407,21 @@ func (suite *ManagerTestSuite) TestMisreportedSmallMedia() { return io.NopCloser(bytes.NewBuffer(b)), int64(2 * actualSize), nil } - // Process the media with no additional info provided. - attachment, err := suite.manager. - PreProcessMedia(data, accountID, nil). - LoadAttachment(context.Background()) - if err != nil { - suite.FailNow(err.Error()) - } + ctx := context.Background() + + // process the media with no additional info provided + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) + + // do a blocking call to fetch the attachment + attachment, err := processing.Load(ctx) + suite.NoError(err) + suite.NotNil(attachment) suite.Equal(actualSize, attachment.File.FileSize) } @@ -1378,13 +1443,21 @@ func (suite *ManagerTestSuite) TestNoReportedSizeSmallMedia() { return io.NopCloser(bytes.NewBuffer(b)), 0, nil } - // Process the media with no additional info provided. - attachment, err := suite.manager. - PreProcessMedia(data, accountID, nil). - LoadAttachment(context.Background()) - if err != nil { - suite.FailNow(err.Error()) - } + ctx := context.Background() + + // process the media with no additional info provided + processing, err := suite.manager.CreateMedia(ctx, + accountID, + data, + media.AdditionalMediaInfo{}, + ) + suite.NoError(err) + suite.NotNil(processing) + + // do a blocking call to fetch the attachment + attachment, err := processing.Load(ctx) + suite.NoError(err) + suite.NotNil(attachment) suite.Equal(actualSize, attachment.File.FileSize) } diff --git a/internal/media/processingemoji.go b/internal/media/processingemoji.go index b62c4f76e..d61043523 100644 --- a/internal/media/processingemoji.go +++ b/internal/media/processingemoji.go @@ -24,14 +24,16 @@ import ( "slices" "codeberg.org/gruf/go-bytesize" - "codeberg.org/gruf/go-errors/v2" + errorsv2 "codeberg.org/gruf/go-errors/v2" "codeberg.org/gruf/go-runners" "github.com/h2non/filetype" "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/regexes" + "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/uris" "github.com/superseriousbusiness/gotosocial/internal/util" ) @@ -40,7 +42,6 @@ import ( // various functions for retrieving data from the process. type ProcessingEmoji struct { emoji *gtsmodel.Emoji // processing emoji details - existing bool // indicates whether this is an existing emoji ID being refreshed / recached newPathID string // new emoji path ID to use when being refreshed dataFn DataFunc // load-data function, returns media stream done bool // done is set when process finishes with non ctx canceled type error @@ -49,61 +50,72 @@ type ProcessingEmoji struct { mgr *Manager // mgr instance (access to db / storage) } -// EmojiID returns the ID of the underlying emoji without blocking processing. -func (p *ProcessingEmoji) EmojiID() string { +// ID returns the ID of the underlying emoji. +func (p *ProcessingEmoji) ID() string { return p.emoji.ID // immutable, safe outside mutex. } // LoadEmoji blocks until the static and fullsize image has been processed, and then returns the completed emoji. -func (p *ProcessingEmoji) LoadEmoji(ctx context.Context) (*gtsmodel.Emoji, error) { - // Attempt to load synchronously. +func (p *ProcessingEmoji) Load(ctx context.Context) (*gtsmodel.Emoji, error) { emoji, done, err := p.load(ctx) - if err == nil { - // No issue, return media. - return emoji, nil - } - if !done { - // Provided context was cancelled, e.g. request cancelled - // early. Queue this item for asynchronous processing. - log.Warnf(ctx, "reprocessing emoji %s after canceled ctx", p.emoji.ID) - p.mgr.state.Workers.Media.Queue.Push(p.Process) - } - - return nil, err -} - -// Process allows the receiving object to fit the runners.WorkerFunc signature. It performs a (blocking) load and logs on error. -func (p *ProcessingEmoji) Process(ctx context.Context) { - if _, _, err := p.load(ctx); err != nil { - log.Errorf(ctx, "error processing emoji: %v", err) + // On a context-canceled error (marked as !done), requeue for loading. + p.mgr.state.Workers.Dereference.Queue.Push(func(ctx context.Context) { + if _, _, err := p.load(ctx); err != nil { + log.Errorf(ctx, "error loading emoji: %v", err) + } + }) } + return emoji, err } -// load performs a concurrency-safe load of ProcessingEmoji, only marking itself as complete when returned error is NOT a context cancel. -func (p *ProcessingEmoji) load(ctx context.Context) (*gtsmodel.Emoji, bool, error) { - var ( - done bool - err error - ) - +// load is the package private form of load() that is wrapped to catch context canceled. +func (p *ProcessingEmoji) load(ctx context.Context) ( + emoji *gtsmodel.Emoji, + done bool, + err error, +) { err = p.proc.Process(func() error { - if p.done { + if done = p.done; done { // Already proc'd. return p.err } defer func() { // This is only done when ctx NOT cancelled. - done = err == nil || !errors.IsV2(err, + done = (err == nil || !errorsv2.IsV2(err, context.Canceled, context.DeadlineExceeded, + )) + + if !done { + return + } + + // Anything from here, we + // need to ensure happens + // (i.e. no ctx canceled). + ctx = gtscontext.WithValues( + context.Background(), + ctx, // values ) + // On error, clean + // downloaded files. + if err != nil { + p.cleanup(ctx) + } + if !done { return } + // Update with latest details, whatever happened. + e := p.mgr.state.DB.UpdateEmoji(ctx, p.emoji) + if e != nil { + log.Errorf(ctx, "error updating emoji in db: %v", e) + } + // Store final values. p.done = true p.err = err @@ -111,39 +123,31 @@ func (p *ProcessingEmoji) load(ctx context.Context) (*gtsmodel.Emoji, bool, erro // Attempt to store media and calculate // full-size media attachment details. + // + // This will update p.emoji as it goes. if err = p.store(ctx); err != nil { return err } // Finish processing by reloading media into // memory to get dimension and generate a thumb. + // + // This will update p.emoji as it goes. if err = p.finish(ctx); err != nil { - return err - } - - if p.existing { - // Existing emoji we're updating, so only update. - err = p.mgr.state.DB.UpdateEmoji(ctx, p.emoji) - return err + return err //nolint:revive } - // New emoji media, first time caching. - err = p.mgr.state.DB.PutEmoji(ctx, p.emoji) - return err + return nil }) - - if err != nil { - return nil, done, err - } - - return p.emoji, done, nil + emoji = p.emoji + return } // store calls the data function attached to p if it hasn't been called yet, // and updates the underlying attachment fields as necessary. It will then stream // bytes from p's reader directly into storage so that it can be retrieved later. func (p *ProcessingEmoji) store(ctx context.Context) error { - // Load media from provided data fn. + // Load media from provided data fun rc, sz, err := p.dataFn(ctx) if err != nil { return gtserror.Newf("error executing data function: %w", err) @@ -168,8 +172,9 @@ func (p *ProcessingEmoji) store(ctx context.Context) error { // Check that provided size isn't beyond max. We check beforehand // so that we don't attempt to stream the emoji into storage if not needed. - if size := bytesize.Size(sz); sz > 0 && size > maxSize { - return gtserror.Newf("given emoji size %s greater than max allowed %s", size, maxSize) + if sz > 0 && sz > int64(maxSize) { + sz := bytesize.Size(sz) // improves log readability + return gtserror.Newf("given emoji size %s greater than max allowed %s", sz, maxSize) } // Prepare to read bytes from @@ -196,14 +201,14 @@ func (p *ProcessingEmoji) store(ctx context.Context) error { // Initial file size was misreported, so we didn't read // fully into hdrBuf. Reslice it to the size we did read. - log.Warnf(ctx, - "recovered from misreported file size; reported %d; read %d", - fileSize, n, - ) hdrBuf = hdrBuf[:n] + fileSize = n + p.emoji.ImageFileSize = fileSize } // Parse file type info from header buffer. + // This should only ever error if the buffer + // is empty (ie., the attachment is 0 bytes). info, err := filetype.Match(hdrBuf) if err != nil { return gtserror.Newf("error parsing file type: %w", err) @@ -227,10 +232,13 @@ func (p *ProcessingEmoji) store(ctx context.Context) error { pathID = p.emoji.ID } - // Determine instance account ID from already generated image static path. - instanceAccID := regexes.FilePath.FindStringSubmatch(p.emoji.ImageStaticPath)[1] + // Determine instance account ID from generated image static path. + instanceAccID, ok := getInstanceAccountID(p.emoji.ImageStaticPath) + if !ok { + return gtserror.Newf("invalid emoji static path; no instance account id: %s", p.emoji.ImageStaticPath) + } - // Calculate emoji file path. + // Calculate final media attachment file path. p.emoji.ImagePath = uris.StoragePathForAttachment( instanceAccID, string(TypeEmoji), @@ -239,32 +247,32 @@ func (p *ProcessingEmoji) store(ctx context.Context) error { info.Extension, ) - // This shouldn't already exist, but we do a check as it's worth logging. + // File shouldn't already exist in storage at this point, + // but we do a check as it's worth logging / cleaning up. if have, _ := p.mgr.state.Storage.Has(ctx, p.emoji.ImagePath); have { - log.Warnf(ctx, "emoji already exists at storage path: %s", p.emoji.ImagePath) + log.Warnf(ctx, "emoji already exists at: %s", p.emoji.ImagePath) // Attempt to remove existing emoji at storage path (might be broken / out-of-date) if err := p.mgr.state.Storage.Delete(ctx, p.emoji.ImagePath); err != nil { - return gtserror.Newf("error removing emoji from storage: %v", err) + return gtserror.Newf("error removing emoji %s from storage: %v", p.emoji.ImagePath, err) } } // Write the final image reader stream to our storage. - wroteSize, err := p.mgr.state.Storage.PutStream(ctx, p.emoji.ImagePath, r) + sz, err = p.mgr.state.Storage.PutStream(ctx, p.emoji.ImagePath, r) if err != nil { return gtserror.Newf("error writing emoji to storage: %w", err) } - // Once again check size in case none was provided previously. - if size := bytesize.Size(wroteSize); size > maxSize { - if err := p.mgr.state.Storage.Delete(ctx, p.emoji.ImagePath); err != nil { - log.Errorf(ctx, "error removing too-large-emoji from storage: %v", err) - } - - return gtserror.Newf("calculated emoji size %s greater than max allowed %s", size, maxSize) + // Perform final size check in case none was + // given previously, or size was mis-reported. + // (error here will later perform p.cleanup()). + if sz > int64(maxSize) { + sz := bytesize.Size(sz) // improves log readability + return gtserror.Newf("written emoji size %s greater than max allowed %s", sz, maxSize) } - // Fill in remaining attachment data now it's stored. + // Fill in remaining emoji data now it's stored. p.emoji.ImageURL = uris.URIForAttachment( instanceAccID, string(TypeEmoji), @@ -273,14 +281,14 @@ func (p *ProcessingEmoji) store(ctx context.Context) error { info.Extension, ) p.emoji.ImageContentType = info.MIME.Value - p.emoji.ImageFileSize = int(wroteSize) + p.emoji.ImageFileSize = int(sz) p.emoji.Cached = util.Ptr(true) return nil } func (p *ProcessingEmoji) finish(ctx context.Context) error { - // Fetch a stream to the original file in storage. + // Get a stream to the original file for further processing. rc, err := p.mgr.state.Storage.GetStream(ctx, p.emoji.ImagePath) if err != nil { return gtserror.Newf("error loading file from storage: %w", err) @@ -293,32 +301,69 @@ func (p *ProcessingEmoji) finish(ctx context.Context) error { return gtserror.Newf("error decoding image: %w", err) } - // The image should be in-memory by now. + // staticImg should be in-memory by + // now so we're done with storage. if err := rc.Close(); err != nil { return gtserror.Newf("error closing file: %w", err) } - // This shouldn't already exist, but we do a check as it's worth logging. + // Static img shouldn't exist in storage at this point, + // but we do a check as it's worth logging / cleaning up. if have, _ := p.mgr.state.Storage.Has(ctx, p.emoji.ImageStaticPath); have { - log.Warnf(ctx, "static emoji already exists at storage path: %s", p.emoji.ImagePath) + log.Warnf(ctx, "static emoji already exists at: %s", p.emoji.ImageStaticPath) - // Attempt to remove static existing emoji at storage path (might be broken / out-of-date) + // Attempt to remove existing thumbnail (might be broken / out-of-date). if err := p.mgr.state.Storage.Delete(ctx, p.emoji.ImageStaticPath); err != nil { - return gtserror.Newf("error removing static emoji from storage: %v", err) + return gtserror.Newf("error removing static emoji %s from storage: %v", p.emoji.ImageStaticPath, err) } } - // Create an emoji PNG encoder stream. + // Create emoji PNG encoder stream. enc := staticImg.ToPNG() - // Stream-encode the PNG static image into storage. + // Stream-encode the PNG static emoji image into our storage driver. sz, err := p.mgr.state.Storage.PutStream(ctx, p.emoji.ImageStaticPath, enc) if err != nil { return gtserror.Newf("error stream-encoding static emoji to storage: %w", err) } - // Set written image size. + // Set final written thumb size. p.emoji.ImageStaticFileSize = int(sz) return nil } + +// cleanup will remove any traces of processing emoji from storage, +// and perform any other necessary cleanup steps after failure. +func (p *ProcessingEmoji) cleanup(ctx context.Context) { + var err error + + if p.emoji.ImagePath != "" { + // Ensure emoji file at path is deleted from storage. + err = p.mgr.state.Storage.Delete(ctx, p.emoji.ImagePath) + if err != nil && !storage.IsNotFound(err) { + log.Errorf(ctx, "error deleting %s: %v", p.emoji.ImagePath, err) + } + } + + if p.emoji.ImageStaticPath != "" { + // Ensure emoji static file at path is deleted from storage. + err = p.mgr.state.Storage.Delete(ctx, p.emoji.ImageStaticPath) + if err != nil && !storage.IsNotFound(err) { + log.Errorf(ctx, "error deleting %s: %v", p.emoji.ImageStaticPath, err) + } + } + + // Ensure marked as not cached. + p.emoji.Cached = util.Ptr(false) +} + +// getInstanceAccountID determines the instance account ID from +// emoji static image storage path. returns false on failure. +func getInstanceAccountID(staticPath string) (string, bool) { + matches := regexes.FilePath.FindStringSubmatch(staticPath) + if len(matches) < 2 { + return "", false + } + return matches[1], true +} diff --git a/internal/media/processingmedia.go b/internal/media/processingmedia.go index b65e3cd48..466c3443f 100644 --- a/internal/media/processingmedia.go +++ b/internal/media/processingmedia.go @@ -19,6 +19,7 @@ package media import ( "bytes" + "cmp" "context" "image/jpeg" "io" @@ -29,6 +30,7 @@ import ( terminator "codeberg.org/superseriousbusiness/exif-terminator" "github.com/disintegration/imaging" "github.com/h2non/filetype" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" @@ -41,18 +43,16 @@ import ( // currently being processed. It exposes functions // for retrieving data from the process. type ProcessingMedia struct { - media *gtsmodel.MediaAttachment // processing media attachment details - dataFn DataFunc // load-data function, returns media stream - recache bool // recaching existing (uncached) media - done bool // done is set when process finishes with non ctx canceled type error - proc runners.Processor // proc helps synchronize only a singular running processing instance - err error // error stores permanent error value when done - mgr *Manager // mgr instance (access to db / storage) + media *gtsmodel.MediaAttachment // processing media attachment details + dataFn DataFunc // load-data function, returns media stream + done bool // done is set when process finishes with non ctx canceled type error + proc runners.Processor // proc helps synchronize only a singular running processing instance + err error // error stores permanent error value when done + mgr *Manager // mgr instance (access to db / storage) } -// AttachmentID returns the ID of the underlying -// media attachment without blocking processing. -func (p *ProcessingMedia) AttachmentID() string { +// ID returns the ID of the underlying media. +func (p *ProcessingMedia) ID() string { return p.media.ID // immutable, safe outside mutex. } @@ -65,124 +65,102 @@ func (p *ProcessingMedia) AttachmentID() string { // will still be returned in that case, but it will // only be partially complete and should be treated // as a placeholder. -func (p *ProcessingMedia) LoadAttachment(ctx context.Context) (*gtsmodel.MediaAttachment, error) { - // Attempt to load synchronously. +func (p *ProcessingMedia) Load(ctx context.Context) (*gtsmodel.MediaAttachment, error) { media, done, err := p.load(ctx) - if err == nil { - // No issue, return media. - return media, nil - } - if !done { - // Provided context was cancelled, - // e.g. request aborted early before - // its context could be used to finish - // loading the attachment. Enqueue for - // asynchronous processing, which will - // use a background context. - log.Warnf(ctx, "reprocessing media %s after canceled ctx", p.media.ID) - p.mgr.state.Workers.Media.Queue.Push(p.Process) + // On a context-canceled error (marked as !done), requeue for loading. + p.mgr.state.Workers.Dereference.Queue.Push(func(ctx context.Context) { + if _, _, err := p.load(ctx); err != nil { + log.Errorf(ctx, "error loading media: %v", err) + } + }) } - - // Media could not be retrieved FULLY, - // but partial attachment should be present. return media, err } -// Process allows the receiving object to fit the -// runners.WorkerFunc signature. It performs a -// (blocking) load and logs on error. -func (p *ProcessingMedia) Process(ctx context.Context) { - if _, _, err := p.load(ctx); err != nil { - log.Errorf(ctx, "error(s) processing media: %v", err) - } -} - -// load performs a concurrency-safe load of ProcessingMedia, only -// marking itself as complete when returned error is NOT a context cancel. -func (p *ProcessingMedia) load(ctx context.Context) (*gtsmodel.MediaAttachment, bool, error) { - var ( - done bool - err error - ) - +// load is the package private form of load() that is wrapped to catch context canceled. +func (p *ProcessingMedia) load(ctx context.Context) ( + media *gtsmodel.MediaAttachment, + done bool, + err error, +) { err = p.proc.Process(func() error { - if p.done { + if done = p.done; done { // Already proc'd. return p.err } defer func() { // This is only done when ctx NOT cancelled. - done = err == nil || !errorsv2.IsV2(err, + done = (err == nil || !errorsv2.IsV2(err, context.Canceled, context.DeadlineExceeded, - ) + )) if !done { return } + // Anything from here, we + // need to ensure happens + // (i.e. no ctx canceled). + ctx = gtscontext.WithValues( + context.Background(), + ctx, // values + ) + + // On error or unknown media types, perform error cleanup. + if err != nil || p.media.Type == gtsmodel.FileTypeUnknown { + p.cleanup(ctx) + } + + // Update with latest details, whatever happened. + e := p.mgr.state.DB.UpdateAttachment(ctx, p.media) + if e != nil { + log.Errorf(ctx, "error updating media in db: %v", e) + } + // Store final values. p.done = true p.err = err }() - // Gather errors as we proceed. - var errs = gtserror.NewMultiError(4) + // TODO: in time update this + // to perhaps follow a similar + // freshness window to statuses + // / accounts? But that's a big + // maybe, media don't change in + // the same way so this is largely + // just to slow down fail retries. + const maxfreq = 6 * time.Hour + + // Check whether media is uncached but repeatedly failing, + // specifically limit the frequency at which we allow this. + if !p.media.UpdatedAt.Equal(p.media.CreatedAt) && // i.e. not new + p.media.UpdatedAt.Add(maxfreq).Before(time.Now()) { + return nil + } // Attempt to store media and calculate // full-size media attachment details. // // This will update p.media as it goes. - storeErr := p.store(ctx) - if storeErr != nil { - errs.Append(storeErr) + if err = p.store(ctx); err != nil { + return err } // Finish processing by reloading media into // memory to get dimension and generate a thumb. // // This will update p.media as it goes. - if finishErr := p.finish(ctx); finishErr != nil { - errs.Append(finishErr) - } - - // If this isn't a file we were able to process, - // we may have partially stored it (eg., it's a - // jpeg, which is fine, but streaming it to storage - // was interrupted halfway through and so it was - // never decoded). Try to clean up in this case. - if p.media.Type == gtsmodel.FileTypeUnknown { - deleteErr := p.mgr.state.Storage.Delete(ctx, p.media.File.Path) - if deleteErr != nil && !storage.IsNotFound(deleteErr) { - errs.Append(deleteErr) - } - } - - var dbErr error - switch { - case !p.recache: - // First time caching this attachment, insert it. - dbErr = p.mgr.state.DB.PutAttachment(ctx, p.media) - - case p.recache && len(errs) == 0: - // Existing attachment we're recaching, update it. - // - // (We only want to update if everything went OK so far, - // otherwise we'd better leave previous version alone.) - dbErr = p.mgr.state.DB.UpdateAttachment(ctx, p.media) + if err = p.finish(ctx); err != nil { + return err //nolint:revive } - if dbErr != nil { - errs.Append(dbErr) - } - - err = errs.Combine() - return err + return nil }) - - return p.media, done, err + media = p.media + return } // store calls the data function attached to p if it hasn't been called yet, @@ -231,10 +209,6 @@ func (p *ProcessingMedia) store(ctx context.Context) error { // Initial file size was misreported, so we didn't read // fully into hdrBuf. Reslice it to the size we did read. - log.Warnf(ctx, - "recovered from misreported file size; reported %d; read %d", - fileSize, n, - ) hdrBuf = hdrBuf[:n] fileSize = n p.media.File.FileSize = fileSize @@ -273,20 +247,13 @@ func (p *ProcessingMedia) store(ctx context.Context) error { } default: - // The file is not a supported format that - // we can process, so we can't do much with it. - log.Warnf(ctx, - "media extension '%s' not officially supported, will be processed as "+ - "type '%s' with minimal metadata, and will not be cached locally", - info.Extension, gtsmodel.FileTypeUnknown, - ) - - // Don't bother storing this. + // The file is not a supported format that we can process, so we can't do much with it. + log.Warnf(ctx, "unsupported media extension '%s'; not caching locally", info.Extension) store = false } // Fill in correct attachment - // data now we're parsed it. + // data now we've parsed it. p.media.URL = uris.URIForAttachment( p.media.AccountID, string(TypeAttachment), @@ -295,15 +262,11 @@ func (p *ProcessingMedia) store(ctx context.Context) error { info.Extension, ) - // Prefer discovered mime type, fall back to - // generic "this contains some bytes" type. - mime := info.MIME.Value - if mime == "" { - mime = "application/octet-stream" - } + // Prefer discovered MIME, fallback to generic data stream. + mime := cmp.Or(info.MIME.Value, "application/octet-stream") p.media.File.ContentType = mime - // Calculate attachment file path. + // Calculate final media attachment file path. p.media.File.Path = uris.StoragePathForAttachment( p.media.AccountID, string(TypeAttachment), @@ -323,23 +286,23 @@ func (p *ProcessingMedia) store(ctx context.Context) error { // File shouldn't already exist in storage at this point, // but we do a check as it's worth logging / cleaning up. if have, _ := p.mgr.state.Storage.Has(ctx, p.media.File.Path); have { - log.Warnf(ctx, "media already exists at storage path: %s", p.media.File.Path) + log.Warnf(ctx, "media already exists at: %s", p.media.File.Path) // Attempt to remove existing media at storage path (might be broken / out-of-date) if err := p.mgr.state.Storage.Delete(ctx, p.media.File.Path); err != nil { - return gtserror.Newf("error removing media from storage: %v", err) + return gtserror.Newf("error removing media %s from storage: %v", p.media.File.Path, err) } } - // Write the final reader stream to our storage. - wroteSize, err := p.mgr.state.Storage.PutStream(ctx, p.media.File.Path, r) + // Write the final reader stream to our storage driver. + sz, err = p.mgr.state.Storage.PutStream(ctx, p.media.File.Path, r) if err != nil { return gtserror.Newf("error writing media to storage: %w", err) } // Set actual written size // as authoritative file size. - p.media.File.FileSize = int(wroteSize) + p.media.File.FileSize = int(sz) // We can now consider this cached. p.media.Cached = util.Ptr(true) @@ -348,36 +311,9 @@ func (p *ProcessingMedia) store(ctx context.Context) error { } func (p *ProcessingMedia) finish(ctx context.Context) error { - // Make a jolly assumption about thumbnail type. - p.media.Thumbnail.ContentType = mimeImageJpeg - - // Calculate attachment thumbnail file path - p.media.Thumbnail.Path = uris.StoragePathForAttachment( - p.media.AccountID, - string(TypeAttachment), - string(SizeSmall), - p.media.ID, - // Always encode attachment - // thumbnails as jpg. - "jpg", - ) - - // Calculate attachment thumbnail serve path. - p.media.Thumbnail.URL = uris.URIForAttachment( - p.media.AccountID, - string(TypeAttachment), - string(SizeSmall), - p.media.ID, - // Always encode attachment - // thumbnails as jpg. - "jpg", - ) - - // If original file hasn't been stored, there's - // likely something wrong with the data, or we - // don't want to store it. Skip everything else. + // Nothing else to do if + // media was not cached. if !*p.media.Cached { - p.media.Processing = gtsmodel.ProcessingStatusProcessed return nil } @@ -398,8 +334,7 @@ func (p *ProcessingMedia) finish(ctx context.Context) error { // .jpeg, .gif, .webp image type case mimeImageJpeg, mimeImageGif, mimeImageWebp: - fullImg, err = decodeImage( - rc, + fullImg, err = decodeImage(rc, imaging.AutoOrientation(true), ) if err != nil { @@ -451,9 +386,9 @@ func (p *ProcessingMedia) finish(ctx context.Context) error { } // Set full-size dimensions in attachment info. - p.media.FileMeta.Original.Width = int(fullImg.Width()) - p.media.FileMeta.Original.Height = int(fullImg.Height()) - p.media.FileMeta.Original.Size = int(fullImg.Size()) + p.media.FileMeta.Original.Width = fullImg.Width() + p.media.FileMeta.Original.Height = fullImg.Height() + p.media.FileMeta.Original.Size = fullImg.Size() p.media.FileMeta.Original.Aspect = fullImg.AspectRatio() // Get smaller thumbnail image @@ -475,44 +410,72 @@ func (p *ProcessingMedia) finish(ctx context.Context) error { p.media.Blurhash = hash } - // Thumbnail shouldn't already exist in storage at this point, + // Thumbnail shouldn't exist in storage at this point, // but we do a check as it's worth logging / cleaning up. if have, _ := p.mgr.state.Storage.Has(ctx, p.media.Thumbnail.Path); have { - log.Warnf(ctx, "thumbnail already exists at storage path: %s", p.media.Thumbnail.Path) + log.Warnf(ctx, "thumbnail already exists at: %s", p.media.Thumbnail.Path) - // Attempt to remove existing thumbnail at storage path (might be broken / out-of-date) + // Attempt to remove existing thumbnail (might be broken / out-of-date). if err := p.mgr.state.Storage.Delete(ctx, p.media.Thumbnail.Path); err != nil { - return gtserror.Newf("error removing thumbnail from storage: %v", err) + return gtserror.Newf("error removing thumbnail %s from storage: %v", p.media.Thumbnail.Path, err) } } // Create a thumbnail JPEG encoder stream. enc := thumbImg.ToJPEG(&jpeg.Options{ + // Good enough for // a thumbnail. Quality: 70, }) - // Stream-encode the JPEG thumbnail image into storage. + // Stream-encode the JPEG thumbnail image into our storage driver. sz, err := p.mgr.state.Storage.PutStream(ctx, p.media.Thumbnail.Path, enc) if err != nil { return gtserror.Newf("error stream-encoding thumbnail to storage: %w", err) } + // Set final written thumb size. + p.media.Thumbnail.FileSize = int(sz) + // Set thumbnail dimensions in attachment info. p.media.FileMeta.Small = gtsmodel.Small{ - Width: int(thumbImg.Width()), - Height: int(thumbImg.Height()), - Size: int(thumbImg.Size()), + Width: thumbImg.Width(), + Height: thumbImg.Height(), + Size: thumbImg.Size(), Aspect: thumbImg.AspectRatio(), } - // Set written image size. - p.media.Thumbnail.FileSize = int(sz) - - // Finally set the attachment as processed and update time. + // Finally set the attachment as processed. p.media.Processing = gtsmodel.ProcessingStatusProcessed - p.media.File.UpdatedAt = time.Now() return nil } + +// cleanup will remove any traces of processing media from storage. +// and perform any other necessary cleanup steps after failure. +func (p *ProcessingMedia) cleanup(ctx context.Context) { + var err error + + if p.media.File.Path != "" { + // Ensure media file at path is deleted from storage. + err = p.mgr.state.Storage.Delete(ctx, p.media.File.Path) + if err != nil && !storage.IsNotFound(err) { + log.Errorf(ctx, "error deleting %s: %v", p.media.File.Path, err) + } + } + + if p.media.Thumbnail.Path != "" { + // Ensure media thumbnail at path is deleted from storage. + err = p.mgr.state.Storage.Delete(ctx, p.media.Thumbnail.Path) + if err != nil && !storage.IsNotFound(err) { + log.Errorf(ctx, "error deleting %s: %v", p.media.Thumbnail.Path, err) + } + } + + // Also ensure marked as unknown and finished + // processing so gets inserted as placeholder URL. + p.media.Processing = gtsmodel.ProcessingStatusProcessed + p.media.Type = gtsmodel.FileTypeUnknown + p.media.Cached = util.Ptr(false) +} diff --git a/internal/media/refetch.go b/internal/media/refetch.go index a1483ccd4..c239655d2 100644 --- a/internal/media/refetch.go +++ b/internal/media/refetch.go @@ -112,19 +112,19 @@ func (m *Manager) RefetchEmojis(ctx context.Context, domain string, dereferenceM return dereferenceMedia(ctx, emojiImageIRI) } - processingEmoji, err := m.PreProcessEmoji(ctx, dataFunc, emoji.Shortcode, emoji.ID, emoji.URI, &AdditionalEmojiInfo{ + processingEmoji, err := m.RefreshEmoji(ctx, emoji, dataFunc, AdditionalEmojiInfo{ Domain: &emoji.Domain, ImageRemoteURL: &emoji.ImageRemoteURL, ImageStaticRemoteURL: &emoji.ImageStaticRemoteURL, Disabled: emoji.Disabled, VisibleInPicker: emoji.VisibleInPicker, - }, true) + }) if err != nil { log.Errorf(ctx, "emoji %s could not be refreshed because of an error during processing: %s", shortcodeDomain, err) continue } - if _, err := processingEmoji.LoadEmoji(ctx); err != nil { + if _, err := processingEmoji.Load(ctx); err != nil { log.Errorf(ctx, "emoji %s could not be refreshed because of an error during loading: %s", shortcodeDomain, err) continue } diff --git a/internal/media/types.go b/internal/media/types.go index 6e7727cd5..cea026b98 100644 --- a/internal/media/types.go +++ b/internal/media/types.go @@ -61,47 +61,85 @@ const ( TypeEmoji Type = "emoji" // TypeEmoji is the key for emoji type requests ) -// AdditionalMediaInfo represents additional information that should be added to an attachment -// when processing a piece of media. +// AdditionalMediaInfo represents additional information that +// should be added to attachment when processing a piece of media. type AdditionalMediaInfo struct { - // Time that this media was created; defaults to time.Now(). + + // Time that this media was + // created; defaults to time.Now(). CreatedAt *time.Time - // ID of the status to which this media is attached; defaults to "". + + // ID of the status to which this + // media is attached; defaults to "". StatusID *string - // URL of the media on a remote instance; defaults to "". + + // URL of the media on a + // remote instance; defaults to "". RemoteURL *string - // Image description of this media; defaults to "". + + // Image description of + // this media; defaults to "". Description *string - // Blurhash of this media; defaults to "". + + // Blurhash of this + // media; defaults to "". Blurhash *string - // ID of the scheduled status to which this media is attached; defaults to "". + + // ID of the scheduled status to which + // this media is attached; defaults to "". ScheduledStatusID *string - // Mark this media as in-use as an avatar; defaults to false. + + // Mark this media as in-use + // as an avatar; defaults to false. Avatar *bool - // Mark this media as in-use as a header; defaults to false. + + // Mark this media as in-use + // as a header; defaults to false. Header *bool - // X focus coordinate for this media; defaults to 0. + + // X focus coordinate for + // this media; defaults to 0. FocusX *float32 - // Y focus coordinate for this media; defaults to 0. + + // Y focus coordinate for + // this media; defaults to 0. FocusY *float32 } // AdditionalEmojiInfo represents additional information // that should be taken into account when processing an emoji. type AdditionalEmojiInfo struct { - // Time that this emoji was created; defaults to time.Now(). + + // ActivityPub URI of + // this remote emoji. + URI *string + + // Time that this emoji was + // created; defaults to time.Now(). CreatedAt *time.Time - // Domain the emoji originated from. Blank for this instance's domain. Defaults to "". + + // Domain the emoji originated from. Blank + // for this instance's domain. Defaults to "". Domain *string - // URL of this emoji on a remote instance; defaults to "". + + // URL of this emoji on a + // remote instance; defaults to "". ImageRemoteURL *string - // URL of the static version of this emoji on a remote instance; defaults to "". + + // URL of the static version of this emoji + // on a remote instance; defaults to "". ImageStaticRemoteURL *string - // Whether this emoji should be disabled (not shown) on this instance; defaults to false. + + // Whether this emoji should be disabled (not + // shown) on this instance; defaults to false. Disabled *bool - // Whether this emoji should be visible in the instance's emoji picker; defaults to true. + + // Whether this emoji should be visible in + // the instance's emoji picker; defaults to true. VisibleInPicker *bool - // ID of the category this emoji should be placed in; defaults to "". + + // ID of the category this emoji + // should be placed in; defaults to "". CategoryID *string } diff --git a/internal/media/util.go b/internal/media/util.go index 1595da6d7..296bdb883 100644 --- a/internal/media/util.go +++ b/internal/media/util.go @@ -37,6 +37,5 @@ func newHdrBuf(fileSize int) []byte { if fileSize > 0 && fileSize < bufSize { bufSize = fileSize } - return make([]byte, bufSize) } |