Files
MeBox/internal/service/transcoder.go
T

434 lines
12 KiB
Go

// Package service — HLS on-demand transcoder.
//
// TranscoderService spawns ffmpeg processes that segment a source media file
// into HLS (.m3u8 + .ts). The output lives under cache.cache_dir/hls/<id>.
// The HTTP layer serves these files directly with a normal http.FileServer.
//
// Encoder selection (read once at startup from the config):
//
// transcoder.encoder = "" | "nvenc" | "qsv" | "vaapi"
//
// "" software libx264 (default; runs anywhere)
// nvenc h264_nvenc (NVIDIA GPU, requires --gpus all on Docker)
// qsv h264_qsv (Intel iGPU, requires /dev/dri:/dev/dri)
// vaapi h264_vaapi (Mesa/Intel VAAPI, requires /dev/dri:/dev/dri
// plus the kernel module loaded)
//
// Concurrency model:
// - Each Media has at most one active ffmpeg job.
// - jobs[mediaID] tracks the running goroutine + cancel func.
// - Calling Start while a job already exists is a no-op.
// - When the playlist file appears on disk we consider the job "ready"
// and unblock the HTTP handler that was waiting on it.
package service
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// TranscoderService orchestrates background ffmpeg transcodes.
type TranscoderService struct {
cfg *config.Config
log *zap.Logger
repo *repository.Container
hub *Hub
mu sync.Mutex
jobs map[string]*hlsJob
}
// hlsJob holds the live state of one ffmpeg run.
type hlsJob struct {
mediaID string
outputDir string
cancel context.CancelFunc
startedAt time.Time
playlistOK bool
encoder string
}
// NewTranscoderService is the constructor.
func NewTranscoderService(cfg *config.Config, log *zap.Logger, repo *repository.Container, hub *Hub) *TranscoderService {
return &TranscoderService{
cfg: cfg,
log: log,
repo: repo,
hub: hub,
jobs: make(map[string]*hlsJob),
}
}
// HLSDir is the per-media directory that holds index.m3u8 + segment files.
func (t *TranscoderService) HLSDir(mediaID string) string {
return filepath.Join(t.cfg.Cache.CacheDir, "hls", mediaID)
}
// PlaylistPath returns the absolute path of the m3u8 playlist for a media.
func (t *TranscoderService) PlaylistPath(mediaID string) string {
return filepath.Join(t.HLSDir(mediaID), "index.m3u8")
}
// EnsureJob makes sure a transcode is running for mediaID. The function is
// non-blocking: it returns the playlist path immediately. The caller is
// expected to poll until WaitReady reports true.
func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (string, error) {
m, err := t.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return "", err
}
if m == nil {
return "", ErrMediaNotFound
}
if _, err := os.Stat(m.Path); err != nil {
return "", ErrMediaNotFound
}
if _, err := t.resolveFFmpegPath(); err != nil {
return "", err
}
t.mu.Lock()
if _, ok := t.jobs[mediaID]; ok {
t.mu.Unlock()
return t.PlaylistPath(mediaID), nil
}
outDir := t.HLSDir(mediaID)
if err := os.MkdirAll(outDir, 0o755); err != nil {
t.mu.Unlock()
return "", err
}
jobCtx, cancel := context.WithCancel(context.Background())
job := &hlsJob{
mediaID: mediaID,
outputDir: outDir,
cancel: cancel,
startedAt: time.Now(),
encoder: t.cfg.Transcoder.Encoder,
}
t.jobs[mediaID] = job
t.mu.Unlock()
go t.runFFmpeg(jobCtx, job, m.Path)
return t.PlaylistPath(mediaID), nil
}
// WaitReady blocks (with a deadline) until the playlist file shows up on
// disk. Returns true on success.
func (t *TranscoderService) WaitReady(ctx context.Context, mediaID string, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for {
if _, err := os.Stat(t.PlaylistPath(mediaID)); err == nil {
t.mu.Lock()
if j, ok := t.jobs[mediaID]; ok {
j.playlistOK = true
}
t.mu.Unlock()
return true
}
if time.Now().After(deadline) || ctx.Err() != nil {
return false
}
select {
case <-ctx.Done():
return false
case <-time.After(250 * time.Millisecond):
}
}
}
// StopJob cancels a running ffmpeg process for mediaID, if any.
func (t *TranscoderService) StopJob(mediaID string) {
t.mu.Lock()
defer t.mu.Unlock()
if j, ok := t.jobs[mediaID]; ok {
j.cancel()
delete(t.jobs, mediaID)
}
}
// StopAll terminates every running transcode (called on graceful shutdown).
func (t *TranscoderService) StopAll() {
t.mu.Lock()
defer t.mu.Unlock()
for id, j := range t.jobs {
j.cancel()
delete(t.jobs, id)
}
}
// ActiveJob is the JSON shape exposed to the React Tasks panel.
type ActiveJob struct {
MediaID string `json:"media_id"`
Encoder string `json:"encoder"`
StartedAt time.Time `json:"started_at"`
PlaylistOK bool `json:"playlist_ok"`
}
// Active returns a snapshot of the currently running transcode jobs.
func (t *TranscoderService) Active() []ActiveJob {
t.mu.Lock()
defer t.mu.Unlock()
out := make([]ActiveJob, 0, len(t.jobs))
for _, j := range t.jobs {
out = append(out, ActiveJob{
MediaID: j.mediaID,
Encoder: j.encoder,
StartedAt: j.startedAt,
PlaylistOK: j.playlistOK,
})
}
return out
}
func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source string) {
bin, err := t.resolveFFmpegPath()
if err != nil {
t.log.Warn("ffmpeg unavailable", zap.String("media_id", job.mediaID), zap.Error(err))
t.mu.Lock()
delete(t.jobs, job.mediaID)
t.mu.Unlock()
t.hub.Publish("transcode", map[string]any{
"media_id": job.mediaID,
"status": "error",
"error": err.Error(),
})
return
}
playlist := filepath.Join(job.outputDir, "index.m3u8")
segments := filepath.Join(job.outputDir, "seg_%05d.ts")
args := buildFFmpegArgs(t.cfg, source, playlist, segments)
cmd := exec.CommandContext(ctx, bin, args...)
cmd.Stderr = os.Stderr
t.log.Info("transcode started",
zap.String("media_id", job.mediaID),
zap.String("encoder", job.encoder),
zap.String("source", source),
)
t.hub.Publish("transcode", map[string]any{
"media_id": job.mediaID,
"encoder": job.encoder,
"status": "started",
})
if err := cmd.Run(); err != nil && !errors.Is(ctx.Err(), context.Canceled) {
t.log.Warn("ffmpeg exited",
zap.String("media_id", job.mediaID),
zap.Error(err),
)
}
t.mu.Lock()
delete(t.jobs, job.mediaID)
t.mu.Unlock()
t.hub.Publish("transcode", map[string]any{
"media_id": job.mediaID,
"status": "stopped",
"duration": time.Since(job.startedAt).Seconds(),
})
}
func (t *TranscoderService) resolveFFmpegPath() (string, error) {
var lastErr error
for _, bin := range executableCandidates(strings.TrimSpace(t.cfg.App.FFmpegPath), "ffmpeg") {
if err := validateFFmpegForTranscode(context.Background(), bin, t.cfg.Transcoder.Encoder); err != nil {
lastErr = err
continue
}
t.cfg.App.FFmpegPath = bin
return bin, nil
}
if lastErr != nil {
return "", fmt.Errorf("no usable ffmpeg found for HLS transcode: %w", lastErr)
}
return "", fmt.Errorf("ffmpeg not found in PATH or common local app directories; configure app.ffmpeg_path to an existing local ffmpeg")
}
func validateFFmpegForTranscode(ctx context.Context, bin, encoder string) error {
required := requiredVideoEncoder(encoder)
out, err := commandOutput(ctx, 8*time.Second, bin, "-hide_banner", "-encoders")
if err != nil {
return fmt.Errorf("%s cannot list encoders: %w", bin, err)
}
if !hasFFmpegListEntry(string(out), required) {
return fmt.Errorf("%s does not provide required encoder %s", bin, required)
}
out, err = commandOutput(ctx, 8*time.Second, bin, "-hide_banner", "-muxers")
if err != nil || !hasFFmpegListEntry(string(out), "hls") {
out, err = commandOutput(ctx, 8*time.Second, bin, "-hide_banner", "-formats")
if err != nil {
return fmt.Errorf("%s cannot list muxers/formats: %w", bin, err)
}
}
if !hasFFmpegListEntry(string(out), "hls") {
return fmt.Errorf("%s does not provide hls muxer", bin)
}
return nil
}
func requiredVideoEncoder(encoder string) string {
switch encoder {
case "nvenc":
return "h264_nvenc"
case "qsv":
return "h264_qsv"
case "vaapi":
return "h264_vaapi"
default:
return "libx264"
}
}
func hasFFmpegListEntry(output, name string) bool {
for _, line := range strings.Split(output, "\n") {
fields := strings.Fields(line)
for _, field := range fields {
if field == name {
return true
}
}
}
return false
}
// buildFFmpegArgs assembles the ffmpeg command line for the configured
// encoder. The function is package-level so the unit test can pin its
// behaviour without spawning a real ffmpeg process.
func buildFFmpegArgs(cfg *config.Config, source, playlist, segments string) []string {
enc := cfg.Transcoder.Encoder
bitrate := cfg.Transcoder.VideoBitrate
if bitrate == "" {
bitrate = "1500k"
}
maxrate := cfg.Transcoder.MaxRate
if maxrate == "" {
maxrate = "1800k"
}
bufsize := cfg.Transcoder.BufSize
if bufsize == "" {
bufsize = "3000k"
}
preset := cfg.Transcoder.Preset
if preset == "" {
preset = "veryfast"
}
height := cfg.Transcoder.MaxHeight
if height <= 0 {
height = 720
}
segDur := cfg.Transcoder.SegmentSeconds
if segDur <= 0 {
segDur = 4
}
// Hardware-accel arguments differ in three places:
// - Optional input flags (-hwaccel + device init)
// - Optional input pixel-format upload filter
// - The actual -c:v encoder name + preset/quality flag
var pre, vf, vcodec, vpreset string
switch enc {
case "nvenc":
pre = "-hwaccel cuda -hwaccel_output_format cuda"
vf = fmt.Sprintf("scale_cuda=-2:min(%d\\,ih)", height)
vcodec = "h264_nvenc"
vpreset = "p4"
case "qsv":
pre = "-hwaccel qsv -hwaccel_output_format qsv"
vf = fmt.Sprintf("scale_qsv=-1:min(%d\\,ih)", height)
vcodec = "h264_qsv"
vpreset = preset
case "vaapi":
device := cfg.App.VAAPIDevice
if device == "" {
device = "/dev/dri/renderD128"
}
pre = fmt.Sprintf("-hwaccel vaapi -vaapi_device %s -hwaccel_output_format vaapi", device)
vf = fmt.Sprintf("scale_vaapi=-2:min(%d\\,ih),format=nv12|vaapi,hwupload", height)
vcodec = "h264_vaapi"
vpreset = ""
default:
// software
pre = ""
vf = fmt.Sprintf("scale=-2:min(%d\\,ih)", height)
vcodec = "libx264"
vpreset = preset
}
args := []string{"-y", "-fflags", "+genpts"}
for _, p := range splitNonEmptyArgs(pre) {
args = append(args, p)
}
args = append(args, "-i", source, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", vf, "-c:v", vcodec)
if vpreset != "" {
args = append(args, "-preset", vpreset)
}
args = append(args,
"-pix_fmt", "yuv420p",
"-b:v", bitrate,
"-maxrate", maxrate,
"-bufsize", bufsize,
"-c:a", "aac",
"-ar", "48000",
"-b:a", "128k",
"-ac", "2",
"-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", segDur),
"-f", "hls",
"-hls_time", fmt.Sprintf("%d", segDur),
"-hls_list_size", "0",
"-hls_segment_filename", segments,
playlist,
)
return args
}
// splitNonEmptyArgs is a tiny helper that mirrors strings.Fields for the
// pre-input flag string without dragging the strings import into the hot
// path of every call to buildFFmpegArgs.
func splitNonEmptyArgs(s string) []string {
if s == "" {
return nil
}
out := make([]string, 0, 4)
field := make([]rune, 0, 16)
flush := func() {
if len(field) > 0 {
out = append(out, string(field))
field = field[:0]
}
}
for _, r := range s {
if r == ' ' || r == '\t' {
flush()
continue
}
field = append(field, r)
}
flush()
return out
}
// HumanFFmpegProfile is exposed for the admin UI / settings view.
func (t *TranscoderService) HumanFFmpegProfile() string {
return fmt.Sprintf("ffmpeg=%s, encoder=%s, output=%s",
t.cfg.App.FFmpegPath, t.cfg.Transcoder.Encoder, filepath.Join(t.cfg.Cache.CacheDir, "hls"))
}