mirror of
https://github.com/krau/SaveAny-Bot.git
synced 2026-08-07 05:23:22 +08:00
* feat(telegram): split oversized videos losslessly * fix(telegram): respect Premium upload limits * fix(telegram): keep split videos in one album
359 lines
9.4 KiB
Go
359 lines
9.4 KiB
Go
package telegram
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/celestix/gotgproto/ext"
|
|
"github.com/charmbracelet/log"
|
|
"github.com/gotd/td/telegram/message"
|
|
"github.com/rs/xid"
|
|
|
|
"github.com/krau/SaveAny-Bot/config"
|
|
)
|
|
|
|
const (
|
|
videoPartTargetRatio = 0.95
|
|
videoSplitAttempts = 4
|
|
minSegmentDuration = 1.0
|
|
maxLosslessVideoParts = 10
|
|
)
|
|
|
|
type losslessVideoPart struct {
|
|
Path string
|
|
Name string
|
|
Size int64
|
|
}
|
|
|
|
type mediaToolRunner func(ctx context.Context, name string, args ...string) ([]byte, error)
|
|
|
|
var runMediaTool mediaToolRunner = func(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
message := strings.TrimSpace(string(output))
|
|
if message == "" {
|
|
return nil, err
|
|
}
|
|
return nil, fmt.Errorf("%w: %s", err, message)
|
|
}
|
|
return output, nil
|
|
}
|
|
|
|
func createLosslessVideoParts(
|
|
ctx context.Context,
|
|
r io.ReadSeeker,
|
|
filename string,
|
|
fileSize, maxPartSize int64,
|
|
) ([]losslessVideoPart, func(), error) {
|
|
inputPath, sourceCleanup, err := sourceFile(r)
|
|
if err != nil {
|
|
return nil, func() {}, fmt.Errorf("failed to prepare seekable video source: %w", err)
|
|
}
|
|
|
|
tempBase := config.C().Temp.BasePath
|
|
if err := os.MkdirAll(tempBase, 0o755); err != nil {
|
|
sourceCleanup()
|
|
return nil, func() {}, fmt.Errorf("failed to create video split base directory: %w", err)
|
|
}
|
|
tempDir, err := os.MkdirTemp(tempBase, "telegram-video-split-*")
|
|
if err != nil {
|
|
sourceCleanup()
|
|
return nil, func() {}, fmt.Errorf("failed to create video split directory: %w", err)
|
|
}
|
|
cleanup := func() {
|
|
if err := os.RemoveAll(tempDir); err != nil {
|
|
log.FromContext(ctx).Warnf("Failed to clean lossless video parts: %s", err)
|
|
}
|
|
sourceCleanup()
|
|
}
|
|
|
|
parts, err := splitLosslessVideo(ctx, inputPath, tempDir, filename, fileSize, maxPartSize)
|
|
if err != nil {
|
|
cleanup()
|
|
return nil, func() {}, err
|
|
}
|
|
return parts, cleanup, nil
|
|
}
|
|
|
|
func splitLosslessVideo(
|
|
ctx context.Context,
|
|
inputPath, outputDir, filename string,
|
|
fileSize, maxPartSize int64,
|
|
) ([]losslessVideoPart, error) {
|
|
if fileSize <= maxPartSize {
|
|
return nil, fmt.Errorf("video size %d does not exceed part limit %d", fileSize, maxPartSize)
|
|
}
|
|
if maxPartSize <= 0 {
|
|
return nil, fmt.Errorf("invalid video part limit: %d", maxPartSize)
|
|
}
|
|
|
|
duration, err := probeMediaDuration(ctx, inputPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to probe source video duration: %w", err)
|
|
}
|
|
if duration < minSegmentDuration {
|
|
return nil, fmt.Errorf("invalid source video duration: %.3f", duration)
|
|
}
|
|
|
|
targetSize := int64(float64(maxPartSize) * videoPartTargetRatio)
|
|
segmentDuration := initialSegmentDuration(duration, fileSize, targetSize)
|
|
extension := videoPartExtension(filename)
|
|
outputPattern := filepath.Join(outputDir, "part-%03d"+extension)
|
|
|
|
var lastOversize int64
|
|
for attempt := 0; attempt < videoSplitAttempts; attempt++ {
|
|
if err := clearVideoParts(outputDir); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := runFFmpegSegment(ctx, inputPath, outputPattern, extension, segmentDuration); err != nil {
|
|
return nil, fmt.Errorf("failed to losslessly split video: %w", err)
|
|
}
|
|
|
|
parts, largest, err := collectVideoParts(ctx, outputDir, filename)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(parts) < 2 {
|
|
return nil, fmt.Errorf("video split produced %d part(s), expected at least 2", len(parts))
|
|
}
|
|
if len(parts) > maxLosslessVideoParts {
|
|
return nil, fmt.Errorf(
|
|
"video split produced %d parts, exceeding the single-album limit of %d",
|
|
len(parts),
|
|
maxLosslessVideoParts,
|
|
)
|
|
}
|
|
if largest <= maxPartSize {
|
|
return parts, nil
|
|
}
|
|
|
|
lastOversize = largest
|
|
segmentDuration *= float64(targetSize) / float64(largest)
|
|
if segmentDuration < minSegmentDuration {
|
|
break
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf(
|
|
"unable to keep lossless video parts below %d bytes; largest part was %d bytes",
|
|
maxPartSize,
|
|
lastOversize,
|
|
)
|
|
}
|
|
|
|
func initialSegmentDuration(duration float64, fileSize, targetSize int64) float64 {
|
|
partCount := math.Ceil(float64(fileSize) / float64(targetSize))
|
|
if partCount < 2 {
|
|
partCount = 2
|
|
}
|
|
segmentDuration := duration / partCount
|
|
if segmentDuration < minSegmentDuration {
|
|
return minSegmentDuration
|
|
}
|
|
return segmentDuration
|
|
}
|
|
|
|
func videoPartExtension(filename string) string {
|
|
extension := strings.ToLower(filepath.Ext(filepath.Base(filename)))
|
|
switch extension {
|
|
case ".avi", ".m4v", ".mkv", ".mov", ".mp4", ".ts", ".webm":
|
|
return extension
|
|
default:
|
|
return ".mp4"
|
|
}
|
|
}
|
|
|
|
func runFFmpegSegment(
|
|
ctx context.Context,
|
|
inputPath, outputPattern, extension string,
|
|
segmentDuration float64,
|
|
) error {
|
|
args := []string{
|
|
"-hide_banner",
|
|
"-loglevel", "error",
|
|
"-nostdin",
|
|
"-y",
|
|
"-i", inputPath,
|
|
"-map", "0",
|
|
"-map_metadata", "0",
|
|
"-c", "copy",
|
|
"-f", "segment",
|
|
"-segment_time", strconv.FormatFloat(segmentDuration, 'f', 3, 64),
|
|
"-segment_start_number", "1",
|
|
"-reset_timestamps", "1",
|
|
"-avoid_negative_ts", "make_zero",
|
|
}
|
|
if extension == ".mp4" || extension == ".m4v" || extension == ".mov" {
|
|
args = append(args, "-segment_format_options", "movflags=+faststart")
|
|
}
|
|
args = append(args, outputPattern)
|
|
_, err := runMediaTool(ctx, "ffmpeg", args...)
|
|
return err
|
|
}
|
|
|
|
func probeMediaDuration(ctx context.Context, filePath string) (float64, error) {
|
|
output, err := runMediaTool(
|
|
ctx,
|
|
"ffprobe",
|
|
"-v", "error",
|
|
"-show_entries", "format=duration",
|
|
"-of", "default=noprint_wrappers=1:nokey=1",
|
|
filePath,
|
|
)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
duration, err := strconv.ParseFloat(strings.TrimSpace(string(output)), 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid ffprobe duration %q: %w", strings.TrimSpace(string(output)), err)
|
|
}
|
|
return duration, nil
|
|
}
|
|
|
|
func clearVideoParts(outputDir string) error {
|
|
entries, err := os.ReadDir(outputDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read video split directory: %w", err)
|
|
}
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
if err := os.Remove(filepath.Join(outputDir, entry.Name())); err != nil {
|
|
return fmt.Errorf("failed to clear old video part %s: %w", entry.Name(), err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func collectVideoParts(
|
|
ctx context.Context,
|
|
outputDir, filename string,
|
|
) ([]losslessVideoPart, int64, error) {
|
|
matches, err := filepath.Glob(filepath.Join(outputDir, "part-*"))
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("failed to list video parts: %w", err)
|
|
}
|
|
sort.Strings(matches)
|
|
if len(matches) == 0 {
|
|
return nil, 0, fmt.Errorf("ffmpeg did not produce any video parts")
|
|
}
|
|
|
|
extension := videoPartExtension(filename)
|
|
base := strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filepath.Base(filename)))
|
|
if base == "" || base == "." {
|
|
base = xid.New().String()
|
|
}
|
|
parts := make([]losslessVideoPart, 0, len(matches))
|
|
var largest int64
|
|
for index, match := range matches {
|
|
info, err := os.Stat(match)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("failed to stat video part %s: %w", match, err)
|
|
}
|
|
if info.Size() <= 0 {
|
|
return nil, 0, fmt.Errorf("video part %s is empty", match)
|
|
}
|
|
if _, err := probeMediaDuration(ctx, match); err != nil {
|
|
return nil, 0, fmt.Errorf("failed to validate video part %s: %w", match, err)
|
|
}
|
|
if info.Size() > largest {
|
|
largest = info.Size()
|
|
}
|
|
parts = append(parts, losslessVideoPart{
|
|
Path: match,
|
|
Name: fmt.Sprintf("%s.part%03d%s", base, index+1, extension),
|
|
Size: info.Size(),
|
|
})
|
|
}
|
|
return parts, largest, nil
|
|
}
|
|
|
|
func partStoragePath(storagePath, partName string) string {
|
|
directory := path.Dir(path.Clean(storagePath))
|
|
if directory == "." || directory == "/" {
|
|
return partName
|
|
}
|
|
return path.Join(directory, partName)
|
|
}
|
|
|
|
func (t *Telegram) uploadLosslessVideoParts(
|
|
ctx context.Context,
|
|
tctx *ext.Context,
|
|
storagePath string,
|
|
parts []losslessVideoPart,
|
|
sourceCaption *string,
|
|
) error {
|
|
if len(parts) == 0 {
|
|
return fmt.Errorf("no lossless video parts to upload")
|
|
}
|
|
if len(parts) > maxLosslessVideoParts {
|
|
return fmt.Errorf(
|
|
"refusing to upload %d lossless video parts as multiple albums; maximum is %d",
|
|
len(parts),
|
|
maxLosslessVideoParts,
|
|
)
|
|
}
|
|
|
|
prepared := make([]preparedMedia, 0, len(parts))
|
|
for index, part := range parts {
|
|
partFile, err := os.Open(part.Path)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open video part %s: %w", part.Name, err)
|
|
}
|
|
media, prepareErr := t.prepareMedia(
|
|
ctx,
|
|
tctx,
|
|
partFile,
|
|
partStoragePath(storagePath, part.Name),
|
|
part.Size,
|
|
videoPartCaption(sourceCaption, index),
|
|
)
|
|
closeErr := partFile.Close()
|
|
if prepareErr != nil {
|
|
return fmt.Errorf("failed to prepare video part %s: %w", part.Name, prepareErr)
|
|
}
|
|
if closeErr != nil {
|
|
return fmt.Errorf("failed to close video part %s: %w", part.Name, closeErr)
|
|
}
|
|
prepared = append(prepared, *media)
|
|
}
|
|
|
|
builder := tctx.Sender.WithUploader(prepared[0].uploader).To(prepared[0].peer)
|
|
if len(prepared) == 1 {
|
|
if _, err := builder.Media(ctx, prepared[0].media); err != nil {
|
|
return fmt.Errorf("failed to send video part: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
media := make([]message.MultiMediaOption, len(prepared))
|
|
for index := range prepared {
|
|
media[index] = prepared[index].media
|
|
}
|
|
if _, err := builder.Album(ctx, media[0], media[1:]...); err != nil {
|
|
return fmt.Errorf("failed to send video parts as album: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func videoPartCaption(sourceCaption *string, index int) *string {
|
|
if sourceCaption == nil {
|
|
return nil
|
|
}
|
|
if index == 0 {
|
|
return sourceCaption
|
|
}
|
|
empty := ""
|
|
return &empty
|
|
}
|