mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 20:26:36 +08:00
fix: stabilize STRM and cloud playback
This commit is contained in:
@@ -1670,7 +1670,8 @@ func (e *EmbyService) mediaSource(m *model.Media, asEmbedded, directOnly bool) m
|
||||
"MediaStreams": e.mediaStreams(m),
|
||||
}
|
||||
if !asEmbedded {
|
||||
src["DirectStreamUrl"] = embyDirectStreamURL(m.ID, container)
|
||||
streamURL := embyDirectStreamURL(m.ID, container)
|
||||
src["DirectStreamUrl"] = streamURL
|
||||
// 直连解码模式下不下发 TranscodingUrl,迫使客户端本地解码直连,
|
||||
// 宿主机不参与转码。
|
||||
if !directOnly {
|
||||
@@ -1678,13 +1679,12 @@ func (e *EmbyService) mediaSource(m *model.Media, asEmbedded, directOnly bool) m
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(m.STRMURL) != "" {
|
||||
// STRM / cloud:// media still plays through /Videos/{id}/stream.
|
||||
// That route delegates to StreamService, which appends the caller's
|
||||
// token to internal /api/cloud/play redirects and only then 302s to the
|
||||
// provider/CDN. Returning m.STRMURL directly here would make Emby/Yamby
|
||||
// clients hit /api/cloud/play without an auth token and fail with 401.
|
||||
// STRM / cloud:// media plays through /Videos/{id}/stream. Some
|
||||
// third-party Emby clients still prefer MediaSource.Path even when
|
||||
// SupportsDirectPlay=false; pointing Path at the token-aware stream
|
||||
// endpoint keeps those clients away from naked /api/cloud/play URLs.
|
||||
src["IsRemote"] = true
|
||||
src["Path"] = m.STRMURL
|
||||
src["Path"] = embyDirectStreamURL(m.ID, container)
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
@@ -363,8 +363,8 @@ func TestEmbyPlaybackInfoKeepsSTRMBehindStreamEndpoint(t *testing.T) {
|
||||
if src["DirectStreamUrl"] != "/Videos/cloud-1/stream" {
|
||||
t.Fatalf("strm playback must stay behind token-aware stream endpoint: %#v", src)
|
||||
}
|
||||
if src["Path"] != "/api/cloud/play/quark?ref=f1" {
|
||||
t.Fatalf("path should expose the strm target for diagnostics: %#v", src)
|
||||
if src["Path"] != "/Videos/cloud-1/stream" {
|
||||
t.Fatalf("path should use token-aware stream endpoint: %#v", src)
|
||||
}
|
||||
streams := src["MediaStreams"].([]map[string]any)
|
||||
if len(streams) == 0 || streams[0]["Type"] != "Video" {
|
||||
|
||||
@@ -58,9 +58,9 @@ func NewStreamService(cfg *config.Config, log *zap.Logger, repo *repository.Cont
|
||||
var ErrMediaNotFound = errors.New("media not found")
|
||||
|
||||
// ErrCloudPlaybackUnavailable 表示媒体行存在但属于云盘媒体、且当前无法
|
||||
// 构造可用的播放重定向(例如 STRM 播放被关闭或 STRMURL 缺失)。调用方
|
||||
// 应把它与「媒体不存在」区分开,避免把配置类故障当成 404 返回给播放器。
|
||||
var ErrCloudPlaybackUnavailable = errors.New("cloud media playback unavailable: strm playback disabled or media missing play url; re-scan the library or enable strm playback")
|
||||
// 构造可用的播放重定向(通常是 STRMURL 缺失,需要重新扫描媒体库)。
|
||||
// 调用方应把它与「媒体不存在」区分开,避免把配置类故障当成 404 返回给播放器。
|
||||
var ErrCloudPlaybackUnavailable = errors.New("cloud media playback unavailable: media missing play url; re-scan the library")
|
||||
|
||||
// normalizeCloudPlayTarget 把存库的云盘播放 URL 规范化为相对路径。
|
||||
//
|
||||
@@ -234,9 +234,9 @@ func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaI
|
||||
if m == nil {
|
||||
return ErrMediaNotFound
|
||||
}
|
||||
if strings.TrimSpace(m.STRMURL) != "" && STRMPlaybackEnabled(r.Context(), s.repo) {
|
||||
if strmURL := strings.TrimSpace(m.STRMURL); strmURL != "" && (isCloudPlaybackTarget(strmURL) || STRMPlaybackEnabled(r.Context(), s.repo)) {
|
||||
// 云盘播放 URL 先规范化为相对路径,免疫扫描时固化的旧 host。
|
||||
target := normalizeCloudPlayTarget(m.STRMURL)
|
||||
target := normalizeCloudPlayTarget(strmURL)
|
||||
target = withAuthTokenForInternalRedirect(target, r, PublicServerURL(r.Context(), s.repo, s.cfg))
|
||||
http.Redirect(w, r, absoluteInternalRedirect(target, r), http.StatusFound)
|
||||
return nil
|
||||
@@ -263,6 +263,11 @@ func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaI
|
||||
return nil
|
||||
}
|
||||
|
||||
func isCloudPlaybackTarget(raw string) bool {
|
||||
_, _, ok := parseCloudMediaPlaybackURL(raw)
|
||||
return ok
|
||||
}
|
||||
|
||||
// ServeHLSPlaylist makes sure a transcode is running and writes the m3u8.
|
||||
// We block (with a 30s timeout) until the playlist file shows up.
|
||||
func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request, mediaID string) error {
|
||||
|
||||
@@ -85,7 +85,37 @@ func TestServeFileRedirectsInternalSTRMAsAbsoluteURLWithToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeFileHonorsSTRMPlaybackDisabled(t *testing.T) {
|
||||
func TestServeFileRedirectUsesForwardedTunnelHost(t *testing.T) {
|
||||
repos := newStreamTestRepo(t)
|
||||
if err := repos.DB.Create(&model.Media{
|
||||
Base: model.Base{ID: "cloud-1"},
|
||||
Title: "Cloud",
|
||||
Path: "cloud://openlist/Movie.mkv",
|
||||
STRMURL: "/api/cloud/play/openlist?ref=movie",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8080/api/stream/cloud-1?api_key=jwt123", nil)
|
||||
req.Header.Set("X-Forwarded-Host", "media.example.com")
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
if err := svc.ServeFile(w, req, "cloud-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want 302", w.Code)
|
||||
}
|
||||
loc := w.Header().Get("Location")
|
||||
if !strings.HasPrefix(loc, "https://media.example.com/api/cloud/play/openlist?") ||
|
||||
!strings.Contains(loc, "ref=movie") ||
|
||||
!strings.Contains(loc, "token=jwt123") {
|
||||
t.Fatalf("redirect Location should use forwarded tunnel host and token, got %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeFileRedirectsCloudMediaWhenSTRMPlaybackDisabled(t *testing.T) {
|
||||
repos := newStreamTestRepo(t)
|
||||
if err := repos.Setting.Set(t.Context(), STRMEnabledSettingKey, "false"); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -103,13 +133,15 @@ func TestServeFileHonorsSTRMPlaybackDisabled(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
err := svc.ServeFile(w, req, "cloud-1")
|
||||
// 云盘媒体在 STRM 播放关闭时返回明确的「云盘播放不可用」错误,
|
||||
// 而不是和「媒体不存在」混在一起(后者会让播放器显示 404)。
|
||||
if err != ErrCloudPlaybackUnavailable {
|
||||
t.Fatalf("disabled STRM should not redirect cloud media, err=%v status=%d location=%q", err, w.Code, w.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatalf("cloud playback should ignore local STRM toggle: %v", err)
|
||||
}
|
||||
if loc := w.Header().Get("Location"); loc != "" {
|
||||
t.Fatalf("disabled STRM leaked redirect Location %q", loc)
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want 302", w.Code)
|
||||
}
|
||||
loc := w.Header().Get("Location")
|
||||
if !strings.Contains(loc, "/api/cloud/play/openlist?") || !strings.Contains(loc, "token=jwt123") {
|
||||
t.Fatalf("redirect Location should target tokenized cloud play endpoint, got %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
@@ -36,12 +37,13 @@ type STRMService struct {
|
||||
}
|
||||
|
||||
type GenerateSTRMOptions struct {
|
||||
LibraryID string `json:"library_id"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
IncludeLocal bool `json:"include_local"`
|
||||
LibraryID string `json:"library_id"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
IncludeLocal bool `json:"include_local"`
|
||||
PlaybackToken string `json:"-"`
|
||||
}
|
||||
|
||||
type GenerateSTRMResult struct {
|
||||
@@ -146,7 +148,7 @@ func (s *STRMService) defaultOutputDir(lib *model.Library) string {
|
||||
|
||||
func (s *STRMService) generateOne(ctx context.Context, lib model.Library, media model.Media, outputDir string, opts GenerateSTRMOptions) GenerateSTRMItem {
|
||||
item := GenerateSTRMItem{MediaID: media.ID, Title: media.Title}
|
||||
playURL := s.strmPlaybackURL(ctx, media, opts.BaseURL)
|
||||
playURL := s.strmPlaybackURL(ctx, media, opts.BaseURL, opts.PlaybackToken)
|
||||
if playURL == "" {
|
||||
item.Action = "skipped"
|
||||
item.Reason = "no playable strm target"
|
||||
@@ -185,19 +187,68 @@ func (s *STRMService) generateOne(ctx context.Context, lib model.Library, media
|
||||
item.Reason = err.Error()
|
||||
return item
|
||||
}
|
||||
_ = s.upsertGeneratedRecord(ctx, media, filePath, playURL, lib.Type)
|
||||
if err := s.upsertGeneratedRecord(ctx, media, filePath, playURL, lib.Type); err != nil {
|
||||
item.Action = "error"
|
||||
item.Reason = err.Error()
|
||||
return item
|
||||
}
|
||||
item.Action = action
|
||||
return item
|
||||
}
|
||||
|
||||
func (s *STRMService) strmPlaybackURL(ctx context.Context, media model.Media, baseURL string) string {
|
||||
if raw := strings.TrimSpace(media.STRMURL); raw != "" {
|
||||
return absolutizeSTRMURL(raw, firstNonEmpty(baseURL, PublicServerURL(ctx, s.repo, s.cfg)))
|
||||
}
|
||||
func (s *STRMService) strmPlaybackURL(ctx context.Context, media model.Media, baseURL, playbackToken string) string {
|
||||
if media.ID == "" {
|
||||
return ""
|
||||
}
|
||||
return buildAbsoluteSTRMAPIURL(firstNonEmpty(baseURL, PublicServerURL(ctx, s.repo, s.cfg)), "/api/stream/"+url.PathEscape(media.ID), nil)
|
||||
query := url.Values{}
|
||||
token := strings.TrimSpace(playbackToken)
|
||||
if token == "" {
|
||||
token = s.defaultSTRMPlaybackToken(ctx)
|
||||
}
|
||||
if token != "" {
|
||||
query.Set("token", token)
|
||||
}
|
||||
return buildAbsoluteSTRMAPIURL(firstNonEmpty(baseURL, PublicServerURL(ctx, s.repo, s.cfg)), "/api/stream/"+url.PathEscape(media.ID), query)
|
||||
}
|
||||
|
||||
func (s *STRMService) defaultSTRMPlaybackToken(ctx context.Context) string {
|
||||
if s == nil || s.repo == nil || s.repo.User == nil || s.cfg == nil || strings.TrimSpace(s.cfg.Secrets.JWTSecret) == "" {
|
||||
return ""
|
||||
}
|
||||
admin, err := s.repo.User.FirstAdmin(ctx)
|
||||
if err != nil || admin == nil {
|
||||
if err != nil && s.log != nil {
|
||||
s.log.Warn("generate strm playback token failed", zap.Error(err))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
token, err := signSTRMPlaybackToken(admin, s.cfg.Secrets.JWTSecret)
|
||||
if err != nil {
|
||||
if s.log != nil {
|
||||
s.log.Warn("sign strm playback token failed", zap.Error(err))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func signSTRMPlaybackToken(u *model.User, secret string) (string, error) {
|
||||
if u == nil || strings.TrimSpace(u.ID) == "" || strings.TrimSpace(secret) == "" {
|
||||
return "", ErrSTRMURLInvalid
|
||||
}
|
||||
claims := Claims{
|
||||
UserID: u.ID,
|
||||
Role: u.Role,
|
||||
Tier: u.Tier,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(EmbyTokenDuration)),
|
||||
Issuer: "mediastationgo",
|
||||
Subject: u.ID,
|
||||
},
|
||||
}
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return t.SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
func (s *STRMService) strmRelativePath(lib model.Library, media model.Media) string {
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -41,10 +43,11 @@ func TestGenerateSTRMForLibraryWritesFilesAndRecords(t *testing.T) {
|
||||
svc := NewSTRMService(zap.NewNop(), repos, &config.Config{})
|
||||
|
||||
res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{
|
||||
LibraryID: lib.ID,
|
||||
OutputDir: outDir,
|
||||
BaseURL: "http://nas.example:18080",
|
||||
IncludeLocal: true,
|
||||
LibraryID: lib.ID,
|
||||
OutputDir: outDir,
|
||||
BaseURL: "http://nas.example:18080",
|
||||
IncludeLocal: true,
|
||||
PlaybackToken: "strm-token",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -54,8 +57,8 @@ func TestGenerateSTRMForLibraryWritesFilesAndRecords(t *testing.T) {
|
||||
}
|
||||
cloudSTRM := filepath.Join(outDir, "云盘电影 (2026)", "云盘电影 (2026).strm")
|
||||
localSTRM := filepath.Join(outDir, "本地电影 (2025)", "本地电影 (2025).strm")
|
||||
assertFileContains(t, cloudSTRM, "http://nas.example:18080/api/cloud/play/openlist?ref=movie")
|
||||
assertFileContains(t, localSTRM, "http://nas.example:18080/api/stream/local-media")
|
||||
assertFileContains(t, cloudSTRM, "http://nas.example:18080/api/stream/cloud-media?token=strm-token")
|
||||
assertFileContains(t, localSTRM, "http://nas.example:18080/api/stream/local-media?token=strm-token")
|
||||
|
||||
var count int64
|
||||
if err := repos.DB.Model(&model.STRMRecord{}).Count(&count).Error; err != nil {
|
||||
@@ -66,10 +69,11 @@ func TestGenerateSTRMForLibraryWritesFilesAndRecords(t *testing.T) {
|
||||
}
|
||||
|
||||
res, err = svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{
|
||||
LibraryID: lib.ID,
|
||||
OutputDir: outDir,
|
||||
BaseURL: "http://nas.example:18080",
|
||||
IncludeLocal: true,
|
||||
LibraryID: lib.ID,
|
||||
OutputDir: outDir,
|
||||
BaseURL: "http://nas.example:18080",
|
||||
IncludeLocal: true,
|
||||
PlaybackToken: "strm-token",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -79,13 +83,76 @@ func TestGenerateSTRMForLibraryWritesFilesAndRecords(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSTRMForLibrarySignsDefaultPlaybackToken(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}, &model.User{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
admin := model.User{Username: "admin", PasswordHash: "x", Role: "admin", Tier: "plus", IsActive: true}
|
||||
if err := repos.User.Create(t.Context(), &admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lib := model.Library{Name: "电影", Path: "cloud://openlist/电影", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{Base: model.Base{ID: "cloud-media"}, LibraryID: lib.ID, Title: "云盘电影", Year: 2026, Path: "cloud://openlist/电影/云盘电影.mkv", STRMURL: "/api/cloud/play/openlist?ref=movie"}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
outDir := filepath.Join(t.TempDir(), "strm")
|
||||
const secret = "test-secret"
|
||||
svc := NewSTRMService(zap.NewNop(), repos, &config.Config{Secrets: config.SecretsConfig{JWTSecret: secret}})
|
||||
res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{
|
||||
LibraryID: lib.ID,
|
||||
OutputDir: outDir,
|
||||
BaseURL: "http://nas.example:18080",
|
||||
IncludeLocal: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Generated != 1 || len(res.Errors) != 0 {
|
||||
t.Fatalf("result = %#v, want generated=1 with no errors", res)
|
||||
}
|
||||
cloudSTRM := filepath.Join(outDir, "云盘电影 (2026)", "云盘电影 (2026).strm")
|
||||
got := readSTRM(t, cloudSTRM)
|
||||
if !strings.HasPrefix(got, "http://nas.example:18080/api/stream/cloud-media?token=") {
|
||||
t.Fatalf("generated url = %q, want tokenized /api/stream url", got)
|
||||
}
|
||||
token := strings.TrimPrefix(got, "http://nas.example:18080/api/stream/cloud-media?token=")
|
||||
claims := &Claims{}
|
||||
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil || !parsed.Valid {
|
||||
t.Fatalf("generated token did not validate: %v", err)
|
||||
}
|
||||
if claims.UserID != admin.ID || claims.Role != "admin" || claims.Tier != "plus" {
|
||||
t.Fatalf("claims = %#v, want admin identity", claims)
|
||||
}
|
||||
if ttl := time.Until(claims.ExpiresAt.Time); ttl < EmbyTokenDuration-time.Minute {
|
||||
t.Fatalf("token ttl = %v, want close to %v", ttl, EmbyTokenDuration)
|
||||
}
|
||||
}
|
||||
|
||||
func assertFileContains(t *testing.T, path, want string) {
|
||||
t.Helper()
|
||||
if got := readSTRM(t, path); got != want {
|
||||
t.Fatalf("%s = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func readSTRM(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.TrimSpace(string(data)); got != want {
|
||||
t.Fatalf("%s = %q, want %q", path, got, want)
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user