Files
silo-server/internal/playback/prepare_file.go
T
QuickandGitHub 9c69760106 feat(downloads): distribute preparation and delivery (#607)
* feat(downloads): distribute preparation and delivery

* fix(downloads): harden distributed job routing

* fix(downloads): release probe capacity and bound tracking
2026-08-12 15:08:51 -04:00

146 lines
4.9 KiB
Go

package playback
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/Silo-Server/silo-server/internal/models"
)
// PrepareTarget describes the concrete encode target for a prepared download
// artifact (remux or transcode-to-file).
type PrepareTarget struct {
Container string
CodecVideo string // "copy" for remux, else an encoder codec (e.g. "h264")
CodecAudio string // "copy" or "aac"
Resolution string // "" = keep source resolution (no scale)
AudioTrackIndex int
TargetBitrateKbps int // 0 = encoder default/CRF; >0 caps video bitrate
}
// ResolvePrepareTarget computes the encode target for a remux/transcode download
// of file, reusing Resolve so a download's encoding matches the streaming
// decision for the same client (no duplicated codec logic).
//
// - remux: copy video; copy audio unless the client can't decode it (then AAC);
// keep source resolution.
// - transcode: H.264/AAC, downscaled to the client's max resolution when the
// source exceeds it.
func ResolvePrepareTarget(file *models.MediaFile, format string, caps ClientCapabilities, settings AdminSettings) PrepareTarget {
t := PrepareTarget{Container: "mp4", AudioTrackIndex: -1}
decision := Resolve(file, caps, settings)
if format == "remux" {
t.CodecVideo = "copy"
if decision.TranscodeAudio {
t.CodecAudio = "aac"
} else {
t.CodecAudio = "copy"
}
return t
}
// transcode
t.CodecVideo = "h264"
t.CodecAudio = "aac"
if caps.MaxResolution != "" && resolutionOrder(file.Resolution) > resolutionOrder(caps.MaxResolution) {
t.Resolution = caps.MaxResolution
}
return t
}
// PrepareFile encodes a single finalized MP4 (with a relocated moov atom via
// -movflags +faststart, enabling clean seek/resume) from opts.InputPath. It
// writes to outputPath+".part" and atomically renames on success so a partial
// file is never observable at outputPath. The call blocks until ffmpeg exits.
func PrepareFile(ctx context.Context, opts TranscodeOpts, outputPath string) error {
if outputPath == "" {
return fmt.Errorf("prepare-file: empty output path")
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return fmt.Errorf("prepare-file: create output dir: %w", err)
}
partPath := outputPath + ".part"
// A reclaimed job overwrites its own .part; ffmpeg -y handles that, but remove
// any stale partial first so a failed prior attempt can't be mistaken for output.
_ = os.Remove(partPath)
// Resolve a multi-device hw_device list to one concrete GPU for this
// encode. Run blocks until ffmpeg exits, so the deferred release fires at
// exactly the process-exit boundary.
opts = normalizeTranscodeOpts(opts)
hwDevice, releaseHWDevice := AcquireHWDevice(opts.HWDevice, opts.HWAccel)
opts.HWDevice = hwDevice
defer releaseHWDevice()
args := buildPrepareFileArgs(opts, partPath)
bin := opts.FFmpegPath
if bin == "" {
bin = ffmpegBinary()
}
cmd := exec.CommandContext(ctx, bin, args...)
stderr := newBoundedTailBuffer(stderrTailMaxBytes)
cmd.Stderr = stderr
cmd.WaitDelay = 3 * time.Second
if err := cmd.Run(); err != nil {
_ = os.Remove(partPath)
if tail := truncateStderr(stderr.String()); tail != "" {
return fmt.Errorf("%w: %w (stderr: %s)", ErrTranscodeFailed, err, tail)
}
return fmt.Errorf("%w: %w", ErrTranscodeFailed, err)
}
if err := os.Rename(partPath, outputPath); err != nil {
_ = os.Remove(partPath)
return fmt.Errorf("prepare-file: finalize artifact: %w", err)
}
return nil
}
// buildPrepareFileArgs constructs single-file ffmpeg args. It mirrors
// buildFFmpegArgs' input/stream/codec/audio/subtitle handling but emits one
// faststart MP4 instead of HLS segments. Full-file output needs no seek or
// segment-boundary keyframes.
func buildPrepareFileArgs(opts TranscodeOpts, outputPath string) []string {
opts = normalizeTranscodeOpts(opts)
isVideoCopy := opts.TargetCodecVideo == "copy"
isAudioCopy := opts.TargetCodecAudio == "copy"
args := []string{"-nostdin", "-hide_banner", "-loglevel", "error"}
if !isVideoCopy {
args = appendHWAccelArgs(args, opts)
}
args = append(args,
"-fflags", "+genpts+fastseek",
"-analyzeduration", "3000000",
"-probesize", "5000000",
)
args = append(args, "-i", opts.InputPath)
args = append(args, "-map_metadata", "-1", "-map_chapters", "-1")
args = appendStreamSelectionArgs(args, opts)
if isVideoCopy {
args = append(args, "-c:v", "copy")
} else {
args = appendVideoArgs(args, opts)
}
if isVideoCopy && !isAudioCopy {
args = append(args, "-threads", "1", "-filter_threads", "1", "-filter_complex_threads", "1")
}
args = appendAudioArgs(args, opts)
if !isVideoCopy {
args = appendVideoFilterArgs(args, opts)
}
// One finalized MP4. +faststart relocates the moov atom in a finalization pass
// (impossible over a pure pipe) so the file is cleanly seekable and resumable.
args = append(args, "-movflags", "+faststart", "-f", "mp4", "-y", outputPath)
return args
}