fix: cancel in-flight encode on SIGINT/SIGTERM via context plumbing

Thread context.Context from main through watcher, encoder, and metadata
clients so a Ctrl-C during a multi-hour encode immediately kills the
ffmpeg/ffprobe/opusenc children instead of waiting for them to finish.

- main: signal.NotifyContext replaces the manual sigChan + done goroutine
- watcher.Start: takes ctx, exits on ctx.Done(); processFn signature is
  now func(context.Context, string) error
- encoder: Transcode and every helper (extractAudio, encodeOpus,
  encodeVideo, GetMediaInfo, GetStreamLanguages, calculateZscaleWidth)
  take ctx; every exec.Command becomes exec.CommandContext so the child
  is SIGKILL'd on cancel
- metadata: FetchMovieMetadata, FetchSeriesMetadata, fetchTVMazeJSON
  take ctx and use http.NewRequestWithContext

Mover stays ctx-free intentionally: a rename is fast enough that
mid-cancel cleanup is the next-restart's problem. processFile's
deferred RemoveAll(workDir) and failToFailed still run after cancel,
so partial output dies in the work dir and the source moves to failed/.
This commit is contained in:
Esa Kataja
2026-05-16 20:38:26 +03:00
parent 191bc955e4
commit 459ab30d94
4 changed files with 54 additions and 48 deletions
+19 -18
View File
@@ -1,6 +1,7 @@
package encoder
import (
"context"
"encoding/json"
"fmt"
"os/exec"
@@ -42,8 +43,8 @@ type StreamMetadata struct {
Title string `json:"title"`
}
func (e *Encoder) GetStreamLanguages(path string) ([]StreamMetadata, error) {
cmd := exec.Command(e.ffprobePath, "-v", "error", "-show_streams", "-print_format", "json", path)
func (e *Encoder) GetStreamLanguages(ctx context.Context, path string) ([]StreamMetadata, error) {
cmd := exec.CommandContext(ctx, e.ffprobePath, "-v", "error", "-show_streams", "-print_format", "json", path)
output, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("ffprobe error: %w", err)
@@ -103,8 +104,8 @@ func (e *Encoder) CheckDeps() error {
return nil
}
func (e *Encoder) GetMediaInfo(path string) (width, height int, interlaced bool, err error) {
cmd := exec.Command(e.ffprobePath, "-v", "error", "-show_streams", "-print_format", "json", path)
func (e *Encoder) GetMediaInfo(ctx context.Context, path string) (width, height int, interlaced bool, err error) {
cmd := exec.CommandContext(ctx, e.ffprobePath, "-v", "error", "-show_streams", "-print_format", "json", path)
output, err := cmd.CombinedOutput()
if err != nil {
return 0, 0, false, fmt.Errorf("ffprobe error: %w", err)
@@ -131,7 +132,7 @@ func (e *Encoder) GetMediaInfo(path string) (width, height int, interlaced bool,
return 0, 0, false, fmt.Errorf("no video stream found")
}
cmd = exec.Command(e.ffmpegPath,
cmd = exec.CommandContext(ctx, e.ffmpegPath,
"-hide_banner",
"-nostats",
"-i", path,
@@ -168,8 +169,8 @@ func detectInterlaced(idetOutput string) bool {
return interlaced > prog
}
func (e *Encoder) calculateZscaleWidth(path string, originalHeight int) (string, int, error) {
cmd := exec.Command(e.ffprobePath, "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,sample_aspect_ratio", "-print_format", "json", path)
func (e *Encoder) calculateZscaleWidth(ctx context.Context, path string, originalHeight int) (string, int, error) {
cmd := exec.CommandContext(ctx, e.ffprobePath, "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,sample_aspect_ratio", "-print_format", "json", path)
output, err := cmd.CombinedOutput()
if err != nil {
return "", 0, fmt.Errorf("ffprobe error: %w", err)
@@ -221,18 +222,18 @@ func (e *Encoder) calculateZscaleWidth(path string, originalHeight int) (string,
return zscale, newWidth, nil
}
func (e *Encoder) Transcode(input, workDir string, job *types.Job, metadata *types.Metadata, interlaced bool, streamLangs []StreamMetadata) error {
audioWavs, err := e.extractAudio(input, workDir, streamLangs)
func (e *Encoder) Transcode(ctx context.Context, input, workDir string, job *types.Job, metadata *types.Metadata, interlaced bool, streamLangs []StreamMetadata) error {
audioWavs, err := e.extractAudio(ctx, input, workDir, streamLangs)
if err != nil {
return fmt.Errorf("extracting audio: %w", err)
}
opusFiles, err := e.encodeOpus(audioWavs, workDir)
opusFiles, err := e.encodeOpus(ctx, audioWavs, workDir)
if err != nil {
return fmt.Errorf("encoding opus: %w", err)
}
if err := e.encodeVideo(input, workDir, opusFiles, job, metadata, interlaced, streamLangs); err != nil {
if err := e.encodeVideo(ctx, input, workDir, opusFiles, job, metadata, interlaced, streamLangs); err != nil {
return fmt.Errorf("encoding video: %w", err)
}
@@ -253,7 +254,7 @@ func audioStreamsInSourceOrder(streamLangs []StreamMetadata) []StreamMetadata {
return audio
}
func (e *Encoder) extractAudio(input, workDir string, streamLangs []StreamMetadata) ([]string, error) {
func (e *Encoder) extractAudio(ctx context.Context, input, workDir string, streamLangs []StreamMetadata) ([]string, error) {
audio := audioStreamsInSourceOrder(streamLangs)
if len(audio) == 0 {
return nil, fmt.Errorf("no audio streams found")
@@ -262,7 +263,7 @@ func (e *Encoder) extractAudio(input, workDir string, streamLangs []StreamMetada
wavs := make([]string, 0, len(audio))
for srcAudioIndex := range audio {
wavPath := filepath.Join(workDir, fmt.Sprintf("audio.%d.wav", srcAudioIndex))
cmd := exec.Command(e.ffmpegPath, "-i", input,
cmd := exec.CommandContext(ctx, e.ffmpegPath, "-i", input,
"-map", fmt.Sprintf("0:a:%d", srcAudioIndex),
"-vn", "-c:a", "pcm_s16le", "-ar", "48000",
"-f", "wav", wavPath)
@@ -274,12 +275,12 @@ func (e *Encoder) extractAudio(input, workDir string, streamLangs []StreamMetada
return wavs, nil
}
func (e *Encoder) encodeOpus(wavs []string, workDir string) ([]string, error) {
func (e *Encoder) encodeOpus(ctx context.Context, wavs []string, workDir string) ([]string, error) {
var opusFiles []string
for _, wav := range wavs {
base := filepath.Base(strings.Replace(wav, ".wav", ".opus", 1))
out := filepath.Join(workDir, base)
cmd := exec.Command(e.opusencPath, "--bitrate", "128k", wav, out)
cmd := exec.CommandContext(ctx, e.opusencPath, "--bitrate", "128k", wav, out)
if logOut, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("opusenc: %s %w", logOut, err)
}
@@ -288,12 +289,12 @@ func (e *Encoder) encodeOpus(wavs []string, workDir string) ([]string, error) {
return opusFiles, nil
}
func (e *Encoder) encodeVideo(input, workDir string, opusFiles []string, job *types.Job, metadata *types.Metadata, interlaced bool, streamLangs []StreamMetadata) error {
func (e *Encoder) encodeVideo(ctx context.Context, input, workDir string, opusFiles []string, job *types.Job, metadata *types.Metadata, interlaced bool, streamLangs []StreamMetadata) error {
outFile := filepath.Join(workDir, "output.mkv")
svtParams := fmt.Sprintf("film-grain=10:film-grain-denoise=1:scd=1:qm-min=4:qm-max=15:keyint=10s")
zscaleStr, newWidth, err := e.calculateZscaleWidth(input, 0)
zscaleStr, newWidth, err := e.calculateZscaleWidth(ctx, input, 0)
if err != nil {
return fmt.Errorf("calculating zscale: %w", err)
}
@@ -386,7 +387,7 @@ func (e *Encoder) encodeVideo(input, workDir string, opusFiles []string, job *ty
args = append(args, outFile)
args = append([]string{"-hide_banner", "-v", "error"}, args...)
cmd := exec.Command(e.ffmpegPath, args...)
cmd := exec.CommandContext(ctx, e.ffmpegPath, args...)
fmt.Printf("DEBUG FFmpeg command: ffmpeg %s\n", strings.Join(args, " "))
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("ffmpeg encode: %s %w", out, err)