Files
SaveAny-Bot/storage/telegram/video_split.go
T
Krau c2f8ab3c01 refactor: quality overhaul (#234)
* fix: prevent queue deadlock after cancelling tasks

Get no longer recurses while holding the mutex.
Cancelled queued tasks leave the map, making their IDs reusable.
Closed empty queues return ErrQueueClosed.

* fix: init task queue lazily and close on shutdown

AddTask is safe before Run by initializing the queue once.
Close unblocks workers waiting in Get.

* fix: make resource fingerprints deterministic

Sort map keys before hashing so Resource.ID is stable.

* fix: harden per-item processing tracking in tasks

Use the resource fingerprint as the dedup key on insert and delete.
Check and set processing entries atomically instead of TOCTOU.
Count only successful downloads.

* fix: honor IgnoreErrors in batch tasks

Element failures no longer cancel sibling elements or stop later groups.

* fix: report streamed upload bytes in batch tasks

Stream downloads report their byte count as the upload total.

* fix: validate i18n key parity across locales

geni18n fails when a language file misses any key.
Align the syncpeers completion key between en and zh-Hans.
Translate three untranslated parse keys in zh-Hans.

* refactor: share progress throttling helpers

Move size-tiered and count-based throttling into progressutil.
Localize hardcoded Chinese progress strings.
Drop five duplicated implementations and dead local copies.

* refactor: share unique filename logic

Storage backends use fsutil.UniquePath instead of local loops.

* fix: sanitize local storage paths

Reject absolute paths and dot-dot escapes in Save.
Check close errors and wrap creation failures.

* fix: preserve webdav error causes

Wrap mkdir and write failures with %w and drop dead error values.

* fix: kill rclone subprocess on reader close

Prevent cat processes from hanging after the pipe is closed.

* fix: fail alist init instead of exiting

Replace log.Fatalf with wrapped errors so a bad alist cannot kill the bot.
Cancel token refresh with the init context and re-login on 401.

* fix: enforce telegram album limits and track saved paths

Use tglimit.MaxAlbumItems for album batching and video splitting.
Exists now reports previously saved paths instead of always false.

* fix: guard storage registry maps

Protect Storages and UserStorages with mutexes and expose read accessors.

* fix: harden JS parser plugin runtime

Validate semver instead of panicking and require canHandle.
Recover plugin worker panics and time out CanHandle calls.
Return a copy from the registry and sanitize install filenames.

* fix: guard kemono parser against nil fields

Skip sparse preview and attachment entries instead of panicking.

* refactor: remove commented-out dead code

Drop unused kemono legacy types and commented response structs.

* test: cover invalid plugin version rejection

* fix: show all queued tasks in /task list

Render up to ten tasks and append the truncation note once.

* fix: guard callback data parsing

Reject malformed callback payloads before indexing split parts.

* fix: isolate media groups per user

Key pending groups by chat, user and group id.

* fix: require permission for callback handlers

* fix: initialize userbot context once

Replace the racy lazy init with sync.OnceValue.

* fix: avoid leaking raw errors in /dir reply

* fix: notify users on invalid update version

* fix: fail fast when API listen fails

Bind synchronously and surface errors instead of logging them.

* fix: add timeouts and backoff to webhook delivery

* refactor: remove dead code from api and bot

Drop the empty ProgressTracker shim, unused token context key and a redundant SetBotCommands call.

* fix: load remote config without local lookup

Skip the local file search after reading a config URL and add a timeout.

* refactor: drop unused hook config

* docs: document parser plugin config

* ci: fix BuildTime formatting and align checkout

Actions format does not format dates; pass the raw timestamp.

* chore: ignore cache directory

* fix: make cache init idempotent

* fix: upload only downloaded batch elements

Failed elements keep partial cache files and never reach the backend.
Successfully downloaded siblings still upload when one element fails.

* fix: record telegram saved paths only after upload

Skip-large returns a sentinel so skipped files are not marked as saved.

* fix: deduplicate alist token refreshes

Guard token access with a mutex and merge concurrent logins.
Reuse a recent refresh to avoid login storms.

* test: cover concurrent alist 401 retry

Ten parallel uploads share a single re-login under -race.

* fix: count parsed resources in progress text

* fix: localize storage lookup errors in /dir

Use the shared i18n key and escape the dynamic error.

* fix: deduplicate concurrent storage initialization

singleflight merges first-time inits so side effects are not duplicated.

* fix: guard nil progress trackers in api-created tasks

Batch, telegraph and transfer tasks run without a Telegram tracker
when created through the API; their callbacks must not panic.

* fix: keep album order when filtering failed batch items

Download results are stored by original index so surviving
elements keep their source order.

* test: cover nil-tracker task execution

* fix: report partial failure in batch done message

IgnoreErrors runs with failed elements show success and failed
counts instead of claiming every file completed.

* fix: skip login refresh for token-only alist storage

401 responses surface the auth error instead of sending a
credential-less login; the refresh window never exceeds TokenExp.

* test: cover alist refresh semantics

Concurrent refresh uses username/password; token-only storage
never attempts a login on 401.

* fix: call tracker from notifyProgress instead of recursing

The helper called itself, overflowing the stack on any batch
task with a progress tracker.

* fix: propagate cancellation past IgnoreErrors

Cancelled tasks must not be reported as successful: only
ordinary element failures are ignored.

* test: cover notifyProgress tracker call

* test: cover cancellation with IgnoreErrors

* fix: refresh alist token after startup 401s

The init login no longer satisfies the dedup window, so an
early 401 triggers a real refresh; later 401s reuse it.
Clarify that streaming uploads cannot replay their body.

* test: exercise the alist 401 refresh path

Concurrent uploads now reject the init token, share one
refresh and retry with the new token; token-only stays inert.

* style: trim verbose comments

Drop process-style explanations; keep one-line behavior notes.

* style: gofmt test files

* fix: drop unbounded saved-path cache in telegram storage

Telegram cannot reliably query remote file existence, so the
cache answered a question it could not answer and grew without
bound. Exists returns false again, as before.

* style: format codes
2026-08-17 19:10:03 +08:00

374 lines
9.8 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"
"github.com/krau/SaveAny-Bot/pkg/consts/tglimit"
)
const (
videoPartTargetRatio = 0.95
videoSplitAttempts = 4
minSegmentDuration = 1.0
maxLosslessVideoParts = tglimit.MaxAlbumItems
)
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 range videoSplitAttempts {
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,
progress *uploadProgress,
) 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,
)
}
resetLosslessVideoUploadProgress(progress, parts)
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),
progress,
)
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 resetLosslessVideoUploadProgress(progress *uploadProgress, parts []losslessVideoPart) {
if progress == nil {
return
}
var total int64
for _, part := range parts {
total += part.Size
}
progress.reset(total)
}
func videoPartCaption(sourceCaption *string, index int) *string {
if sourceCaption == nil {
return nil
}
if index == 0 {
return sourceCaption
}
empty := ""
return &empty
}