From 72ba5666a6ffd06ccdfd2db8dacc47de7f777a4c Mon Sep 17 00:00:00 2001 From: kim <89579420+NyaaaWhatsUpDoc@users.noreply.github.com> Date: Wed, 17 Jul 2024 15:26:33 +0000 Subject: [PATCH] [chore] media pipeline improvements (#3110) * don't set emoji / media image paths on failed download, migrate FileType from string to integer * fix incorrect uses of util.PtrOr, fix returned frontend media * fix migration not setting arguments correctly in where clause * fix not providing default with not null column * whoops * ensure a default gets set for media attachment file type * remove the exclusive flag from writing files in disk storage * rename PtrOr -> PtrOrZero, and rename PtrValueOr -> PtrOrValue to match * slight wording changes * use singular / plural word forms (no parentheses), is better for screen readers * update testmodels with unknown media type to have unset file details, update attachment focus handling converting to frontend, update tests * store first instance in ffmpeg wasm pool, fill remaining with closed instances --- cmd/process-emoji/main.go | 3 + cmd/process-media/main.go | 3 + internal/api/client/accounts/mute.go | 2 +- internal/api/client/filters/v1/validate.go | 4 +- .../filters/v2/filterkeywordget_test.go | 2 +- .../client/filters/v2/filterkeywordpost.go | 2 +- internal/api/client/filters/v2/filterpost.go | 4 +- internal/api/client/filters/v2/filterput.go | 4 +- .../20240318115336_account_settings.go | 6 +- ...40715204203_media_pipeline_improvements.go | 124 +++++++++++++ .../emoji.go | 65 +++++++ .../media.go | 127 +++++++++++++ internal/gtsmodel/mediaattachment.go | 34 +++- internal/media/ffmpeg/pool.go | 33 +++- internal/media/manager.go | 171 +++--------------- internal/media/manager_test.go | 8 +- internal/media/processingemoji.go | 57 +++--- internal/media/processingmedia.go | 36 +++- internal/processing/account/follow.go | 4 +- internal/processing/admin/emoji.go | 4 +- internal/processing/filters/v1/create.go | 2 +- internal/processing/filters/v1/update.go | 12 +- internal/processing/filters/v2/update.go | 2 +- internal/storage/storage.go | 9 +- internal/typeutils/internaltofrontend.go | 165 +++++++---------- internal/typeutils/internaltofrontend_test.go | 26 ++- internal/typeutils/util.go | 106 ++++++----- internal/util/ptr.go | 15 +- testrig/testmodels.go | 38 +--- 29 files changed, 669 insertions(+), 399 deletions(-) create mode 100644 internal/db/bundb/migrations/20240715204203_media_pipeline_improvements.go create mode 100644 internal/db/bundb/migrations/20240715204203_media_pipeline_improvements/emoji.go create mode 100644 internal/db/bundb/migrations/20240715204203_media_pipeline_improvements/media.go diff --git a/cmd/process-emoji/main.go b/cmd/process-emoji/main.go index b06eb84f8..0e999503e 100644 --- a/cmd/process-emoji/main.go +++ b/cmd/process-emoji/main.go @@ -24,6 +24,7 @@ import ( "os/signal" "syscall" + "codeberg.org/gruf/go-logger/v2/level" "codeberg.org/gruf/go-storage/memory" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db/bundb" @@ -40,6 +41,8 @@ func main() { ctx, cncl := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT) defer cncl() + log.SetLevel(level.INFO) + if len(os.Args) != 3 { log.Panic(ctx, "Usage: go run ./cmd/process-emoji ") } diff --git a/cmd/process-media/main.go b/cmd/process-media/main.go index 096d718f9..7487917bf 100644 --- a/cmd/process-media/main.go +++ b/cmd/process-media/main.go @@ -24,6 +24,7 @@ import ( "os/signal" "syscall" + "codeberg.org/gruf/go-logger/v2/level" "codeberg.org/gruf/go-storage/memory" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db/bundb" @@ -39,6 +40,8 @@ func main() { ctx, cncl := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT) defer cncl() + log.SetLevel(level.INFO) + if len(os.Args) != 4 { log.Panic(ctx, "Usage: go run ./cmd/process-media ") } diff --git a/internal/api/client/accounts/mute.go b/internal/api/client/accounts/mute.go index 37cd3bbff..affb0f055 100644 --- a/internal/api/client/accounts/mute.go +++ b/internal/api/client/accounts/mute.go @@ -138,7 +138,7 @@ func (m *Module) AccountMutePOSTHandler(c *gin.Context) { func normalizeCreateUpdateMute(form *apimodel.UserMuteCreateUpdateRequest) error { // Apply defaults for missing fields. - form.Notifications = util.Ptr(util.PtrValueOr(form.Notifications, false)) + form.Notifications = util.Ptr(util.PtrOrValue(form.Notifications, false)) // Normalize mute duration if necessary. // If we parsed this as JSON, expires_in diff --git a/internal/api/client/filters/v1/validate.go b/internal/api/client/filters/v1/validate.go index 550df54fa..cce00fdc4 100644 --- a/internal/api/client/filters/v1/validate.go +++ b/internal/api/client/filters/v1/validate.go @@ -40,8 +40,8 @@ func validateNormalizeCreateUpdateFilter(form *model.FilterCreateUpdateRequestV1 } // Apply defaults for missing fields. - form.WholeWord = util.Ptr(util.PtrValueOr(form.WholeWord, false)) - form.Irreversible = util.Ptr(util.PtrValueOr(form.Irreversible, false)) + form.WholeWord = util.Ptr(util.PtrOrValue(form.WholeWord, false)) + form.Irreversible = util.Ptr(util.PtrOrValue(form.Irreversible, false)) if *form.Irreversible { return errors.New("irreversible aka server-side drop filters are not supported yet") diff --git a/internal/api/client/filters/v2/filterkeywordget_test.go b/internal/api/client/filters/v2/filterkeywordget_test.go index a5d8754a6..13e90c0c2 100644 --- a/internal/api/client/filters/v2/filterkeywordget_test.go +++ b/internal/api/client/filters/v2/filterkeywordget_test.go @@ -100,7 +100,7 @@ func (suite *FiltersTestSuite) TestGetFilterKeyword() { suite.NotEmpty(filterKeyword) suite.Equal(expectedFilterKeyword.ID, filterKeyword.ID) suite.Equal(expectedFilterKeyword.Keyword, filterKeyword.Keyword) - suite.Equal(util.PtrValueOr(expectedFilterKeyword.WholeWord, false), filterKeyword.WholeWord) + suite.Equal(util.PtrOrValue(expectedFilterKeyword.WholeWord, false), filterKeyword.WholeWord) } func (suite *FiltersTestSuite) TestGetAnotherAccountsFilterKeyword() { diff --git a/internal/api/client/filters/v2/filterkeywordpost.go b/internal/api/client/filters/v2/filterkeywordpost.go index fab7dc812..ba8f80135 100644 --- a/internal/api/client/filters/v2/filterkeywordpost.go +++ b/internal/api/client/filters/v2/filterkeywordpost.go @@ -147,7 +147,7 @@ func validateNormalizeCreateUpdateFilterKeyword(form *apimodel.FilterKeywordCrea return err } - form.WholeWord = util.Ptr(util.PtrValueOr(form.WholeWord, false)) + form.WholeWord = util.Ptr(util.PtrOrValue(form.WholeWord, false)) return nil } diff --git a/internal/api/client/filters/v2/filterpost.go b/internal/api/client/filters/v2/filterpost.go index 732b81041..13270b1e5 100644 --- a/internal/api/client/filters/v2/filterpost.go +++ b/internal/api/client/filters/v2/filterpost.go @@ -192,7 +192,7 @@ func validateNormalizeCreateFilter(form *apimodel.FilterCreateRequestV2) error { if err := validate.FilterTitle(form.Title); err != nil { return err } - action := util.PtrValueOr(form.FilterAction, apimodel.FilterActionWarn) + action := util.PtrOrValue(form.FilterAction, apimodel.FilterActionWarn) if err := validate.FilterAction(action); err != nil { return err } @@ -253,7 +253,7 @@ func validateNormalizeCreateFilter(form *apimodel.FilterCreateRequestV2) error { if err := validate.FilterKeyword(formKeyword.Keyword); err != nil { return err } - form.Keywords[i].WholeWord = util.Ptr(util.PtrValueOr(formKeyword.WholeWord, false)) + form.Keywords[i].WholeWord = util.Ptr(util.PtrOrValue(formKeyword.WholeWord, false)) } for _, formStatus := range form.Statuses { if err := validate.ULID(formStatus.StatusID, "status_id"); err != nil { diff --git a/internal/api/client/filters/v2/filterput.go b/internal/api/client/filters/v2/filterput.go index cc3531838..c86dc36dc 100644 --- a/internal/api/client/filters/v2/filterput.go +++ b/internal/api/client/filters/v2/filterput.go @@ -289,7 +289,7 @@ func validateNormalizeUpdateFilter(form *apimodel.FilterUpdateRequestV2) error { } } - destroy := util.PtrValueOr(formKeyword.Destroy, false) + destroy := util.PtrOrValue(formKeyword.Destroy, false) form.Keywords[i].Destroy = &destroy if destroy && formKeyword.ID == nil { @@ -305,7 +305,7 @@ func validateNormalizeUpdateFilter(form *apimodel.FilterUpdateRequestV2) error { } } - destroy := util.PtrValueOr(formStatus.Destroy, false) + destroy := util.PtrOrValue(formStatus.Destroy, false) form.Statuses[i].Destroy = &destroy switch { diff --git a/internal/db/bundb/migrations/20240318115336_account_settings.go b/internal/db/bundb/migrations/20240318115336_account_settings.go index 25c64e826..3bf58e21e 100644 --- a/internal/db/bundb/migrations/20240318115336_account_settings.go +++ b/internal/db/bundb/migrations/20240318115336_account_settings.go @@ -78,12 +78,12 @@ func init() { CreatedAt: account.CreatedAt, Reason: account.Reason, Privacy: newgtsmodel.Visibility(account.Privacy), - Sensitive: util.Ptr(util.PtrValueOr(account.Sensitive, false)), + Sensitive: util.Ptr(util.PtrOrValue(account.Sensitive, false)), Language: account.Language, StatusContentType: account.StatusContentType, CustomCSS: account.CustomCSS, - EnableRSS: util.Ptr(util.PtrValueOr(account.EnableRSS, false)), - HideCollections: util.Ptr(util.PtrValueOr(account.HideCollections, false)), + EnableRSS: util.Ptr(util.PtrOrValue(account.EnableRSS, false)), + HideCollections: util.Ptr(util.PtrOrValue(account.HideCollections, false)), } // Insert the settings model. diff --git a/internal/db/bundb/migrations/20240715204203_media_pipeline_improvements.go b/internal/db/bundb/migrations/20240715204203_media_pipeline_improvements.go new file mode 100644 index 000000000..5f01f53ef --- /dev/null +++ b/internal/db/bundb/migrations/20240715204203_media_pipeline_improvements.go @@ -0,0 +1,124 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package migrations + +import ( + "context" + + old_gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20240715204203_media_pipeline_improvements" + new_gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + + "github.com/uptrace/bun" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + if err := db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + if _, err := tx.NewAddColumn(). + Table("media_attachments"). + ColumnExpr("? INTEGER NOT NULL DEFAULT ?", bun.Ident("type_new"), 0). + Exec(ctx); err != nil { + return err + } + + for old, new := range map[old_gtsmodel.FileType]new_gtsmodel.FileType{ + old_gtsmodel.FileTypeAudio: new_gtsmodel.FileTypeAudio, + old_gtsmodel.FileTypeImage: new_gtsmodel.FileTypeImage, + old_gtsmodel.FileTypeGifv: new_gtsmodel.FileTypeImage, + old_gtsmodel.FileTypeVideo: new_gtsmodel.FileTypeVideo, + old_gtsmodel.FileTypeUnknown: new_gtsmodel.FileTypeUnknown, + } { + if _, err := tx.NewUpdate(). + Table("media_attachments"). + Where("? = ?", bun.Ident("type"), old). + Set("? = ?", bun.Ident("type_new"), new). + Exec(ctx); err != nil { + return err + } + } + + if _, err := tx.NewDropColumn(). + Table("media_attachments"). + ColumnExpr("?", bun.Ident("type")). + Exec(ctx); err != nil { + return err + } + + if _, err := tx.NewRaw( + "ALTER TABLE ? RENAME COLUMN ? TO ?", + bun.Ident("media_attachments"), + bun.Ident("type_new"), + bun.Ident("type"), + ).Exec(ctx); err != nil { + return err + } + + return nil + }); err != nil { + return err + } + + // Zero-out attachment data + // for "unknown" non-locally + // stored media attachments. + if _, err := db.NewUpdate(). + Table("media_attachments"). + Where("? = ?", bun.Ident("type"), new_gtsmodel.FileTypeUnknown). + Set("? = ?", bun.Ident("url"), ""). + Set("? = ?", bun.Ident("file_path"), ""). + Set("? = ?", bun.Ident("file_content_type"), ""). + Set("? = ?", bun.Ident("file_file_size"), 0). + Set("? = ?", bun.Ident("thumbnail_path"), ""). + Set("? = ?", bun.Ident("thumbnail_content_type"), ""). + Set("? = ?", bun.Ident("thumbnail_file_size"), 0). + Set("? = ?", bun.Ident("thumbnail_url"), ""). + Exec(ctx); err != nil { + return err + } + + // Zero-out emoji data for + // non-locally stored emoji. + if _, err := db.NewUpdate(). + Table("emojis"). + WhereOr("? = ?", bun.Ident("image_url"), ""). + WhereOr("? = ?", bun.Ident("image_path"), ""). + Set("? = ?", bun.Ident("image_path"), ""). + Set("? = ?", bun.Ident("image_url"), ""). + Set("? = ?", bun.Ident("image_file_size"), 0). + Set("? = ?", bun.Ident("image_content_type"), ""). + Set("? = ?", bun.Ident("image_static_path"), ""). + Set("? = ?", bun.Ident("image_static_url"), ""). + Set("? = ?", bun.Ident("image_static_file_size"), 0). + Set("? = ?", bun.Ident("image_static_content_type"), ""). + Exec(ctx); err != nil { + return err + } + + return nil + } + + down := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + return nil + }) + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/db/bundb/migrations/20240715204203_media_pipeline_improvements/emoji.go b/internal/db/bundb/migrations/20240715204203_media_pipeline_improvements/emoji.go new file mode 100644 index 000000000..f4567ab25 --- /dev/null +++ b/internal/db/bundb/migrations/20240715204203_media_pipeline_improvements/emoji.go @@ -0,0 +1,65 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package gtsmodel + +import "time" + +// Emoji represents a custom emoji that's been uploaded through the admin UI or downloaded from a remote instance. +type Emoji struct { + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database + CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created + UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated + Shortcode string `bun:",nullzero,notnull,unique:domainshortcode"` // String shortcode for this emoji -- the part that's between colons. This should be a-zA-Z_ eg., 'blob_hug' 'purple_heart' 'Gay_Otter' Must be unique with domain. + Domain string `bun:",nullzero,unique:domainshortcode"` // Origin domain of this emoji, eg 'example.org', 'queer.party'. empty string for local emojis. + ImageRemoteURL string `bun:",nullzero"` // Where can this emoji be retrieved remotely? Null for local emojis. + ImageStaticRemoteURL string `bun:",nullzero"` // Where can a static / non-animated version of this emoji be retrieved remotely? Null for local emojis. + ImageURL string `bun:",nullzero"` // Where can this emoji be retrieved from the local server? Null for remote emojis. + ImageStaticURL string `bun:",nullzero"` // Where can a static version of this emoji be retrieved from the local server? Null for remote emojis. + ImagePath string `bun:",notnull"` // Path of the emoji image in the server storage system. + ImageStaticPath string `bun:",notnull"` // Path of a static version of the emoji image in the server storage system + ImageContentType string `bun:",notnull"` // MIME content type of the emoji image + ImageStaticContentType string `bun:",notnull"` // MIME content type of the static version of the emoji image. + ImageFileSize int `bun:",notnull"` // Size of the emoji image file in bytes, for serving purposes. + ImageStaticFileSize int `bun:",notnull"` // Size of the static version of the emoji image file in bytes, for serving purposes. + Disabled *bool `bun:",nullzero,notnull,default:false"` // Has a moderation action disabled this emoji from being shown? + URI string `bun:",nullzero,notnull,unique"` // ActivityPub uri of this emoji. Something like 'https://example.org/emojis/1234' + VisibleInPicker *bool `bun:",nullzero,notnull,default:true"` // Is this emoji visible in the admin emoji picker? + Category *EmojiCategory `bun:"rel:belongs-to"` // In which emoji category is this emoji visible? + CategoryID string `bun:"type:CHAR(26),nullzero"` // ID of the category this emoji belongs to. + Cached *bool `bun:",nullzero,notnull,default:false"` // whether emoji is cached in locally in gotosocial storage. +} + +// IsLocal returns true if the emoji is +// local to this instance., ie., it did +// not originate from a remote instance. +func (e *Emoji) IsLocal() bool { + return e.Domain == "" +} + +// ShortcodeDomain returns the [shortcode]@[domain] for the given emoji. +func (e *Emoji) ShortcodeDomain() string { + return e.Shortcode + "@" + e.Domain +} + +// EmojiCategory represents a grouping of custom emojis. +type EmojiCategory struct { + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database + CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created + UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated + Name string `bun:",nullzero,notnull,unique"` // name of this category +} diff --git a/internal/db/bundb/migrations/20240715204203_media_pipeline_improvements/media.go b/internal/db/bundb/migrations/20240715204203_media_pipeline_improvements/media.go new file mode 100644 index 000000000..471a5abd1 --- /dev/null +++ b/internal/db/bundb/migrations/20240715204203_media_pipeline_improvements/media.go @@ -0,0 +1,127 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package gtsmodel + +import ( + "time" +) + +// MediaAttachment represents a user-uploaded media attachment: an image/video/audio/gif that is +// somewhere in storage and that can be retrieved and served by the router. +type MediaAttachment struct { + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database + CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created + UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated + StatusID string `bun:"type:CHAR(26),nullzero"` // ID of the status to which this is attached + URL string `bun:",nullzero"` // Where can the attachment be retrieved on *this* server + RemoteURL string `bun:",nullzero"` // Where can the attachment be retrieved on a remote server (empty for local media) + Type FileType `bun:",notnull"` // Type of file (image/gifv/audio/video/unknown) + FileMeta FileMeta `bun:",embed:,notnull"` // Metadata about the file + AccountID string `bun:"type:CHAR(26),nullzero,notnull"` // To which account does this attachment belong + Description string `bun:""` // Description of the attachment (for screenreaders) + ScheduledStatusID string `bun:"type:CHAR(26),nullzero"` // To which scheduled status does this attachment belong + Blurhash string `bun:",nullzero"` // What is the generated blurhash of this attachment + Processing ProcessingStatus `bun:",notnull,default:2"` // What is the processing status of this attachment + File File `bun:",embed:file_,notnull,nullzero"` // metadata for the whole file + Thumbnail Thumbnail `bun:",embed:thumbnail_,notnull,nullzero"` // small image thumbnail derived from a larger image, video, or audio file. + Avatar *bool `bun:",nullzero,notnull,default:false"` // Is this attachment being used as an avatar? + Header *bool `bun:",nullzero,notnull,default:false"` // Is this attachment being used as a header? + Cached *bool `bun:",nullzero,notnull,default:false"` // Is this attachment currently cached by our instance? +} + +// IsLocal returns whether media attachment is local. +func (m *MediaAttachment) IsLocal() bool { + return m.RemoteURL == "" +} + +// IsRemote returns whether media attachment is remote. +func (m *MediaAttachment) IsRemote() bool { + return m.RemoteURL != "" +} + +// File refers to the metadata for the whole file +type File struct { + Path string `bun:",notnull"` // Path of the file in storage. + ContentType string `bun:",notnull"` // MIME content type of the file. + FileSize int `bun:",notnull"` // File size in bytes +} + +// Thumbnail refers to a small image thumbnail derived from a larger image, video, or audio file. +type Thumbnail struct { + Path string `bun:",notnull"` // Path of the file in storage. + ContentType string `bun:",notnull"` // MIME content type of the file. + FileSize int `bun:",notnull"` // File size in bytes + URL string `bun:",nullzero"` // What is the URL of the thumbnail on the local server + RemoteURL string `bun:",nullzero"` // What is the remote URL of the thumbnail (empty for local media) +} + +// ProcessingStatus refers to how far along in the processing stage the attachment is. +type ProcessingStatus int + +// MediaAttachment processing states. +const ( + ProcessingStatusReceived ProcessingStatus = 0 // ProcessingStatusReceived indicates the attachment has been received and is awaiting processing. No thumbnail available yet. + ProcessingStatusProcessing ProcessingStatus = 1 // ProcessingStatusProcessing indicates the attachment is currently being processed. Thumbnail is available but full media is not. + ProcessingStatusProcessed ProcessingStatus = 2 // ProcessingStatusProcessed indicates the attachment has been fully processed and is ready to be served. + ProcessingStatusError ProcessingStatus = 666 // ProcessingStatusError indicates something went wrong processing the attachment and it won't be tried again--these can be deleted. +) + +// FileType refers to the file type of the media attaachment. +type FileType string + +// MediaAttachment file types. +const ( + FileTypeImage FileType = "Image" // FileTypeImage is for jpegs, pngs, and standard gifs + FileTypeGifv FileType = "Gifv" // FileTypeGif is for soundless looping videos that behave like gifs + FileTypeAudio FileType = "Audio" // FileTypeAudio is for audio-only files (no video) + FileTypeVideo FileType = "Video" // FileTypeVideo is for files with audio + visual + FileTypeUnknown FileType = "Unknown" // FileTypeUnknown is for unknown file types (surprise surprise!) +) + +// FileMeta describes metadata about the actual contents of the file. +type FileMeta struct { + Original Original `bun:"embed:original_"` + Small Small `bun:"embed:small_"` + Focus Focus `bun:"embed:focus_"` +} + +// Small can be used for a thumbnail of any media type +type Small struct { + Width int // width in pixels + Height int // height in pixels + Size int // size in pixels (width * height) + Aspect float32 // aspect ratio (width / height) +} + +// Original can be used for original metadata for any media type +type Original struct { + Width int // width in pixels + Height int // height in pixels + Size int // size in pixels (width * height) + Aspect float32 // aspect ratio (width / height) + Duration *float32 // video-specific: duration of the video in seconds + Framerate *float32 // video-specific: fps + Bitrate *uint64 // video-specific: bitrate +} + +// Focus describes the 'center' of the image for display purposes. +// X and Y should each be between -1 and 1 +type Focus struct { + X float32 + Y float32 +} diff --git a/internal/gtsmodel/mediaattachment.go b/internal/gtsmodel/mediaattachment.go index 471a5abd1..eb792ae3b 100644 --- a/internal/gtsmodel/mediaattachment.go +++ b/internal/gtsmodel/mediaattachment.go @@ -30,7 +30,7 @@ type MediaAttachment struct { StatusID string `bun:"type:CHAR(26),nullzero"` // ID of the status to which this is attached URL string `bun:",nullzero"` // Where can the attachment be retrieved on *this* server RemoteURL string `bun:",nullzero"` // Where can the attachment be retrieved on a remote server (empty for local media) - Type FileType `bun:",notnull"` // Type of file (image/gifv/audio/video/unknown) + Type FileType `bun:",notnull,default:0"` // Type of file (image/gifv/audio/video/unknown) FileMeta FileMeta `bun:",embed:,notnull"` // Metadata about the file AccountID string `bun:"type:CHAR(26),nullzero,notnull"` // To which account does this attachment belong Description string `bun:""` // Description of the attachment (for screenreaders) @@ -81,18 +81,34 @@ const ( ProcessingStatusError ProcessingStatus = 666 // ProcessingStatusError indicates something went wrong processing the attachment and it won't be tried again--these can be deleted. ) -// FileType refers to the file type of the media attaachment. -type FileType string +// FileType refers to the file +// type of the media attaachment. +type FileType int -// MediaAttachment file types. const ( - FileTypeImage FileType = "Image" // FileTypeImage is for jpegs, pngs, and standard gifs - FileTypeGifv FileType = "Gifv" // FileTypeGif is for soundless looping videos that behave like gifs - FileTypeAudio FileType = "Audio" // FileTypeAudio is for audio-only files (no video) - FileTypeVideo FileType = "Video" // FileTypeVideo is for files with audio + visual - FileTypeUnknown FileType = "Unknown" // FileTypeUnknown is for unknown file types (surprise surprise!) + // MediaAttachment file types. + FileTypeUnknown FileType = 0 // FileTypeUnknown is for unknown file types (surprise surprise!) + FileTypeImage FileType = 1 // FileTypeImage is for jpegs, pngs, and standard gifs + FileTypeAudio FileType = 2 // FileTypeAudio is for audio-only files (no video) + FileTypeVideo FileType = 3 // FileTypeVideo is for files with audio + visual ) +// String returns a stringified, frontend API compatible form of FileType. +func (t FileType) String() string { + switch t { + case FileTypeUnknown: + return "unknown" + case FileTypeImage: + return "image" + case FileTypeAudio: + return "audio" + case FileTypeVideo: + return "video" + default: + panic("invalid filetype") + } +} + // FileMeta describes metadata about the actual contents of the file. type FileMeta struct { Original Original `bun:"embed:original_"` diff --git a/internal/media/ffmpeg/pool.go b/internal/media/ffmpeg/pool.go index 9f6446be3..e63b10e69 100644 --- a/internal/media/ffmpeg/pool.go +++ b/internal/media/ffmpeg/pool.go @@ -34,14 +34,33 @@ type wasmInstancePool struct { } func (p *wasmInstancePool) Init(ctx context.Context, sz int) error { - p.pool = make(chan *wasm.Instance, sz) - for i := 0; i < sz; i++ { - inst, err := p.inst.New(ctx) - if err != nil { - return err - } - p.pool <- inst + // Initialize for first time + // to preload module into the + // wazero compilation cache. + inst, err := p.inst.New(ctx) + if err != nil { + return err } + + // Clamp to 1. + if sz <= 0 { + sz = 1 + } + + // Allocate new pool instance channel. + p.pool = make(chan *wasm.Instance, sz) + + // Store only one + // open instance + // at init time. + p.pool <- inst + + // Fill reminaing with closed + // instances for later opening. + for i := 0; i < sz-1; i++ { + p.pool <- new(wasm.Instance) + } + return nil } diff --git a/internal/media/manager.go b/internal/media/manager.go index 82b066edc..13bcebe79 100644 --- a/internal/media/manager.go +++ b/internal/media/manager.go @@ -102,74 +102,19 @@ func (m *Manager) CreateMedia( ) { now := time.Now() - // Generate new ID. - id := id.NewULID() - - // Placeholder URL for attachment. - url := uris.URIForAttachment( - accountID, - string(TypeAttachment), - string(SizeOriginal), - id, - "unknown", - ) - - // Placeholder storage path for attachment. - path := uris.StoragePathForAttachment( - accountID, - string(TypeAttachment), - string(SizeOriginal), - id, - "unknown", - ) - - // Calculate attachment thumbnail file path - thumbPath := uris.StoragePathForAttachment( - accountID, - string(TypeAttachment), - string(SizeSmall), - id, - - // Always encode attachment - // thumbnails as jpeg. - "jpeg", - ) - - // Calculate attachment thumbnail URL. - thumbURL := uris.URIForAttachment( - accountID, - string(TypeAttachment), - string(SizeSmall), - id, - - // Always encode attachment - // thumbnails as jpeg. - "jpeg", - ) - // 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, + ID: id.NewULID(), + AccountID: accountID, + Type: gtsmodel.FileTypeUnknown, + Processing: gtsmodel.ProcessingStatusReceived, + Avatar: util.Ptr(false), + Header: util.Ptr(false), + Cached: util.Ptr(false), 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: "image/jpeg", - Path: thumbPath, - URL: thumbURL, - }, - Avatar: util.Ptr(false), - Header: util.Ptr(false), - Cached: util.Ptr(false), } // Check if we were provided additional info @@ -252,56 +197,23 @@ func (m *Manager) CreateEmoji( // 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, 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 } - // Generate static URL for attachment. - staticURL := uris.URIForAttachment( - instanceAcc.ID, - string(TypeEmoji), - string(SizeStatic), - id, - - // All static emojis - // are encoded as png. - "png", - ) - - // Generate static image path for attachment. - staticPath := uris.StoragePathForAttachment( - instanceAcc.ID, - string(TypeEmoji), - string(SizeStatic), - id, - - // All static emojis - // are encoded as png. - "png", - ) - // 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: "image/png", - Disabled: util.Ptr(false), - VisibleInPicker: util.Ptr(true), - CreatedAt: now, - UpdatedAt: now, + ID: id, + Shortcode: shortcode, + Domain: domain, + Disabled: util.Ptr(false), + VisibleInPicker: util.Ptr(true), + CreatedAt: now, + UpdatedAt: now, } // Finally, create new emoji. @@ -327,12 +239,6 @@ func (m *Manager) RefreshEmoji( *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) - } - // Create references to old emoji image // paths before they get updated with new // path ID. These are required for later @@ -380,38 +286,6 @@ func (m *Manager) RefreshEmoji( return rct, nil } - // 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) - } - - // Generate new static URL for emoji. - emoji.ImageStaticURL = uris.URIForAttachment( - instanceAcc.ID, - string(TypeEmoji), - string(SizeStatic), - newPathID, - - // All static emojis - // are encoded as png. - "png", - ) - - // Generate new static image storage path for emoji. - emoji.ImageStaticPath = uris.StoragePathForAttachment( - instanceAcc.ID, - string(TypeEmoji), - string(SizeStatic), - newPathID, - - // All static emojis - // are encoded as png. - "png", - ) - // Finally, create new emoji in database. processingEmoji, err := m.createEmoji(ctx, func(ctx context.Context, emoji *gtsmodel.Emoji) error { @@ -425,8 +299,8 @@ func (m *Manager) RefreshEmoji( return nil, err } - // Set the refreshed path ID used. - processingEmoji.newPathID = newPathID + // Generate a new path ID to use instead. + processingEmoji.newPathID = id.NewULID() return processingEmoji, nil } @@ -441,6 +315,12 @@ func (m *Manager) createEmoji( *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) + } + // Check if we have additional info to add to the emoji, // and overwrite some of the emoji fields if so. if info.URI != nil { @@ -475,9 +355,10 @@ func (m *Manager) createEmoji( // Return wrapped emoji for later processing. processingEmoji := &ProcessingEmoji{ - emoji: emoji, - dataFn: data, - mgr: m, + instAccID: instanceAcc.ID, + emoji: emoji, + dataFn: data, + mgr: m, } return processingEmoji, nil diff --git a/internal/media/manager_test.go b/internal/media/manager_test.go index 24e0ddd1e..c908b2994 100644 --- a/internal/media/manager_test.go +++ b/internal/media/manager_test.go @@ -358,11 +358,10 @@ func (suite *ManagerTestSuite) TestPDFProcess() { 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("application/octet-stream", attachment.File.ContentType) - suite.Equal("image/jpeg", attachment.Thumbnail.ContentType) - suite.Empty(attachment.Blurhash) + suite.Zero(attachment.File.ContentType) + suite.Zero(attachment.Thumbnail.ContentType) + suite.Zero(attachment.Blurhash) // now make sure the attachment is in the database dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) @@ -376,7 +375,6 @@ func (suite *ManagerTestSuite) TestPDFProcess() { stored, err := suite.storage.Has(ctx, attachment.File.Path) suite.NoError(err) suite.False(stored) - stored, err = suite.storage.Has(ctx, attachment.Thumbnail.Path) suite.NoError(err) suite.False(stored) diff --git a/internal/media/processingemoji.go b/internal/media/processingemoji.go index 996a3aa03..f4265759b 100644 --- a/internal/media/processingemoji.go +++ b/internal/media/processingemoji.go @@ -26,7 +26,6 @@ import ( "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" @@ -36,6 +35,7 @@ import ( // various functions for retrieving data from the process. type ProcessingEmoji struct { emoji *gtsmodel.Emoji // processing emoji details + instAccID string // instance account ID 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 @@ -191,21 +191,24 @@ func (p *ProcessingEmoji) store(ctx context.Context) error { pathID = p.emoji.ID } - // 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 final media attachment file path. + // Calculate final emoji media file path. p.emoji.ImagePath = uris.StoragePathForAttachment( - instanceAccID, + p.instAccID, string(TypeEmoji), string(SizeOriginal), pathID, ext, ) + // Calculate final emoji static media file path. + p.emoji.ImageStaticPath = uris.StoragePathForAttachment( + p.instAccID, + string(TypeEmoji), + string(SizeStatic), + pathID, + "png", + ) + // Copy temporary file into storage at path. filesz, err := p.mgr.state.Storage.PutFile(ctx, p.emoji.ImagePath, @@ -228,19 +231,31 @@ func (p *ProcessingEmoji) store(ctx context.Context) error { p.emoji.ImageFileSize = int(filesz) p.emoji.ImageStaticFileSize = int(staticsz) - // Fill in remaining emoji data now it's stored. + // Generate an emoji media static URL. p.emoji.ImageURL = uris.URIForAttachment( - instanceAccID, + p.instAccID, string(TypeEmoji), string(SizeOriginal), pathID, ext, ) + // Generate an emoji image static URL. + p.emoji.ImageStaticURL = uris.URIForAttachment( + p.instAccID, + string(TypeEmoji), + string(SizeStatic), + pathID, + "png", + ) + // Get mimetype for the file container // type, falling back to generic data. p.emoji.ImageContentType = getMimeType(ext) + // Set the known emoji static content type. + p.emoji.ImageStaticContentType = "image/png" + // We can now consider this cached. p.emoji.Cached = util.Ptr(true) @@ -268,16 +283,16 @@ func (p *ProcessingEmoji) cleanup(ctx context.Context) { } } + // Unset processor-calculated fields. + p.emoji.ImageStaticContentType = "" + p.emoji.ImageStaticFileSize = 0 + p.emoji.ImageStaticPath = "" + p.emoji.ImageStaticURL = "" + p.emoji.ImageContentType = "" + p.emoji.ImageFileSize = 0 + p.emoji.ImagePath = "" + p.emoji.ImageURL = "" + // 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 e5af46a2f..393f7d715 100644 --- a/internal/media/processingmedia.go +++ b/internal/media/processingmedia.go @@ -248,6 +248,15 @@ func (p *ProcessingMedia) store(ctx context.Context) error { return gtserror.Newf("error generating thumb blurhash: %w", err) } } + + // Calculate final media attachment thumbnail path. + p.media.Thumbnail.Path = uris.StoragePathForAttachment( + p.media.AccountID, + string(TypeAttachment), + string(SizeSmall), + p.media.ID, + "jpeg", + ) } // Calculate final media attachment file path. @@ -285,8 +294,7 @@ func (p *ProcessingMedia) store(ctx context.Context) error { p.media.Thumbnail.FileSize = int(thumbsz) } - // Fill in correct attachment - // data now we've parsed it. + // Generate a media attachment URL. p.media.URL = uris.URIForAttachment( p.media.AccountID, string(TypeAttachment), @@ -295,10 +303,22 @@ func (p *ProcessingMedia) store(ctx context.Context) error { ext, ) + // Generate a media attachment thumbnail URL. + p.media.Thumbnail.URL = uris.URIForAttachment( + p.media.AccountID, + string(TypeAttachment), + string(SizeSmall), + p.media.ID, + "jpeg", + ) + // Get mimetype for the file container // type, falling back to generic data. p.media.File.ContentType = getMimeType(ext) + // Set the known thumbnail content type. + p.media.Thumbnail.ContentType = "image/jpeg" + // We can now consider this cached. p.media.Cached = util.Ptr(true) @@ -329,6 +349,18 @@ func (p *ProcessingMedia) cleanup(ctx context.Context) { } } + // Unset all processor-calculated media fields. + p.media.FileMeta.Original = gtsmodel.Original{} + p.media.FileMeta.Small = gtsmodel.Small{} + p.media.File.ContentType = "" + p.media.File.FileSize = 0 + p.media.File.Path = "" + p.media.Thumbnail.FileSize = 0 + p.media.Thumbnail.ContentType = "" + p.media.Thumbnail.Path = "" + p.media.Thumbnail.URL = "" + p.media.URL = "" + // Also ensure marked as unknown and finished // processing so gets inserted as placeholder URL. p.media.Processing = gtsmodel.ProcessingStatusProcessed diff --git a/internal/processing/account/follow.go b/internal/processing/account/follow.go index 6c066d6a6..59de8834b 100644 --- a/internal/processing/account/follow.go +++ b/internal/processing/account/follow.go @@ -117,8 +117,8 @@ func (p *Processor) FollowCreate(ctx context.Context, requestingAccount *gtsmode if targetAccount.IsLocal() && !*targetAccount.Locked { rel.Requested = false rel.Following = true - rel.ShowingReblogs = util.PtrValueOr(fr.ShowReblogs, true) - rel.Notifying = util.PtrValueOr(fr.Notify, false) + rel.ShowingReblogs = util.PtrOrValue(fr.ShowReblogs, true) + rel.Notifying = util.PtrOrValue(fr.Notify, false) } // Handle side effects async. diff --git a/internal/processing/admin/emoji.go b/internal/processing/admin/emoji.go index cf5bacef8..66193ccfe 100644 --- a/internal/processing/admin/emoji.go +++ b/internal/processing/admin/emoji.go @@ -325,8 +325,8 @@ func (p *Processor) emojiUpdateCopy( // Attempt to create the new local emoji. emoji, errWithCode := p.createEmoji(ctx, - util.PtrValueOr(shortcode, ""), - util.PtrValueOr(categoryName, ""), + util.PtrOrValue(shortcode, ""), + util.PtrOrValue(categoryName, ""), data, ) if errWithCode != nil { diff --git a/internal/processing/filters/v1/create.go b/internal/processing/filters/v1/create.go index 4d8ffc3e1..18367dfce 100644 --- a/internal/processing/filters/v1/create.go +++ b/internal/processing/filters/v1/create.go @@ -71,7 +71,7 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form FilterID: filter.ID, Filter: filter, Keyword: form.Phrase, - WholeWord: util.Ptr(util.PtrValueOr(form.WholeWord, false)), + WholeWord: util.Ptr(util.PtrOrValue(form.WholeWord, false)), } filter.Keywords = []*gtsmodel.FilterKeyword{filterKeyword} diff --git a/internal/processing/filters/v1/update.go b/internal/processing/filters/v1/update.go index 2c2fe5574..81340b4be 100644 --- a/internal/processing/filters/v1/update.go +++ b/internal/processing/filters/v1/update.go @@ -108,11 +108,11 @@ func (p *Processor) Update( if expiresAt != filter.ExpiresAt { forbiddenFields = append(forbiddenFields, "expires_in") } - if contextHome != util.PtrValueOr(filter.ContextHome, false) || - contextNotifications != util.PtrValueOr(filter.ContextNotifications, false) || - contextPublic != util.PtrValueOr(filter.ContextPublic, false) || - contextThread != util.PtrValueOr(filter.ContextThread, false) || - contextAccount != util.PtrValueOr(filter.ContextAccount, false) { + if contextHome != util.PtrOrValue(filter.ContextHome, false) || + contextNotifications != util.PtrOrValue(filter.ContextNotifications, false) || + contextPublic != util.PtrOrValue(filter.ContextPublic, false) || + contextThread != util.PtrOrValue(filter.ContextThread, false) || + contextAccount != util.PtrOrValue(filter.ContextAccount, false) { forbiddenFields = append(forbiddenFields, "context") } if len(forbiddenFields) > 0 { @@ -132,7 +132,7 @@ func (p *Processor) Update( filter.ContextThread = &contextThread filter.ContextAccount = &contextAccount filterKeyword.Keyword = form.Phrase - filterKeyword.WholeWord = util.Ptr(util.PtrValueOr(form.WholeWord, false)) + filterKeyword.WholeWord = util.Ptr(util.PtrOrValue(form.WholeWord, false)) // We only want to update the relevant filter keyword. filter.Keywords = []*gtsmodel.FilterKeyword{filterKeyword} diff --git a/internal/processing/filters/v2/update.go b/internal/processing/filters/v2/update.go index d8297de38..0d443d58e 100644 --- a/internal/processing/filters/v2/update.go +++ b/internal/processing/filters/v2/update.go @@ -189,7 +189,7 @@ func applyKeywordChanges(filter *gtsmodel.Filter, formKeywords []apimodel.Filter FilterID: filter.ID, Filter: filter, Keyword: *formKeyword.Keyword, - WholeWord: util.Ptr(util.PtrValueOr(formKeyword.WholeWord, false)), + WholeWord: util.Ptr(util.PtrOrValue(formKeyword.WholeWord, false)), } filterKeywordsByID[filterKeyword.ID] = filterKeyword // Don't need to set columns, as we're using all of them. diff --git a/internal/storage/storage.go b/internal/storage/storage.go index d05fe3519..508433c1a 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -26,7 +26,6 @@ import ( "net/url" "os" "path" - "syscall" "time" "codeberg.org/gruf/go-bytesize" @@ -245,13 +244,9 @@ func NewFileStorage() (*Driver, error) { // Load runtime configuration basePath := config.GetStorageLocalBasePath() - // Use default disk config but with - // increased write buffer size and - // 'exclusive' bit sets when creating - // files to ensure we don't overwrite - // existing files unless intending to. + // Use default disk config with + // increased write buffer size. diskCfg := disk.DefaultConfig() - diskCfg.OpenWrite.Flags |= syscall.O_EXCL diskCfg.WriteBufSize = int(16 * bytesize.KiB) // Open the disk storage implementation diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 6350f3269..f11c4af21 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -21,8 +21,6 @@ import ( "context" "errors" "fmt" - "math" - "strconv" "strings" "time" @@ -321,9 +319,9 @@ func (c *Converter) accountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A } var ( - locked = util.PtrValueOr(a.Locked, true) - discoverable = util.PtrValueOr(a.Discoverable, false) - bot = util.PtrValueOr(a.Bot, false) + locked = util.PtrOrValue(a.Locked, true) + discoverable = util.PtrOrValue(a.Discoverable, false) + bot = util.PtrOrValue(a.Bot, false) ) // Remaining properties are simple and @@ -565,84 +563,59 @@ func (c *Converter) AppToAPIAppPublic(ctx context.Context, a *gtsmodel.Applicati } // AttachmentToAPIAttachment converts a gts model media attacahment into its api representation for serialization on the API. -func (c *Converter) AttachmentToAPIAttachment(ctx context.Context, a *gtsmodel.MediaAttachment) (apimodel.Attachment, error) { - apiAttachment := apimodel.Attachment{ - ID: a.ID, - Type: strings.ToLower(string(a.Type)), - } +func (c *Converter) AttachmentToAPIAttachment(ctx context.Context, media *gtsmodel.MediaAttachment) (apimodel.Attachment, error) { + var api apimodel.Attachment + api.Type = media.Type.String() + api.ID = media.ID - // Don't try to serialize meta for - // unknown attachments, there's no point. - if a.Type != gtsmodel.FileTypeUnknown { - apiAttachment.Meta = &apimodel.MediaMeta{ - Original: apimodel.MediaDimensions{ - Width: a.FileMeta.Original.Width, - Height: a.FileMeta.Original.Height, - }, - Small: apimodel.MediaDimensions{ - Width: a.FileMeta.Small.Width, - Height: a.FileMeta.Small.Height, - Size: strconv.Itoa(a.FileMeta.Small.Width) + "x" + strconv.Itoa(a.FileMeta.Small.Height), - Aspect: float32(a.FileMeta.Small.Aspect), - }, + // Only add file details if + // we have stored locally. + if media.File.Path != "" { + api.Meta = new(apimodel.MediaMeta) + api.Meta.Original = apimodel.MediaDimensions{ + Width: media.FileMeta.Original.Width, + Height: media.FileMeta.Original.Height, + Aspect: media.FileMeta.Original.Aspect, + Size: toAPISize(media.FileMeta.Original.Width, media.FileMeta.Original.Height), + FrameRate: toAPIFrameRate(media.FileMeta.Original.Framerate), + Duration: util.PtrOrZero(media.FileMeta.Original.Duration), + Bitrate: int(util.PtrOrZero(media.FileMeta.Original.Bitrate)), + } + + // Copy over local file URL. + api.URL = util.Ptr(media.URL) + api.TextURL = util.Ptr(media.URL) + + // Set file focus details. + // (this doesn't make much sense if media + // has no image, but the API doesn't yet + // distinguish between zero values vs. none). + api.Meta.Focus = new(apimodel.MediaFocus) + api.Meta.Focus.X = media.FileMeta.Focus.X + api.Meta.Focus.Y = media.FileMeta.Focus.Y + + // Only add thumbnail details if + // we have thumbnail stored locally. + if media.Thumbnail.Path != "" { + api.Meta.Small = apimodel.MediaDimensions{ + Width: media.FileMeta.Small.Width, + Height: media.FileMeta.Small.Height, + Aspect: media.FileMeta.Small.Aspect, + Size: toAPISize(media.FileMeta.Small.Width, media.FileMeta.Small.Height), + } + + // Copy over local thumbnail file URL. + api.PreviewURL = util.Ptr(media.Thumbnail.URL) } } - if i := a.Blurhash; i != "" { - apiAttachment.Blurhash = &i - } + // Set remaining API attachment fields. + api.Blurhash = util.PtrIf(media.Blurhash) + api.RemoteURL = util.PtrIf(media.RemoteURL) + api.PreviewRemoteURL = util.PtrIf(media.Thumbnail.RemoteURL) + api.Description = util.PtrIf(media.Description) - if i := a.URL; i != "" { - apiAttachment.URL = &i - apiAttachment.TextURL = &i - } - - if i := a.Thumbnail.URL; i != "" { - apiAttachment.PreviewURL = &i - } - - if i := a.RemoteURL; i != "" { - apiAttachment.RemoteURL = &i - } - - if i := a.Thumbnail.RemoteURL; i != "" { - apiAttachment.PreviewRemoteURL = &i - } - - if i := a.Description; i != "" { - apiAttachment.Description = &i - } - - // Type-specific fields. - switch a.Type { - - case gtsmodel.FileTypeImage: - apiAttachment.Meta.Original.Size = strconv.Itoa(a.FileMeta.Original.Width) + "x" + strconv.Itoa(a.FileMeta.Original.Height) - apiAttachment.Meta.Original.Aspect = float32(a.FileMeta.Original.Aspect) - apiAttachment.Meta.Focus = &apimodel.MediaFocus{ - X: a.FileMeta.Focus.X, - Y: a.FileMeta.Focus.Y, - } - - case gtsmodel.FileTypeVideo, gtsmodel.FileTypeAudio: - if i := a.FileMeta.Original.Duration; i != nil { - apiAttachment.Meta.Original.Duration = *i - } - - if i := a.FileMeta.Original.Framerate; i != nil { - // The masto api expects this as a string in - // the format `integer/1`, so 30fps is `30/1`. - round := math.Round(float64(*i)) - fr := strconv.Itoa(int(round)) - apiAttachment.Meta.Original.FrameRate = fr + "/1" - } - - if i := a.FileMeta.Original.Bitrate; i != nil { - apiAttachment.Meta.Original.Bitrate = int(*i) - } - } - - return apiAttachment, nil + return api, nil } // MentionToAPIMention converts a gts model mention into its api (frontend) representation for serialization on the API. @@ -681,6 +654,7 @@ func (c *Converter) MentionToAPIMention(ctx context.Context, m *gtsmodel.Mention // EmojiToAPIEmoji converts a gts model emoji into its api (frontend) representation for serialization on the API. func (c *Converter) EmojiToAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) (apimodel.Emoji, error) { var category string + if e.CategoryID != "" { if e.Category == nil { var err error @@ -778,14 +752,15 @@ func (c *Converter) StatusToAPIStatus( return nil, err } - // Normalize status for the API by pruning - // out unknown attachment types and replacing - // them with a helpful message. + // Normalize status for API by pruning + // attachments that were not locally + // stored, replacing them with a helpful + // message + links to remote. var aside string - aside, apiStatus.MediaAttachments = placeholdUnknownAttachments(apiStatus.MediaAttachments) + aside, apiStatus.MediaAttachments = placeholderAttachments(apiStatus.MediaAttachments) apiStatus.Content += aside if apiStatus.Reblog != nil { - aside, apiStatus.Reblog.MediaAttachments = placeholdUnknownAttachments(apiStatus.Reblog.MediaAttachments) + aside, apiStatus.Reblog.MediaAttachments = placeholderAttachments(apiStatus.Reblog.MediaAttachments) apiStatus.Reblog.Content += aside } @@ -962,15 +937,15 @@ func filterableTextFields(s *gtsmodel.Status) []string { func filterAppliesInContext(filter *gtsmodel.Filter, filterContext statusfilter.FilterContext) bool { switch filterContext { case statusfilter.FilterContextHome: - return util.PtrValueOr(filter.ContextHome, false) + return util.PtrOrValue(filter.ContextHome, false) case statusfilter.FilterContextNotifications: - return util.PtrValueOr(filter.ContextNotifications, false) + return util.PtrOrValue(filter.ContextNotifications, false) case statusfilter.FilterContextPublic: - return util.PtrValueOr(filter.ContextPublic, false) + return util.PtrOrValue(filter.ContextPublic, false) case statusfilter.FilterContextThread: - return util.PtrValueOr(filter.ContextThread, false) + return util.PtrOrValue(filter.ContextThread, false) case statusfilter.FilterContextAccount: - return util.PtrValueOr(filter.ContextAccount, false) + return util.PtrOrValue(filter.ContextAccount, false) } return false } @@ -2083,7 +2058,7 @@ func (c *Converter) FilterKeywordToAPIFilterV1(ctx context.Context, filterKeywor ID: filterKeyword.ID, Phrase: filterKeyword.Keyword, Context: filterToAPIFilterContexts(filter), - WholeWord: util.PtrValueOr(filterKeyword.WholeWord, false), + WholeWord: util.PtrOrValue(filterKeyword.WholeWord, false), ExpiresAt: filterExpiresAtToAPIFilterExpiresAt(filter.ExpiresAt), Irreversible: filter.Action == gtsmodel.FilterActionHide, }, nil @@ -2121,19 +2096,19 @@ func filterExpiresAtToAPIFilterExpiresAt(expiresAt time.Time) *string { func filterToAPIFilterContexts(filter *gtsmodel.Filter) []apimodel.FilterContext { apiContexts := make([]apimodel.FilterContext, 0, apimodel.FilterContextNumValues) - if util.PtrValueOr(filter.ContextHome, false) { + if util.PtrOrValue(filter.ContextHome, false) { apiContexts = append(apiContexts, apimodel.FilterContextHome) } - if util.PtrValueOr(filter.ContextNotifications, false) { + if util.PtrOrValue(filter.ContextNotifications, false) { apiContexts = append(apiContexts, apimodel.FilterContextNotifications) } - if util.PtrValueOr(filter.ContextPublic, false) { + if util.PtrOrValue(filter.ContextPublic, false) { apiContexts = append(apiContexts, apimodel.FilterContextPublic) } - if util.PtrValueOr(filter.ContextThread, false) { + if util.PtrOrValue(filter.ContextThread, false) { apiContexts = append(apiContexts, apimodel.FilterContextThread) } - if util.PtrValueOr(filter.ContextAccount, false) { + if util.PtrOrValue(filter.ContextAccount, false) { apiContexts = append(apiContexts, apimodel.FilterContextAccount) } return apiContexts @@ -2154,7 +2129,7 @@ func (c *Converter) FilterKeywordToAPIFilterKeyword(ctx context.Context, filterK return &apimodel.FilterKeyword{ ID: filterKeyword.ID, Keyword: filterKeyword.Keyword, - WholeWord: util.PtrValueOr(filterKeyword.WholeWord, false), + WholeWord: util.PtrOrValue(filterKeyword.WholeWord, false), } } diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index 9fd4cea46..e9f53e100 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -851,7 +851,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments "muted": false, "bookmarked": false, "pinned": false, - "content": "\u003cp\u003ehi \u003cspan class=\"h-card\"\u003e\u003ca href=\"http://localhost:8080/@admin\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e@\u003cspan\u003eadmin\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e here's some media for ya\u003c/p\u003e\u003chr\u003e\u003cp\u003e\u003ci lang=\"en\"\u003eℹ️ Note from localhost:8080: 2 attachments in this status could not be downloaded. Treat the following external links with care:\u003c/i\u003e\u003c/p\u003e\u003cul\u003e\u003cli\u003e\u003ca href=\"http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e01HE7ZGJYTSYMXF927GF9353KR.svg\u003c/a\u003e [SVG line art of a sloth, public domain]\u003c/li\u003e\u003cli\u003e\u003ca href=\"http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e01HE892Y8ZS68TQCNPX7J888P3.mp3\u003c/a\u003e [Jolly salsa song, public domain.]\u003c/li\u003e\u003c/ul\u003e", + "content": "\u003cp\u003ehi \u003cspan class=\"h-card\"\u003e\u003ca href=\"http://localhost:8080/@admin\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e@\u003cspan\u003eadmin\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e here's some media for ya\u003c/p\u003e\u003chr\u003e\u003chr\u003e\u003cp\u003e\u003ci lang=\"en\"\u003eℹ️ Note from localhost:8080: 2 attachments in this status were not downloaded. Treat the following external links with care:\u003c/i\u003e\u003c/p\u003e\u003cul\u003e\u003cli\u003e\u003ca href=\"http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e01HE7ZGJYTSYMXF927GF9353KR.svg\u003c/a\u003e [SVG line art of a sloth, public domain]\u003c/li\u003e\u003cli\u003e\u003ca href=\"http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e01HE892Y8ZS68TQCNPX7J888P3.mp3\u003c/a\u003e [Jolly salsa song, public domain.]\u003c/li\u003e\u003c/ul\u003e", "reblog": null, "account": { "id": "01FHMQX3GAABWSM0S2VZEC2SWC", @@ -1070,30 +1070,30 @@ func (suite *InternalToFrontendTestSuite) TestStatusToWebStatus() { { "id": "01HE7ZFX9GKA5ZZVD4FACABSS9", "type": "unknown", - "url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE7ZFX9GKA5ZZVD4FACABSS9.svg", - "text_url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE7ZFX9GKA5ZZVD4FACABSS9.svg", - "preview_url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE7ZFX9GKA5ZZVD4FACABSS9.jpg", + "url": null, + "text_url": null, + "preview_url": null, "remote_url": "http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg", "preview_remote_url": null, "meta": null, "description": "SVG line art of a sloth, public domain", "blurhash": "L26*j+~qE1RP?wxut7ofRlM{R*of", "Sensitive": true, - "MIMEType": "image/svg" + "MIMEType": "" }, { "id": "01HE88YG74PVAB81PX2XA9F3FG", "type": "unknown", - "url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE88YG74PVAB81PX2XA9F3FG.mp3", - "text_url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE88YG74PVAB81PX2XA9F3FG.mp3", - "preview_url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE88YG74PVAB81PX2XA9F3FG.jpg", + "url": null, + "text_url": null, + "preview_url": null, "remote_url": "http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3", "preview_remote_url": null, "meta": null, "description": "Jolly salsa song, public domain.", "blurhash": null, "Sensitive": true, - "MIMEType": "audio/mpeg" + "MIMEType": "" } ], "LanguageTag": "en", @@ -1357,13 +1357,19 @@ func (suite *InternalToFrontendTestSuite) TestVideoAttachmentToFrontend() { "height": 404, "frame_rate": "30/1", "duration": 15.033334, - "bitrate": 1206522 + "bitrate": 1206522, + "size": "720x404", + "aspect": 1.7821782 }, "small": { "width": 720, "height": 404, "size": "720x404", "aspect": 1.7821782 + }, + "focus": { + "x": 0, + "y": 0 } }, "description": "A cow adorably licking another cow!", diff --git a/internal/typeutils/util.go b/internal/typeutils/util.go index d674bc150..f28cd2554 100644 --- a/internal/typeutils/util.go +++ b/internal/typeutils/util.go @@ -20,6 +20,7 @@ package typeutils import ( "context" "fmt" + "math" "net/url" "path" "slices" @@ -35,6 +36,26 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/text" ) +// toAPISize converts a set of media dimensions +// to mastodon API compatible size string. +func toAPISize(width, height int) string { + return strconv.Itoa(width) + + "x" + + strconv.Itoa(height) +} + +// toAPIFrameRate converts a media framerate ptr +// to mastodon API compatible framerate string. +func toAPIFrameRate(framerate *float32) string { + if framerate == nil { + return "" + } + // The masto api expects this as a string in + // the format `integer/1`, so 30fps is `30/1`. + round := math.Round(float64(*framerate)) + return strconv.Itoa(int(round)) + "/1" +} + type statusInteractions struct { Favourited bool Muted bool @@ -92,7 +113,7 @@ func misskeyReportInlineURLs(content string) []*url.URL { return urls } -// placeholdUnknownAttachments separates any attachments with type `unknown` +// placeholderAttachments separates any attachments with missing local URL // out of the given slice, and returns a piece of text containing links to // those attachments, as well as the slice of remaining "known" attachments. // If there are no unknown-type attachments in the provided slice, an empty @@ -104,62 +125,61 @@ func misskeyReportInlineURLs(content string) []*url.URL { // Example: // //
-//

ℹ️ Note from your.instance.com: 2 attachments in this status could not be downloaded. Treat the following external links with care:

+//

ℹ️ Note from your.instance.com: 2 attachment(s) in this status were not downloaded. Treat the following external link(s) with care:

// -func placeholdUnknownAttachments(arr []*apimodel.Attachment) (string, []*apimodel.Attachment) { - // Extract unknown-type attachments into a separate - // slice, deleting them from arr in the process. - var unknowns []*apimodel.Attachment - arr = slices.DeleteFunc(arr, func(elem *apimodel.Attachment) bool { - unknown := elem.Type == "unknown" - if unknown { - // Set aside unknown-type attachment. - unknowns = append(unknowns, elem) - } +func placeholderAttachments(arr []*apimodel.Attachment) (string, []*apimodel.Attachment) { - return unknown + // Extract non-locally stored attachments into a + // separate slice, deleting them from input slice. + var nonLocal []*apimodel.Attachment + arr = slices.DeleteFunc(arr, func(elem *apimodel.Attachment) bool { + if elem.URL == nil { + nonLocal = append(nonLocal, elem) + return true + } + return false }) - unknownsLen := len(unknowns) - if unknownsLen == 0 { - // No unknown attachments, - // nothing to do. + if len(nonLocal) == 0 { + // No non-locally + // stored media. return "", arr } - // Plural / singular. - var ( - attachments string - links string - ) - - if unknownsLen == 1 { - attachments = "1 attachment" - links = "link" - } else { - attachments = strconv.Itoa(unknownsLen) + " attachments" - links = "links" - } - var note strings.Builder note.WriteString(`
`) - note.WriteString(`

`) - note.WriteString(`ℹ️ Note from ` + config.GetHost() + `: ` + attachments + ` in this status could not be downloaded. Treat the following external ` + links + ` with care:`) - note.WriteString(`

`) - note.WriteString(`
    `) - for _, a := range unknowns { - var ( - remoteURL = *a.RemoteURL - base = path.Base(remoteURL) - entry = fmt.Sprintf(`%s`, remoteURL, base) - ) + note.WriteString(`

    ℹ️ Note from `) + note.WriteString(config.GetHost()) + note.WriteString(`: `) + note.WriteString(strconv.Itoa(len(nonLocal))) + + if len(nonLocal) > 1 { + // Use plural word form. + note.WriteString(` attachments in this status were not downloaded. ` + + `Treat the following external links with care:`) + } else { + // Use singular word form. + note.WriteString(` attachment in this status was not downloaded. ` + + `Treat the following external link with care:`) + } + + note.WriteString(`

      `) + for _, a := range nonLocal { + note.WriteString(`
    • `) + note.WriteString(``) + note.WriteString(path.Base(*a.RemoteURL)) + note.WriteString(``) if d := a.Description; d != nil && *d != "" { - entry += ` [` + *d + `]` + note.WriteString(` [`) + note.WriteString(*d) + note.WriteString(`]`) } - note.WriteString(`
    • ` + entry + `
    • `) + note.WriteString(``) } note.WriteString(`
    `) diff --git a/internal/util/ptr.go b/internal/util/ptr.go index d7c30da85..8a89666c4 100644 --- a/internal/util/ptr.go +++ b/internal/util/ptr.go @@ -43,10 +43,19 @@ func PtrIf[T comparable](t T) *T { return &t } -// PtrValueOr returns either value of ptr, or default. -func PtrValueOr[T any](t *T, _default T) T { +// PtrOrZero returns either value of ptr, or zero. +func PtrOrZero[T any](t *T) T { + if t == nil { + var z T + return z + } + return *t +} + +// PtrOrValue returns either contained value of ptr, or 'value'. +func PtrOrValue[T any](t *T, value T) T { if t != nil { return *t } - return _default + return value } diff --git a/testrig/testmodels.go b/testrig/testmodels.go index efd4785a5..c0cf47b81 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -1188,20 +1188,11 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { Description: "SVG line art of a sloth, public domain", Blurhash: "L26*j+~qE1RP?wxut7ofRlM{R*of", Processing: 2, - File: gtsmodel.File{ - Path: "01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE7ZFX9GKA5ZZVD4FACABSS9.svg", - ContentType: "image/svg", - FileSize: 147819, - }, - Thumbnail: gtsmodel.Thumbnail{ - Path: "01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE7ZFX9GKA5ZZVD4FACABSS9.jpg", - ContentType: "image/jpeg", - FileSize: 0, - URL: "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE7ZFX9GKA5ZZVD4FACABSS9.jpg", - }, - Avatar: util.Ptr(false), - Header: util.Ptr(false), - Cached: util.Ptr(false), + File: gtsmodel.File{}, + Thumbnail: gtsmodel.Thumbnail{RemoteURL: ""}, + Avatar: util.Ptr(false), + Header: util.Ptr(false), + Cached: util.Ptr(false), }, "remote_account_2_status_1_attachment_3": { ID: "01HE88YG74PVAB81PX2XA9F3FG", @@ -1216,20 +1207,11 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { Description: "Jolly salsa song, public domain.", Blurhash: "", Processing: 2, - File: gtsmodel.File{ - Path: "01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE88YG74PVAB81PX2XA9F3FG.mp3", - ContentType: "audio/mpeg", - FileSize: 147819, - }, - Thumbnail: gtsmodel.Thumbnail{ - Path: "01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE88YG74PVAB81PX2XA9F3FG.jpg", - ContentType: "image/jpeg", - FileSize: 0, - URL: "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE88YG74PVAB81PX2XA9F3FG.jpg", - }, - Avatar: util.Ptr(false), - Header: util.Ptr(false), - Cached: util.Ptr(false), + File: gtsmodel.File{}, + Thumbnail: gtsmodel.Thumbnail{RemoteURL: ""}, + Avatar: util.Ptr(false), + Header: util.Ptr(false), + Cached: util.Ptr(false), }, } }