优化播放逻辑

This commit is contained in:
truewhile
2026-10-21 12:02:11 +08:00
parent 121a0a3cc5
commit 55854a0adb
25 changed files with 943 additions and 129 deletions
+24 -2
View File
@@ -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,
+10 -2
View File
@@ -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)
}
}
+37
View File
@@ -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())
}
}
+43 -3
View File
@@ -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)
}
}
}
+15 -2
View File
@@ -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{
+24 -6
View File
@@ -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")
+10 -3
View File
@@ -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)
}
}
+109
View File
@@ -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())
}
}
+97
View File
@@ -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)
}
}
+29 -11
View File
@@ -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")
}
+83 -32
View File
@@ -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.
+4 -2
View File
@@ -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{
+13 -10
View File
@@ -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