mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
434 lines
12 KiB
Go
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"))
|
|
}
|