added full gifts support
This commit is contained in:
parent
ea5b86de8f
commit
354128c106
35 changed files with 1502 additions and 103 deletions
199
internal/app/files/gif_admin.go
Normal file
199
internal/app/files/gif_admin.go
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// ValidateGifUpload is a pure check (no store writes), used by a dry-run
|
||||
// preview before AdminUploadGifMaterial actually materializes the document.
|
||||
func (s *Service) ValidateGifUpload(fileName string, data []byte) (string, bool) {
|
||||
if len(data) == 0 || int64(len(data)) > domain.MaxGifCatalogUploadSize {
|
||||
return "", false
|
||||
}
|
||||
return detectGifCatalogUploadMime(data)
|
||||
}
|
||||
|
||||
// AdminUploadGifMaterial turns a raw uploaded GIF/MP4 file into a loose
|
||||
// Document not yet attached to any catalog entry -- the
|
||||
// upload-bytes-into-a-document-row shape AdminUploadStickerMaterial uses for
|
||||
// stickers, but routed through the same GIF->MP4 normalization the ordinary
|
||||
// user upload path uses (normalizeUploadedGIF in photos.go).
|
||||
//
|
||||
// Transcoding is mandatory, not an optimization: a Telegram client only treats
|
||||
// a document as a playable GIF when it is a silent H.264 MP4 carrying a video
|
||||
// attribute (DocumentData::isGifv() requires mime video/mp4, and the inline
|
||||
// GIF layout sizes each cell from document->dimensions). Storing the raw
|
||||
// upload would produce a catalog entry the picker lays out at zero size and
|
||||
// never animates, so both mime shapes go through the transcoder: it returns
|
||||
// the canonical bytes plus the real width/height/duration the attributes need.
|
||||
//
|
||||
// MP4 input works even though the transcoder stages its input in a .gif temp
|
||||
// file -- ffmpeg detects the format from content, not the extension -- and
|
||||
// re-encoding it is what guarantees faststart/yuv420p/no-audio regardless of
|
||||
// how the operator's file was produced.
|
||||
func (s *Service) AdminUploadGifMaterial(ctx context.Context, fileName string, data []byte) (domain.Document, error) {
|
||||
if len(data) == 0 || int64(len(data)) > domain.MaxGifCatalogUploadSize {
|
||||
return domain.Document{}, domain.ErrGifCatalogFileInvalid
|
||||
}
|
||||
if _, ok := detectGifCatalogUploadMime(data); !ok {
|
||||
return domain.Document{}, domain.ErrGifCatalogFileInvalid
|
||||
}
|
||||
if s.gifs == nil {
|
||||
return domain.Document{}, fmt.Errorf("%w: ffmpeg/ffprobe are required to normalize a GIF for playback", domain.ErrGifCatalogFileInvalid)
|
||||
}
|
||||
converted, err := s.gifs.Transcode(ctx, data)
|
||||
if err != nil || len(converted.Data) == 0 || converted.Width <= 0 || converted.Height <= 0 {
|
||||
s.log.Warn("admin GIF catalog upload conversion failed", zap.Int("input_bytes", len(data)), zap.Error(err))
|
||||
return domain.Document{}, domain.ErrGifCatalogFileInvalid
|
||||
}
|
||||
objectKey, err := s.blobs.Put(ctx, converted.Data)
|
||||
if err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
sum := sha256.Sum256(converted.Data)
|
||||
docID := randomID()
|
||||
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("doc:%d", docID),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len(converted.Data)),
|
||||
SHA256: append([]byte(nil), sum[:]...),
|
||||
MimeType: "video/mp4",
|
||||
}); err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
name := strings.TrimSpace(fileName)
|
||||
if name == "" {
|
||||
name = "animation.gif"
|
||||
}
|
||||
doc := domain.Document{
|
||||
ID: docID,
|
||||
AccessHash: randomID(),
|
||||
FileReference: randomFileReference(),
|
||||
Date: int(time.Now().Unix()),
|
||||
MimeType: "video/mp4",
|
||||
Size: int64(len(converted.Data)),
|
||||
DCID: s.dc,
|
||||
Attributes: canonicalGIFVideoAttributes(
|
||||
[]domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: name}},
|
||||
converted, false),
|
||||
}
|
||||
if err := s.media.PutDocument(ctx, doc); err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// detectGifCatalogUploadMime accepts exactly what an inline "gif" result is
|
||||
// allowed to carry (see inlineExternalContentMimeAllowed): a real GIF, or an
|
||||
// MP4 (Telegram normalizes animated GIFs to silent MP4 for delivery, so an
|
||||
// operator uploading an already-converted MP4 is the common case, not an
|
||||
// edge case).
|
||||
func detectGifCatalogUploadMime(data []byte) (string, bool) {
|
||||
switch {
|
||||
case len(data) >= 6 && (string(data[0:6]) == "GIF87a" || string(data[0:6]) == "GIF89a"):
|
||||
return "image/gif", true
|
||||
case len(data) >= 12 && string(data[4:8]) == "ftyp":
|
||||
return "video/mp4", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// AdminCreateGifCatalogEntry adds an already-materialized document (from
|
||||
// AdminUploadGifMaterial) to the catalog @gif serves.
|
||||
func (s *Service) AdminCreateGifCatalogEntry(ctx context.Context, title string, documentID int64) (domain.GifCatalogEntry, error) {
|
||||
if s.gifCatalog == nil {
|
||||
return domain.GifCatalogEntry{}, domain.ErrGifCatalogUnavailable
|
||||
}
|
||||
title = strings.TrimSpace(title)
|
||||
if len(title) > domain.MaxGifCatalogTitleLen || documentID == 0 {
|
||||
return domain.GifCatalogEntry{}, domain.ErrGifCatalogEntryInvalid
|
||||
}
|
||||
if _, found, err := s.media.GetDocument(ctx, documentID); err != nil {
|
||||
return domain.GifCatalogEntry{}, err
|
||||
} else if !found {
|
||||
return domain.GifCatalogEntry{}, domain.ErrGifCatalogEntryInvalid
|
||||
}
|
||||
entry, err := s.gifCatalog.CreateGifCatalogEntry(ctx, domain.GifCatalogEntry{
|
||||
ID: randomID(),
|
||||
Title: title,
|
||||
DocumentID: documentID,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.GifCatalogEntry{}, err
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// AdminListGifCatalog returns every entry (enabled and disabled), for the
|
||||
// admin panel's list view.
|
||||
func (s *Service) AdminListGifCatalog(ctx context.Context) ([]domain.GifCatalogEntry, error) {
|
||||
if s.gifCatalog == nil {
|
||||
return nil, domain.ErrGifCatalogUnavailable
|
||||
}
|
||||
return s.gifCatalog.ListGifCatalog(ctx, false)
|
||||
}
|
||||
|
||||
// ListGifCatalog is bots.gifCatalogSource's read: onlyEnabled=true is what
|
||||
// @gif actually serves.
|
||||
func (s *Service) ListGifCatalog(ctx context.Context, onlyEnabled bool) ([]domain.GifCatalogEntry, error) {
|
||||
if s.gifCatalog == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.gifCatalog.ListGifCatalog(ctx, onlyEnabled)
|
||||
}
|
||||
|
||||
// AdminSetGifCatalogEnabled toggles whether an entry is served.
|
||||
func (s *Service) AdminSetGifCatalogEnabled(ctx context.Context, id int64, enabled bool) (bool, error) {
|
||||
if s.gifCatalog == nil {
|
||||
return false, domain.ErrGifCatalogUnavailable
|
||||
}
|
||||
changed, err := s.gifCatalog.SetGifCatalogEnabled(ctx, id, enabled)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !changed {
|
||||
return false, domain.ErrGifCatalogEntryNotFound
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// AdminSetGifCatalogSortOrder rewrites an entry's display position.
|
||||
func (s *Service) AdminSetGifCatalogSortOrder(ctx context.Context, id int64, order int) (bool, error) {
|
||||
if s.gifCatalog == nil {
|
||||
return false, domain.ErrGifCatalogUnavailable
|
||||
}
|
||||
changed, err := s.gifCatalog.SetGifCatalogSortOrder(ctx, id, order)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !changed {
|
||||
return false, domain.ErrGifCatalogEntryNotFound
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// AdminDeleteGifCatalogEntry removes an entry from the catalog. The
|
||||
// referenced document is left alone.
|
||||
func (s *Service) AdminDeleteGifCatalogEntry(ctx context.Context, id int64) (bool, error) {
|
||||
if s.gifCatalog == nil {
|
||||
return false, domain.ErrGifCatalogUnavailable
|
||||
}
|
||||
changed, err := s.gifCatalog.DeleteGifCatalogEntry(ctx, id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !changed {
|
||||
return false, domain.ErrGifCatalogEntryNotFound
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
|
@ -46,8 +46,8 @@ const (
|
|||
|
||||
// Service 实现 upload 分片累积、blob 落盘、getFile 下载,并把上传文件组装成 Photo / Document。
|
||||
type Service struct {
|
||||
media store.MediaStore
|
||||
blobs BlobBackend
|
||||
media store.MediaStore
|
||||
blobs BlobBackend
|
||||
// otherBackends holds additional, non-active BlobBackend instances keyed
|
||||
// by Name() (e.g. "localfs" while s3 is active, or vice versa). Only
|
||||
// used for reading/deleting rows written before the deployment switched
|
||||
|
|
@ -56,14 +56,14 @@ type Service struct {
|
|||
// (the row/bytes still exist, but nothing would know how to reach them).
|
||||
otherBackends map[string]BlobBackend
|
||||
uploadParts UploadPartBackend
|
||||
dc int
|
||||
log *zap.Logger
|
||||
thumbs VideoThumbnailer
|
||||
thumbsSet bool
|
||||
gifs GIFTranscoder
|
||||
gifsSet bool
|
||||
blobCache *blobMetaCache
|
||||
byteCache *blobBytesCache
|
||||
dc int
|
||||
log *zap.Logger
|
||||
thumbs VideoThumbnailer
|
||||
thumbsSet bool
|
||||
gifs GIFTranscoder
|
||||
gifsSet bool
|
||||
blobCache *blobMetaCache
|
||||
byteCache *blobBytesCache
|
||||
// blobMetaSF/blobBytesSF 合并对同一热 blob 的并发首次访问:否则每个并发 getFile 都各打
|
||||
// 一发 PG GetFileBlob + backend GetRange(热门贴纸/reaction/头像被大量用户同时拉时尤甚)。
|
||||
blobMetaSF singleflight.Group
|
||||
|
|
@ -87,6 +87,8 @@ type Service struct {
|
|||
premiumPromoMu sync.RWMutex
|
||||
premiumPromo domain.PremiumPromoCatalog
|
||||
premiumPromoReady bool
|
||||
|
||||
gifCatalog store.GifCatalogStore
|
||||
}
|
||||
|
||||
// Option 配置 files 服务的可选能力。
|
||||
|
|
@ -165,6 +167,19 @@ func WithUploadPartBackend(backend UploadPartBackend) Option {
|
|||
}
|
||||
}
|
||||
|
||||
// WithGifCatalog injects the store backing the admin-curated GIF catalog
|
||||
// (AdminUploadGifMaterial/AdminCreateGifCatalogEntry and friends below, plus
|
||||
// the ListGifCatalog the built-in @gif inline bot reads through
|
||||
// bots.gifCatalogSource). Without it those methods report
|
||||
// domain.ErrGifCatalogUnavailable.
|
||||
func WithGifCatalog(c store.GifCatalogStore) Option {
|
||||
return func(s *Service) {
|
||||
if c != nil {
|
||||
s.gifCatalog = c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewService 创建 files 服务。dc 是本 server 的 DC id,写入新建 document/photo 的 dc_id。
|
||||
func NewService(media store.MediaStore, blobs BlobBackend, dc int, opts ...Option) *Service {
|
||||
s := &Service{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue