Files
convert/internal/convert/runner.go
T
2026-11-12 18:04:53 +03:00

186 lines
3.5 KiB
Go

package convert
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
"convert/internal/media"
)
type Runner struct {
mu sync.Mutex
cancel context.CancelFunc
stdin io.WriteCloser
active bool
}
func NewRunner() *Runner {
return &Runner{}
}
func (r *Runner) IsRunning() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.active
}
func (r *Runner) Cancel() bool {
r.mu.Lock()
defer r.mu.Unlock()
if !r.active {
return false
}
if r.stdin != nil {
_, _ = io.WriteString(r.stdin, "q\n")
}
if r.cancel != nil {
r.cancel()
}
return true
}
func (r *Runner) Run(
parentCtx context.Context,
ffmpegBin string,
built *BuiltCommand,
onProgress func(Progress),
) error {
if ffmpegBin == "" {
return fmt.Errorf("ffmpeg binary not found")
}
if built == nil {
return fmt.Errorf("built command is nil")
}
if err := EnsureUniqueOutputDir(built.OutputPath); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
r.mu.Lock()
if r.active {
r.mu.Unlock()
return fmt.Errorf("conversion already in progress")
}
ctx, cancel := context.WithCancel(parentCtx)
r.cancel = cancel
r.active = true
r.mu.Unlock()
defer func() {
r.mu.Lock()
r.active = false
r.cancel = nil
r.stdin = nil
r.mu.Unlock()
cancel()
}()
args := append([]string{"-progress", "pipe:1", "-nostats"}, built.Args...)
cmd := exec.CommandContext(ctx, ffmpegBin, args...)
media.HideCmdWindow(cmd)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
stdin, err := cmd.StdinPipe()
if err == nil {
r.mu.Lock()
r.stdin = stdin
r.mu.Unlock()
}
var stderrBuf bytes.Buffer
cmd.Stderr = &limitedBuffer{buf: &stderrBuf, max: 32 * 1024}
_, statErr := os.Stat(built.OutputPath)
existedBefore := statErr == nil
hasNoClobber := false
for _, a := range built.Args {
if a == "-n" {
hasNoClobber = true
break
}
}
if existedBefore && hasNoClobber {
return fmt.Errorf("output file already exists: %s", built.OutputPath)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start ffmpeg: %w", err)
}
parser := newProgressParser(built.ExpectedDuration)
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
if prog, ok := parser.feed(scanner.Text()); ok && onProgress != nil {
prog.OutputPath = built.OutputPath
onProgress(prog)
}
}
waitErr := cmd.Wait()
if ctx.Err() != nil {
if !(existedBefore && hasNoClobber) {
_ = os.Remove(built.OutputPath)
}
return fmt.Errorf("conversion cancelled")
}
if waitErr != nil {
if !(existedBefore && hasNoClobber) {
_ = os.Remove(built.OutputPath)
}
errTail := strings.TrimSpace(stderrBuf.String())
if len(errTail) > 800 {
errTail = errTail[len(errTail)-800:]
}
if errTail != "" {
return fmt.Errorf("ffmpeg error: %s", errTail)
}
return fmt.Errorf("ffmpeg failed: %w", waitErr)
}
var outSize int64
if st, err := os.Stat(built.OutputPath); err == nil {
outSize = st.Size()
}
if onProgress != nil {
onProgress(Progress{
Percent: 100,
OutTimeSec: built.ExpectedDuration,
TotalSec: built.ExpectedDuration,
TotalSize: outSize,
OutputBytes: outSize,
OutputPath: built.OutputPath,
Done: true,
})
}
return nil
}
type limitedBuffer struct {
buf *bytes.Buffer
max int
}
func (l *limitedBuffer) Write(p []byte) (int, error) {
if l.buf.Len()+len(p) > l.max {
// keep latest tail
excess := l.buf.Len() + len(p) - l.max
if excess >= l.buf.Len() {
l.buf.Reset()
} else {
b := l.buf.Bytes()[excess:]
l.buf.Reset()
l.buf.Write(b)
}
}
return l.buf.Write(p)
}