Initial open source release
This commit is contained in:
commit
74992e893f
377 changed files with 118084 additions and 0 deletions
860
internal/app/files/seed.go
Normal file
860
internal/app/files/seed.go
Normal file
|
|
@ -0,0 +1,860 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// 本文件实现从外部导出的 reaction / sticker 资源目录导入媒体种子:
|
||||
// JSON 元数据(外部导出 document id/access_hash/file_reference/attributes/thumbs)落 documents /
|
||||
// sticker_sets / available_reactions 表,二进制 .tgs/.webp/缩略图落 blob backend。
|
||||
// dc_id 统一重写为本 server 的 DC,使客户端从本 DC 下载。导入幂等(已存在则跳过)。
|
||||
|
||||
// SeedStats 汇报一次种子导入结果。
|
||||
type SeedStats struct {
|
||||
Reactions int
|
||||
StickerSets int
|
||||
Documents int
|
||||
Blobs int
|
||||
Skipped bool
|
||||
}
|
||||
|
||||
// SeedMedia 从导出根目录导入 reaction 与 sticker 资源。maxRegularSets<=0 表示不限。
|
||||
func (s *Service) SeedMedia(ctx context.Context, root string, maxRegularSets int) (SeedStats, error) {
|
||||
var stats SeedStats
|
||||
if root == "" {
|
||||
stats.Skipped = true
|
||||
return stats, nil
|
||||
}
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
// 目录不存在:跳过而非失败(开发机可能未放资源)。
|
||||
stats.Skipped = true
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// reactions
|
||||
if n, err := s.media.CountAvailableReactions(ctx); err != nil {
|
||||
return stats, err
|
||||
} else if n == 0 {
|
||||
if err := s.seedReactions(ctx, root, &stats); err != nil {
|
||||
return stats, fmt.Errorf("seed reactions: %w", err)
|
||||
}
|
||||
} else if incomplete, err := s.availableReactionSeedNeedsRepair(ctx); err != nil {
|
||||
return stats, err
|
||||
} else if incomplete {
|
||||
if err := s.seedReactions(ctx, root, &stats); err != nil {
|
||||
return stats, fmt.Errorf("repair reactions: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// sticker sets(default 系统集 + 常规集)
|
||||
if n, err := s.media.CountStickerSets(ctx); err != nil {
|
||||
return stats, err
|
||||
} else if n == 0 {
|
||||
if err := s.seedStickerSets(ctx, root, maxRegularSets, &stats); err != nil {
|
||||
return stats, fmt.Errorf("seed sticker sets: %w", err)
|
||||
}
|
||||
} else if stale, err := s.stickerSetDocumentThumbsNeedInlineCache(ctx); err != nil {
|
||||
return stats, err
|
||||
} else if stale {
|
||||
if err := s.seedStickerSets(ctx, root, maxRegularSets, &stats); err != nil {
|
||||
return stats, fmt.Errorf("repair sticker set thumbs: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if stats.Reactions == 0 && stats.StickerSets == 0 {
|
||||
stats.Skipped = true
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// ---- reactions ----
|
||||
|
||||
func (s *Service) seedReactions(ctx context.Context, root string, stats *SeedStats) error {
|
||||
reactionsDir := filepath.Join(root, "telegram_reactions_export", "reactions")
|
||||
rawPath := filepath.Join(root, "telegram_reactions_export", "global_json", "available_reactions_raw.json")
|
||||
raw, err := os.ReadFile(rawPath)
|
||||
if err != nil {
|
||||
return nil // 没有 reaction 资源就跳过
|
||||
}
|
||||
var parsed struct {
|
||||
Result struct {
|
||||
Reactions []seedReactionJSON `json:"reactions"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return fmt.Errorf("parse available_reactions_raw.json: %w", err)
|
||||
}
|
||||
index, err := scanSeedDir(reactionsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, rj := range parsed.Result.Reactions {
|
||||
ar := domain.AvailableReaction{
|
||||
Reaction: rj.Reaction,
|
||||
Title: rj.Title,
|
||||
Inactive: rj.Inactive,
|
||||
Premium: rj.Premium,
|
||||
Order: i,
|
||||
}
|
||||
set := func(dst *int64, d *seedDocumentJSON) error {
|
||||
if d == nil || d.ID == 0 {
|
||||
return nil
|
||||
}
|
||||
doc, err := s.importDocument(ctx, *d, reactionsDir, index, stats)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*dst = doc.ID
|
||||
return nil
|
||||
}
|
||||
if err := set(&ar.StaticIconID, rj.StaticIcon); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := set(&ar.AppearAnimationID, rj.AppearAnimation); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := set(&ar.SelectAnimationID, rj.SelectAnimation); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := set(&ar.ActivateAnimationID, rj.ActivateAnimation); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := set(&ar.EffectAnimationID, rj.EffectAnimation); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := set(&ar.AroundAnimationID, rj.AroundAnimation); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := set(&ar.CenterIconID, rj.CenterIcon); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.media.PutAvailableReaction(ctx, ar); err != nil {
|
||||
return err
|
||||
}
|
||||
stats.Reactions++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) availableReactionSeedNeedsRepair(ctx context.Context) (bool, error) {
|
||||
reactions, err := s.media.ListAvailableReactions(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var docIDs []int64
|
||||
for _, r := range reactions {
|
||||
for _, id := range r.DocumentIDs() {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
docIDs = append(docIDs, id)
|
||||
if _, ok, err := s.media.GetFileBlob(ctx, fmt.Sprintf("doc:%d", id)); err != nil {
|
||||
return false, err
|
||||
} else if !ok {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if stale, err := s.documentsNeedInlineCachedThumbs(ctx, docIDs); err != nil || stale {
|
||||
return stale, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// ---- sticker sets ----
|
||||
|
||||
func (s *Service) seedStickerSets(ctx context.Context, root string, maxRegular int, stats *SeedStats) error {
|
||||
// default 系统集:目录名 → system_key。
|
||||
defaultDir := filepath.Join(root, "telegram_default_stickers_export")
|
||||
order := 0
|
||||
if entries, err := os.ReadDir(defaultDir); err == nil {
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
systemKey := systemKeyForDefaultSet(name)
|
||||
setDir := filepath.Join(defaultDir, name)
|
||||
if err := s.importStickerSetDir(ctx, setDir, systemKey, order, stats); err != nil {
|
||||
return fmt.Errorf("import default set %s: %w", name, err)
|
||||
}
|
||||
order++
|
||||
}
|
||||
}
|
||||
|
||||
// 常规贴纸集。
|
||||
regularDir := filepath.Join(root, "telegram_stickers_export")
|
||||
if entries, err := os.ReadDir(regularDir); err == nil {
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
imported := 0
|
||||
for _, name := range names {
|
||||
if maxRegular > 0 && imported >= maxRegular {
|
||||
break
|
||||
}
|
||||
setDir := filepath.Join(regularDir, name)
|
||||
if err := s.importStickerSetDir(ctx, setDir, "", order, stats); err != nil {
|
||||
return fmt.Errorf("import sticker set %s: %w", name, err)
|
||||
}
|
||||
order++
|
||||
imported++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) importStickerSetDir(ctx context.Context, setDir, systemKey string, order int, stats *SeedStats) error {
|
||||
infoPath := filepath.Join(setDir, "set_info.json")
|
||||
raw, err := os.ReadFile(infoPath)
|
||||
if err != nil {
|
||||
return nil // 该目录无 set_info.json → 跳过
|
||||
}
|
||||
var info struct {
|
||||
Result seedStickerSetResultJSON `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &info); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", infoPath, err)
|
||||
}
|
||||
sj := info.Result.Set
|
||||
if sj.ID == 0 {
|
||||
return nil
|
||||
}
|
||||
stickersDir := filepath.Join(setDir, "stickers")
|
||||
index, err := scanSeedDir(stickersDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
docIDs := make([]int64, 0, len(info.Result.Documents))
|
||||
docs := make([]domain.Document, 0, len(info.Result.Documents))
|
||||
docIDBySource := make(map[int64]int64, len(info.Result.Documents))
|
||||
for _, dj := range info.Result.Documents {
|
||||
sourceID := dj.ID
|
||||
doc, err := s.importDocument(ctx, dj, stickersDir, index, stats)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if doc.ID != 0 {
|
||||
docIDBySource[sourceID] = doc.ID
|
||||
docIDs = append(docIDs, doc.ID)
|
||||
docs = append(docs, doc)
|
||||
}
|
||||
}
|
||||
|
||||
kind := stickerSetKind(sj, systemKey)
|
||||
set := domain.StickerSet{
|
||||
ID: sj.ID,
|
||||
AccessHash: sj.AccessHash,
|
||||
ShortName: sj.ShortName,
|
||||
Title: sj.Title,
|
||||
Count: sj.Count,
|
||||
Hash: sj.Hash,
|
||||
Kind: kind,
|
||||
Official: sj.Official,
|
||||
Animated: true, // 导出资源均为 .tgs 动画贴纸
|
||||
Emojis: sj.Emojis,
|
||||
Masks: sj.Masks,
|
||||
Archived: sj.Archived,
|
||||
Installed: seedStickerSetInstalled(kind),
|
||||
ThumbDocumentID: seedDocumentStorageID(derefInt64(sj.ThumbDocumentID)),
|
||||
Thumbs: seedStickerSetPhotoSizes(sj.Thumbs),
|
||||
ThumbDCID: s.dc,
|
||||
ThumbVersion: sj.ThumbVersion,
|
||||
DocumentIDs: docIDs,
|
||||
Packs: seedStickerPacks(sj.Packs, info.Result.Packs, docIDBySource),
|
||||
SortOrder: order,
|
||||
SystemKey: systemKey,
|
||||
}
|
||||
if set.Count == 0 {
|
||||
set.Count = len(docIDs)
|
||||
}
|
||||
if err := s.media.PutStickerSet(ctx, set); err != nil {
|
||||
return err
|
||||
}
|
||||
s.stickerSetCache.put(set, docs)
|
||||
stats.StickerSets++
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- 单个 document 导入 ----
|
||||
|
||||
func (s *Service) importDocument(ctx context.Context, dj seedDocumentJSON, binDir string, index seedDirIndex, stats *SeedStats) (domain.Document, error) {
|
||||
if dj.ID == 0 {
|
||||
return domain.Document{}, nil
|
||||
}
|
||||
storageID := seedDocumentStorageID(dj.ID)
|
||||
ref, _ := hex.DecodeString(dj.FileReference)
|
||||
doc := domain.Document{
|
||||
ID: storageID,
|
||||
AccessHash: dj.AccessHash,
|
||||
FileReference: ref,
|
||||
Date: parseSeedDate(dj.Date),
|
||||
MimeType: dj.MimeType,
|
||||
Size: dj.Size,
|
||||
DCID: s.dc,
|
||||
Attributes: seedDocumentAttributes(dj.Attributes),
|
||||
}
|
||||
|
||||
// 主体 blob:doc:<server-owned-id>
|
||||
if mainPath, ok := index.main[dj.ID]; ok {
|
||||
data, err := os.ReadFile(mainPath)
|
||||
if err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
objectKey, err := s.blobs.Put(ctx, data)
|
||||
if err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("doc:%d", storageID),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len(data)),
|
||||
MimeType: dj.MimeType,
|
||||
}); err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
s.prewarmSmallBlob(objectKey, data)
|
||||
stats.Blobs++
|
||||
}
|
||||
|
||||
// 缩略图:PhotoPathSize 内联;小的 PhotoSize 静态图同时写 blob 并作为
|
||||
// PhotoCachedSize 返回,让 TDesktop 处理 document 元数据时即可填本地 image cache。
|
||||
thumbs := make([]domain.PhotoSize, 0, len(dj.Thumbs))
|
||||
for _, tj := range dj.Thumbs {
|
||||
ps, downloadable := seedPhotoSize(tj)
|
||||
if ps.Kind == "" {
|
||||
continue
|
||||
}
|
||||
if downloadable {
|
||||
thumbPath, ok := index.thumb[dj.ID][ps.Type]
|
||||
if !ok {
|
||||
continue // 无可服务的缩略图文件,丢弃该尺寸(保留 PhotoPathSize 占位)
|
||||
}
|
||||
data, err := os.ReadFile(thumbPath)
|
||||
if err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
objectKey, err := s.blobs.Put(ctx, data)
|
||||
if err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
ps.Size = len(data)
|
||||
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("doc:%d:%s", storageID, ps.Type),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len(data)),
|
||||
MimeType: seedThumbMimeType(data),
|
||||
}); err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
ps = seedInlineCachedDocumentThumb(ps, data)
|
||||
s.prewarmSmallBlob(objectKey, data)
|
||||
stats.Blobs++
|
||||
}
|
||||
thumbs = append(thumbs, ps)
|
||||
}
|
||||
doc.Thumbs = seedPreferRasterDocumentThumbs(thumbs)
|
||||
|
||||
if err := s.media.PutDocument(ctx, doc); err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
stats.Documents++
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func (s *Service) prewarmSmallBlob(objectKey string, data []byte) {
|
||||
if len(data) > 0 && len(data) <= blobBytesCacheMaxEntryBytes {
|
||||
s.byteCache.put(objectKey, data)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 目录扫描:docID → 主体文件 / 可下载缩略图 ----
|
||||
|
||||
type seedDirIndex struct {
|
||||
main map[int64]string // docID -> 主体文件路径
|
||||
thumb map[int64]map[string]string // docID -> thumbType -> 缩略图文件路径
|
||||
}
|
||||
|
||||
var seedTrailingDigits = regexp.MustCompile(`(\d{6,})`)
|
||||
var seedThumbMarker = regexp.MustCompile(`_thumb\d+_`)
|
||||
|
||||
const seedInlineCachedDocumentThumbMaxBytes = 32 * 1024
|
||||
|
||||
// Exported Telegram resources keep their original id in filenames/JSON, but
|
||||
// telesrv owns the document catalog it serves. Imported high source ids are
|
||||
// normalized once at seed time so RPC/storage use one server-owned id.
|
||||
const seedExternalDocumentIDOffset int64 = 4_000_000_000_000_000_000
|
||||
|
||||
var seedThumbType = regexp.MustCompile(`PhotoSize_type([a-z])`)
|
||||
|
||||
func seedDocumentStorageID(sourceID int64) int64 {
|
||||
if sourceID <= 0 {
|
||||
return 0
|
||||
}
|
||||
if sourceID > seedExternalDocumentIDOffset {
|
||||
return sourceID - seedExternalDocumentIDOffset
|
||||
}
|
||||
return sourceID
|
||||
}
|
||||
|
||||
func scanSeedDir(dir string) (seedDirIndex, error) {
|
||||
idx := seedDirIndex{main: map[int64]string{}, thumb: map[int64]map[string]string{}}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return idx, nil // 目录不存在 → 空 index
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
full := filepath.Join(dir, name)
|
||||
if marker := seedThumbMarker.FindStringIndex(name); marker != nil {
|
||||
// 只收可下载的 PhotoSize 缩略图(jpg);PhotoPathSize(svg) 内联在 JSON。
|
||||
if ext != ".jpg" && ext != ".jpeg" {
|
||||
continue
|
||||
}
|
||||
m := seedThumbType.FindStringSubmatch(name)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
docID := docIDFromName(name[:marker[0]])
|
||||
if docID == 0 {
|
||||
continue
|
||||
}
|
||||
if idx.thumb[docID] == nil {
|
||||
idx.thumb[docID] = map[string]string{}
|
||||
}
|
||||
idx.thumb[docID][m[1]] = full
|
||||
continue
|
||||
}
|
||||
if ext == ".svg" || ext == ".json" {
|
||||
continue
|
||||
}
|
||||
docID := docIDFromName(strings.TrimSuffix(name, filepath.Ext(name)))
|
||||
if docID == 0 {
|
||||
continue
|
||||
}
|
||||
idx.main[docID] = full
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
// docIDFromName 取 base name 中末尾最长的数字串作为 document id。
|
||||
func docIDFromName(base string) int64 {
|
||||
matches := seedTrailingDigits.FindAllString(base, -1)
|
||||
if len(matches) == 0 {
|
||||
return 0
|
||||
}
|
||||
last := matches[len(matches)-1]
|
||||
id, err := strconv.ParseInt(last, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func systemKeyForDefaultSet(dirName string) string {
|
||||
switch dirName {
|
||||
case "DefaultSet_AnimatedEmoji":
|
||||
return "animated_emoji"
|
||||
case "DefaultSet_AnimatedEmojiAnimations":
|
||||
return "animated_emoji_animations"
|
||||
case "DefaultSet_EmojiGenericAnimations":
|
||||
return "emoji_generic_animations"
|
||||
case "DefaultSet_Dice_Normal":
|
||||
return "dice:\U0001f3b2"
|
||||
case "DefaultSet_Dice_Dart":
|
||||
return "dice:\U0001f3af"
|
||||
case "DefaultSet_Dice_Basketball":
|
||||
return "dice:\U0001f3c0"
|
||||
case "DefaultSet_Dice_Football":
|
||||
return "dice:⚽"
|
||||
case "DefaultSet_Dice_Bowling":
|
||||
return "dice:\U0001f3b3"
|
||||
case "DefaultSet_Dice_Casino":
|
||||
return "dice:\U0001f3b0"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func stickerSetKind(sj seedStickerSetJSON, systemKey string) domain.StickerSetKind {
|
||||
switch {
|
||||
case systemKey != "":
|
||||
return domain.StickerSetKindSystem
|
||||
case sj.Emojis:
|
||||
return domain.StickerSetKindEmoji
|
||||
case sj.Masks:
|
||||
return domain.StickerSetKindMasks
|
||||
default:
|
||||
return domain.StickerSetKindStickers
|
||||
}
|
||||
}
|
||||
|
||||
func seedStickerSetInstalled(kind domain.StickerSetKind) bool {
|
||||
return kind != domain.StickerSetKindSystem
|
||||
}
|
||||
|
||||
// ---- JSON → domain 转换 ----
|
||||
|
||||
func seedDocumentAttributes(attrs []seedAttrJSON) []domain.DocumentAttribute {
|
||||
out := make([]domain.DocumentAttribute, 0, len(attrs))
|
||||
for _, a := range attrs {
|
||||
switch a.Type {
|
||||
case "DocumentAttributeImageSize":
|
||||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrImageSize, W: a.W, H: a.H})
|
||||
case "DocumentAttributeAnimated":
|
||||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAnimated})
|
||||
case "DocumentAttributeSticker":
|
||||
attr := domain.DocumentAttribute{Kind: domain.DocAttrSticker, Alt: a.Alt, Mask: a.Mask}
|
||||
if a.Stickerset != nil {
|
||||
attr.StickerSetID = a.Stickerset.ID
|
||||
attr.StickerSetAccessHash = a.Stickerset.AccessHash
|
||||
}
|
||||
out = append(out, attr)
|
||||
case "DocumentAttributeCustomEmoji":
|
||||
attr := domain.DocumentAttribute{Kind: domain.DocAttrCustomEmoji, Alt: a.Alt, Free: a.Free, TextColor: a.TextColor}
|
||||
if a.Stickerset != nil {
|
||||
attr.StickerSetID = a.Stickerset.ID
|
||||
attr.StickerSetAccessHash = a.Stickerset.AccessHash
|
||||
}
|
||||
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})
|
||||
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":
|
||||
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrFilename, FileName: a.FileName})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func seedPhotoSizes(thumbs []seedThumbJSON) []domain.PhotoSize {
|
||||
out := make([]domain.PhotoSize, 0, len(thumbs))
|
||||
for _, t := range thumbs {
|
||||
ps, _ := seedPhotoSize(t)
|
||||
if ps.Kind != "" {
|
||||
out = append(out, ps)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func seedStickerSetPhotoSizes(thumbs []seedThumbJSON) []domain.PhotoSize {
|
||||
out := make([]domain.PhotoSize, 0, len(thumbs))
|
||||
for _, t := range thumbs {
|
||||
ps, downloadable := seedPhotoSize(t)
|
||||
if ps.Kind == "" || downloadable {
|
||||
continue
|
||||
}
|
||||
out = append(out, ps)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func seedPhotoSize(t seedThumbJSON) (domain.PhotoSize, bool) {
|
||||
switch t.Type {
|
||||
case "PhotoSize":
|
||||
return domain.PhotoSize{Kind: domain.PhotoSizeKindDefault, Type: t.SizeType, W: t.W, H: t.H, Size: t.Size}, true
|
||||
case "PhotoStrippedSize":
|
||||
b, _ := hex.DecodeString(t.Bytes)
|
||||
return domain.PhotoSize{Kind: domain.PhotoSizeKindStripped, Type: t.SizeType, Bytes: b}, false
|
||||
case "PhotoCachedSize":
|
||||
b, _ := hex.DecodeString(t.Bytes)
|
||||
return domain.PhotoSize{Kind: domain.PhotoSizeKindCached, Type: t.SizeType, W: t.W, H: t.H, Bytes: b}, false
|
||||
case "PhotoPathSize":
|
||||
b, _ := hex.DecodeString(t.Bytes)
|
||||
return domain.PhotoSize{Kind: domain.PhotoSizeKindPath, Type: t.SizeType, Bytes: b}, false
|
||||
case "PhotoSizeProgressive":
|
||||
return domain.PhotoSize{Kind: domain.PhotoSizeKindProgressive, Type: t.SizeType, W: t.W, H: t.H, Sizes: t.Sizes}, true
|
||||
default:
|
||||
return domain.PhotoSize{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func seedInlineCachedDocumentThumb(ps domain.PhotoSize, data []byte) domain.PhotoSize {
|
||||
if ps.Kind != domain.PhotoSizeKindDefault || len(data) == 0 || len(data) > seedInlineCachedDocumentThumbMaxBytes {
|
||||
return ps
|
||||
}
|
||||
ps.Kind = domain.PhotoSizeKindCached
|
||||
ps.Size = 0
|
||||
ps.Bytes = append([]byte(nil), data...)
|
||||
return ps
|
||||
}
|
||||
|
||||
func seedPreferRasterDocumentThumbs(sizes []domain.PhotoSize) []domain.PhotoSize {
|
||||
if !documentThumbsHaveRaster(sizes) {
|
||||
return sizes
|
||||
}
|
||||
out := sizes[:0]
|
||||
for _, size := range sizes {
|
||||
if size.Kind == domain.PhotoSizeKindPath {
|
||||
continue
|
||||
}
|
||||
out = append(out, size)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func seedThumbMimeType(data []byte) string {
|
||||
switch {
|
||||
case len(data) >= 12 && data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' &&
|
||||
data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P':
|
||||
return "image/webp"
|
||||
case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
|
||||
return "image/jpeg"
|
||||
case len(data) >= 8 && data[0] == 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G':
|
||||
return "image/png"
|
||||
case len(data) >= 6 && data[0] == 'G' && data[1] == 'I' && data[2] == 'F':
|
||||
return "image/gif"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) stickerSetDocumentThumbsNeedInlineCache(ctx context.Context) (bool, error) {
|
||||
var ids []int64
|
||||
for _, kind := range []domain.StickerSetKind{
|
||||
domain.StickerSetKindStickers,
|
||||
domain.StickerSetKindEmoji,
|
||||
domain.StickerSetKindMasks,
|
||||
domain.StickerSetKindSystem,
|
||||
} {
|
||||
sets, err := s.media.ListStickerSets(ctx, kind)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, set := range sets {
|
||||
ids = append(ids, set.DocumentIDs...)
|
||||
}
|
||||
}
|
||||
return s.documentsNeedInlineCachedThumbs(ctx, ids)
|
||||
}
|
||||
|
||||
func (s *Service) documentsNeedInlineCachedThumbs(ctx context.Context, ids []int64) (bool, error) {
|
||||
if len(ids) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
unique := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
unique = append(unique, id)
|
||||
}
|
||||
docs, err := s.media.GetDocuments(ctx, unique)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, doc := range docs {
|
||||
if documentThumbsHaveRaster(doc.Thumbs) && documentThumbsHavePath(doc.Thumbs) {
|
||||
return true, nil
|
||||
}
|
||||
for _, thumb := range doc.Thumbs {
|
||||
if thumb.Kind == domain.PhotoSizeKindDefault && thumb.Size > 0 && thumb.Size <= seedInlineCachedDocumentThumbMaxBytes {
|
||||
return true, nil
|
||||
}
|
||||
if thumb.Kind == domain.PhotoSizeKindCached && len(thumb.Bytes) > 0 {
|
||||
blob, ok, err := s.media.GetFileBlob(ctx, fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if ok {
|
||||
want := seedThumbMimeType(thumb.Bytes)
|
||||
if want != "application/octet-stream" && blob.MimeType != want {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func documentThumbsHaveRaster(sizes []domain.PhotoSize) bool {
|
||||
for _, size := range sizes {
|
||||
switch size.Kind {
|
||||
case domain.PhotoSizeKindCached:
|
||||
if len(size.Bytes) > 0 {
|
||||
return true
|
||||
}
|
||||
case domain.PhotoSizeKindDefault:
|
||||
if size.Type != "" && size.Size > 0 {
|
||||
return true
|
||||
}
|
||||
case domain.PhotoSizeKindProgressive:
|
||||
if size.Type != "" && len(size.Sizes) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func documentThumbsHavePath(sizes []domain.PhotoSize) bool {
|
||||
for _, size := range sizes {
|
||||
if size.Kind == domain.PhotoSizeKindPath && len(size.Bytes) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func seedStickerPacks(setPacks, resultPacks []seedStickerPackJSON, docIDBySource map[int64]int64) []domain.StickerPack {
|
||||
packs := setPacks
|
||||
if len(packs) == 0 {
|
||||
packs = resultPacks
|
||||
}
|
||||
out := make([]domain.StickerPack, 0, len(packs))
|
||||
for _, p := range packs {
|
||||
documents := make([]int64, 0, len(p.Documents))
|
||||
for _, sourceID := range p.Documents {
|
||||
if id, ok := docIDBySource[sourceID]; ok {
|
||||
documents = append(documents, id)
|
||||
continue
|
||||
}
|
||||
if id := seedDocumentStorageID(sourceID); id != 0 {
|
||||
documents = append(documents, id)
|
||||
}
|
||||
}
|
||||
out = append(out, domain.StickerPack{Emoticon: p.Emoticon, DocumentIDs: documents})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseSeedDate(s string) int {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return int(t.Unix())
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func derefInt64(v *int64) int64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// ---- seed JSON 结构 ----
|
||||
|
||||
type seedInputStickerSetJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
AccessHash int64 `json:"access_hash"`
|
||||
}
|
||||
|
||||
type seedAttrJSON struct {
|
||||
Type string `json:"_"`
|
||||
W int `json:"w"`
|
||||
H int `json:"h"`
|
||||
Alt string `json:"alt"`
|
||||
Mask bool `json:"mask"`
|
||||
Duration float64 `json:"duration"`
|
||||
RoundMessage bool `json:"round_message"`
|
||||
SupportsStreaming bool `json:"supports_streaming"`
|
||||
Voice bool `json:"voice"`
|
||||
Title string `json:"title"`
|
||||
Performer string `json:"performer"`
|
||||
FileName string `json:"file_name"`
|
||||
Free bool `json:"free"`
|
||||
TextColor bool `json:"text_color"`
|
||||
Stickerset *seedInputStickerSetJSON `json:"stickerset"`
|
||||
}
|
||||
|
||||
type seedThumbJSON struct {
|
||||
Type string `json:"_"`
|
||||
SizeType string `json:"type"`
|
||||
W int `json:"w"`
|
||||
H int `json:"h"`
|
||||
Size int `json:"size"`
|
||||
Bytes string `json:"bytes"`
|
||||
Sizes []int `json:"sizes"`
|
||||
}
|
||||
|
||||
type seedDocumentJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
AccessHash int64 `json:"access_hash"`
|
||||
FileReference string `json:"file_reference"`
|
||||
Date string `json:"date"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Size int64 `json:"size"`
|
||||
DCID int `json:"dc_id"`
|
||||
Attributes []seedAttrJSON `json:"attributes"`
|
||||
Thumbs []seedThumbJSON `json:"thumbs"`
|
||||
}
|
||||
|
||||
type seedStickerPackJSON struct {
|
||||
Emoticon string `json:"emoticon"`
|
||||
Documents []int64 `json:"documents"`
|
||||
}
|
||||
|
||||
type seedStickerSetJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
AccessHash int64 `json:"access_hash"`
|
||||
Title string `json:"title"`
|
||||
ShortName string `json:"short_name"`
|
||||
Count int `json:"count"`
|
||||
Hash int `json:"hash"`
|
||||
Archived bool `json:"archived"`
|
||||
Official bool `json:"official"`
|
||||
Masks bool `json:"masks"`
|
||||
Emojis bool `json:"emojis"`
|
||||
Thumbs []seedThumbJSON `json:"thumbs"`
|
||||
ThumbDCID int `json:"thumb_dc_id"`
|
||||
ThumbVersion int `json:"thumb_version"`
|
||||
ThumbDocumentID *int64 `json:"thumb_document_id"`
|
||||
Packs []seedStickerPackJSON `json:"packs"`
|
||||
}
|
||||
|
||||
type seedStickerSetResultJSON struct {
|
||||
Set seedStickerSetJSON `json:"set"`
|
||||
Packs []seedStickerPackJSON `json:"packs"`
|
||||
Documents []seedDocumentJSON `json:"documents"`
|
||||
}
|
||||
|
||||
type seedReactionJSON struct {
|
||||
Reaction string `json:"reaction"`
|
||||
Title string `json:"title"`
|
||||
Inactive bool `json:"inactive"`
|
||||
Premium bool `json:"premium"`
|
||||
StaticIcon *seedDocumentJSON `json:"static_icon"`
|
||||
AppearAnimation *seedDocumentJSON `json:"appear_animation"`
|
||||
SelectAnimation *seedDocumentJSON `json:"select_animation"`
|
||||
ActivateAnimation *seedDocumentJSON `json:"activate_animation"`
|
||||
EffectAnimation *seedDocumentJSON `json:"effect_animation"`
|
||||
AroundAnimation *seedDocumentJSON `json:"around_animation"`
|
||||
CenterIcon *seedDocumentJSON `json:"center_icon"`
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue