mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
优化转码逻辑
This commit is contained in:
@@ -123,6 +123,7 @@ func (b *serviceContainerBuilder) initContentServices() {
|
||||
b.c.Danmaku.SetStrmResolver(b.c.Strm.ResolvePlay)
|
||||
// STRM 直连失败后的 HLS 转码:把 .strm 解析成 ffmpeg 可读取的本地路径或 HTTP 直链。
|
||||
b.c.Transcoder.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
|
||||
b.c.Transcoder.SetProbe(b.c.FFprobe)
|
||||
// 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。
|
||||
if b.c.EmbyRemote != nil {
|
||||
b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) {
|
||||
|
||||
@@ -6,19 +6,25 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ServeHLSPlaylist makes sure a transcode is running and writes the m3u8.
|
||||
// We block (with a 30s timeout) until the playlist file shows up.
|
||||
//
|
||||
// Optional query `start` (seconds) restarts ffmpeg from that source offset so
|
||||
// the web player can scrub the full timeline without waiting for a full
|
||||
// head-to-tail transcode.
|
||||
func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request, mediaID string) error {
|
||||
// 「客户端直连解码」模式下宿主机不提供转码,HLS 一律拒绝,
|
||||
// 迫使播放器走 direct play 本地解码。
|
||||
if s.directPlayOnly(r.Context()) {
|
||||
return ErrTranscodeDisabled
|
||||
}
|
||||
if _, err := s.transcoder.EnsureJob(r.Context(), mediaID); err != nil {
|
||||
startSec := parseHLSStartSec(r)
|
||||
if _, err := s.transcoder.EnsureJobFrom(r.Context(), mediaID, startSec); err != nil {
|
||||
return err
|
||||
}
|
||||
s.transcoder.TouchJob(mediaID)
|
||||
@@ -48,10 +54,31 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request,
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseHLSStartSec(r *http.Request) float64 {
|
||||
if r == nil {
|
||||
return 0
|
||||
}
|
||||
raw := strings.TrimSpace(r.URL.Query().Get("start"))
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
v, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil || v < 0 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func appendQueryToHLSSegments(playlist, rawQuery string) string {
|
||||
if strings.TrimSpace(rawQuery) == "" {
|
||||
return playlist
|
||||
}
|
||||
// Segment fetches only need auth/profile tokens; drop start= so a seek
|
||||
// restart does not keep forcing EnsureJobFrom on every .ts hit.
|
||||
q := filterHLSSegmentQuery(rawQuery)
|
||||
if q == "" {
|
||||
return playlist
|
||||
}
|
||||
lines := strings.SplitAfter(playlist, "\n")
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
@@ -65,12 +92,31 @@ func appendQueryToHLSSegments(playlist, rawQuery string) string {
|
||||
} else if strings.HasSuffix(line, "\n") {
|
||||
lineEnding = "\n"
|
||||
}
|
||||
lines[i] = strings.TrimRight(line, "\r\n") + "?" + rawQuery + lineEnding
|
||||
lines[i] = strings.TrimRight(line, "\r\n") + "?" + q + lineEnding
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "")
|
||||
}
|
||||
|
||||
func filterHLSSegmentQuery(rawQuery string) string {
|
||||
parts := strings.Split(rawQuery, "&")
|
||||
kept := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
key := part
|
||||
if i := strings.IndexByte(part, '='); i >= 0 {
|
||||
key = part[:i]
|
||||
}
|
||||
if strings.EqualFold(key, "start") {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, part)
|
||||
}
|
||||
return strings.Join(kept, "&")
|
||||
}
|
||||
|
||||
// ServeHLSSegment writes a single .ts segment from the on-disk cache.
|
||||
func (s *StreamService) ServeHLSSegment(w http.ResponseWriter, r *http.Request, mediaID, segment string) error {
|
||||
s.transcoder.TouchJob(mediaID)
|
||||
|
||||
@@ -50,6 +50,7 @@ type TranscoderService struct {
|
||||
mu sync.Mutex
|
||||
jobs map[string]*hlsJob
|
||||
strmResolve func(ctx context.Context, raw string) (*StrmPlayResult, error)
|
||||
probe *FFprobeService
|
||||
}
|
||||
|
||||
// hlsJob holds the live state of one ffmpeg run.
|
||||
@@ -61,6 +62,9 @@ type hlsJob struct {
|
||||
lastAccess time.Time
|
||||
playlistOK bool
|
||||
encoder string
|
||||
// startSec is the source seek offset fed to ffmpeg (-ss). The HLS
|
||||
// playlist itself always starts at t=0 for that session.
|
||||
startSec float64
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -92,13 +96,24 @@ 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.
|
||||
// EnsureJob makes sure a transcode is running for mediaID from the start of
|
||||
// the source. Prefer EnsureJobFrom when the player seeks into the middle.
|
||||
func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (string, error) {
|
||||
return t.EnsureJobFrom(ctx, mediaID, 0)
|
||||
}
|
||||
|
||||
// EnsureJobFrom starts (or reuses) an HLS job that seeks the source to
|
||||
// startSec before encoding. Reusing only happens when an active job already
|
||||
// matches that offset; otherwise the previous job is cancelled and the HLS
|
||||
// cache dir is wiped so the player can jump without waiting for a full
|
||||
// head-to-tail transcode.
|
||||
func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, startSec float64) (string, error) {
|
||||
if !t.cfg.Transcoder.Enabled {
|
||||
return "", ErrTranscodeDisabled
|
||||
}
|
||||
if startSec < 0 {
|
||||
startSec = 0
|
||||
}
|
||||
m, err := t.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -106,11 +121,16 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
|
||||
if m == nil {
|
||||
return "", ErrMediaNotFound
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
if _, ok := t.jobs[mediaID]; ok {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
if existing, ok := t.jobs[mediaID]; ok {
|
||||
if sameHLSStart(existing.startSec, startSec) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
}
|
||||
existing.cancel()
|
||||
delete(t.jobs, mediaID)
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
@@ -118,27 +138,34 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
input.StartSec = startSec
|
||||
t.maybeFillDuration(ctx, m, input)
|
||||
if _, err := t.resolveFFmpegPath(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
outDir := t.HLSDir(mediaID)
|
||||
// Wipe prior segments so a mid-file restart cannot serve stale early chunks.
|
||||
_ = os.RemoveAll(outDir)
|
||||
if err := os.MkdirAll(outDir, 0o750); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
if _, ok := t.jobs[mediaID]; ok {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
if existing, ok := t.jobs[mediaID]; ok {
|
||||
if sameHLSStart(existing.startSec, startSec) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
}
|
||||
existing.cancel()
|
||||
delete(t.jobs, mediaID)
|
||||
}
|
||||
if max := t.maxConcurrent(); max > 0 && len(t.jobs) >= max {
|
||||
t.mu.Unlock()
|
||||
return "", ErrTranscodeBusy
|
||||
}
|
||||
|
||||
outDir := t.HLSDir(mediaID)
|
||||
if err := os.MkdirAll(outDir, 0o750); err != nil {
|
||||
t.mu.Unlock()
|
||||
return "", err
|
||||
}
|
||||
|
||||
jobCtx, cancel := context.WithCancel(context.Background())
|
||||
job := &hlsJob{
|
||||
mediaID: mediaID,
|
||||
@@ -147,6 +174,7 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
|
||||
startedAt: time.Now(),
|
||||
lastAccess: time.Now(),
|
||||
encoder: t.effectiveEncoder(),
|
||||
startSec: startSec,
|
||||
}
|
||||
t.jobs[mediaID] = job
|
||||
t.mu.Unlock()
|
||||
@@ -156,6 +184,14 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
}
|
||||
|
||||
func sameHLSStart(a, b float64) bool {
|
||||
const tol = 0.75
|
||||
if a < b {
|
||||
return b-a < tol
|
||||
}
|
||||
return a-b < tol
|
||||
}
|
||||
|
||||
// SetStrmPlayTargetResolver wires STRM URL resolution so ffmpeg can transcode
|
||||
// remote .strm media (HTTP 直链 or local source path) after direct play fails.
|
||||
func (t *TranscoderService) SetStrmPlayTargetResolver(resolve func(ctx context.Context, raw string) (*StrmPlayResult, error)) {
|
||||
@@ -165,6 +201,40 @@ func (t *TranscoderService) SetStrmPlayTargetResolver(resolve func(ctx context.C
|
||||
t.strmResolve = resolve
|
||||
}
|
||||
|
||||
// SetProbe wires ffprobe so STRM/HLS jobs can persist source duration when the
|
||||
// media row still has duration_sec=0 (common for .strm that was never probed).
|
||||
func (t *TranscoderService) SetProbe(probe *FFprobeService) {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
t.probe = probe
|
||||
}
|
||||
|
||||
func (t *TranscoderService) maybeFillDuration(ctx context.Context, m *model.Media, input transcodeInput) {
|
||||
if t == nil || t.probe == nil || m == nil || m.DurationSec > 0 || strings.TrimSpace(input.Source) == "" {
|
||||
return
|
||||
}
|
||||
var (
|
||||
res *ProbeResult
|
||||
err error
|
||||
)
|
||||
if isHTTPSource(input.Source) {
|
||||
res, err = t.probe.ProbeHTTP(ctx, input.Source, input.Headers)
|
||||
} else {
|
||||
res, err = t.probe.Probe(ctx, input.Source)
|
||||
}
|
||||
if err != nil || res == nil || res.DurationSec <= 0 {
|
||||
return
|
||||
}
|
||||
m.DurationSec = res.DurationSec
|
||||
if t.repo == nil || t.repo.DB == nil {
|
||||
return
|
||||
}
|
||||
if err := t.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", m.ID).Update("duration_sec", res.DurationSec).Error; err != nil && t.log != nil {
|
||||
t.log.Debug("persist probed duration failed", zap.String("media_id", m.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TranscoderService) resolveTranscodeInput(ctx context.Context, m *model.Media) (transcodeInput, error) {
|
||||
if m == nil {
|
||||
return transcodeInput{}, ErrMediaNotFound
|
||||
|
||||
@@ -11,8 +11,9 @@ import (
|
||||
)
|
||||
|
||||
type transcodeInput struct {
|
||||
Source string
|
||||
Headers map[string]string
|
||||
Source string
|
||||
Headers map[string]string
|
||||
StartSec float64
|
||||
}
|
||||
|
||||
type ffmpegArgSettings struct {
|
||||
@@ -125,6 +126,11 @@ func baseFFmpegArgs(preInput string, realtime bool) []string {
|
||||
|
||||
func appendInputAndVideoArgs(args []string, input transcodeInput, settings ffmpegArgSettings, video ffmpegVideoPlan) []string {
|
||||
args = append(args, ffmpegHTTPInputArgs(input)...)
|
||||
// Input seek (-ss before -i) lets mid-file HLS restarts jump without
|
||||
// decoding everything before the click position.
|
||||
if input.StartSec > 0.05 {
|
||||
args = append(args, "-ss", strconv.FormatFloat(input.StartSec, 'f', 3, 64))
|
||||
}
|
||||
args = append(args, "-i", input.Source, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", video.filter, "-c:v", video.codec)
|
||||
if settings.threads > 0 && video.codec == "libx264" {
|
||||
args = append(args, "-threads", strconv.Itoa(settings.threads))
|
||||
|
||||
@@ -40,6 +40,7 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, input tr
|
||||
zap.String("media_id", job.mediaID),
|
||||
zap.String("encoder", job.encoder),
|
||||
zap.String("source", input.Source),
|
||||
zap.Float64("start_sec", input.StartSec),
|
||||
)
|
||||
t.hub.Publish("transcode", map[string]any{
|
||||
"media_id": job.mediaID,
|
||||
|
||||
@@ -193,3 +193,47 @@ func TestBuildFFmpegArgsHTTPInputReconnect(t *testing.T) {
|
||||
t.Fatalf("http flags must come before -i, args=%v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgsInputSeekBeforeDashI(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Transcoder.MaxHeight = 720
|
||||
cfg.Transcoder.SegmentSeconds = 4
|
||||
args := buildFFmpegArgsForInput(cfg, transcodeInput{
|
||||
Source: "/x.mkv",
|
||||
StartSec: 125.5,
|
||||
}, "/o/x.m3u8", "/o/seg_%05d.ts")
|
||||
idxSS, idxI := -1, -1
|
||||
for i, arg := range args {
|
||||
if arg == "-ss" {
|
||||
idxSS = i
|
||||
}
|
||||
if arg == "-i" && idxI < 0 {
|
||||
idxI = i
|
||||
}
|
||||
}
|
||||
if idxSS < 0 || idxI < 0 || idxSS > idxI {
|
||||
t.Fatalf("expected -ss before -i, args=%v", args)
|
||||
}
|
||||
if args[idxSS+1] != "125.500" {
|
||||
t.Fatalf("start = %q", args[idxSS+1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameHLSStart(t *testing.T) {
|
||||
if !sameHLSStart(10, 10.2) {
|
||||
t.Fatal("expected close starts to match")
|
||||
}
|
||||
if sameHLSStart(10, 12) {
|
||||
t.Fatal("expected distant starts to differ")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterHLSSegmentQueryDropsStart(t *testing.T) {
|
||||
got := filterHLSSegmentQuery("token=abc&start=120.5&profile_id=1")
|
||||
if strings.Contains(got, "start=") {
|
||||
t.Fatalf("start should be stripped, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "token=abc") || !strings.Contains(got, "profile_id=1") {
|
||||
t.Fatalf("auth/profile query should remain, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user