feat: sync GIFv and saved GIF support
This commit is contained in:
parent
c0088f1160
commit
5f7c0b9804
21 changed files with 641 additions and 50 deletions
|
|
@ -49,8 +49,8 @@ codebase.
|
|||
| ✅ | Rich messages | Telegram Desktop rich text messages, rich content conversion, send/edit/scheduled flows, dialog/history projections, and memory/PostgreSQL persistence. |
|
||||
| ✅ | AI compose and ChatBot | Input-box rewrite/polish, default and custom tones, addstyle previews, local and external provider chains, streamed `@ChatBot` draft replies, and Business AI reply hooks. |
|
||||
| ✅ | Supergroups and channels | Create, join, leave, invite links, participants, admins, forum topics, history, send/edit/delete/read, reactions, public search, and previews. |
|
||||
| ✅ | Media and files | Upload, download, local blob storage, photos, documents, thumbnails, external media fetch, web page previews, map tile cache hooks, profile/channel photos. |
|
||||
| ✅ | Stickers and reactions | Sticker/reaction catalog, seed support, recent reactions, top reactions, default reactions, and moderation-oriented reaction paths. |
|
||||
| ✅ | Media and files | Upload, download, local blob storage, photos, documents, thumbnails, canonical GIFv conversion, external media fetch, web page previews, map tile cache hooks, profile/channel photos. |
|
||||
| ✅ | Stickers and reactions | Sticker/reaction catalog, seed support, saved GIFs, recent reactions, top reactions, default reactions, and moderation-oriented reaction paths. |
|
||||
| ✅ | Gifts and stars | Star gifts and local stars ledger foundations for compatibility and future feature work. |
|
||||
| ✅ | Bots and mini apps | Bot service foundations, callbacks, inline helpers, webview/mini-app paths, a minimal Bot API gateway for libraries such as `python-telegram-bot`, persistent `getUpdates` delivery, and demo tools. |
|
||||
| ✅ | Calls and live streams | Private call signaling foundations, group call state, RTMP live streaming, scheduled video chats, channel `join_as`, SFU/TURN building blocks, liveness, and expiry workers. |
|
||||
|
|
|
|||
|
|
@ -46,8 +46,8 @@ https://github.com/user-attachments/assets/25e651dc-a022-4d60-8b9b-ca3e8bfe216c
|
|||
| ✅ | 富文本消息 | Telegram Desktop rich text message、富文本内容转换、send/edit/scheduled 流程、dialog/history 投影,以及 memory/PostgreSQL 持久化。 |
|
||||
| ✅ | AI 输入框与 ChatBot | 输入框改写/润色、默认和自定义 tone、addstyle 预览、本地与外部 provider 链、流式 `@ChatBot` 草稿回复、Business AI 回复钩子。 |
|
||||
| ✅ | 超级群与频道 | create、join、leave、邀请链接、成员、管理员、forum topics、history、send/edit/delete/read、reactions、公开搜索和预览。 |
|
||||
| ✅ | 媒体与文件 | upload、download、本地 blob 存储、照片、文档、缩略图、外链媒体抓取、网页预览、地图缩略图缓存、用户/频道头像。 |
|
||||
| ✅ | Stickers 与 Reactions | sticker/reaction catalog、seed 支持、recent reactions、top reactions、default reactions、reaction moderation 相关路径。 |
|
||||
| ✅ | 媒体与文件 | upload、download、本地 blob 存储、照片、文档、缩略图、规范 GIFv 转换、外链媒体抓取、网页预览、地图缩略图缓存、用户/频道头像。 |
|
||||
| ✅ | Stickers 与 Reactions | sticker/reaction catalog、seed 支持、saved GIFs、recent reactions、top reactions、default reactions、reaction moderation 相关路径。 |
|
||||
| ✅ | Gifts 与 Stars | star gifts、本地 stars ledger 基础,用于兼容和后续功能扩展。 |
|
||||
| ✅ | Bots 与 Mini Apps | bot 服务基础、callbacks、inline helpers、webview/mini-app 路径、适配 `python-telegram-bot` 等库的最小 Bot API gateway、持久化 `getUpdates` 投递队列和 demo 工具。 |
|
||||
| ✅ | 通话与直播 | 私聊通话信令基础、group call 状态、RTMP live stream、定时视频通话、频道 `join_as` 身份、SFU/TURN building blocks、liveness 与 expiry worker。 |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
DROP INDEX IF EXISTS user_sticker_collections_order_idx;
|
||||
CREATE INDEX user_sticker_collections_order_idx
|
||||
ON user_sticker_collections (owner_user_id, kind, used_at DESC);
|
||||
ALTER TABLE user_sticker_collections DROP COLUMN IF EXISTS order_key;
|
||||
DROP SEQUENCE IF EXISTS user_sticker_collections_order_key_seq;
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
CREATE SEQUENCE IF NOT EXISTS user_sticker_collections_order_key_seq;
|
||||
|
||||
ALTER TABLE user_sticker_collections ADD COLUMN order_key bigint;
|
||||
|
||||
WITH ordered AS (
|
||||
SELECT ctid, row_number() OVER (
|
||||
ORDER BY owner_user_id, kind, used_at, document_id
|
||||
) AS n
|
||||
FROM user_sticker_collections
|
||||
)
|
||||
UPDATE user_sticker_collections AS c
|
||||
SET order_key = ordered.n
|
||||
FROM ordered
|
||||
WHERE c.ctid = ordered.ctid;
|
||||
|
||||
SELECT setval(
|
||||
'user_sticker_collections_order_key_seq',
|
||||
GREATEST(COALESCE((SELECT max(order_key) FROM user_sticker_collections), 0), 1),
|
||||
true
|
||||
);
|
||||
|
||||
ALTER TABLE user_sticker_collections
|
||||
ALTER COLUMN order_key SET DEFAULT nextval('user_sticker_collections_order_key_seq'),
|
||||
ALTER COLUMN order_key SET NOT NULL;
|
||||
|
||||
DROP INDEX IF EXISTS user_sticker_collections_order_idx;
|
||||
CREATE INDEX user_sticker_collections_order_idx
|
||||
ON user_sticker_collections (owner_user_id, kind, order_key DESC);
|
||||
|
||||
-- Saved GIFs must reference canonical GIFv documents. Remove dangling/raw-GIF
|
||||
-- entries explicitly instead of making getSavedGifs silently skip bad state.
|
||||
DELETE FROM user_sticker_collections AS c
|
||||
WHERE c.kind = 'gif'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM documents AS d
|
||||
WHERE d.id = c.document_id
|
||||
AND lower(d.mime_type) = 'video/mp4'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(d.attributes) a
|
||||
WHERE a->>'kind' = 'animated'
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(d.attributes) a
|
||||
WHERE a->>'kind' = 'video'
|
||||
AND COALESCE((a->>'round_message')::boolean, false) = false
|
||||
AND COALESCE((a->>'w')::int, 0) > 0
|
||||
AND COALESCE((a->>'h')::int, 0) > 0
|
||||
AND COALESCE((a->>'duration')::double precision, 0) > 0
|
||||
)
|
||||
);
|
||||
|
||||
-- Legacy raw image/gif rows are ordinary files, not TDesktop GIFv. Repair the
|
||||
-- durable shared-media indexes without pretending their bytes were transcoded.
|
||||
DELETE FROM message_box_media AS i
|
||||
USING message_boxes AS b
|
||||
WHERE i.owner_user_id = b.owner_user_id
|
||||
AND i.box_id = b.box_id
|
||||
AND i.category = 3
|
||||
AND lower(COALESCE(b.media #>> '{document,mime_type}', '')) = 'image/gif';
|
||||
|
||||
INSERT INTO message_box_media (owner_user_id, box_id, peer_id, category, message_date)
|
||||
SELECT b.owner_user_id, b.box_id, b.peer_id, 4, b.message_date
|
||||
FROM message_boxes AS b
|
||||
WHERE NOT b.deleted
|
||||
AND lower(COALESCE(b.media #>> '{document,mime_type}', '')) = 'image/gif'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
DELETE FROM channel_message_media AS i
|
||||
USING channel_messages AS m
|
||||
WHERE i.channel_id = m.channel_id
|
||||
AND i.id = m.id
|
||||
AND i.category = 3
|
||||
AND lower(COALESCE(m.media #>> '{document,mime_type}', '')) = 'image/gif';
|
||||
|
||||
INSERT INTO channel_message_media (channel_id, id, category, message_date)
|
||||
SELECT m.channel_id, m.id, 4, m.message_date
|
||||
FROM channel_messages AS m
|
||||
WHERE NOT m.deleted
|
||||
AND lower(COALESCE(m.media #>> '{document,mime_type}', '')) = 'image/gif'
|
||||
ON CONFLICT DO NOTHING;
|
||||
177
internal/app/files/gif_transcoder.go
Normal file
177
internal/app/files/gif_transcoder.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
gifTranscodeTimeout = 20 * time.Second
|
||||
gifTranscodeMaxInputBytes = 50 << 20
|
||||
gifTranscodeMaxOutputBytes = 200 << 20
|
||||
gifTranscodeMaxConcurrent = 2
|
||||
)
|
||||
|
||||
// GIFVideo 是服务端把真实 GIF 规范化为 Telegram GIFv 后的结果。
|
||||
type GIFVideo struct {
|
||||
Data []byte
|
||||
Width int
|
||||
Height int
|
||||
Duration float64
|
||||
}
|
||||
|
||||
// GIFTranscoder 必须输出无声、faststart 的 H.264 MP4,并返回可持久化的视频元数据。
|
||||
type GIFTranscoder interface {
|
||||
Transcode(ctx context.Context, data []byte) (GIFVideo, error)
|
||||
}
|
||||
|
||||
type FFmpegGIFTranscoder struct {
|
||||
ffmpeg string
|
||||
ffprobe string
|
||||
timeout time.Duration
|
||||
slots chan struct{}
|
||||
}
|
||||
|
||||
func NewFFmpegGIFTranscoder() (*FFmpegGIFTranscoder, error) {
|
||||
ffmpeg, err := exec.LookPath("ffmpeg")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ffprobeName := "ffprobe"
|
||||
if ext := filepath.Ext(ffmpeg); ext != "" {
|
||||
ffprobeName += ext
|
||||
}
|
||||
ffprobe := filepath.Join(filepath.Dir(ffmpeg), ffprobeName)
|
||||
if _, err := os.Stat(ffprobe); err != nil {
|
||||
ffprobe, err = exec.LookPath("ffprobe")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &FFmpegGIFTranscoder{
|
||||
ffmpeg: ffmpeg, ffprobe: ffprobe, timeout: gifTranscodeTimeout,
|
||||
slots: make(chan struct{}, gifTranscodeMaxConcurrent),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *FFmpegGIFTranscoder) Transcode(ctx context.Context, data []byte) (GIFVideo, error) {
|
||||
if t == nil || t.ffmpeg == "" || t.ffprobe == "" {
|
||||
return GIFVideo{}, fmt.Errorf("gif transcoder unavailable")
|
||||
}
|
||||
if len(data) == 0 || len(data) > gifTranscodeMaxInputBytes {
|
||||
return GIFVideo{}, fmt.Errorf("gif input size out of range: %d", len(data))
|
||||
}
|
||||
select {
|
||||
case t.slots <- struct{}{}:
|
||||
defer func() { <-t.slots }()
|
||||
case <-ctx.Done():
|
||||
return GIFVideo{}, ctx.Err()
|
||||
}
|
||||
runCtx, cancel := context.WithTimeout(ctx, t.timeout)
|
||||
defer cancel()
|
||||
|
||||
input, err := os.CreateTemp("", "telesrv-gif-*.gif")
|
||||
if err != nil {
|
||||
return GIFVideo{}, fmt.Errorf("create gif input: %w", err)
|
||||
}
|
||||
inputPath := input.Name()
|
||||
defer os.Remove(inputPath)
|
||||
if _, err := input.Write(data); err != nil {
|
||||
input.Close()
|
||||
return GIFVideo{}, fmt.Errorf("write gif input: %w", err)
|
||||
}
|
||||
if err := input.Close(); err != nil {
|
||||
return GIFVideo{}, fmt.Errorf("close gif input: %w", err)
|
||||
}
|
||||
output, err := os.CreateTemp("", "telesrv-gifv-*.mp4")
|
||||
if err != nil {
|
||||
return GIFVideo{}, fmt.Errorf("create gif output: %w", err)
|
||||
}
|
||||
outputPath := output.Name()
|
||||
output.Close()
|
||||
defer os.Remove(outputPath)
|
||||
|
||||
cmd := exec.CommandContext(runCtx, t.ffmpeg,
|
||||
"-hide_banner", "-loglevel", "error", "-y",
|
||||
"-i", inputPath, "-map", "0:v:0", "-an",
|
||||
"-vf", "scale=ceil(iw/2)*2:ceil(ih/2)*2:flags=lanczos",
|
||||
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
|
||||
"-pix_fmt", "yuv420p", "-movflags", "+faststart", outputPath)
|
||||
stderr, err := cmd.CombinedOutput()
|
||||
if runCtx.Err() != nil {
|
||||
return GIFVideo{}, runCtx.Err()
|
||||
}
|
||||
if err != nil {
|
||||
return GIFVideo{}, commandError("ffmpeg gif transcode", err, stderr)
|
||||
}
|
||||
info, err := os.Stat(outputPath)
|
||||
if err != nil || info.Size() <= 0 || info.Size() > gifTranscodeMaxOutputBytes {
|
||||
return GIFVideo{}, fmt.Errorf("gif output size invalid")
|
||||
}
|
||||
meta, err := t.probe(runCtx, outputPath)
|
||||
if err != nil {
|
||||
return GIFVideo{}, err
|
||||
}
|
||||
video, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
return GIFVideo{}, fmt.Errorf("read gif output: %w", err)
|
||||
}
|
||||
meta.Data = video
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func (t *FFmpegGIFTranscoder) probe(ctx context.Context, path string) (GIFVideo, error) {
|
||||
cmd := exec.CommandContext(ctx, t.ffprobe, "-v", "error", "-select_streams", "v:0",
|
||||
"-show_entries", "stream=width,height,duration:format=duration", "-of", "json", path)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return GIFVideo{}, commandError("ffprobe gif output", err, out)
|
||||
}
|
||||
var result struct {
|
||||
Streams []struct {
|
||||
Width, Height int
|
||||
Duration string
|
||||
} `json:"streams"`
|
||||
Format struct {
|
||||
Duration string `json:"duration"`
|
||||
} `json:"format"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &result); err != nil || len(result.Streams) != 1 {
|
||||
return GIFVideo{}, fmt.Errorf("invalid ffprobe gif metadata")
|
||||
}
|
||||
duration := parsePositiveFloat(result.Streams[0].Duration)
|
||||
if duration == 0 {
|
||||
duration = parsePositiveFloat(result.Format.Duration)
|
||||
}
|
||||
stream := result.Streams[0]
|
||||
if stream.Width <= 0 || stream.Height <= 0 || duration <= 0 {
|
||||
return GIFVideo{}, fmt.Errorf("incomplete gif video metadata")
|
||||
}
|
||||
return GIFVideo{Width: stream.Width, Height: stream.Height, Duration: duration}, nil
|
||||
}
|
||||
|
||||
func parsePositiveFloat(value string) float64 {
|
||||
v, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
if err != nil || v <= 0 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func commandError(op string, err error, output []byte) error {
|
||||
msg := strings.TrimSpace(string(output))
|
||||
if len(msg) > 512 {
|
||||
msg = msg[:512]
|
||||
}
|
||||
if msg == "" {
|
||||
return fmt.Errorf("%s: %w", op, err)
|
||||
}
|
||||
return fmt.Errorf("%s: %w: %s", op, err, msg)
|
||||
}
|
||||
39
internal/app/files/gif_transcoder_test.go
Normal file
39
internal/app/files/gif_transcoder_test.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/gif"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFFmpegGIFTranscoderProducesCanonicalMP4(t *testing.T) {
|
||||
transcoder, err := NewFFmpegGIFTranscoder()
|
||||
if err != nil {
|
||||
t.Skipf("ffmpeg/ffprobe unavailable: %v", err)
|
||||
}
|
||||
palette := color.Palette{color.Black, color.White}
|
||||
first := image.NewPaletted(image.Rect(0, 0, 3, 5), palette)
|
||||
second := image.NewPaletted(image.Rect(0, 0, 3, 5), palette)
|
||||
for i := range second.Pix {
|
||||
second.Pix[i] = 1
|
||||
}
|
||||
var input bytes.Buffer
|
||||
if err := gif.EncodeAll(&input, &gif.GIF{
|
||||
Image: []*image.Paletted{first, second}, Delay: []int{10, 10}, LoopCount: 0,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := transcoder.Transcode(context.Background(), input.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("Transcode: %v", err)
|
||||
}
|
||||
if len(result.Data) < 12 || string(result.Data[4:8]) != "ftyp" {
|
||||
t.Fatalf("output is not MP4: %x", result.Data[:min(len(result.Data), 16)])
|
||||
}
|
||||
if result.Width != 4 || result.Height != 6 || result.Duration <= 0 {
|
||||
t.Fatalf("metadata = %+v, want even 4x6 positive duration", result)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
|
|
@ -242,6 +243,10 @@ func (s *Service) CreateDocumentFromUpload(ctx context.Context, file domain.Uplo
|
|||
if body.Size == 0 {
|
||||
return domain.Document{}, domain.ErrDocumentInvalid
|
||||
}
|
||||
body, spec, err = s.normalizeUploadedGIF(ctx, body, spec)
|
||||
if err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
// faststart:MP4 视频若 moov 在末尾,搬到文件头以支持流式播放。普通 Telegram 客户端
|
||||
// 上传前会做这步;DrKLO 发 story 视频不转码导致 moov 在末尾,TDesktop 流式播放路径
|
||||
// 无法解复用(av_read_frame Invalid data)。不转码、保留原编码(含 HEVC)。
|
||||
|
|
@ -306,6 +311,55 @@ func (s *Service) CreateDocumentFromUpload(ctx context.Context, file domain.Uplo
|
|||
return doc, nil
|
||||
}
|
||||
|
||||
func (s *Service) normalizeUploadedGIF(ctx context.Context, body assembledUploadBlob, spec domain.DocumentSpec) (assembledUploadBlob, domain.DocumentSpec, error) {
|
||||
if !strings.EqualFold(strings.TrimSpace(spec.MimeType), "image/gif") || spec.ForceFile {
|
||||
return body, spec, nil
|
||||
}
|
||||
if s.gifs == nil || body.Size <= 0 || body.Size > gifTranscodeMaxInputBytes {
|
||||
return assembledUploadBlob{}, domain.DocumentSpec{}, domain.ErrDocumentInvalid
|
||||
}
|
||||
data, total, err := s.blobs.GetRange(ctx, body.ObjectKey, 0, gifTranscodeMaxInputBytes+1)
|
||||
if err != nil || total != body.Size || int64(len(data)) != body.Size {
|
||||
return assembledUploadBlob{}, domain.DocumentSpec{}, domain.ErrDocumentInvalid
|
||||
}
|
||||
started := time.Now()
|
||||
converted, err := s.gifs.Transcode(ctx, data)
|
||||
if err != nil || len(converted.Data) == 0 || converted.Width <= 0 || converted.Height <= 0 || converted.Duration <= 0 {
|
||||
s.log.Warn("GIF to MP4 conversion failed", zap.Int64("input_bytes", body.Size), zap.Error(err))
|
||||
return assembledUploadBlob{}, domain.DocumentSpec{}, domain.ErrDocumentInvalid
|
||||
}
|
||||
objectKey, err := s.blobs.Put(ctx, converted.Data)
|
||||
if err != nil {
|
||||
return assembledUploadBlob{}, domain.DocumentSpec{}, err
|
||||
}
|
||||
sum := sha256.Sum256(converted.Data)
|
||||
body = assembledUploadBlob{ObjectKey: objectKey, Size: int64(len(converted.Data)), SHA256: append([]byte(nil), sum[:]...)}
|
||||
spec.MimeType = "video/mp4"
|
||||
spec.Attributes = canonicalGIFVideoAttributes(spec.Attributes, converted, spec.NosoundVideo)
|
||||
s.log.Info("GIF normalized to MP4",
|
||||
zap.Int64("input_bytes", total), zap.Int("output_bytes", len(converted.Data)),
|
||||
zap.Int("width", converted.Width), zap.Int("height", converted.Height),
|
||||
zap.Float64("duration", converted.Duration), zap.Duration("dur", time.Since(started)))
|
||||
return body, spec, nil
|
||||
}
|
||||
|
||||
func canonicalGIFVideoAttributes(attrs []domain.DocumentAttribute, video GIFVideo, nosoundVideo bool) []domain.DocumentAttribute {
|
||||
out := make([]domain.DocumentAttribute, 0, 3)
|
||||
for _, attr := range attrs {
|
||||
if attr.Kind == domain.DocAttrFilename {
|
||||
out = append(out, attr)
|
||||
}
|
||||
}
|
||||
if !nosoundVideo {
|
||||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAnimated})
|
||||
}
|
||||
out = append(out, domain.DocumentAttribute{
|
||||
Kind: domain.DocAttrVideo, W: video.Width, H: video.Height,
|
||||
Duration: video.Duration, SupportsStreaming: true, NoSound: true, VideoCodec: "h264",
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// maxFaststartBytes 限制 faststart 一次性载入内存的视频大小;超过则跳过(流式 faststart
|
||||
// 复杂度高,超大视频是边角)。与缩略图回退路径同样全量读 blob,内存模式无新增。
|
||||
const maxFaststartBytes = 200 << 20
|
||||
|
|
@ -423,6 +477,18 @@ func (s *Service) CreateDocumentFromBytes(ctx context.Context, data []byte, spec
|
|||
if strings.TrimSpace(spec.MimeType) == "" {
|
||||
spec.MimeType = "application/octet-stream"
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(spec.MimeType), "image/gif") && !spec.ForceFile {
|
||||
if s.gifs == nil || len(data) > gifTranscodeMaxInputBytes {
|
||||
return domain.Document{}, domain.ErrDocumentInvalid
|
||||
}
|
||||
converted, err := s.gifs.Transcode(ctx, data)
|
||||
if err != nil || len(converted.Data) == 0 || converted.Width <= 0 || converted.Height <= 0 || converted.Duration <= 0 {
|
||||
return domain.Document{}, domain.ErrDocumentInvalid
|
||||
}
|
||||
data = converted.Data
|
||||
spec.MimeType = "video/mp4"
|
||||
spec.Attributes = canonicalGIFVideoAttributes(spec.Attributes, converted, spec.NosoundVideo)
|
||||
}
|
||||
objectKey, size, sum, err := s.blobs.PutReader(ctx, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return domain.Document{}, err
|
||||
|
|
|
|||
|
|
@ -14,6 +14,76 @@ import (
|
|||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestCreateDocumentFromUploadNormalizesGIFToGIFv(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
media := newFakeMediaStore()
|
||||
blobs, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalFS: %v", err)
|
||||
}
|
||||
transcoder := &fakeGIFTranscoder{result: GIFVideo{Data: []byte("fake-mp4"), Width: 320, Height: 240, Duration: 1.5}}
|
||||
svc := NewService(media, blobs, 2, WithGIFTranscoder(transcoder), WithVideoThumbnailer(nil))
|
||||
if _, err := svc.SaveFilePart(ctx, 10, 90, 0, []byte("GIF89a-fake")); err != nil {
|
||||
t.Fatalf("SaveFilePart: %v", err)
|
||||
}
|
||||
doc, err := svc.CreateDocumentFromUpload(ctx,
|
||||
domain.UploadedFileRef{OwnerUserID: 10, FileID: 90, Parts: 1, Name: "animation.gif"},
|
||||
domain.DocumentSpec{MimeType: "image/gif", Attributes: []domain.DocumentAttribute{
|
||||
{Kind: domain.DocAttrFilename, FileName: "animation.gif"},
|
||||
{Kind: domain.DocAttrImageSize, W: 320, H: 240},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocumentFromUpload: %v", err)
|
||||
}
|
||||
if transcoder.calls != 1 || doc.MimeType != "video/mp4" || !doc.IsGif() || doc.Size != int64(len("fake-mp4")) {
|
||||
t.Fatalf("document = %+v calls=%d, want canonical GIFv", doc, transcoder.calls)
|
||||
}
|
||||
blob, ok, err := media.GetFileBlob(ctx, fmt.Sprintf("doc:%d", doc.ID))
|
||||
if err != nil || !ok || blob.MimeType != "video/mp4" {
|
||||
t.Fatalf("gifv blob = %+v ok=%v err=%v", blob, ok, err)
|
||||
}
|
||||
got, err := blobs.Get(ctx, blob.ObjectKey)
|
||||
if err != nil || !bytes.Equal(got, []byte("fake-mp4")) {
|
||||
t.Fatalf("gifv bytes=%q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDocumentFromUploadGIFFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
spec domain.DocumentSpec
|
||||
wantMime string
|
||||
wantGIF bool
|
||||
wantCalls int
|
||||
}{
|
||||
{name: "album nosound is normal video", spec: domain.DocumentSpec{MimeType: "image/gif", NosoundVideo: true}, wantMime: "video/mp4", wantGIF: false, wantCalls: 1},
|
||||
{name: "force file preserves original", spec: domain.DocumentSpec{MimeType: "image/gif", ForceFile: true}, wantMime: "image/gif", wantGIF: false, wantCalls: 0},
|
||||
}
|
||||
for i, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
media := newFakeMediaStore()
|
||||
blobs, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transcoder := &fakeGIFTranscoder{result: GIFVideo{Data: []byte("mp4"), Width: 10, Height: 12, Duration: 1}}
|
||||
svc := NewService(media, blobs, 2, WithGIFTranscoder(transcoder), WithVideoThumbnailer(nil))
|
||||
fileID := int64(910 + i)
|
||||
if _, err := svc.SaveFilePart(ctx, 10, fileID, 0, []byte("gif")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc, err := svc.CreateDocumentFromUpload(ctx, domain.UploadedFileRef{OwnerUserID: 10, FileID: fileID, Parts: 1}, tc.spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc.MimeType != tc.wantMime || doc.IsGif() != tc.wantGIF || transcoder.calls != tc.wantCalls {
|
||||
t.Fatalf("doc=%+v calls=%d", doc, transcoder.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDocumentFromUploadGeneratesVideoThumbWhenMissing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
media := newFakeMediaStore()
|
||||
|
|
@ -189,6 +259,26 @@ func TestCreateDocumentFromBytesStoresBodyAndAttributes(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCreateDocumentFromBytesNormalizesExternalGIF(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
media := newFakeMediaStore()
|
||||
blobs, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transcoder := &fakeGIFTranscoder{result: GIFVideo{Data: []byte("external-mp4"), Width: 20, Height: 10, Duration: 2}}
|
||||
svc := NewService(media, blobs, 2, WithGIFTranscoder(transcoder), WithVideoThumbnailer(nil))
|
||||
doc, err := svc.CreateDocumentFromBytes(ctx, []byte("external-gif"), domain.DocumentSpec{
|
||||
MimeType: "image/gif", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: "x.gif"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !doc.IsGif() || doc.MimeType != "video/mp4" || transcoder.calls != 1 {
|
||||
t.Fatalf("doc=%+v calls=%d", doc, transcoder.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAvatarMarkupGeneratesDownloadableStaticSizes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
media := newFakeMediaStore()
|
||||
|
|
@ -442,6 +532,17 @@ type fakeVideoThumbnailer struct {
|
|||
err error
|
||||
}
|
||||
|
||||
type fakeGIFTranscoder struct {
|
||||
result GIFVideo
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeGIFTranscoder) Transcode(_ context.Context, _ []byte) (GIFVideo, error) {
|
||||
f.calls++
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func (f *fakeVideoThumbnailer) Extract(context.Context, []byte, string) ([]byte, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
|
|
|
|||
|
|
@ -648,7 +648,7 @@ func seedDocumentAttributes(attrs []seedAttrJSON) []domain.DocumentAttribute {
|
|||
}
|
||||
out = append(out, attr)
|
||||
case "DocumentAttributeVideo":
|
||||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: a.W, H: a.H, Duration: a.Duration, RoundMessage: a.RoundMessage, SupportsStreaming: a.SupportsStreaming})
|
||||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: a.W, H: a.H, Duration: a.Duration, RoundMessage: a.RoundMessage, SupportsStreaming: a.SupportsStreaming, NoSound: a.NoSound, VideoCodec: a.VideoCodec})
|
||||
case "DocumentAttributeAudio":
|
||||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAudio, AudioDuration: int(a.Duration), Voice: a.Voice, Title: a.Title, Performer: a.Performer})
|
||||
case "DocumentAttributeFilename":
|
||||
|
|
@ -889,6 +889,8 @@ type seedAttrJSON struct {
|
|||
Duration float64 `json:"duration"`
|
||||
RoundMessage bool `json:"round_message"`
|
||||
SupportsStreaming bool `json:"supports_streaming"`
|
||||
NoSound bool `json:"nosound"`
|
||||
VideoCodec string `json:"video_codec"`
|
||||
Voice bool `json:"voice"`
|
||||
Title string `json:"title"`
|
||||
Performer string `json:"performer"`
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ type Service struct {
|
|||
log *zap.Logger
|
||||
thumbs VideoThumbnailer
|
||||
thumbsSet bool
|
||||
gifs GIFTranscoder
|
||||
gifsSet bool
|
||||
blobCache *blobMetaCache
|
||||
byteCache *blobBytesCache
|
||||
// blobMetaSF/blobBytesSF 合并对同一热 blob 的并发首次访问:否则每个并发 getFile 都各打
|
||||
|
|
@ -91,6 +93,14 @@ func WithVideoThumbnailer(thumbnailer VideoThumbnailer) Option {
|
|||
}
|
||||
}
|
||||
|
||||
// WithGIFTranscoder 覆盖真实 GIF→MP4 规范化器。传 nil 可用于测试不可用路径。
|
||||
func WithGIFTranscoder(transcoder GIFTranscoder) Option {
|
||||
return func(s *Service) {
|
||||
s.gifs = transcoder
|
||||
s.gifsSet = true
|
||||
}
|
||||
}
|
||||
|
||||
// WithUploadPartQuota 覆盖用户级 in-flight 上传分片配额;字段 <=0 表示该维度不限制。
|
||||
func WithUploadPartQuota(quota domain.UploadPartQuota) Option {
|
||||
return func(s *Service) {
|
||||
|
|
@ -135,6 +145,14 @@ func NewService(media store.MediaStore, blobs BlobBackend, dc int, opts ...Optio
|
|||
s.thumbs = thumbnailer
|
||||
}
|
||||
}
|
||||
if !s.gifsSet {
|
||||
transcoder, err := NewFFmpegGIFTranscoder()
|
||||
if err != nil {
|
||||
s.log.Warn("ffmpeg/ffprobe not found; GIF uploads will be rejected", zap.Error(err))
|
||||
} else {
|
||||
s.gifs = transcoder
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -109,6 +109,9 @@ type DocumentSpec struct {
|
|||
Attributes []DocumentAttribute
|
||||
Thumb *UploadedFileRef // 可选缩略图上传,生成 doc:<id>:m
|
||||
ForceFile bool
|
||||
// NosoundVideo 对应 inputMediaUploadedDocument.nosound_video。真实 GIF 仍会
|
||||
// 转成 MP4,但该标志存在时按普通无声视频落库,不附 animated 属性(album 路径)。
|
||||
NosoundVideo bool
|
||||
}
|
||||
|
||||
// FileDownloadRequest 是 upload.getFile 解析后的下载请求;
|
||||
|
|
@ -205,6 +208,8 @@ type DocumentAttribute struct {
|
|||
Duration float64 `json:"duration,omitempty"`
|
||||
RoundMessage bool `json:"round_message,omitempty"`
|
||||
SupportsStreaming bool `json:"supports_streaming,omitempty"`
|
||||
NoSound bool `json:"no_sound,omitempty"`
|
||||
VideoCodec string `json:"video_codec,omitempty"`
|
||||
|
||||
// audio
|
||||
AudioDuration int `json:"audio_duration,omitempty"`
|
||||
|
|
@ -350,14 +355,24 @@ func (d Document) stickerMaterialMimeOrExt() string {
|
|||
return mimeType
|
||||
}
|
||||
|
||||
// IsGif reports whether the document is a savable GIF (documentAttributeAnimated).
|
||||
// IsGif reports whether the document is a canonical, savable Telegram GIFv.
|
||||
// Telegram 的 GIF 是无声 MP4;仅有 animated 属性的 raw image/gif 不是合法 saved GIF。
|
||||
func (d Document) IsGif() bool {
|
||||
if !strings.EqualFold(strings.TrimSpace(d.MimeType), "video/mp4") {
|
||||
return false
|
||||
}
|
||||
var animated, video bool
|
||||
for _, attr := range d.Attributes {
|
||||
if attr.Kind == DocAttrAnimated {
|
||||
return true
|
||||
switch attr.Kind {
|
||||
case DocAttrAnimated:
|
||||
animated = true
|
||||
case DocAttrVideo:
|
||||
if !attr.RoundMessage && attr.W > 0 && attr.H > 0 && attr.Duration > 0 {
|
||||
video = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
return animated && video
|
||||
}
|
||||
|
||||
// Photo 是已存储的 Telegram 照片(头像或图片消息)。
|
||||
|
|
|
|||
|
|
@ -95,8 +95,8 @@ func ClassifyMediaCategories(media *MessageMedia, entities []MessageEntity) []Me
|
|||
}
|
||||
|
||||
// classifyDocumentCategory 按 TL DocumentAttribute 判定一个文档落入哪个媒体类别。
|
||||
// 客户端的标签页 UI 以 TL 属性(而非 MIME)为准,故这里也只看属性。优先级:
|
||||
// sticker(不入任何标签页)> animated(GIF) > audio(music/voice) > video(video/round) > 通用文件。
|
||||
// 客户端的标签页 UI 以 TL 属性和 GIFv MIME 共同判定。优先级:sticker(不入任何标签页)>
|
||||
// canonical animated MP4 (GIF) > audio(music/voice) > video(video/round) > 通用文件。
|
||||
func classifyDocumentCategory(doc *Document) (MediaCategory, bool) {
|
||||
if doc == nil {
|
||||
return MediaCategoryNone, false
|
||||
|
|
@ -130,7 +130,7 @@ func classifyDocumentCategory(doc *Document) (MediaCategory, bool) {
|
|||
switch {
|
||||
case hasSticker:
|
||||
return MediaCategoryNone, false // 贴纸/自定义 emoji 不出现在共享媒体
|
||||
case hasAnimated:
|
||||
case hasAnimated && doc.IsGif():
|
||||
return MediaCategoryGif, true
|
||||
case hasAudio:
|
||||
if audioVoice {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ func TestClassifyMediaCategories(t *testing.T) {
|
|||
{"poll", &MessageMedia{Kind: MessageMediaKindPoll}, nil, []MediaCategory{MediaCategoryPoll}},
|
||||
{"video", doc(DocumentAttribute{Kind: DocAttrVideo}), nil, []MediaCategory{MediaCategoryVideo}},
|
||||
{"round video note", doc(DocumentAttribute{Kind: DocAttrVideo, RoundMessage: true}), nil, []MediaCategory{MediaCategoryRoundVideo}},
|
||||
{"gif animation", doc(DocumentAttribute{Kind: DocAttrAnimated}), nil, []MediaCategory{MediaCategoryGif}},
|
||||
{"gif animation", &MessageMedia{Kind: MessageMediaKindDocument, Document: &Document{MimeType: "video/mp4", Attributes: []DocumentAttribute{{Kind: DocAttrAnimated}, {Kind: DocAttrVideo, W: 320, H: 240, Duration: 1}}}}, nil, []MediaCategory{MediaCategoryGif}},
|
||||
{"raw gif is a file", &MessageMedia{Kind: MessageMediaKindDocument, Document: &Document{MimeType: "image/gif", Attributes: []DocumentAttribute{{Kind: DocAttrAnimated}}}}, nil, []MediaCategory{MediaCategoryFile}},
|
||||
{"music", doc(DocumentAttribute{Kind: DocAttrAudio}), nil, []MediaCategory{MediaCategoryMusic}},
|
||||
{"voice", doc(DocumentAttribute{Kind: DocAttrAudio, Voice: true}), nil, []MediaCategory{MediaCategoryVoice}},
|
||||
{"generic file", doc(DocumentAttribute{Kind: DocAttrFilename, FileName: "x.pdf"}), nil, []MediaCategory{MediaCategoryFile}},
|
||||
|
|
|
|||
|
|
@ -459,13 +459,18 @@ func tgDocumentAttributes(mimeType string, attrs []domain.DocumentAttribute) []t
|
|||
Stickerset: tgInputStickerSetFromIDs(a.StickerSetID, a.StickerSetAccessHash),
|
||||
})
|
||||
case domain.DocAttrVideo:
|
||||
out = append(out, &tg.DocumentAttributeVideo{
|
||||
video := &tg.DocumentAttributeVideo{
|
||||
RoundMessage: a.RoundMessage,
|
||||
SupportsStreaming: a.SupportsStreaming,
|
||||
Nosound: a.NoSound,
|
||||
Duration: a.Duration,
|
||||
W: a.W,
|
||||
H: a.H,
|
||||
})
|
||||
}
|
||||
if a.VideoCodec != "" {
|
||||
video.SetVideoCodec(a.VideoCodec)
|
||||
}
|
||||
out = append(out, video)
|
||||
case domain.DocAttrAudio:
|
||||
attr := &tg.DocumentAttributeAudio{
|
||||
Voice: a.Voice,
|
||||
|
|
|
|||
|
|
@ -100,6 +100,9 @@ func stickersetInvalidErr() error { return tgerr.New(406, "STICKERSET_INVALID")
|
|||
// stickerInvalidErr 表示输入文档不是合法贴纸/GIF(faveSticker/saveRecentSticker/saveGif)。
|
||||
func stickerInvalidErr() error { return tgerr.New(400, "STICKER_DOCUMENT_INVALID") }
|
||||
|
||||
// gifIDInvalidErr 表示 messages.saveGif 引用的文档不存在或不是规范 GIFv。
|
||||
func gifIDInvalidErr() error { return tgerr.New(400, "GIF_ID_INVALID") }
|
||||
|
||||
func mediaCaptionTooLongErr() error { return tgerr.New(400, "MEDIA_CAPTION_TOO_LONG") }
|
||||
|
||||
func replyMarkupInvalidErr() error { return tgerr.New(400, "REPLY_MARKUP_INVALID") }
|
||||
|
|
|
|||
|
|
@ -246,7 +246,11 @@ func TestMessagesGetSearchCountersUsesMediaCategoryCounts(t *testing.T) {
|
|||
send(4, "music", docMedia(4, domain.DocumentAttribute{Kind: domain.DocAttrAudio, Title: "song"}), nil)
|
||||
send(5, "voice", docMedia(5, domain.DocumentAttribute{Kind: domain.DocAttrAudio, Voice: true}), nil)
|
||||
send(6, "round", docMedia(6, domain.DocumentAttribute{Kind: domain.DocAttrVideo, RoundMessage: true}), nil)
|
||||
send(7, "gif", docMedia(7, domain.DocumentAttribute{Kind: domain.DocAttrAnimated}), nil)
|
||||
gif := docMedia(7,
|
||||
domain.DocumentAttribute{Kind: domain.DocAttrAnimated},
|
||||
domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: 320, H: 240, Duration: 1})
|
||||
gif.Document.MimeType = "video/mp4"
|
||||
send(7, "gif", gif, nil)
|
||||
send(8, "poll", &domain.MessageMedia{Kind: domain.MessageMediaKindPoll}, nil)
|
||||
|
||||
filters := []tg.MessagesFilterClass{
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gotd/td/tg"
|
||||
|
||||
|
|
@ -27,6 +29,9 @@ func (r *Router) stickerCollectionSvc() (stickerCollectionService, bool) {
|
|||
func (r *Router) stickerDocumentFromInput(ctx context.Context, input tg.InputDocumentClass, requireGif bool) (domain.Document, error) {
|
||||
in, ok := input.(*tg.InputDocument)
|
||||
if !ok || in.ID == 0 || r.deps.Files == nil {
|
||||
if requireGif {
|
||||
return domain.Document{}, gifIDInvalidErr()
|
||||
}
|
||||
return domain.Document{}, stickerInvalidErr()
|
||||
}
|
||||
doc, found, err := r.deps.Files.GetDocument(ctx, in.ID)
|
||||
|
|
@ -34,6 +39,9 @@ func (r *Router) stickerDocumentFromInput(ctx context.Context, input tg.InputDoc
|
|||
return domain.Document{}, internalErr()
|
||||
}
|
||||
ok = found && doc.AccessHash == in.AccessHash
|
||||
if ok && len(in.FileReference) > 0 && !bytes.Equal(in.FileReference, doc.FileReference) {
|
||||
ok = false
|
||||
}
|
||||
if ok {
|
||||
if requireGif {
|
||||
ok = doc.IsGif()
|
||||
|
|
@ -42,6 +50,9 @@ func (r *Router) stickerDocumentFromInput(ctx context.Context, input tg.InputDoc
|
|||
}
|
||||
}
|
||||
if !ok {
|
||||
if requireGif {
|
||||
return domain.Document{}, gifIDInvalidErr()
|
||||
}
|
||||
return domain.Document{}, stickerInvalidErr()
|
||||
}
|
||||
return doc, nil
|
||||
|
|
@ -95,7 +106,7 @@ func (r *Router) onMessagesSaveRecentSticker(ctx context.Context, req *tg.Messag
|
|||
|
||||
func (r *Router) onMessagesSaveGif(ctx context.Context, req *tg.MessagesSaveGifRequest) (bool, error) {
|
||||
if req == nil {
|
||||
return false, stickerInvalidErr()
|
||||
return false, gifIDInvalidErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -137,7 +148,10 @@ func (r *Router) onMessagesGetFavedStickers(ctx context.Context, hash int64) (tg
|
|||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
docs := r.stickerCollectionDocuments(ctx, userID, domain.StickerCollectionFaved, nil)
|
||||
docs, err := r.stickerCollectionDocuments(ctx, userID, domain.StickerCollectionFaved, nil)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
catalogHash := stickerDocumentsHash(docs)
|
||||
if hash != 0 && hash == catalogHash {
|
||||
return &tg.MessagesFavedStickersNotModified{}, nil
|
||||
|
|
@ -159,7 +173,10 @@ func (r *Router) onMessagesGetRecentStickers(ctx context.Context, req *tg.Messag
|
|||
kind = domain.StickerCollectionRecentAttached
|
||||
}
|
||||
var dates []int
|
||||
docs := r.stickerCollectionDocuments(ctx, userID, kind, &dates)
|
||||
docs, err := r.stickerCollectionDocuments(ctx, userID, kind, &dates)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
catalogHash := stickerDocumentsHash(docs)
|
||||
if req != nil && req.Hash != 0 && req.Hash == catalogHash {
|
||||
return &tg.MessagesRecentStickersNotModified{}, nil
|
||||
|
|
@ -177,7 +194,10 @@ func (r *Router) onMessagesGetSavedGifs(ctx context.Context, hash int64) (tg.Mes
|
|||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
docs := r.stickerCollectionDocuments(ctx, userID, domain.StickerCollectionGif, nil)
|
||||
docs, err := r.stickerCollectionDocuments(ctx, userID, domain.StickerCollectionGif, nil)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
catalogHash := stickerDocumentsHash(docs)
|
||||
if hash != 0 && hash == catalogHash {
|
||||
return &tg.MessagesSavedGifsNotModified{}, nil
|
||||
|
|
@ -186,21 +206,24 @@ func (r *Router) onMessagesGetSavedGifs(ctx context.Context, hash int64) (tg.Mes
|
|||
}
|
||||
|
||||
// stickerCollectionDocuments 取某集合并解析为完整文档(最新在前,顺序与集合一致)。
|
||||
// 解析不到的文档(已删/不可用)跳过。若 datesOut 非 nil,按相同顺序填充 used_at。
|
||||
func (r *Router) stickerCollectionDocuments(ctx context.Context, userID int64, kind domain.StickerCollectionKind, datesOut *[]int) []domain.Document {
|
||||
// 集合引用缺失或类型错误属于坏数据并 fail-fast;若 datesOut 非 nil,按相同顺序填充 used_at。
|
||||
func (r *Router) stickerCollectionDocuments(ctx context.Context, userID int64, kind domain.StickerCollectionKind, datesOut *[]int) ([]domain.Document, error) {
|
||||
svc, ok := r.stickerCollectionSvc()
|
||||
if !ok || r.deps.Files == nil {
|
||||
if datesOut != nil {
|
||||
*datesOut = []int{}
|
||||
}
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
items, err := svc.ListStickerCollection(ctx, userID, kind, domain.MaxStickerCollectionItems(kind))
|
||||
if err != nil || len(items) == 0 {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
if datesOut != nil {
|
||||
*datesOut = []int{}
|
||||
}
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
ids := make([]int64, 0, len(items))
|
||||
dateByID := make(map[int64]int, len(items))
|
||||
|
|
@ -210,10 +233,7 @@ func (r *Router) stickerCollectionDocuments(ctx context.Context, userID int64, k
|
|||
}
|
||||
resolved, err := r.deps.Files.GetDocuments(ctx, ids)
|
||||
if err != nil {
|
||||
if datesOut != nil {
|
||||
*datesOut = []int{}
|
||||
}
|
||||
return nil
|
||||
return nil, err
|
||||
}
|
||||
byID := documentsByID(resolved)
|
||||
docs := make([]domain.Document, 0, len(items))
|
||||
|
|
@ -221,7 +241,13 @@ func (r *Router) stickerCollectionDocuments(ctx context.Context, userID int64, k
|
|||
for _, id := range ids { // 保持集合顺序(最新在前)
|
||||
doc, ok := byID[id]
|
||||
if !ok {
|
||||
continue
|
||||
return nil, fmt.Errorf("sticker collection %s references missing document %d", kind, id)
|
||||
}
|
||||
if kind == domain.StickerCollectionGif && !doc.IsGif() {
|
||||
return nil, fmt.Errorf("saved gif collection references non-GIFv document %d", id)
|
||||
}
|
||||
if kind != domain.StickerCollectionGif && !doc.IsSticker() {
|
||||
return nil, fmt.Errorf("sticker collection %s references non-sticker document %d", kind, id)
|
||||
}
|
||||
docs = append(docs, doc)
|
||||
dates = append(dates, dateByID[id])
|
||||
|
|
@ -229,7 +255,7 @@ func (r *Router) stickerCollectionDocuments(ctx context.Context, userID int64, k
|
|||
if datesOut != nil {
|
||||
*datesOut = dates
|
||||
}
|
||||
return docs
|
||||
return docs, nil
|
||||
}
|
||||
|
||||
func stickerDocumentsHash(docs []domain.Document) int64 {
|
||||
|
|
|
|||
|
|
@ -14,20 +14,28 @@ import (
|
|||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func stickerCollectionRouter(t *testing.T) *Router {
|
||||
func stickerCollectionRouter(t *testing.T) (*Router, *captureSessions) {
|
||||
t.Helper()
|
||||
files := &fakeFiles{docs: map[int64]domain.Document{
|
||||
101: {ID: 101, AccessHash: 11, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
|
||||
102: {ID: 102, AccessHash: 12, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
|
||||
201: {ID: 201, AccessHash: 21, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrAnimated}}},
|
||||
201: {ID: 201, AccessHash: 21, MimeType: "video/mp4", Attributes: []domain.DocumentAttribute{
|
||||
{Kind: domain.DocAttrAnimated},
|
||||
{Kind: domain.DocAttrVideo, W: 320, H: 240, Duration: 1},
|
||||
}},
|
||||
202: {ID: 202, AccessHash: 22, MimeType: "video/mp4", Attributes: []domain.DocumentAttribute{
|
||||
{Kind: domain.DocAttrAnimated},
|
||||
{Kind: domain.DocAttrVideo, W: 640, H: 360, Duration: 2},
|
||||
}},
|
||||
301: {ID: 301, AccessHash: 31, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrAudio}}},
|
||||
}}
|
||||
passwordStore := memory.NewPasswordStore()
|
||||
sessions := &captureSessions{}
|
||||
return New(Config{}, Deps{
|
||||
Account: appaccount.NewService(passwordStore, appaccount.WithStickerCollections(passwordStore)),
|
||||
Files: files,
|
||||
Sessions: &captureSessions{},
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
Sessions: sessions,
|
||||
}, zaptest.NewLogger(t), clock.System), sessions
|
||||
}
|
||||
|
||||
func inputDoc(id, accessHash int64) *tg.InputDocument {
|
||||
|
|
@ -36,7 +44,7 @@ func inputDoc(id, accessHash int64) *tg.InputDocument {
|
|||
|
||||
// TestFavedStickersRoundTrip 回归:faveSticker/getFavedStickers 此前未注册/返空。
|
||||
func TestFavedStickersRoundTrip(t *testing.T) {
|
||||
r := stickerCollectionRouter(t)
|
||||
r, _ := stickerCollectionRouter(t)
|
||||
ctx := WithUserID(context.Background(), 1000000001)
|
||||
|
||||
// 非贴纸文档拒绝。
|
||||
|
|
@ -80,7 +88,7 @@ func TestFavedStickersRoundTrip(t *testing.T) {
|
|||
|
||||
// TestRecentStickersRoundTrip 验证 saveRecentSticker/getRecentStickers + dates + clear。
|
||||
func TestRecentStickersRoundTrip(t *testing.T) {
|
||||
r := stickerCollectionRouter(t)
|
||||
r, _ := stickerCollectionRouter(t)
|
||||
ctx := WithUserID(context.Background(), 1000000001)
|
||||
|
||||
if ok, err := r.onMessagesSaveRecentSticker(ctx, &tg.MessagesSaveRecentStickerRequest{ID: inputDoc(101, 11)}); err != nil || !ok {
|
||||
|
|
@ -110,22 +118,48 @@ func TestRecentStickersRoundTrip(t *testing.T) {
|
|||
|
||||
// TestSavedGifsRoundTrip 验证 saveGif/getSavedGifs + 非 GIF 拒绝。
|
||||
func TestSavedGifsRoundTrip(t *testing.T) {
|
||||
r := stickerCollectionRouter(t)
|
||||
r, sessions := stickerCollectionRouter(t)
|
||||
ctx := WithUserID(context.Background(), 1000000001)
|
||||
|
||||
// 非 GIF(贴纸)拒绝。
|
||||
if ok, err := r.onMessagesSaveGif(ctx, &tg.MessagesSaveGifRequest{ID: inputDoc(101, 11)}); ok || !tgerr.Is(err, "STICKER_DOCUMENT_INVALID") {
|
||||
t.Fatalf("save non-gif = ok %v err %v, want STICKER_DOCUMENT_INVALID", ok, err)
|
||||
if ok, err := r.onMessagesSaveGif(ctx, &tg.MessagesSaveGifRequest{ID: inputDoc(101, 11)}); ok || !tgerr.Is(err, "GIF_ID_INVALID") {
|
||||
t.Fatalf("save non-gif = ok %v err %v, want GIF_ID_INVALID", ok, err)
|
||||
}
|
||||
if ok, err := r.onMessagesSaveGif(ctx, &tg.MessagesSaveGifRequest{ID: inputDoc(201, 21)}); err != nil || !ok {
|
||||
t.Fatalf("save gif = ok %v err %v", ok, err)
|
||||
}
|
||||
if pushed, ok := sessions.lastUserPush().(*tg.Updates); !ok || len(pushed.Updates) != 1 {
|
||||
t.Fatalf("save gif push = %T %+v, want updateSavedGifs", sessions.lastUserPush(), pushed)
|
||||
} else if _, ok := pushed.Updates[0].(*tg.UpdateSavedGifs); !ok {
|
||||
t.Fatalf("save gif update = %T, want *tg.UpdateSavedGifs", pushed.Updates[0])
|
||||
}
|
||||
if ok, err := r.onMessagesSaveGif(ctx, &tg.MessagesSaveGifRequest{ID: inputDoc(202, 22)}); err != nil || !ok {
|
||||
t.Fatalf("save second gif = ok %v err %v", ok, err)
|
||||
}
|
||||
out, err := r.onMessagesGetSavedGifs(ctx, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("get saved gifs: %v", err)
|
||||
}
|
||||
if got := out.(*tg.MessagesSavedGifs); len(got.Gifs) != 1 {
|
||||
t.Fatalf("saved gifs = %d, want 1", len(got.Gifs))
|
||||
full := out.(*tg.MessagesSavedGifs)
|
||||
if len(full.Gifs) != 2 || full.Gifs[0].(*tg.Document).ID != 202 {
|
||||
t.Fatalf("saved gifs = %+v, want newest 202 first", full.Gifs)
|
||||
}
|
||||
again, err := r.onMessagesGetSavedGifs(ctx, full.Hash)
|
||||
if err != nil {
|
||||
t.Fatalf("get saved gifs by hash: %v", err)
|
||||
}
|
||||
if _, ok := again.(*tg.MessagesSavedGifsNotModified); !ok {
|
||||
t.Fatalf("get saved gifs by hash = %T, want NotModified", again)
|
||||
}
|
||||
if ok, err := r.onMessagesSaveGif(ctx, &tg.MessagesSaveGifRequest{ID: inputDoc(201, 21), Unsave: true}); err != nil || !ok {
|
||||
t.Fatalf("unsave gif = ok %v err %v", ok, err)
|
||||
}
|
||||
out, err = r.onMessagesGetSavedGifs(ctx, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("get after unsave: %v", err)
|
||||
}
|
||||
if got := out.(*tg.MessagesSavedGifs); len(got.Gifs) != 1 || got.Gifs[0].(*tg.Document).ID != 202 {
|
||||
t.Fatalf("saved gifs after unsave = %+v, want [202]", got.Gifs)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -530,9 +530,10 @@ func (r *Router) resolveInputMedia(ctx context.Context, userID int64, input tg.I
|
|||
return nil, fileReferenceInvalidErr()
|
||||
}
|
||||
spec := domain.DocumentSpec{
|
||||
MimeType: in.MimeType,
|
||||
Attributes: domainDocumentAttributes(in.Attributes),
|
||||
ForceFile: in.ForceFile,
|
||||
MimeType: in.MimeType,
|
||||
Attributes: domainDocumentAttributes(in.Attributes),
|
||||
ForceFile: in.ForceFile,
|
||||
NosoundVideo: in.NosoundVideo,
|
||||
}
|
||||
if thumb, ok := in.GetThumb(); ok {
|
||||
if tref, ok := uploadedFileRef(userID, thumb); ok {
|
||||
|
|
@ -951,7 +952,7 @@ func domainDocumentAttributes(attrs []tg.DocumentAttributeClass) []domain.Docume
|
|||
}
|
||||
out = append(out, attr)
|
||||
case *tg.DocumentAttributeVideo:
|
||||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: v.W, H: v.H, Duration: v.Duration, RoundMessage: v.RoundMessage, SupportsStreaming: v.SupportsStreaming})
|
||||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: v.W, H: v.H, Duration: v.Duration, RoundMessage: v.RoundMessage, SupportsStreaming: v.SupportsStreaming, NoSound: v.Nosound, VideoCodec: v.VideoCodec})
|
||||
case *tg.DocumentAttributeAudio:
|
||||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAudio, AudioDuration: v.Duration, Voice: v.Voice, Title: v.Title, Performer: v.Performer, Waveform: v.Waveform})
|
||||
case *tg.DocumentAttributeFilename:
|
||||
|
|
|
|||
|
|
@ -540,17 +540,19 @@ func (s *PasswordStore) SaveStickerCollectionItem(ctx context.Context, userID in
|
|||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO user_sticker_collections (owner_user_id, kind, document_id, used_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (owner_user_id, kind, document_id) DO UPDATE SET used_at = EXCLUDED.used_at`,
|
||||
ON CONFLICT (owner_user_id, kind, document_id) DO UPDATE
|
||||
SET used_at = EXCLUDED.used_at,
|
||||
order_key = nextval('user_sticker_collections_order_key_seq')`,
|
||||
userID, string(kind), documentID, now); err != nil {
|
||||
return fmt.Errorf("upsert sticker collection item: %w", err)
|
||||
}
|
||||
// 截断超上界:单次有序窗口扫描(索引 user_sticker_collections_order_idx 服务
|
||||
// used_at DESC 排序),按 ctid 删除排名 > max 的旧项,避免 NOT IN 双扫全集。
|
||||
// order_key DESC 排序),按 ctid 删除排名 > max 的旧项,避免 NOT IN 双扫全集。
|
||||
if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM user_sticker_collections
|
||||
WHERE ctid IN (
|
||||
SELECT ctid FROM (
|
||||
SELECT ctid, ROW_NUMBER() OVER (ORDER BY used_at DESC, document_id DESC) AS rn
|
||||
SELECT ctid, ROW_NUMBER() OVER (ORDER BY order_key DESC) AS rn
|
||||
FROM user_sticker_collections
|
||||
WHERE owner_user_id = $1 AND kind = $2
|
||||
) t WHERE rn > $3
|
||||
|
|
@ -572,7 +574,7 @@ func (s *PasswordStore) ListStickerCollection(ctx context.Context, userID int64,
|
|||
SELECT document_id, used_at
|
||||
FROM user_sticker_collections
|
||||
WHERE owner_user_id = $1 AND kind = $2
|
||||
ORDER BY used_at DESC, document_id DESC
|
||||
ORDER BY order_key DESC
|
||||
LIMIT $3`, userID, string(kind), limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list sticker collection: %w", err)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,17 @@ func TestStickerCollectionsRoundTripPostgres(t *testing.T) {
|
|||
t.Fatalf("faved after re-fave = %v, want 101 first", ids)
|
||||
}
|
||||
|
||||
// used_at 只有秒精度:同秒保存仍必须严格按最后一次 mutation 排序。
|
||||
if err := store.SaveStickerCollectionItem(ctx, owner, faved, 102, false, 3000, 100); err != nil {
|
||||
t.Fatalf("same-second fave 102: %v", err)
|
||||
}
|
||||
if err := store.SaveStickerCollectionItem(ctx, owner, faved, 101, false, 3000, 100); err != nil {
|
||||
t.Fatalf("same-second re-fave 101: %v", err)
|
||||
}
|
||||
if ids := stickerIDs(t, store, ctx, owner, faved); len(ids) != 2 || ids[0] != 101 || ids[1] != 102 {
|
||||
t.Fatalf("same-second order = %v, want [101 102]", ids)
|
||||
}
|
||||
|
||||
// 截断:max=2,加第 3 个挤掉最旧。
|
||||
if err := store.SaveStickerCollectionItem(ctx, owner, faved, 103, false, 1003, 2); err != nil {
|
||||
t.Fatalf("fave 103 (max 2): %v", err)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue