* feat(downloads): distribute preparation and delivery * fix(downloads): harden distributed job routing * fix(downloads): release probe capacity and bound tracking
146 lines
4.9 KiB
Go
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
|
|
}
|