mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
优化播放逻辑
This commit is contained in:
@@ -124,10 +124,32 @@ func cloud115HLSSessionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing media_id"})
|
||||
return
|
||||
}
|
||||
if !enforceScopedPlaybackToken(c, mediaID) {
|
||||
if c.Request.Context().Err() != nil {
|
||||
c.AbortWithStatus(499)
|
||||
return
|
||||
}
|
||||
err := svc.Cloud115.HLSProxy().ServeChild(
|
||||
actualMediaID, err := svc.Cloud115.HLSProxy().SessionMediaID(c.Param("session"), c.Param("key"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": "hls_session_not_found", "error": "hls session not found"})
|
||||
return
|
||||
}
|
||||
if mediaID != actualMediaID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "playback session media mismatch"})
|
||||
return
|
||||
}
|
||||
if !enforceScopedPlaybackToken(c, actualMediaID) {
|
||||
return
|
||||
}
|
||||
if svc.Repo == nil || svc.Repo.Media == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||
return
|
||||
}
|
||||
m, findErr := svc.Repo.Media.FindByID(c.Request.Context(), actualMediaID)
|
||||
if findErr != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||
return
|
||||
}
|
||||
err = svc.Cloud115.HLSProxy().ServeChild(
|
||||
c.Request.Context(),
|
||||
c.Writer,
|
||||
c.Request,
|
||||
|
||||
@@ -418,7 +418,11 @@ func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id"))
|
||||
scope, ok := localHLSPlaybackScope(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id"), scope)
|
||||
if errors.Is(err, service.ErrTranscodeDisabled) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"})
|
||||
return
|
||||
@@ -444,7 +448,11 @@ func embyVideoHLSSegmentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg")); err != nil {
|
||||
scope, ok := localHLSPlaybackScope(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg"), scope); err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func TestStopHLSChecksMediaVisibility(t *testing.T) {
|
||||
router, svc, secret := newPlaybackScopeTestRouter(t)
|
||||
svc.Transcoder = service.NewTranscoderService(svc.Cfg, zap.NewNop(), svc.Repo, nil)
|
||||
router.DELETE("/api/hls/:id", middleware.AuthRequired(secret), stopTranscodeHandler(svc))
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/hls/nonexistent?session_id=player-a", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+signedTestToken(t, secret))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("stop of a nonexistent/invisible media: status=%d body=%s, want 404", rec.Code, rec.Body.String())
|
||||
}
|
||||
profile := &model.PlayProfile{UserID: "user-1", Name: "restricted", IsDefault: true, AllowedLibraryIDs: `["other-library"]`}
|
||||
if err := svc.Repo.DB.Create(profile).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/hls/media-1?session_id=player-a", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+signedTestToken(t, secret))
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("invisible existing media can be stopped: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,15 @@ func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if !enforceScopedPlaybackToken(c, m.ID) {
|
||||
return
|
||||
}
|
||||
err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id"))
|
||||
scope, ok := localHLSPlaybackScope(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id"), scope)
|
||||
if errors.Is(err, service.ErrHLSGenerationSuperseded) {
|
||||
c.JSON(http.StatusConflict, gin.H{"code": "hls_generation_superseded", "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrMediaNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
@@ -62,7 +70,11 @@ func hlsSegmentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if !enforceScopedPlaybackToken(c, m.ID) {
|
||||
return
|
||||
}
|
||||
err = svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg"))
|
||||
scope, ok := localHLSPlaybackScope(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
err = svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg"), scope)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -72,11 +84,39 @@ func hlsSegmentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func stopTranscodeHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
svc.Transcoder.StopJob(c.Param("id"))
|
||||
m, err := findMediaForPlaybackEndpoint(c, svc, c.Param("id"))
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||
return
|
||||
}
|
||||
if !enforceScopedPlaybackToken(c, m.ID) {
|
||||
return
|
||||
}
|
||||
scope, ok := localHLSPlaybackScope(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if svc.Transcoder != nil {
|
||||
svc.Transcoder.StopPlayback(m.ID, scope)
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func localHLSPlaybackScope(c *gin.Context) (service.HLSPlaybackScope, bool) {
|
||||
userID := currentUserID(c)
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing playback owner"})
|
||||
return service.HLSPlaybackScope{}, false
|
||||
}
|
||||
scope, err := service.HLSPlaybackScopeForRequest(c.Request, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return service.HLSPlaybackScope{}, false
|
||||
}
|
||||
return scope, true
|
||||
}
|
||||
|
||||
func imageProxyHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw := c.Query("url")
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCloud115HLSChildRejectsMismatchedSessionMediaBeforeFetch(t *testing.T) {
|
||||
fetched := false
|
||||
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
|
||||
proxy.client = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
fetched = true
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"video/mp2t"}}, Body: io.NopCloser(strings.NewReader("private-segment")), Request: req}, nil
|
||||
})}
|
||||
proxy.sessions["session-b"] = &cloud115HLSSession{
|
||||
ID: "session-b", MediaID: "media-b", ExpiresAt: time.Now().Add(time.Hour),
|
||||
entries: map[string]string{"segment": "https://cpats01.115.com/seg.ts"},
|
||||
}
|
||||
for _, query := range []string{"?media_id=media-a", ""} {
|
||||
fetched = false
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/session-b/segment"+query, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
err := proxy.ServeChild(context.Background(), rec, req, "session-b", "segment")
|
||||
if err == nil || fetched {
|
||||
t.Errorf("query %q must reject the wrong/missing media before fetch; err=%v fetched=%v", query, err, fetched)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCloud115HLSSessionNotFound = errors.New("115 hls session not found")
|
||||
ErrCloud115HLSUpstreamExpired = errors.New("115 hls upstream expired")
|
||||
ErrCloud115HLSSessionNotFound = errors.New("115 hls session not found")
|
||||
ErrCloud115HLSUpstreamExpired = errors.New("115 hls upstream expired")
|
||||
ErrCloud115HLSSessionMediaMismatch = errors.New("115 hls session media mismatch")
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -131,6 +132,9 @@ func (p *Cloud115HLSProxy) ServeChild(ctx context.Context, w http.ResponseWriter
|
||||
if !ok {
|
||||
return ErrCloud115HLSSessionNotFound
|
||||
}
|
||||
if r == nil || strings.TrimSpace(r.URL.Query().Get("media_id")) != session.MediaID {
|
||||
return ErrCloud115HLSSessionMediaMismatch
|
||||
}
|
||||
resp, err := p.fetchUpstream(ctx, upstream, r.Header.Get("Range"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -186,6 +190,15 @@ func (p *Cloud115HLSProxy) ServeChild(ctx context.Context, w http.ResponseWriter
|
||||
return err
|
||||
}
|
||||
|
||||
// SessionMediaID resolves the server-owned media identity before handler authorization.
|
||||
func (p *Cloud115HLSProxy) SessionMediaID(sessionID, key string) (string, error) {
|
||||
session, _, ok := p.lookup(sessionID, key)
|
||||
if !ok {
|
||||
return "", ErrCloud115HLSSessionNotFound
|
||||
}
|
||||
return session.MediaID, nil
|
||||
}
|
||||
|
||||
func (p *Cloud115HLSProxy) newSession(mediaID string, definition int) *cloud115HLSSession {
|
||||
now := time.Now()
|
||||
return &cloud115HLSSession{
|
||||
|
||||
@@ -104,11 +104,13 @@ func (s *Cloud115PlaybackService) HLSProxy() *Cloud115HLSProxy {
|
||||
return s.proxy
|
||||
}
|
||||
|
||||
// PlaybackInfo 返回媒体在 direct/115-cloud/local 三种模式下的能力描述。
|
||||
//
|
||||
// requestedDefinition 为空或 0 时默认选择 1080P;该档位不可用时返回
|
||||
// transcoding 状态(并只触发一次加速转码,避免轮询风暴)。
|
||||
// PlaybackInfo only reads direct/115-cloud/local playback capabilities.
|
||||
// Cloud work is queued exclusively by StartTranscode, never by polling.
|
||||
func (s *Cloud115PlaybackService) PlaybackInfo(ctx context.Context, mediaID string, requestedDefinition int) (*PlaybackInfo, error) {
|
||||
return s.playbackInfo(ctx, mediaID, requestedDefinition, false)
|
||||
}
|
||||
|
||||
func (s *Cloud115PlaybackService) playbackInfo(ctx context.Context, mediaID string, requestedDefinition int, startTranscode bool) (*PlaybackInfo, error) {
|
||||
if s == nil || s.repo == nil || s.repo.Media == nil {
|
||||
return nil, ErrMediaNotFound
|
||||
}
|
||||
@@ -179,13 +181,26 @@ func (s *Cloud115PlaybackService) PlaybackInfo(ctx context.Context, mediaID stri
|
||||
return info, nil
|
||||
}
|
||||
|
||||
info.Transcode = s.ensureCloudTranscode(ctx, account.ID, pickCode, selected, client)
|
||||
if startTranscode {
|
||||
info.Transcode = s.ensureCloudTranscode(ctx, account.ID, pickCode, selected, client)
|
||||
} else {
|
||||
info.Transcode = Cloud115TranscodeState{
|
||||
State: "idle", Definition: selected.ID, Message: "该清晰度尚未就绪,可请求 115 云端转码", RetryAfterSec: 5,
|
||||
}
|
||||
s.mu.Lock()
|
||||
if last, ok := s.pushState[account.ID+":"+pickCode]; ok {
|
||||
info.Transcode.State = "transcoding"
|
||||
info.Transcode.Message = last.message
|
||||
info.Transcode.StartedAt = last.at.Unix()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// StartTranscode 显式触发某个清晰度的云端转码,返回最新状态。
|
||||
func (s *Cloud115PlaybackService) StartTranscode(ctx context.Context, mediaID string, definition int) (*PlaybackInfo, error) {
|
||||
return s.PlaybackInfo(ctx, mediaID, definition)
|
||||
return s.playbackInfo(ctx, mediaID, definition, true)
|
||||
}
|
||||
|
||||
// ResolveCloud115URL 返回指定清晰度的 115 云端 m3u8 地址。
|
||||
@@ -258,6 +273,9 @@ func (s *Cloud115PlaybackService) ensureCloudTranscode(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reserve the in-flight attempt before releasing the lock. Two simultaneous
|
||||
// explicit starts must not both submit a push while the first awaits 115.
|
||||
s.pushState[key] = cloud115PushAttempt{at: now, message: "正在提交 115 云端转码请求…"}
|
||||
s.mu.Unlock()
|
||||
|
||||
err := client.SubmitVideoPush(ctx, pickCode, "vip_push")
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||
)
|
||||
|
||||
func TestCloud115PlaybackInfoTriggersTranscodeOnce(t *testing.T) {
|
||||
func TestCloud115PlaybackInfoIsReadOnlyAndStartTriggersTranscodeOnce(t *testing.T) {
|
||||
var playCalls int32
|
||||
var pushCalls int32
|
||||
var ready int32
|
||||
@@ -93,9 +93,16 @@ func TestCloud115PlaybackInfoTriggersTranscodeOnce(t *testing.T) {
|
||||
}
|
||||
svc := NewCloud115PlaybackService(nil, zap.NewNop(), repos, strm)
|
||||
|
||||
info, err := svc.PlaybackInfo(ctx, media.ID, 0)
|
||||
if _, err := svc.PlaybackInfo(ctx, media.ID, 0); err != nil {
|
||||
t.Fatalf("read playback info: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&pushCalls); got != 0 {
|
||||
t.Fatalf("read-only playback info triggered %d cloud transcode pushes", got)
|
||||
}
|
||||
|
||||
info, err := svc.StartTranscode(ctx, media.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("first playback info: %v", err)
|
||||
t.Fatalf("start transcode: %v", err)
|
||||
}
|
||||
if info.Provider != model.StrmProvider115 || info.DefaultQuality != "4" {
|
||||
t.Fatalf("unexpected playback info: %#v", info)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||
)
|
||||
|
||||
func TestCloud115ConcurrentStartsDoNotDuplicateInFlightPush(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
var pushCalls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if pushCalls.Add(1) == 1 {
|
||||
close(started)
|
||||
}
|
||||
<-release
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"state": true, "code": 0, "data": []any{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
defer releaseOnce.Do(func() { close(release) })
|
||||
oldBase := cloud115.ProAPIBase
|
||||
cloud115.ProAPIBase = server.URL
|
||||
defer func() { cloud115.ProAPIBase = oldBase }()
|
||||
client := cloud115.NewOpenClient("review-app", "access", "refresh")
|
||||
svc := NewCloud115PlaybackService(nil, zap.NewNop(), nil, nil)
|
||||
quality := PlaybackQuality{ID: "4", Label: "1080P", Source: "cloud"}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
first := make(chan struct{})
|
||||
go func() { defer close(first); svc.ensureCloudTranscode(ctx, "account", "pick", quality, client) }()
|
||||
select {
|
||||
case <-started:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("first push did not reach the local server")
|
||||
}
|
||||
second := make(chan Cloud115TranscodeState, 1)
|
||||
go func() { second <- svc.ensureCloudTranscode(ctx, "account", "pick", quality, client) }()
|
||||
secondReceived := false
|
||||
select {
|
||||
case state := <-second:
|
||||
secondReceived = true
|
||||
if state.State != "transcoding" {
|
||||
t.Errorf("in-flight request should be reported as transcoding: %#v", state)
|
||||
}
|
||||
case <-time.After(250 * time.Millisecond):
|
||||
t.Error("second start issued another request instead of reusing the in-flight push")
|
||||
}
|
||||
releaseOnce.Do(func() { close(release) })
|
||||
select {
|
||||
case <-first:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("first push did not finish")
|
||||
}
|
||||
// The duplicate path is awaited before restoring the package-wide fake API.
|
||||
if !secondReceived {
|
||||
select {
|
||||
case <-second:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("second start did not finish")
|
||||
}
|
||||
}
|
||||
if got := pushCalls.Load(); got != 1 {
|
||||
t.Fatalf("pushes=%d, want 1", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var ErrInvalidHLSPlaybackScope = errors.New("invalid HLS playback scope")
|
||||
var ErrHLSGenerationSuperseded = errors.New("HLS playback generation superseded")
|
||||
|
||||
// HLSPlaybackScope separates a media's independent users and player instances.
|
||||
// UserID must come from server authentication, never from a request parameter.
|
||||
type HLSPlaybackScope struct {
|
||||
UserID string
|
||||
SessionID string
|
||||
}
|
||||
|
||||
func HLSPlaybackScopeForRequest(r *http.Request, userID string) (HLSPlaybackScope, error) {
|
||||
scope := HLSPlaybackScope{UserID: strings.TrimSpace(userID)}
|
||||
if scope.UserID == "" || r == nil {
|
||||
return scope, ErrInvalidHLSPlaybackScope
|
||||
}
|
||||
for _, key := range []string{"session_id", "PlaySessionId", "playSessionId", "DeviceId", "deviceId"} {
|
||||
if value := strings.TrimSpace(r.URL.Query().Get(key)); value != "" {
|
||||
scope.SessionID = value
|
||||
break
|
||||
}
|
||||
}
|
||||
if scope.SessionID == "" {
|
||||
if device := strings.TrimSpace(r.Header.Get("X-Emby-Device-Id")); device != "" {
|
||||
scope.SessionID = device
|
||||
}
|
||||
}
|
||||
if len(scope.SessionID) > 256 || strings.IndexFunc(scope.SessionID, unicode.IsControl) >= 0 {
|
||||
return scope, ErrInvalidHLSPlaybackScope
|
||||
}
|
||||
// Clients without a session/device identifier remain compatible but only
|
||||
// share a legacy task with the same authenticated user, never other users.
|
||||
return scope, nil
|
||||
}
|
||||
|
||||
func (scope HLSPlaybackScope) jobKey(mediaID string) string {
|
||||
if scope.UserID == "" && scope.SessionID == "" {
|
||||
return mediaID // Existing internal callers/tests retain their legacy API.
|
||||
}
|
||||
sum := sha256.Sum256([]byte(scope.UserID + "\x00" + mediaID + "\x00" + scope.SessionID))
|
||||
return "player-" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func hlsPlaybackJobKey(mediaID string, scopes []HLSPlaybackScope) string {
|
||||
if len(scopes) == 0 {
|
||||
return mediaID
|
||||
}
|
||||
return scopes[0].jobKey(mediaID)
|
||||
}
|
||||
|
||||
func (job *hlsJob) key() string {
|
||||
if job.jobKey != "" {
|
||||
return job.jobKey
|
||||
}
|
||||
return job.mediaID
|
||||
}
|
||||
|
||||
func (t *TranscoderService) jobPlaylistPath(job *hlsJob) string {
|
||||
dir := job.outputDir
|
||||
if dir == "" {
|
||||
dir = t.HLSDir(job.key())
|
||||
}
|
||||
return filepath.Join(dir, "index.m3u8")
|
||||
}
|
||||
|
||||
// hlsOutputDir captures one immutable run directory. Even if a new run starts
|
||||
// after this check, the request cannot accidentally open its replacement files.
|
||||
func (t *TranscoderService) hlsOutputDir(key string, generation int64) (string, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
job := t.jobs[key]
|
||||
if job == nil {
|
||||
return "", ErrMediaNotFound
|
||||
}
|
||||
if job.seekGen != generation {
|
||||
return "", ErrHLSGenerationSuperseded
|
||||
}
|
||||
t.touchJobLocked(key)
|
||||
if job.outputDir != "" {
|
||||
return job.outputDir, nil
|
||||
}
|
||||
return t.HLSDir(key), nil
|
||||
}
|
||||
|
||||
func (t *TranscoderService) StopPlayback(mediaID string, scope HLSPlaybackScope) {
|
||||
key := scope.jobKey(mediaID)
|
||||
unlock := t.lockJobStart(key)
|
||||
defer unlock()
|
||||
t.mu.Lock()
|
||||
job := t.detachJobLocked(key)
|
||||
t.mu.Unlock()
|
||||
waitJobExit(job, 12*time.Second)
|
||||
// Keep cleanup under the same gate as start: a delayed stop must not wipe
|
||||
// an output directory created by a concurrent replacement request.
|
||||
_ = os.RemoveAll(t.HLSDir(key))
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func cachedScopedHLS(t *testing.T, scopes ...HLSPlaybackScope) (*StreamService, *TranscoderService, map[string]string) {
|
||||
t.Helper()
|
||||
cfg := &config.Config{}
|
||||
cfg.Cache.CacheDir = t.TempDir()
|
||||
cfg.Transcoder.Enabled = true
|
||||
db := newServiceTestDB(t)
|
||||
if err := db.Create(&model.Media{Base: model.Base{ID: "movie"}, Path: "unused-cached-source", DurationSec: 60}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transcoder := NewTranscoderService(cfg, zap.NewNop(), repository.New(db), nil)
|
||||
paths := map[string]string{}
|
||||
for _, scope := range scopes {
|
||||
key := scope.jobKey("movie")
|
||||
dir := filepath.Join(transcoder.HLSDir(key), "run-cached")
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
playlist := filepath.Join(dir, "index.m3u8")
|
||||
if err := os.WriteFile(playlist, []byte("#EXTM3U\n#EXTINF:4,\nseg_00000.ts\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "seg_00000.ts"), []byte(scope.UserID+"/"+scope.SessionID), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transcoder.jobs[key] = &hlsJob{mediaID: "movie", jobKey: key, outputDir: dir, subtitleStream: -1, startedAt: time.Now(), cancel: func() {}}
|
||||
paths[key] = playlist
|
||||
}
|
||||
return NewStreamService(cfg, zap.NewNop(), repository.New(db), transcoder), transcoder, paths
|
||||
}
|
||||
|
||||
func TestHLSPlaybackEnsureReusesOnlyMatchingOwnerAndSession(t *testing.T) {
|
||||
a := HLSPlaybackScope{UserID: "viewer", SessionID: "tab-a"}
|
||||
b := HLSPlaybackScope{UserID: "viewer", SessionID: "tab-b"}
|
||||
_, transcoder, paths := cachedScopedHLS(t, a, b)
|
||||
for _, scope := range []HLSPlaybackScope{a, b} {
|
||||
playlist, err := transcoder.EnsureJobFromSubtitleQuality(t.Context(), "movie", 0, 0, -1, "", scope)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if playlist != paths[scope.jobKey("movie")] {
|
||||
t.Fatalf("scope %#v reused another session's playlist %q", scope, playlist)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHLSPlaybackStopRemovesOnlyItsSessionOutput(t *testing.T) {
|
||||
a := HLSPlaybackScope{UserID: "viewer", SessionID: "tab-a"}
|
||||
b := HLSPlaybackScope{UserID: "viewer", SessionID: "tab-b"}
|
||||
_, transcoder, paths := cachedScopedHLS(t, a, b)
|
||||
transcoder.StopPlayback("movie", a)
|
||||
if _, err := os.Stat(paths[a.jobKey("movie")]); !os.IsNotExist(err) {
|
||||
t.Fatalf("stopped session output must be cleaned up, err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(paths[b.jobKey("movie")]); err != nil {
|
||||
t.Fatalf("another player's output was removed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHLSPlaybackIdleCleanupRemovesExpiredSessionOutput(t *testing.T) {
|
||||
a := HLSPlaybackScope{UserID: "viewer", SessionID: "tab-a"}
|
||||
b := HLSPlaybackScope{UserID: "viewer", SessionID: "tab-b"}
|
||||
_, transcoder, paths := cachedScopedHLS(t, a, b)
|
||||
job := transcoder.jobs[a.jobKey("movie")]
|
||||
job.lastAccess = time.Now().Add(-2 * time.Minute)
|
||||
if !transcoder.stopIdleJob(job, time.Minute) {
|
||||
t.Fatal("expired session was not stopped")
|
||||
}
|
||||
if _, err := os.Stat(paths[a.jobKey("movie")]); !os.IsNotExist(err) {
|
||||
t.Fatalf("expired output was retained: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(paths[b.jobKey("movie")]); err != nil {
|
||||
t.Fatalf("active session output was removed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHLSPlaybackPropagatesHeaderDeviceIdentityToSegments(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/hls/movie/index.m3u8?token=test", nil)
|
||||
req.Header.Set("X-Emby-Device-Id", "legacy-device")
|
||||
scope, err := HLSPlaybackScopeForRequest(req, "viewer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, _, _ := cachedScopedHLS(t, scope)
|
||||
rec := httptest.NewRecorder()
|
||||
if err := svc.ServeHLSPlaylist(rec, req, "movie", scope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var segment string
|
||||
for _, line := range strings.Split(rec.Body.String(), "\n") {
|
||||
if strings.HasPrefix(line, "seg_") {
|
||||
segment = line
|
||||
break
|
||||
}
|
||||
}
|
||||
u, err := url.Parse(segment)
|
||||
if err != nil || u.Query().Get("session_id") == "" {
|
||||
t.Fatalf("playlist must carry the header-only device scope to child URLs: %q err=%v", segment, err)
|
||||
}
|
||||
child := httptest.NewRequest(http.MethodGet, "/api/hls/movie/"+segment, nil)
|
||||
childScope, err := HLSPlaybackScopeForRequest(child, "viewer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if childScope.jobKey("movie") != scope.jobKey("movie") {
|
||||
t.Fatal("child request changed its owner's device scope")
|
||||
}
|
||||
childRec := httptest.NewRecorder()
|
||||
if err := svc.ServeHLSSegment(childRec, child, "movie", "seg_00000.ts", childScope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if childRec.Body.String() != "viewer/"+scope.SessionID {
|
||||
t.Fatalf("wrong scoped segment: %s", childRec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
)
|
||||
|
||||
func TestHLSPlaybackScopesIsolateSegmentReadsAndStop(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Cache.CacheDir = t.TempDir()
|
||||
transcoder := NewTranscoderService(cfg, zap.NewNop(), nil, nil)
|
||||
svc := NewStreamService(cfg, zap.NewNop(), nil, transcoder)
|
||||
a := HLSPlaybackScope{UserID: "user-a", SessionID: "tab-a"}
|
||||
b := HLSPlaybackScope{UserID: "user-b", SessionID: "tab-a"}
|
||||
c := HLSPlaybackScope{UserID: "user-a", SessionID: "tab-c"}
|
||||
for _, scope := range []HLSPlaybackScope{a, b, c} {
|
||||
key := scope.jobKey("movie")
|
||||
dir := filepath.Join(cfg.Cache.CacheDir, key, "run-current")
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "seg_00000.ts"), []byte(scope.UserID+"/"+scope.SessionID), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transcoder.jobs[key] = &hlsJob{mediaID: "movie", jobKey: key, outputDir: dir, seekGen: 100, cancel: func() {}}
|
||||
}
|
||||
for _, scope := range []HLSPlaybackScope{a, b, c} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/hls/movie/seg_00000.ts?_seek=100", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
if err := svc.ServeHLSSegment(rec, req, "movie", "seg_00000.ts", scope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := rec.Body.String(), scope.UserID+"/"+scope.SessionID; got != want {
|
||||
t.Fatalf("segment=%q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
transcoder.StopPlayback("movie", a)
|
||||
if transcoder.jobs[a.jobKey("movie")] != nil {
|
||||
t.Fatal("stopped scope is still active")
|
||||
}
|
||||
if transcoder.jobs[b.jobKey("movie")] == nil || transcoder.jobs[c.jobKey("movie")] == nil {
|
||||
t.Fatal("stopping one player affected another user/tab")
|
||||
}
|
||||
// A delayed stop from a previous local-playback session must not affect a replacement.
|
||||
transcoder.StopPlayback("movie", a)
|
||||
if transcoder.jobs[c.jobKey("movie")] == nil {
|
||||
t.Fatal("old stop cancelled a new session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHLSPlaybackRejectsStaleSegmentGeneration(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Cache.CacheDir = t.TempDir()
|
||||
transcoder := NewTranscoderService(cfg, zap.NewNop(), nil, nil)
|
||||
svc := NewStreamService(cfg, zap.NewNop(), nil, transcoder)
|
||||
scope := HLSPlaybackScope{UserID: "viewer", SessionID: "tab"}
|
||||
key := scope.jobKey("movie")
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "seg_00000.ts"), []byte("new-generation"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transcoder.jobs[key] = &hlsJob{mediaID: "movie", jobKey: key, outputDir: dir, seekGen: 200, cancel: func() {}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/hls/movie/seg_00000.ts?_seek=100", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
if err := svc.ServeHLSSegment(rec, req, "movie", "seg_00000.ts", scope); err == nil {
|
||||
t.Fatal("stale segment request read a replacement generation")
|
||||
}
|
||||
if rec.Body.Len() != 0 {
|
||||
t.Fatal("stale request exposed replacement bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHLSPlaybackScopeRequiresServerOwner(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/?session_id=tab&user_id=attacker-controlled", nil)
|
||||
if _, err := HLSPlaybackScopeForRequest(req, ""); err == nil {
|
||||
t.Fatal("missing authenticated owner was accepted")
|
||||
}
|
||||
a, err := HLSPlaybackScopeForRequest(req, "real-owner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.UserID != "real-owner" {
|
||||
t.Fatal("client parameter supplied the owner")
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/?PlaySessionId=emby-session", nil)
|
||||
b, err := HLSPlaybackScopeForRequest(req, "real-owner")
|
||||
if err != nil || b.SessionID == "" {
|
||||
t.Fatalf("Emby session must stay scoped: %#v %v", b, err)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,8 @@ import (
|
||||
// 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 {
|
||||
func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request, mediaID string, scopes ...HLSPlaybackScope) error {
|
||||
jobKey := hlsPlaybackJobKey(mediaID, scopes)
|
||||
// 「客户端直连解码」模式下宿主机不提供转码,HLS 一律拒绝,
|
||||
// 迫使播放器走 direct play 本地解码。
|
||||
if s.directPlayOnly(r.Context()) {
|
||||
@@ -27,19 +28,23 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request,
|
||||
seekGen := parseHLSSeekGen(r)
|
||||
subtitleStream := parseHLSSubtitleStream(r)
|
||||
quality := parseHLSQuality(r)
|
||||
if _, err := s.transcoder.EnsureJobFromSubtitleQuality(r.Context(), mediaID, startSec, seekGen, subtitleStream, quality); err != nil {
|
||||
if _, err := s.transcoder.EnsureJobFromSubtitleQuality(r.Context(), mediaID, startSec, seekGen, subtitleStream, quality, scopes...); err != nil {
|
||||
return err
|
||||
}
|
||||
s.transcoder.TouchJob(mediaID)
|
||||
s.transcoder.TouchJob(jobKey)
|
||||
readyTimeout := 45 * time.Second
|
||||
if startSec > 0.05 {
|
||||
// Mid-file HTTP seeks (esp. WMV) need longer before the first segment appears.
|
||||
readyTimeout = 120 * time.Second
|
||||
}
|
||||
if !s.transcoder.WaitReady(r.Context(), mediaID, readyTimeout) {
|
||||
if !s.transcoder.WaitReady(r.Context(), jobKey, readyTimeout) {
|
||||
return errors.New("hls playlist not ready")
|
||||
}
|
||||
playlist := s.transcoder.PlaylistPath(mediaID)
|
||||
outputDir, err := s.transcoder.hlsOutputDir(jobKey, seekGen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
playlist := filepath.Join(outputDir, "index.m3u8")
|
||||
f, err := os.Open(playlist) // #nosec G304 -- playlist path is generated under the transcoder cache directory for this media ID.
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -49,12 +54,18 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request,
|
||||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Content-Disposition", "inline")
|
||||
if r.URL.RawQuery != "" {
|
||||
rawQuery := r.URL.RawQuery
|
||||
if len(scopes) > 0 && scopes[0].SessionID != "" {
|
||||
query := r.URL.Query()
|
||||
query.Set("session_id", scopes[0].SessionID)
|
||||
rawQuery = query.Encode()
|
||||
}
|
||||
if rawQuery != "" {
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
playlist := appendQueryToHLSSegments(string(data), r.URL.RawQuery)
|
||||
playlist := appendQueryToHLSSegments(string(data), rawQuery)
|
||||
_, err = io.WriteString(w, playlist)
|
||||
return err
|
||||
}
|
||||
@@ -171,19 +182,26 @@ func filterHLSSegmentQuery(rawQuery string) string {
|
||||
}
|
||||
|
||||
// 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)
|
||||
func (s *StreamService) ServeHLSSegment(w http.ResponseWriter, r *http.Request, mediaID, segment string, scopes ...HLSPlaybackScope) error {
|
||||
jobKey := hlsPlaybackJobKey(mediaID, scopes)
|
||||
outputDir, err := s.transcoder.hlsOutputDir(jobKey, parseHLSSeekGen(r))
|
||||
if err != nil {
|
||||
if len(scopes) > 0 || parseHLSSeekGen(r) > 0 {
|
||||
return err
|
||||
}
|
||||
outputDir = s.transcoder.HLSDir(jobKey) // Legacy internal static-file callers.
|
||||
}
|
||||
// Only allow segments that look like seg_NNNNN.ts so we cannot be tricked
|
||||
// into reading arbitrary files via path traversal.
|
||||
if !strings.HasPrefix(segment, "seg_") || !strings.HasSuffix(segment, ".ts") {
|
||||
return errors.New("bad segment")
|
||||
}
|
||||
full := filepath.Join(s.transcoder.HLSDir(mediaID), segment)
|
||||
full := filepath.Join(outputDir, segment)
|
||||
abs, err := filepath.Abs(full)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir, _ := filepath.Abs(s.transcoder.HLSDir(mediaID))
|
||||
dir, _ := filepath.Abs(outputDir)
|
||||
if !pathWithin(abs, dir) {
|
||||
return errors.New("path escape")
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// 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>.
|
||||
// into HLS (.m3u8 + .ts). Each playback scope owns a cache directory and
|
||||
// every ffmpeg run writes to its own run-* subdirectory.
|
||||
// The HTTP layer serves these files directly with a normal http.FileServer.
|
||||
//
|
||||
// Encoder selection (read once at startup from the config):
|
||||
@@ -15,9 +16,11 @@
|
||||
// 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.
|
||||
// - Each authenticated user/player scope has at most one active ffmpeg job.
|
||||
// - jobs[jobKey] tracks the running goroutine + cancel func; mediaID remains
|
||||
// the actual media identity for database access and activity reporting.
|
||||
// - Legacy internal callers keep media-keyed behaviour.
|
||||
// - Equivalent requests within one scope reuse the current output.
|
||||
// - When the playlist file appears on disk we consider the job "ready"
|
||||
// and unblock the HTTP handler that was waiting on it.
|
||||
package service
|
||||
@@ -49,9 +52,10 @@ type TranscoderService struct {
|
||||
|
||||
mu sync.Mutex
|
||||
jobs map[string]*hlsJob
|
||||
// startGates serializes EnsureJobFrom / StopJob per media so concurrent
|
||||
// playlist hits cannot spawn multiple ffmpeg writers into one HLS dir.
|
||||
startGates sync.Map // mediaID -> *sync.Mutex
|
||||
// Start gates are reference-counted so random playback session keys do not
|
||||
// accumulate after their last starter/stopper has left.
|
||||
startGateMu sync.Mutex
|
||||
startGates map[string]*hlsStartGate
|
||||
strmResolve func(ctx context.Context, raw string) (*StrmPlayResult, error)
|
||||
probe *FFprobeService
|
||||
|
||||
@@ -66,6 +70,7 @@ type TranscoderService struct {
|
||||
|
||||
// hlsJob holds the live state of one ffmpeg run.
|
||||
type hlsJob struct {
|
||||
jobKey string
|
||||
mediaID string
|
||||
outputDir string
|
||||
cancel context.CancelFunc
|
||||
@@ -156,14 +161,19 @@ func (t *TranscoderService) transcodeConfig() *config.Config {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// HLSDir is the cache root for one internal playback job key.
|
||||
func (t *TranscoderService) HLSDir(jobKey string) string {
|
||||
return filepath.Join(t.cfg.Cache.CacheDir, "hls", jobKey)
|
||||
}
|
||||
|
||||
// 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")
|
||||
func (t *TranscoderService) PlaylistPath(jobKey string) string {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if job := t.jobs[jobKey]; job != nil {
|
||||
return t.jobPlaylistPath(job)
|
||||
}
|
||||
return filepath.Join(t.HLSDir(jobKey), "index.m3u8")
|
||||
}
|
||||
|
||||
// EnsureJob makes sure a transcode is running for mediaID from the start of
|
||||
@@ -194,7 +204,8 @@ func (t *TranscoderService) EnsureJobFromSubtitle(ctx context.Context, mediaID s
|
||||
|
||||
// EnsureJobFromSubtitleQuality is EnsureJobFromSubtitle with an explicit local
|
||||
// HLS quality profile. Empty qualityID keeps the global transcoder config.
|
||||
func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, mediaID string, startSec float64, seekGen int64, subtitleStream int, qualityID string) (string, error) {
|
||||
func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, mediaID string, startSec float64, seekGen int64, subtitleStream int, qualityID string, scopes ...HLSPlaybackScope) (string, error) {
|
||||
jobKey := hlsPlaybackJobKey(mediaID, scopes)
|
||||
cfg := t.transcodeConfig()
|
||||
if !cfg.Transcoder.Enabled {
|
||||
return "", ErrTranscodeDisabled
|
||||
@@ -219,28 +230,31 @@ func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, me
|
||||
return "", ErrMediaNotFound
|
||||
}
|
||||
|
||||
gate := t.mediaStartGate(mediaID)
|
||||
gate.Lock()
|
||||
defer gate.Unlock()
|
||||
unlock := t.lockJobStart(jobKey)
|
||||
defer unlock()
|
||||
|
||||
t.mu.Lock()
|
||||
if existing, ok := t.jobs[mediaID]; ok {
|
||||
if existing, ok := t.jobs[jobKey]; ok {
|
||||
reusable := true
|
||||
playlist := t.jobPlaylistPath(existing)
|
||||
if existing.completed {
|
||||
info, err := os.Stat(t.PlaylistPath(mediaID))
|
||||
info, err := os.Stat(playlist)
|
||||
reusable = err == nil && info.Size() > 0
|
||||
}
|
||||
if reusable && sameHLSConfiguration(existing, startSec, subtitleStream, qualityID) {
|
||||
t.touchJobLocked(mediaID)
|
||||
if seekGen > existing.seekGen {
|
||||
existing.seekGen = seekGen
|
||||
}
|
||||
t.touchJobLocked(jobKey)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
return playlist, nil
|
||||
}
|
||||
if reusable && !shouldReplaceHLSJobConfigurationQuality(existing, startSec, seekGen, subtitleStream, qualityID) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.touchJobLocked(jobKey)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
return playlist, nil
|
||||
}
|
||||
prev := t.detachJobLocked(mediaID)
|
||||
prev := t.detachJobLocked(jobKey)
|
||||
t.mu.Unlock()
|
||||
waitJobExit(prev, 12*time.Second)
|
||||
} else {
|
||||
@@ -267,12 +281,23 @@ func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, me
|
||||
return "", err
|
||||
}
|
||||
|
||||
outDir := t.HLSDir(mediaID)
|
||||
// Wipe prior segments so a mid-file restart cannot serve stale early chunks.
|
||||
// Only safe after the previous ffmpeg has exited (waited above / via gate).
|
||||
if err := resetHLSDir(outDir); err != nil {
|
||||
rootDir := t.HLSDir(jobKey)
|
||||
// Runs use distinct directories even when Windows keeps an old segment
|
||||
// open. A delayed request can never read a newer run's seg_00000.ts.
|
||||
if err := resetHLSDir(rootDir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
outDir, err := os.MkdirTemp(rootDir, "run-")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
registered := false
|
||||
defer func() {
|
||||
if !registered {
|
||||
_ = os.RemoveAll(outDir)
|
||||
_ = os.Remove(rootDir)
|
||||
}
|
||||
}()
|
||||
|
||||
t.mu.Lock()
|
||||
if max := t.maxConcurrent(); max > 0 && t.runningJobCountLocked() >= max {
|
||||
@@ -288,6 +313,7 @@ func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, me
|
||||
jobEncoder = "software (subtitle burn-in)"
|
||||
}
|
||||
job := &hlsJob{
|
||||
jobKey: jobKey,
|
||||
mediaID: mediaID,
|
||||
outputDir: outDir,
|
||||
cancel: cancel,
|
||||
@@ -302,7 +328,8 @@ func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, me
|
||||
ffmpegPath: bin,
|
||||
config: cfg,
|
||||
}
|
||||
t.jobs[mediaID] = job
|
||||
t.jobs[jobKey] = job
|
||||
registered = true
|
||||
t.mu.Unlock()
|
||||
|
||||
helper.Go(t.log, "transcoder.monitorIdle", func() { t.monitorIdle(jobCtx, job) })
|
||||
@@ -310,7 +337,7 @@ func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, me
|
||||
defer close(job.done)
|
||||
t.runFFmpeg(jobCtx, job, input)
|
||||
})
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
return filepath.Join(outDir, "index.m3u8"), nil
|
||||
}
|
||||
|
||||
// shouldReplaceHLSJob reports whether an incoming playlist request may cancel
|
||||
@@ -357,9 +384,33 @@ func sameHLSConfiguration(existing *hlsJob, startSec float64, subtitleStream int
|
||||
sameHLSStart(existing.startSec, startSec)
|
||||
}
|
||||
|
||||
func (t *TranscoderService) mediaStartGate(mediaID string) *sync.Mutex {
|
||||
v, _ := t.startGates.LoadOrStore(mediaID, &sync.Mutex{})
|
||||
return v.(*sync.Mutex)
|
||||
type hlsStartGate struct {
|
||||
mu sync.Mutex
|
||||
users int
|
||||
}
|
||||
|
||||
func (t *TranscoderService) lockJobStart(key string) func() {
|
||||
t.startGateMu.Lock()
|
||||
if t.startGates == nil {
|
||||
t.startGates = make(map[string]*hlsStartGate)
|
||||
}
|
||||
gate := t.startGates[key]
|
||||
if gate == nil {
|
||||
gate = &hlsStartGate{}
|
||||
t.startGates[key] = gate
|
||||
}
|
||||
gate.users++
|
||||
t.startGateMu.Unlock()
|
||||
gate.mu.Lock()
|
||||
return func() {
|
||||
gate.mu.Unlock()
|
||||
t.startGateMu.Lock()
|
||||
gate.users--
|
||||
if gate.users == 0 {
|
||||
delete(t.startGates, key)
|
||||
}
|
||||
t.startGateMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// detachJobLocked cancels and removes a job from the map without waiting.
|
||||
|
||||
@@ -49,16 +49,18 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, input tr
|
||||
|
||||
t.mu.Lock()
|
||||
// Only update the map entry if we are still the registered generation.
|
||||
if cur, ok := t.jobs[job.mediaID]; ok && cur == job {
|
||||
if cur, ok := t.jobs[job.key()]; ok && cur == job {
|
||||
if err == nil && ctx.Err() == nil {
|
||||
job.completed = true
|
||||
} else {
|
||||
delete(t.jobs, job.mediaID)
|
||||
delete(t.jobs, job.key())
|
||||
}
|
||||
}
|
||||
t.mu.Unlock()
|
||||
if err != nil || ctx.Err() != nil {
|
||||
job.cancel()
|
||||
_ = os.RemoveAll(job.outputDir)
|
||||
_ = os.Remove(filepath.Dir(job.outputDir)) // Remove the scope root only if empty.
|
||||
}
|
||||
|
||||
t.hub.Publish("transcode", map[string]any{
|
||||
|
||||
@@ -26,16 +26,18 @@ func (t *TranscoderService) WaitReady(ctx context.Context, mediaID string, timeo
|
||||
job, ok := t.jobs[mediaID]
|
||||
var started time.Time
|
||||
var completed bool
|
||||
var playlist string
|
||||
if ok {
|
||||
started = job.startedAt
|
||||
completed = job.completed
|
||||
playlist = t.jobPlaylistPath(job)
|
||||
}
|
||||
t.mu.Unlock()
|
||||
if !ok {
|
||||
return false // A failed/exited job cannot become ready later.
|
||||
}
|
||||
if ok {
|
||||
if info, err := os.Stat(t.PlaylistPath(mediaID)); err == nil {
|
||||
if info, err := os.Stat(playlist); err == nil {
|
||||
// Allow a small clock skew; reject anything older than this job.
|
||||
if !info.ModTime().Before(started.Add(-2 * time.Second)) {
|
||||
t.mu.Lock()
|
||||
@@ -66,9 +68,8 @@ func (t *TranscoderService) WaitReady(ctx context.Context, mediaID string, timeo
|
||||
// briefly for it to exit so a subsequent EnsureJobFrom cannot race-write the
|
||||
// same HLS directory.
|
||||
func (t *TranscoderService) StopJob(mediaID string) {
|
||||
gate := t.mediaStartGate(mediaID)
|
||||
gate.Lock()
|
||||
defer gate.Unlock()
|
||||
unlock := t.lockJobStart(mediaID)
|
||||
defer unlock()
|
||||
|
||||
t.mu.Lock()
|
||||
prev := t.detachJobLocked(mediaID)
|
||||
@@ -170,7 +171,7 @@ func (t *TranscoderService) monitorIdle(ctx context.Context, job *hlsJob) {
|
||||
return
|
||||
case <-ticker.C:
|
||||
t.mu.Lock()
|
||||
current, ok := t.jobs[job.mediaID]
|
||||
current, ok := t.jobs[job.key()]
|
||||
if !ok || current != job {
|
||||
t.mu.Unlock()
|
||||
return
|
||||
@@ -185,11 +186,10 @@ func (t *TranscoderService) monitorIdle(ctx context.Context, job *hlsJob) {
|
||||
}
|
||||
|
||||
func (t *TranscoderService) stopIdleJob(job *hlsJob, timeout time.Duration) bool {
|
||||
gate := t.mediaStartGate(job.mediaID)
|
||||
gate.Lock()
|
||||
defer gate.Unlock()
|
||||
unlock := t.lockJobStart(job.key())
|
||||
defer unlock()
|
||||
t.mu.Lock()
|
||||
current := t.jobs[job.mediaID]
|
||||
current := t.jobs[job.key()]
|
||||
if current != job {
|
||||
t.mu.Unlock()
|
||||
return true
|
||||
@@ -199,9 +199,12 @@ func (t *TranscoderService) stopIdleJob(job *hlsJob, timeout time.Duration) bool
|
||||
t.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
t.detachJobLocked(job.mediaID)
|
||||
t.detachJobLocked(job.key())
|
||||
t.mu.Unlock()
|
||||
waitJobExit(job, 12*time.Second)
|
||||
if job.jobKey != "" {
|
||||
_ = os.RemoveAll(t.HLSDir(job.key()))
|
||||
}
|
||||
t.log.Info("transcode idle timeout", zap.String("media_id", job.mediaID),
|
||||
zap.Duration("idle_for", idleFor), zap.Duration("timeout", timeout))
|
||||
return true
|
||||
|
||||
Reference in New Issue
Block a user